Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f597b57e | ||
|
|
5428a33a0c |
@@ -3,7 +3,32 @@
|
||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||
|
||||
**当前版本:V3.5**(精细化用量统计 / 大模型接口库 / AI Worker 团队 / 对话融合仪表盘 / 系统工作目录 / 流式单token超时)
|
||||
**当前版本:V3.5.2**(真流式对话+思考折叠 / 历史会话管理 / 模型能力标签 / 模型库+系统默认模型 / 语音输入输出 / 知识库导航 / 按千次计费)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 V3.5.2 优化(本轮)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| ⚡ 真流式对话 | 修复“一次性输出全部回答”的假流式 bug:服务端改为**边收边吐**(原先先等完整响应再一次性转发);**思考模型先流式输出思考内容(自动折叠成「💭 思考过程」可展开)再流式输出回答**;回答块下方显示 **输出速度(tok/s)**、tokens/缓存命中/成本/首字延迟 |
|
||||
| 🕘 历史会话管理 | 对话页左侧新增「历史会话」列表:每个会话可 **✏️重命名 / 📌置顶 / 🗑删除**(置顶排前);顶部「📝 Markdown」一键开关,消息默认按 Markdown 渲染 |
|
||||
| 🧠 模型能力标签 | 大模型接口的每个模型可勾选能力:对话/思考/视觉输入/语音输入/语音输出/图片生成/视频生成/Embedding/Rerank;对话按所选模型能力自动适配:**视觉模型→🖼️传图、语音输入→🎤录音转写、语音输出→🔊回答朗读、思考模型→展示思考过程**;不匹配时提前提示不可用 |
|
||||
| 🗂️ 模型库 + 系统默认模型 | AI Worker 新增「🧠 模型库」标签:全能力模型矩阵 + **系统默认模型配置**(语音识别/语音合成/图片生成/视频生成/embedding/rerank…),配置语音识别模型后对话即支持语音输入,配置语音合成模型后回答可生成语音 |
|
||||
| 💰 计费单位修正 | 按调用次数计费改为 **元/千次**(2 元/千次 = 单次 0.002 元);逐模型定价格式改为 **模型名 输入价 缓存输入价 输出价**(缓存命中 token 按缓存价计) |
|
||||
| 🛡️ 弹窗未保存提醒 | 编辑弹窗有未保存修改时,点击弹窗外部或 ✕ 会提醒“是否不保存退出”(所有弹窗通用) |
|
||||
| 📚 知识库导航 | 侧边栏新增「📚 知识库」:全局文档增删改查、txt/md/pdf/docx 上传解析、全文检索;对话中可一键开启「📚 知识库」按钮,发送时自动注入相关知识 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 V3.5.1 优化(上一轮)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| ⭐ 项目一键存为参考 | 项目卡片新增「⭐ 存为参考」按钮,把任意已创建项目一键保存到「从参考项目中新建」列表(复制项目+任务,原项目不受影响);参考列表里显示「来自「原项目」」来源标记,非内置参考可删除,内置 3 个受保护 |
|
||||
| 🐛 修复标签切换 | AI Worker 页「大模型接口库 / 团队」标签点击无反应的 bug(onclick 里页面状态被重置)已修复,三标签正常切换 |
|
||||
| 💬 对话独立首页 | 左侧导航改为「💬 对话 / 📊 仪表盘」并列;首页为全新对话页:**上部=仪表盘关键信息摘要(项目/任务/Worker/成本/调用次数,点击即跳转仪表盘)**,中下部=主体对话界面(更高更宽,流式输出带闪烁光标,每条记录 tokens/缓存命中/成本/首字延迟) |
|
||||
| 📅 成本报表按日期 | 报表新增「按日期」维度:按天统计调用次数/输入/输出/缓存命中/总Tokens/成本,支持 近7天/30天/全部 筛选 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
import secrets
|
||||
import functools
|
||||
import time
|
||||
import base64 as _b64
|
||||
import json as _json
|
||||
from flask import Flask, request, jsonify, session, send_from_directory, redirect, Response
|
||||
|
||||
@@ -15,6 +16,7 @@ import db
|
||||
import engine
|
||||
import llm_gateway
|
||||
import rag
|
||||
import kb
|
||||
import notify
|
||||
import agents
|
||||
import eval as evalmod
|
||||
@@ -25,6 +27,8 @@ import autostart
|
||||
|
||||
app = Flask(__name__, static_folder='static', static_url_path='')
|
||||
app.secret_key = config.SECRET_KEY
|
||||
CHAT_UPLOAD_DIR = os.path.join(config.DATA_DIR, 'chat_uploads')
|
||||
os.makedirs(CHAT_UPLOAD_DIR, exist_ok=True)
|
||||
db.init_db()
|
||||
db.recover_stale_runs()
|
||||
evalmod.create_builtin_datasets()
|
||||
@@ -232,16 +236,18 @@ def require_admin(fn):
|
||||
|
||||
@app.route('/api/health')
|
||||
def health():
|
||||
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.5.0'})
|
||||
return jsonify({'ok': True, 'service': 'ai-worker-platform', 'version': 'v3.5.2'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3.5 内置参考测试项目(从参考项目新建用,不进普通项目列表)
|
||||
# ---------------------------------------------------------------------------
|
||||
def seed_reference_projects():
|
||||
"""幂等:内置 3 个不同维度的简单参考测试项目,供「从参考项目中新建」快速复制。"""
|
||||
if db.q('SELECT COUNT(*) c FROM projects WHERE is_reference=1')[0]['c'] > 0:
|
||||
return
|
||||
"""幂等且自愈:内置 3 个不同维度的简单参考测试项目(ref_builtin=1,受保护),
|
||||
供「从参考项目中新建」快速复制;历史数据自动补标记,缺失自动重建。"""
|
||||
# 兼容历史:已有同名内置参考项目补 ref_builtin 标记
|
||||
for name in ('产品文案速写(参考)', 'Python 小工具(参考)', '市场调研简报(参考)'):
|
||||
db.w('UPDATE projects SET ref_builtin=1 WHERE name=? AND is_reference=1 AND ref_builtin=0', (name,))
|
||||
ts = db.now()
|
||||
refs = [
|
||||
{
|
||||
@@ -287,10 +293,14 @@ def seed_reference_projects():
|
||||
},
|
||||
]
|
||||
for ref in refs:
|
||||
exist = db.q('SELECT id FROM projects WHERE name=? AND is_reference=1 AND ref_builtin=1',
|
||||
(ref['name'],), one=True)
|
||||
if exist:
|
||||
continue
|
||||
pid = db.w(
|
||||
'INSERT INTO projects (name, description, objective, acceptance_criteria, status, '
|
||||
'budget_limit, deliver_type, workspace_dir, is_reference, auto_status, created_at, updated_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,1,?,?,?)',
|
||||
'budget_limit, deliver_type, workspace_dir, is_reference, ref_builtin, auto_status, created_at, updated_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,1,1,?,?,?)',
|
||||
(ref['name'], '内置参考测试项目,可「从参考项目新建」快速复制', ref['objective'],
|
||||
ref['acceptance_criteria'], 'active', 0, 'web', '', 'none', ts, ts))
|
||||
db.w('UPDATE projects SET workspace_dir=? WHERE id=?', (f'ref_{pid}', pid))
|
||||
@@ -860,12 +870,13 @@ def endpoints_api():
|
||||
return jsonify({'ok': False, 'error': 'Base URL 必填'}), 400
|
||||
models = [m.strip() for m in (d.get('models') or []) if m and m.strip()]
|
||||
eid = db.w(
|
||||
'INSERT INTO llm_endpoints (name, provider, base_url, api_key, models, pricing, '
|
||||
'INSERT INTO llm_endpoints (name, provider, base_url, api_key, models, pricing, capabilities, '
|
||||
'input_price, output_price, price_per_call, billing, description, status, created_at, updated_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(d.get('name', '').strip(), d.get('provider', 'custom') or 'custom',
|
||||
d.get('base_url', '').strip(), d.get('api_key', ''), _json.dumps(models),
|
||||
_json.dumps(d.get('pricing') or {}), float(d.get('input_price') or 0),
|
||||
_json.dumps(d.get('pricing') or {}), _json.dumps(d.get('capabilities') or {}),
|
||||
float(d.get('input_price') or 0),
|
||||
float(d.get('output_price') or 0), float(d.get('price_per_call') or 0),
|
||||
d.get('billing', 'token'), d.get('description', ''), d.get('status', 'enabled'),
|
||||
db.now(), db.now()))
|
||||
@@ -878,6 +889,10 @@ def endpoints_api():
|
||||
for r in rows:
|
||||
r['models'] = _json.loads(r.get('models') or '[]')
|
||||
r['pricing'] = _json.loads(r.get('pricing') or '{}')
|
||||
try:
|
||||
r['capabilities'] = _json.loads(r.get('capabilities') or '{}') or {}
|
||||
except Exception:
|
||||
r['capabilities'] = {}
|
||||
r['worker_count'] = sum(1 for w in wc.values() if w.get('endpoint_id') == r['id'])
|
||||
r['workers'] = [w['name'] for w in wc.values() if w.get('endpoint_id') == r['id']][:20]
|
||||
return jsonify({'ok': True, 'data': rows})
|
||||
@@ -913,6 +928,9 @@ def endpoint_detail(eid):
|
||||
if 'pricing' in d:
|
||||
sets.append('pricing=?')
|
||||
args.append(_json.dumps(d['pricing'] or {}))
|
||||
if 'capabilities' in d:
|
||||
sets.append('capabilities=?')
|
||||
args.append(_json.dumps(d['capabilities'] or {}))
|
||||
if sets:
|
||||
args.append(db.now())
|
||||
db.w(f'UPDATE llm_endpoints SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, eid))
|
||||
@@ -1005,20 +1023,72 @@ def team_detail(tid):
|
||||
@app.route('/api/reference_projects')
|
||||
@require_auth
|
||||
def reference_projects():
|
||||
"""内置参考测试项目(3 个,含任务列表),供「从参考项目中新建」快速复制"""
|
||||
"""参考测试项目(内置 + 用户保存的),供「从参考项目中新建」快速复制"""
|
||||
err = _check_perm_point('project.view')
|
||||
if err:
|
||||
return err
|
||||
rows = db.q('SELECT * FROM projects WHERE is_reference=1 ORDER BY id')
|
||||
rows = db.q('SELECT * FROM projects WHERE is_reference=1 ORDER BY ref_builtin DESC, id')
|
||||
out = []
|
||||
for r in rows:
|
||||
r['tasks'] = db.q('SELECT * FROM tasks WHERE project_id=? AND deleted=0 ORDER BY id', (r['id'],))
|
||||
for t in r['tasks']:
|
||||
t['depends_on'] = _json.loads(t.get('depends_on') or '[]')
|
||||
src = db.q('SELECT id, name FROM projects WHERE id=?', (r.get('ref_source_id') or 0,), one=True)
|
||||
r['source_project'] = dict(src) if src else None
|
||||
out.append(r)
|
||||
return jsonify({'ok': True, 'data': out})
|
||||
|
||||
|
||||
@app.route('/api/projects/<int:pid>/save_reference', methods=['POST'])
|
||||
@require_auth
|
||||
def project_save_reference(pid):
|
||||
"""把已创建的项目一键保存为参考项目(复制项目+任务,原项目不受影响),供「从参考项目中新建」使用"""
|
||||
err = _check_project_perm(pid, 'view')
|
||||
if err:
|
||||
return err
|
||||
proj = db.q('SELECT * FROM projects WHERE id=?', (pid,), one=True)
|
||||
if not proj:
|
||||
return jsonify({'ok': False, 'error': '项目不存在'}), 404
|
||||
if proj.get('is_reference'):
|
||||
return jsonify({'ok': False, 'error': '该项目本身已是参考项目'}), 400
|
||||
ts = db.now()
|
||||
new_name = (proj['name'].strip() or '参考项目') + '(参考)'
|
||||
rpid = db.w(
|
||||
'INSERT INTO projects (name, description, objective, acceptance_criteria, status, '
|
||||
'budget_limit, deliver_type, deliver_note, workspace_dir, is_reference, ref_builtin, '
|
||||
'ref_source_id, auto_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,1,0,?,?,?,?)',
|
||||
(new_name, (proj.get('description') or '') + '\n(由项目「' + proj['name'] + '」一键保存为参考)',
|
||||
proj.get('objective') or '', proj.get('acceptance_criteria') or '', 'active',
|
||||
proj.get('budget_limit') or 0, proj.get('deliver_type') or 'web', proj.get('deliver_note') or '',
|
||||
'', pid, 'none', ts, ts))
|
||||
db.w('UPDATE projects SET workspace_dir=? WHERE id=?', (f'ref_{rpid}', rpid))
|
||||
# 复制任务(depends_on 重新映射)
|
||||
n = _copy_tasks_from_reference(proj, rpid)
|
||||
db.w("INSERT INTO project_logs (project_id, level, message, created_at) VALUES (?,?,?,?)",
|
||||
(rpid, 'info', f'由项目「{proj["name"]}」一键保存为参考项目,复制 {n} 个任务', ts))
|
||||
enterprise.audit(enterprise.current_actor(), 'project.save_reference', f'project#{pid}',
|
||||
f'「{proj["name"]}」保存为参考项目 #{rpid}({n} 任务)', request.remote_addr or '')
|
||||
return jsonify({'ok': True, 'id': rpid, 'copied_tasks': n})
|
||||
|
||||
|
||||
@app.route('/api/reference_projects/<int:rid>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def reference_project_delete(rid):
|
||||
"""删除参考项目(内置参考项目受保护)"""
|
||||
err = _check_perm_point('project.manage')
|
||||
if err:
|
||||
return err
|
||||
r = db.q('SELECT * FROM projects WHERE id=? AND is_reference=1', (rid,), one=True)
|
||||
if not r:
|
||||
return jsonify({'ok': False, 'error': '参考项目不存在'}), 404
|
||||
if r.get('ref_builtin'):
|
||||
return jsonify({'ok': False, 'error': '内置参考项目受保护,不可删除'}), 400
|
||||
db.w('DELETE FROM task_logs WHERE task_id IN (SELECT id FROM tasks WHERE project_id=?)', (rid,))
|
||||
db.w('DELETE FROM tasks WHERE project_id=?', (rid,))
|
||||
db.w('DELETE FROM projects WHERE id=?', (rid,))
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@app.route('/api/workspace/probe')
|
||||
@require_auth
|
||||
def workspace_probe():
|
||||
@@ -1067,8 +1137,56 @@ def settings_workspace():
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3.5 · 对话(融合在仪表盘顶部):可选 大模型接口 / AI Worker / 团队,默认主力 AI Worker
|
||||
# ---------------------------------------------------------------------------
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3.5.2 模型能力 / 系统默认模型 / 对话工具
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_CAP_KEYS = {'chat': 'default_chat_model', 'asr': 'default_asr_model', 'tts': 'default_tts_model',
|
||||
'image': 'default_image_model', 'video': 'default_video_model',
|
||||
'embedding': 'default_embedding_model', 'rerank': 'default_rerank_model'}
|
||||
|
||||
|
||||
def default_model_for(cap):
|
||||
"""系统默认模型:设置存 "endpoint_id:model",返回 (endpoint_row, model) 或 None"""
|
||||
key = DEFAULT_CAP_KEYS.get(cap)
|
||||
if not key:
|
||||
return None
|
||||
v = (db.get_setting(key, '') or '').strip()
|
||||
if not v or ':' not in v:
|
||||
return None
|
||||
try:
|
||||
eid, model = v.split(':', 1)
|
||||
ep = db.q('SELECT * FROM llm_endpoints WHERE id=? AND status="enabled"', (int(eid),), one=True)
|
||||
if ep and model:
|
||||
return ep, model
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_image(image):
|
||||
"""把聊天图片引用解析为模型可用的 URL / data URL"""
|
||||
if not image:
|
||||
return None
|
||||
if image.startswith('data:'):
|
||||
return image
|
||||
if image.startswith('http'):
|
||||
return image
|
||||
if image.startswith('/api/chat/attachments/'):
|
||||
fn = os.path.basename(image)
|
||||
p = os.path.join(CHAT_UPLOAD_DIR, fn)
|
||||
if os.path.isfile(p):
|
||||
with open(p, 'rb') as f:
|
||||
raw = f.read()
|
||||
ext = os.path.splitext(fn)[1].lower().lstrip('.')
|
||||
mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
||||
'gif': 'image/gif', 'webp': 'image/webp'}.get(ext, 'image/png')
|
||||
return f'data:{mime};base64,{_b64.b64encode(raw).decode()}'
|
||||
return image
|
||||
|
||||
|
||||
def _chat_target_cfg(session_row):
|
||||
"""解析对话目标,返回 (label, worker_or_none, llm_cfg, system_prompt, model)"""
|
||||
"""解析对话目标,返回 (label, worker_or_none, cfg, system_prompt, model, caps)
|
||||
cfg 供 llm_gateway.stream_chat 使用(worker 与接口目标统一)"""
|
||||
ttype = session_row.get('target_type') or 'worker'
|
||||
tid = session_row.get('target_id') or 0
|
||||
model = session_row.get('model') or ''
|
||||
@@ -1079,10 +1197,11 @@ def _chat_target_cfg(session_row):
|
||||
models = _json.loads(ep.get('models') or '[]') or ['']
|
||||
if not model:
|
||||
model = models[0]
|
||||
caps = llm_gateway.endpoint_model_caps(ep, model)
|
||||
cfg = {'provider': ep.get('provider') or 'custom', 'model': model,
|
||||
'base_url': ep.get('base_url') or '', 'api_key': ep.get('api_key') or '',
|
||||
'endpoint': ep}
|
||||
return f'接口「{ep["name"]}」/{model}', None, cfg, '', model
|
||||
return f'接口「{ep["name"]}」/{model}', None, cfg, '', model, caps
|
||||
if ttype == 'team':
|
||||
team = db.q('SELECT * FROM worker_teams WHERE id=?', (tid,), one=True)
|
||||
if not team:
|
||||
@@ -1092,48 +1211,187 @@ def _chat_target_cfg(session_row):
|
||||
tuple(wids)) if wids else []
|
||||
if not rows:
|
||||
raise ValueError(f'团队「{team["name"]}」没有可用的 AI Worker 成员')
|
||||
# 轮询分配成员:按该会话消息数取模
|
||||
n = db.q('SELECT COUNT(*) c FROM chat_messages WHERE session_id=? AND role="assistant"',
|
||||
(session_row['id'],))[0]['c']
|
||||
worker = rows[n % len(rows)]
|
||||
return f'团队「{team["name"]}」成员「{worker["name"]}」', worker, None, worker.get('system_prompt') or '', ''
|
||||
# worker
|
||||
cfg = llm_gateway.worker_llm_cfg(worker)
|
||||
cfg['endpoint'] = cfg['endpoint'] or None
|
||||
caps = llm_gateway.endpoint_model_caps(cfg.get('endpoint'), cfg['model']) if cfg.get('endpoint') else llm_gateway.guess_model_caps(cfg['model'])
|
||||
return f'团队「{team["name"]}」成员「{worker["name"]}」', worker, cfg, worker.get('system_prompt') or '', cfg['model'], caps
|
||||
worker = db.q('SELECT * FROM workers WHERE id=? AND status="enabled"', (tid,), one=True)
|
||||
if not worker:
|
||||
raise ValueError('对话目标:AI Worker 不存在或已停用')
|
||||
return f'Worker「{worker["name"]}」', worker, None, worker.get('system_prompt') or '', ''
|
||||
cfg = llm_gateway.worker_llm_cfg(worker)
|
||||
caps = llm_gateway.endpoint_model_caps(cfg.get('endpoint'), cfg['model']) if cfg.get('endpoint') else llm_gateway.guess_model_caps(cfg['model'])
|
||||
return f'Worker「{worker["name"]}」', worker, cfg, worker.get('system_prompt') or '', cfg['model'], caps
|
||||
|
||||
|
||||
def _chat_history(sid, limit=24):
|
||||
rows = db.q('SELECT role, content FROM chat_messages WHERE session_id=? AND error="" '
|
||||
"""取会话历史(含图片消息),返回 [{role, content, image}]"""
|
||||
rows = db.q('SELECT role, content, image FROM chat_messages WHERE session_id=? AND error="" '
|
||||
'ORDER BY id DESC LIMIT ?', (sid, limit))
|
||||
rows.reverse()
|
||||
out = []
|
||||
for r in rows:
|
||||
if (r['content'] or '').strip():
|
||||
out.append({'role': r['role'], 'content': r['content'][:8000]})
|
||||
c = (r['content'] or '').strip()
|
||||
img = _resolve_image(r.get('image') or '')
|
||||
if c or img:
|
||||
out.append({'role': r['role'], 'content': c[:8000], 'image': img})
|
||||
return out
|
||||
|
||||
|
||||
@app.route('/api/chat/options')
|
||||
@require_auth
|
||||
def chat_options():
|
||||
"""对话配置:可选 大模型接口/Worker/团队 + 主力 AI Worker"""
|
||||
"""对话配置:可选 大模型接口/Worker/团队 + 主力 AI Worker + 各模型能力标签 + 系统默认模型"""
|
||||
eps = db.q('SELECT id, name, provider, base_url, models, billing, input_price, output_price, '
|
||||
'price_per_call, status FROM llm_endpoints WHERE status="enabled" ORDER BY id')
|
||||
'price_per_call, capabilities, status FROM llm_endpoints WHERE status="enabled" ORDER BY id')
|
||||
caps_map_all = {}
|
||||
for e in eps:
|
||||
e['models'] = _json.loads(e.get('models') or '[]')
|
||||
models = _json.loads(e.get('models') or '[]')
|
||||
e['models'] = models
|
||||
try:
|
||||
caps_map = _json.loads(e.get('capabilities') or '{}') or {}
|
||||
except Exception:
|
||||
caps_map = {}
|
||||
caps_map_all[e['id']] = caps_map
|
||||
e['caps'] = {m: (caps_map.get(m) or llm_gateway.guess_model_caps(m)) for m in models}
|
||||
workers = db.q('SELECT id, name, provider, model, status, endpoint_id FROM workers WHERE status="enabled" ORDER BY id')
|
||||
for w in workers:
|
||||
ep = next((e for e in eps if e['id'] == w.get('endpoint_id')), None)
|
||||
w['caps'] = llm_gateway.endpoint_model_caps(ep, w['model']) if ep else llm_gateway.guess_model_caps(w['model'])
|
||||
teams = db.q('SELECT * FROM worker_teams ORDER BY id DESC')
|
||||
for t in teams:
|
||||
t['worker_ids'] = _json.loads(t.get('worker_ids') or '[]')
|
||||
t['worker_count'] = len(t['worker_ids'])
|
||||
defaults = {cap: default_model_for(cap) for cap in DEFAULT_CAP_KEYS}
|
||||
dflt = {}
|
||||
for cap, val in defaults.items():
|
||||
if val:
|
||||
ep, m = val
|
||||
dflt[cap] = {'endpoint_id': ep['id'], 'endpoint_name': ep['name'], 'model': m}
|
||||
return jsonify({'ok': True, 'data': {
|
||||
'endpoints': eps, 'workers': workers, 'teams': teams,
|
||||
'main_worker_id': db.main_worker_id(),
|
||||
'defaults': dflt,
|
||||
'cap_labels': llm_gateway.CAP_LABELS,
|
||||
}})
|
||||
|
||||
|
||||
@app.route('/api/models/defaults', methods=['GET', 'PUT'])
|
||||
@require_auth
|
||||
@require_perm('setting.manage')
|
||||
def models_defaults():
|
||||
"""系统默认模型:语音识别/语音合成/图片生成/视频生成/embedding/rerank 等,配置后全局生效"""
|
||||
caps = ['chat', 'asr', 'tts', 'image', 'video', 'embedding', 'rerank']
|
||||
if request.method == 'PUT':
|
||||
d = request.get_json(force=True) or {}
|
||||
for cap in caps:
|
||||
val = (d.get(cap) or '').strip()
|
||||
if val and ':' not in val:
|
||||
return jsonify({'ok': False, 'error': f'{cap} 默认模型格式应为 endpoint_id:model'}), 400
|
||||
db.set_setting(DEFAULT_CAP_KEYS[cap], val)
|
||||
return jsonify({'ok': True})
|
||||
eps = db.q('SELECT id, name, models, capabilities FROM llm_endpoints WHERE status="enabled" ORDER BY id')
|
||||
for e in eps:
|
||||
e['models'] = _json.loads(e.get('models') or '[]')
|
||||
try:
|
||||
e['caps'] = _json.loads(e.get('capabilities') or '{}') or {}
|
||||
except Exception:
|
||||
e['caps'] = {}
|
||||
out = {}
|
||||
for cap in caps:
|
||||
dm = default_model_for(cap)
|
||||
out[cap] = {'value': f'{dm[0]["id"]}:{dm[1]}' if dm else '',
|
||||
'endpoint_id': dm[0]['id'] if dm else None, 'model': dm[1] if dm else ''}
|
||||
return jsonify({'ok': True, 'data': {'defaults': out, 'endpoints': eps}})
|
||||
|
||||
|
||||
@app.route('/api/chat/upload_image', methods=['POST'])
|
||||
@require_auth
|
||||
def chat_upload_image():
|
||||
"""对话图片上传:保存到 data/chat_uploads/,返回可访问 URL(供视觉模型对话使用)"""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'ok': False, 'error': '缺少文件字段 file'}), 400
|
||||
fs = request.files['file']
|
||||
if not fs or not fs.filename:
|
||||
return jsonify({'ok': False, 'error': '文件名为空'}), 400
|
||||
ext = os.path.splitext(fs.filename)[1].lower()
|
||||
if ext not in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
|
||||
return jsonify({'ok': False, 'error': '仅支持 png/jpg/gif/webp 图片'}), 400
|
||||
name = f'{db.now()}_{secrets.token_hex(4)}{ext}'
|
||||
fs.save(os.path.join(CHAT_UPLOAD_DIR, name))
|
||||
return jsonify({'ok': True, 'url': f'/api/chat/attachments/{name}'})
|
||||
|
||||
|
||||
@app.route('/api/chat/attachments/<path:name>')
|
||||
@require_auth
|
||||
def chat_attachment(name):
|
||||
return send_from_directory(CHAT_UPLOAD_DIR, os.path.basename(name))
|
||||
|
||||
|
||||
@app.route('/api/chat/transcribe', methods=['POST'])
|
||||
@require_auth
|
||||
def chat_transcribe():
|
||||
"""语音输入:用系统默认 语音识别模型 转写(OpenAI 兼容 /audio/transcriptions)"""
|
||||
dm = default_model_for('asr')
|
||||
if not dm:
|
||||
return jsonify({'ok': False, 'error': '未配置系统默认语音识别模型(模型库 → 系统默认模型)'}), 400
|
||||
ep, model = dm
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'ok': False, 'error': '缺少音频文件'}), 400
|
||||
fs = request.files['file']
|
||||
raw = fs.read()
|
||||
if not raw:
|
||||
return jsonify({'ok': False, 'error': '音频内容为空'}), 400
|
||||
url = (ep.get('base_url') or '').rstrip('/') + '/audio/transcriptions'
|
||||
headers = {'Authorization': f'Bearer {ep.get("api_key") or ""}'}
|
||||
try:
|
||||
import requests
|
||||
resp = requests.post(url, headers=headers, timeout=120,
|
||||
files={'file': (fs.filename or 'audio.wav', raw, 'audio/wav')},
|
||||
data={'model': model})
|
||||
if resp.status_code != 200:
|
||||
return jsonify({'ok': False, 'error': f'转写失败({resp.status_code}): {resp.text[:200]}'}), 400
|
||||
data = resp.json()
|
||||
text = (data.get('text') or '').strip()
|
||||
if not text:
|
||||
return jsonify({'ok': False, 'error': '转写结果为空'}), 400
|
||||
return jsonify({'ok': True, 'text': text})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': f'语音识别失败:{str(e)[:200]}'}), 400
|
||||
|
||||
|
||||
@app.route('/api/chat/tts', methods=['POST'])
|
||||
@require_auth
|
||||
def chat_tts():
|
||||
"""语音输出:用系统默认 语音合成模型 把文本转语音(OpenAI 兼容 /audio/speech)"""
|
||||
dm = default_model_for('tts')
|
||||
if not dm:
|
||||
return jsonify({'ok': False, 'error': '未配置系统默认语音合成模型(模型库 → 系统默认模型)'}), 400
|
||||
ep, model = dm
|
||||
d = request.get_json(force=True) or {}
|
||||
text = (d.get('text') or '').strip()[:4000]
|
||||
if not text:
|
||||
return jsonify({'ok': False, 'error': '文本为空'}), 400
|
||||
url = (ep.get('base_url') or '').rstrip('/') + '/audio/speech'
|
||||
headers = {'Authorization': f'Bearer {ep.get("api_key") or ""}', 'Content-Type': 'application/json'}
|
||||
try:
|
||||
import requests
|
||||
resp = requests.post(url, headers=headers, timeout=120,
|
||||
json={'model': model, 'input': text, 'voice': d.get('voice') or 'alloy'})
|
||||
if resp.status_code != 200:
|
||||
return jsonify({'ok': False, 'error': f'合成失败({resp.status_code}): {resp.text[:200]}'}), 400
|
||||
audio = resp.content
|
||||
ctype = resp.headers.get('Content-Type', 'audio/mpeg')
|
||||
from flask import make_response
|
||||
r = make_response(audio)
|
||||
r.headers['Content-Type'] = ctype
|
||||
return r
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': f'语音合成失败:{str(e)[:200]}'}), 400
|
||||
|
||||
|
||||
@app.route('/api/chat/sessions', methods=['GET', 'POST'])
|
||||
@require_auth
|
||||
def chat_sessions():
|
||||
@@ -1149,7 +1407,7 @@ def chat_sessions():
|
||||
'VALUES (?,?,?,?,?,?)',
|
||||
((d.get('title') or '新对话').strip(), ttype, tid, model, db.now(), db.now()))
|
||||
return jsonify({'ok': True, 'id': sid})
|
||||
rows = db.q('SELECT * FROM chat_sessions ORDER BY id DESC LIMIT 50')
|
||||
rows = db.q('SELECT * FROM chat_sessions ORDER BY pinned DESC, updated_at DESC LIMIT 100')
|
||||
for r in rows:
|
||||
last = db.q('SELECT role, content, created_at FROM chat_messages WHERE session_id=? '
|
||||
'ORDER BY id DESC LIMIT 1', (r['id'],), one=True)
|
||||
@@ -1158,30 +1416,55 @@ def chat_sessions():
|
||||
return jsonify({'ok': True, 'data': rows})
|
||||
|
||||
|
||||
@app.route('/api/chat/sessions/<int:sid>', methods=['GET', 'DELETE'])
|
||||
@app.route('/api/chat/sessions/<int:sid>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@require_auth
|
||||
def chat_session_detail(sid):
|
||||
s = db.q('SELECT * FROM chat_sessions WHERE id=?', (sid,), one=True)
|
||||
if not s:
|
||||
return jsonify({'ok': False, 'error': '会话不存在'}), 404
|
||||
if request.method == 'PUT':
|
||||
"""更新会话:重命名 / 置顶 / 切换知识库开关 / 切换目标"""
|
||||
d = request.get_json(force=True) or {}
|
||||
sets, args = [], []
|
||||
if 'title' in d:
|
||||
title = (d.get('title') or '').strip()
|
||||
if title:
|
||||
sets.append('title=?')
|
||||
args.append(title[:100])
|
||||
if 'pinned' in d:
|
||||
sets.append('pinned=?')
|
||||
args.append(1 if d.get('pinned') else 0)
|
||||
if 'use_kb' in d:
|
||||
sets.append('use_kb=?')
|
||||
args.append(1 if d.get('use_kb') else 0)
|
||||
if 'target_type' in d and 'target_id' in d:
|
||||
sets.append('target_type=?'); args.append(d.get('target_type') or 'worker')
|
||||
sets.append('target_id=?'); args.append(int(d.get('target_id') or 0))
|
||||
sets.append('model=?'); args.append(d.get('model') or '')
|
||||
if sets:
|
||||
args.append(db.now())
|
||||
db.w(f'UPDATE chat_sessions SET {", ".join(sets)}, updated_at=? WHERE id=?', (*args, sid))
|
||||
return jsonify({'ok': True})
|
||||
if request.method == 'DELETE':
|
||||
db.w('DELETE FROM chat_messages WHERE session_id=?', (sid,))
|
||||
db.w('DELETE FROM chat_sessions WHERE id=?', (sid,))
|
||||
return jsonify({'ok': True})
|
||||
msgs = db.q('SELECT * FROM chat_messages WHERE session_id=? ORDER BY id', (sid,))
|
||||
try:
|
||||
label, worker, cfg, sys_prompt, model = _chat_target_cfg(s)
|
||||
label, worker, cfg, sys_prompt, model, caps = _chat_target_cfg(s)
|
||||
s['target_label'] = label
|
||||
s['caps'] = caps
|
||||
except Exception as e:
|
||||
s['target_label'] = f'({str(e)})'
|
||||
s['caps'] = []
|
||||
return jsonify({'ok': True, 'data': {'session': s, 'messages': msgs}})
|
||||
|
||||
|
||||
def _chat_gen(sid, user_content):
|
||||
"""SSE 生成器:流式转发模型输出(单 token 返回超时 / 首字延迟超时 由 llm_gateway 处理)"""
|
||||
import json as _j
|
||||
def _chat_gen(sid, user_content, image=None, use_kb=False):
|
||||
"""SSE 生成器(V3.5.2 真流式):边收边吐;思考模型先流式思考内容再流式回答;
|
||||
支持知识库注入与图片消息。事件:start/reasoning/delta/done/error"""
|
||||
def sse(obj):
|
||||
return f'data: {_j.dumps(obj, ensure_ascii=False)}\n\n'
|
||||
return f'data: {_json.dumps(obj, ensure_ascii=False)}\n\n'
|
||||
s = db.q('SELECT * FROM chat_sessions WHERE id=?', (sid,), one=True)
|
||||
if not s:
|
||||
yield sse({'type': 'error', 'message': '会话不存在'}); return
|
||||
@@ -1190,81 +1473,96 @@ def _chat_gen(sid, user_content):
|
||||
cfg = None
|
||||
sys_prompt = ''
|
||||
model = ''
|
||||
chunks = []
|
||||
worker_id = None
|
||||
caps = []
|
||||
try:
|
||||
label, worker, cfg, sys_prompt, model = _chat_target_cfg(s)
|
||||
label, worker, cfg, sys_prompt, model, caps = _chat_target_cfg(s)
|
||||
except Exception as e:
|
||||
yield sse({'type': 'error', 'message': str(e)}); return
|
||||
# 组装消息
|
||||
yield sse({'type': 'start', 'label': label, 'caps': caps, 'model': model})
|
||||
# 图片:仅视觉模型可带图
|
||||
img_url = _resolve_image(image) if image else None
|
||||
if img_url and 'vision' not in caps:
|
||||
yield sse({'type': 'error', 'message': f'当前模型「{model}」不支持视觉输入(能力标签:{"、".join(caps) or "无"})'}); return
|
||||
# 组装消息(历史 + 知识库 + 本条)
|
||||
history = _chat_history(sid, 24)
|
||||
history.append({'role': 'user', 'content': user_content})
|
||||
if use_kb:
|
||||
ctx, hits = kb.build_context(user_content, 3)
|
||||
if ctx:
|
||||
history.insert(0, {'role': 'system', 'content': ctx})
|
||||
yield sse({'type': 'note', 'message': f'📚 已注入知识库参考 {len(hits)} 段'})
|
||||
if sys_prompt and not any(m['role'] == 'system' for m in history):
|
||||
history.insert(0, {'role': 'system', 'content': sys_prompt})
|
||||
chunks = []
|
||||
if img_url:
|
||||
history.append({'role': 'user', 'content': [{'type': 'text', 'text': user_content},
|
||||
{'type': 'image_url', 'image_url': {'url': img_url}}]})
|
||||
else:
|
||||
history.append({'role': 'user', 'content': user_content})
|
||||
chunks, thinking = [], []
|
||||
t0 = time.time()
|
||||
try:
|
||||
if worker is not None:
|
||||
def on_chunk(piece):
|
||||
chunks.append(piece)
|
||||
r = llm_gateway.chat_worker(worker, history, on_chunk=on_chunk)
|
||||
for piece in chunks:
|
||||
yield sse({'type': 'delta', 'content': piece})
|
||||
worker_id = worker['id']
|
||||
model = r['model']
|
||||
else:
|
||||
# 大模型接口:流式
|
||||
r = llm_gateway.chat_stream(cfg['provider'], cfg['model'], history,
|
||||
base_url=cfg['base_url'] or None, api_key=cfg['api_key'] or None,
|
||||
on_chunk=lambda p: chunks.append(p))
|
||||
for piece in chunks:
|
||||
yield sse({'type': 'delta', 'content': piece})
|
||||
r['cost'] = llm_gateway.calc_cost_ex(cfg['model'], r['prompt_tokens'],
|
||||
r['completion_tokens'], 1, cfg)
|
||||
worker_id = None
|
||||
model = r['model']
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, model, worker_id, prompt_tokens, '
|
||||
'completion_tokens, cached_tokens, cost, latency_ms, first_token_ms, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(sid, 'assistant', ''.join(chunks), model, worker_id,
|
||||
for evt, val in llm_gateway.stream_chat(cfg, history,
|
||||
temperature=worker.get('temperature', 0.7) if worker else 0.7,
|
||||
max_tokens=worker.get('max_tokens') if worker else None):
|
||||
if evt == 'reasoning':
|
||||
thinking.append(val)
|
||||
yield sse({'type': 'reasoning', 'content': val})
|
||||
elif evt == 'delta':
|
||||
chunks.append(val)
|
||||
yield sse({'type': 'delta', 'content': val})
|
||||
elif evt == 'error':
|
||||
raise llm_gateway.LLMError(val)
|
||||
elif evt == 'done':
|
||||
r = val
|
||||
worker_id = worker['id'] if worker else None
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, thinking, image, model, worker_id, '
|
||||
'prompt_tokens, completion_tokens, cached_tokens, cost, latency_ms, first_token_ms, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(sid, 'assistant', ''.join(chunks), ''.join(thinking), '', r['model'], worker_id,
|
||||
r['prompt_tokens'], r['completion_tokens'], r.get('cached_tokens', 0),
|
||||
r['cost'], r.get('elapsed_ms', 0), r.get('first_token_ms') or 0, db.now()))
|
||||
yield sse({'type': 'done', 'usage': {
|
||||
'prompt_tokens': r['prompt_tokens'], 'completion_tokens': r['completion_tokens'],
|
||||
'cached_tokens': r.get('cached_tokens', 0), 'total_tokens': r['total_tokens'],
|
||||
'cost': r['cost'], 'first_token_ms': r.get('first_token_ms'),
|
||||
'elapsed_ms': r.get('elapsed_ms', 0), 'model': model, 'label': label}})
|
||||
'elapsed_ms': r.get('elapsed_ms', 0), 'model': r['model'], 'label': label,
|
||||
'has_thinking': bool(thinking)}})
|
||||
except llm_gateway.LLMError as e:
|
||||
partial = ''.join(chunks)
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, model, worker_id, error, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(sid, 'assistant', partial, model or '', worker_id,
|
||||
str(e)[:500], db.now()))
|
||||
yield sse({'type': 'error', 'message': str(e), 'partial': partial})
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, thinking, image, model, worker_id, error, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
(sid, 'assistant', partial, ''.join(thinking), '', model or '',
|
||||
worker['id'] if worker else None, str(e)[:500], db.now()))
|
||||
yield sse({'type': 'error', 'message': str(e), 'partial': partial,
|
||||
'has_thinking': bool(thinking)})
|
||||
except Exception as e:
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, model, worker_id, error, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?)',
|
||||
(sid, 'assistant', ''.join(chunks), model or '', worker_id, str(e)[:500], db.now()))
|
||||
yield sse({'type': 'error', 'message': str(e)})
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, thinking, image, model, worker_id, error, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
(sid, 'assistant', ''.join(chunks), ''.join(thinking), '', model or '',
|
||||
worker['id'] if worker else None, str(e)[:500], db.now()))
|
||||
yield sse({'type': 'error', 'message': str(e), 'has_thinking': bool(thinking)})
|
||||
|
||||
|
||||
@app.route('/api/chat/sessions/<int:sid>/messages', methods=['POST'])
|
||||
@require_auth
|
||||
def chat_send(sid):
|
||||
"""发送对话消息,服务端按 token 流式返回(SSE)"""
|
||||
"""发送对话消息,服务端按 token 实时流式返回(SSE):思考内容先行,回答随后"""
|
||||
s = db.q('SELECT id FROM chat_sessions WHERE id=?', (sid,), one=True)
|
||||
if not s:
|
||||
return jsonify({'ok': False, 'error': '会话不存在'}), 404
|
||||
d = request.get_json(force=True)
|
||||
content = (d.get('content') or '').strip()
|
||||
if not content:
|
||||
if not content and not d.get('image'):
|
||||
return jsonify({'ok': False, 'error': '消息内容为空'}), 400
|
||||
if len(content) > 60000:
|
||||
content = content[:60000]
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, created_at) VALUES (?,?,?,?)',
|
||||
(sid, 'user', content, db.now()))
|
||||
image = (d.get('image') or '').strip()
|
||||
use_kb = bool(d.get('use_kb'))
|
||||
if use_kb:
|
||||
db.w('UPDATE chat_sessions SET use_kb=1 WHERE id=?', (sid,))
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, image, created_at) VALUES (?,?,?,?,?)',
|
||||
(sid, 'user', content, image, db.now()))
|
||||
db.w('UPDATE chat_sessions SET updated_at=? WHERE id=?', (db.now(), sid))
|
||||
return Response(_chat_gen(sid, content), mimetype='text/event-stream',
|
||||
return Response(_chat_gen(sid, content, image, use_kb), mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
|
||||
|
||||
@@ -1573,7 +1871,26 @@ def report_cost():
|
||||
scope_sql = 'WHERE project_id IN (%s)' % ','.join('?' * len(visible))
|
||||
scope_args = list(visible)
|
||||
group = request.args.get('group', 'project')
|
||||
if group == 'worker':
|
||||
if group == 'date':
|
||||
# 按日期统计(V3.5):date(created_at) 以本地时区分天;days 限制最近 N 天
|
||||
days = 0
|
||||
try:
|
||||
days = int(request.args.get('days') or 0)
|
||||
except Exception:
|
||||
days = 0
|
||||
extra_where = ''
|
||||
extra_args = []
|
||||
if days > 0:
|
||||
extra_where = (scope_sql + ' AND' if scope_sql else 'WHERE') + ' created_at>=?'
|
||||
extra_args = [db.now() - days * 86400]
|
||||
rows = db.q(
|
||||
f'SELECT date(created_at, "unixepoch", "localtime") day, COUNT(*) runs, '
|
||||
f'COUNT(DISTINCT task_id) task_calls, '
|
||||
f'SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens, '
|
||||
f'SUM(cached_tokens) cached_tokens, SUM(total_tokens) tokens, SUM(cost) cost '
|
||||
f'FROM cost_records {scope_sql}{extra_where} GROUP BY day ORDER BY day DESC',
|
||||
scope_args + extra_args)
|
||||
elif group == 'worker':
|
||||
rows = db.q(
|
||||
'SELECT worker_id, provider, model, COUNT(*) runs, COUNT(DISTINCT task_id) task_calls, '
|
||||
'SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens, '
|
||||
@@ -2102,6 +2419,105 @@ def kb_search(pid):
|
||||
return jsonify({'ok': True, 'data': hits if hit else [], 'hit': hit})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3.5.2 · 全局知识库(导航「📚 知识库」:增删改查 / 上传 / 检索)
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route('/api/kb/docs', methods=['GET', 'POST'])
|
||||
@require_auth
|
||||
def kb_docs():
|
||||
err = _check_perm_point('project.view')
|
||||
if err:
|
||||
return err
|
||||
if request.method == 'POST':
|
||||
d = request.get_json(force=True)
|
||||
title = (d.get('title') or '').strip()
|
||||
if not title:
|
||||
return jsonify({'ok': False, 'error': '标题必填'}), 400
|
||||
did = db.w(
|
||||
'INSERT INTO kb_documents (title, content, tags, source, created_at, updated_at) VALUES (?,?,?,?,?,?)',
|
||||
(title, d.get('content') or '', _json.dumps(d.get('tags') or []), d.get('source') or 'manual',
|
||||
db.now(), db.now()))
|
||||
n = kb.rebuild_chunks(did)
|
||||
enterprise.audit(enterprise.current_actor(), 'kb.create', f'kb#{did}', f'「{title}」({n} 块)',
|
||||
request.remote_addr or '')
|
||||
return jsonify({'ok': True, 'id': did, 'chunks': n})
|
||||
q = (request.args.get('q') or '').strip()
|
||||
page = max(int(request.args.get('page', 1)), 1)
|
||||
page_size = min(int(request.args.get('page_size', 20)), 100)
|
||||
where, args = [], []
|
||||
if q:
|
||||
where.append('(title LIKE ? OR content LIKE ?)')
|
||||
args += [f'%{q}%', f'%{q}%']
|
||||
where_sql = (' WHERE ' + ' AND '.join(where)) if where else ''
|
||||
total = db.q(f'SELECT COUNT(*) c FROM kb_documents{where_sql}', args)[0]['c']
|
||||
rows = db.q(f'SELECT * FROM kb_documents{where_sql} ORDER BY id DESC LIMIT ? OFFSET ?',
|
||||
(*args, page_size, (page - 1) * page_size))
|
||||
for r in rows:
|
||||
r['tags'] = _json.loads(r.get('tags') or '[]')
|
||||
r['chunks'] = db.q('SELECT COUNT(*) c FROM kb_chunks WHERE doc_id=?', (r['id'],))[0]['c']
|
||||
r['preview'] = (r.get('content') or '')[:200]
|
||||
return jsonify({'ok': True, 'data': rows, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
|
||||
@app.route('/api/kb/docs/<int:did>', methods=['GET', 'PUT', 'DELETE'])
|
||||
@require_auth
|
||||
def kb_doc_detail(did):
|
||||
d = db.q('SELECT * FROM kb_documents WHERE id=?', (did,), one=True)
|
||||
if not d:
|
||||
return jsonify({'ok': False, 'error': '文档不存在'}), 404
|
||||
if request.method == 'GET':
|
||||
d['tags'] = _json.loads(d.get('tags') or '[]')
|
||||
return jsonify({'ok': True, 'data': d})
|
||||
if request.method == 'DELETE':
|
||||
db.w('DELETE FROM kb_chunks WHERE doc_id=?', (did,))
|
||||
db.w('DELETE FROM kb_documents WHERE id=?', (did,))
|
||||
return jsonify({'ok': True})
|
||||
body = request.get_json(force=True)
|
||||
if 'title' in body and (body.get('title') or '').strip():
|
||||
db.w('UPDATE kb_documents SET title=? WHERE id=?', (body['title'].strip(), did))
|
||||
if 'content' in body:
|
||||
db.w('UPDATE kb_documents SET content=? WHERE id=?', (body.get('content') or '', did))
|
||||
if 'tags' in body:
|
||||
db.w('UPDATE kb_documents SET tags=? WHERE id=?', (_json.dumps(body['tags'] or []), did))
|
||||
db.w('UPDATE kb_documents SET updated_at=? WHERE id=?', (db.now(), did))
|
||||
kb.rebuild_chunks(did)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@app.route('/api/kb/search')
|
||||
@require_auth
|
||||
def kb_search_api():
|
||||
q = request.args.get('q', '').strip()
|
||||
if not q:
|
||||
return jsonify({'ok': True, 'data': []})
|
||||
hits = kb.search(q, top_k=8)
|
||||
return jsonify({'ok': True, 'data': hits})
|
||||
|
||||
|
||||
@app.route('/api/kb/docs/upload', methods=['POST'])
|
||||
@require_auth
|
||||
def kb_upload():
|
||||
"""知识库上传:txt/md/html/csv/json/pdf/docx 抽取文本入库"""
|
||||
if 'files' not in request.files:
|
||||
return jsonify({'ok': False, 'error': '缺少文件字段 files'}), 400
|
||||
saved = []
|
||||
for fs in request.files.getlist('files'):
|
||||
if not fs or not fs.filename:
|
||||
continue
|
||||
raw = fs.read()
|
||||
text, ok = kb.extract_text(fs.filename, raw)
|
||||
title = os.path.splitext(os.path.basename(fs.filename))[0].strip() or fs.filename
|
||||
if not ok or not text.strip():
|
||||
saved.append({'file': fs.filename, 'ok': False, 'error': '无法解析文本内容'})
|
||||
continue
|
||||
did = db.w(
|
||||
'INSERT INTO kb_documents (title, content, tags, source, created_at, updated_at) VALUES (?,?,?,?,?,?)',
|
||||
(title, text[:200000], _json.dumps(['上传']), 'file', db.now(), db.now()))
|
||||
n = kb.rebuild_chunks(did)
|
||||
saved.append({'file': fs.filename, 'ok': True, 'id': did, 'chunks': n, 'len': len(text)})
|
||||
return jsonify({'ok': True, 'saved': saved})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V3 · 交付体系:工作目录 / Demo 部署 / 打包 / 邮件送达
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -401,10 +401,11 @@ CREATE TABLE IF NOT EXISTS llm_endpoints (
|
||||
base_url TEXT DEFAULT '',
|
||||
api_key TEXT DEFAULT '',
|
||||
models TEXT DEFAULT '[]', -- JSON: 可用模型名列表
|
||||
pricing TEXT DEFAULT '{}', -- JSON: {model: {input: 元/1M, output: 元/1M}}
|
||||
pricing TEXT DEFAULT '{}', -- JSON: {model: {input: 元/1M, input_cache: 元/1M, output: 元/1M}}
|
||||
capabilities TEXT DEFAULT '{}', -- JSON: {model: [能力列表 chat/thinking/vision/audio_in/audio_out/image_gen/video_gen/embedding/rerank]}
|
||||
input_price REAL DEFAULT 0, -- 兜底输入价(元/1M tokens)
|
||||
output_price REAL DEFAULT 0, -- 兜底输出价(元/1M tokens)
|
||||
price_per_call REAL DEFAULT 0, -- 按调用次数计费单价(元/次)
|
||||
price_per_call REAL DEFAULT 0, -- 按调用次数计费单价(元/千次)
|
||||
billing TEXT DEFAULT 'token', -- token=按token数计费 / call=按调用次数计费
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'enabled', -- enabled/disabled
|
||||
@@ -427,6 +428,8 @@ CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
target_type TEXT DEFAULT 'worker', -- model=大模型接口 / worker=AI Worker / team=团队
|
||||
target_id INTEGER DEFAULT 0,
|
||||
model TEXT DEFAULT '', -- target_type=model 时选定的模型名
|
||||
pinned INTEGER DEFAULT 0, -- 置顶
|
||||
use_kb INTEGER DEFAULT 0, -- 是否注入知识库上下文
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
@@ -436,6 +439,8 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
session_id INTEGER NOT NULL,
|
||||
role TEXT DEFAULT 'user', -- user/assistant
|
||||
content TEXT DEFAULT '',
|
||||
thinking TEXT DEFAULT '', -- 思考模型的过程内容
|
||||
image TEXT DEFAULT '', -- 用户消息附带的图片(路径/数据URL)
|
||||
model TEXT DEFAULT '',
|
||||
worker_id INTEGER,
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
@@ -448,6 +453,26 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
created_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '[]',
|
||||
source TEXT DEFAULT 'manual',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_id INTEGER NOT NULL,
|
||||
idx INTEGER DEFAULT 0,
|
||||
content TEXT DEFAULT '',
|
||||
tokens TEXT DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_chunks_doc ON kb_chunks(doc_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_msg_session ON chat_messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_worker ON cost_records(worker_id);
|
||||
"""
|
||||
@@ -549,12 +574,28 @@ def _migrate():
|
||||
pcols3 = {r['name'] for r in conn.execute('PRAGMA table_info(projects)')}
|
||||
if 'is_reference' not in pcols3:
|
||||
conn.execute('ALTER TABLE projects ADD COLUMN is_reference INTEGER DEFAULT 0')
|
||||
if 'ref_builtin' not in pcols3:
|
||||
conn.execute('ALTER TABLE projects ADD COLUMN ref_builtin INTEGER DEFAULT 0')
|
||||
if 'ref_source_id' not in pcols3:
|
||||
conn.execute('ALTER TABLE projects ADD COLUMN ref_source_id INTEGER DEFAULT 0')
|
||||
acols = {r['name'] for r in conn.execute('PRAGMA table_info(agent_runs)')}
|
||||
if 'workspace_dir' not in acols:
|
||||
conn.execute("ALTER TABLE agent_runs ADD COLUMN workspace_dir TEXT DEFAULT ''")
|
||||
scols = {r['name'] for r in conn.execute('PRAGMA table_info(chat_sessions)')}
|
||||
if 'model' not in scols:
|
||||
conn.execute("ALTER TABLE chat_sessions ADD COLUMN model TEXT DEFAULT ''")
|
||||
if 'pinned' not in scols:
|
||||
conn.execute('ALTER TABLE chat_sessions ADD COLUMN pinned INTEGER DEFAULT 0')
|
||||
if 'use_kb' not in scols:
|
||||
conn.execute('ALTER TABLE chat_sessions ADD COLUMN use_kb INTEGER DEFAULT 0')
|
||||
mcols = {r['name'] for r in conn.execute('PRAGMA table_info(chat_messages)')}
|
||||
if 'thinking' not in mcols:
|
||||
conn.execute("ALTER TABLE chat_messages ADD COLUMN thinking TEXT DEFAULT ''")
|
||||
if 'image' not in mcols:
|
||||
conn.execute("ALTER TABLE chat_messages ADD COLUMN image TEXT DEFAULT ''")
|
||||
ecols = {r['name'] for r in conn.execute('PRAGMA table_info(llm_endpoints)')}
|
||||
if 'capabilities' not in ecols:
|
||||
conn.execute("ALTER TABLE llm_endpoints ADD COLUMN capabilities TEXT DEFAULT '{}'")
|
||||
# V3.5 新表(幂等)
|
||||
conn.execute('''CREATE TABLE IF NOT EXISTS llm_endpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -606,8 +647,43 @@ def _migrate():
|
||||
error TEXT DEFAULT '',
|
||||
created_at INTEGER
|
||||
)''')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_chat_msg_session ON chat_messages(session_id)')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_cost_worker ON cost_records(worker_id)')
|
||||
# V3.5.2:chat_messages 补 thinking/image;chat_sessions 补 pinned/use_kb;endpoints 补 capabilities
|
||||
mcols = {r['name'] for r in conn.execute('PRAGMA table_info(chat_messages)')}
|
||||
for col, ddl in (
|
||||
('thinking', "ALTER TABLE chat_messages ADD COLUMN thinking TEXT DEFAULT ''"),
|
||||
('image', "ALTER TABLE chat_messages ADD COLUMN image TEXT DEFAULT ''"),
|
||||
):
|
||||
if col not in mcols:
|
||||
conn.execute(ddl)
|
||||
scols = {r['name'] for r in conn.execute('PRAGMA table_info(chat_sessions)')}
|
||||
for col, ddl in (
|
||||
('pinned', 'ALTER TABLE chat_sessions ADD COLUMN pinned INTEGER DEFAULT 0'),
|
||||
('use_kb', 'ALTER TABLE chat_sessions ADD COLUMN use_kb INTEGER DEFAULT 0'),
|
||||
):
|
||||
if col not in scols:
|
||||
conn.execute(ddl)
|
||||
ecols = {r['name'] for r in conn.execute('PRAGMA table_info(llm_endpoints)')}
|
||||
if 'capabilities' not in ecols:
|
||||
conn.execute("ALTER TABLE llm_endpoints ADD COLUMN capabilities TEXT DEFAULT '{}'")
|
||||
# V3.5.2 知识库
|
||||
conn.execute('''CREATE TABLE IF NOT EXISTS kb_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '[]',
|
||||
source TEXT DEFAULT 'manual',
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)''')
|
||||
conn.execute('''CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_id INTEGER NOT NULL,
|
||||
idx INTEGER DEFAULT 0,
|
||||
content TEXT DEFAULT '',
|
||||
tokens TEXT DEFAULT '[]'
|
||||
)''')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_kb_chunks_doc ON kb_chunks(doc_id)')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_kb_title ON kb_documents(title)')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
V3.5.2 全局知识库
|
||||
=================
|
||||
- kb_documents / kb_chunks:文档 + 分块(jieba 分词,BM25 风格检索)
|
||||
- 基本功能:增删改查、上传(txt/md/pdf)、全文检索、上下文注入(对话可选)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
|
||||
import db
|
||||
import config
|
||||
|
||||
KB_UPLOAD_DIR = os.path.join(config.DATA_DIR, 'kb_uploads')
|
||||
|
||||
|
||||
def _tok(text):
|
||||
"""jieba 分词(去停用字、只留长度>=2 的 token)"""
|
||||
try:
|
||||
import jieba
|
||||
toks = []
|
||||
for t in jieba.cut_for_search((text or '').lower()):
|
||||
t = t.strip()
|
||||
if len(t) >= 2 and not t.isdigit():
|
||||
toks.append(t)
|
||||
return toks
|
||||
except Exception:
|
||||
return [w for w in re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z0-9_]{2,}', (text or '').lower())]
|
||||
|
||||
|
||||
def _chunks(content, size=400, overlap=60):
|
||||
"""把文档切成小块(按段落聚合 + 超长硬切 + 前后重叠)"""
|
||||
content = content or ''
|
||||
paras = [p for p in re.split(r'\n+', content) if p.strip()]
|
||||
blocks, buf = [], ''
|
||||
for p in paras:
|
||||
if buf and len(buf) + len(p) > size:
|
||||
blocks.append(buf)
|
||||
buf = ''
|
||||
buf = (buf + '\n' + p) if buf else p
|
||||
if buf:
|
||||
blocks.append(buf)
|
||||
out = []
|
||||
for b in blocks:
|
||||
while len(b) > size:
|
||||
out.append(b[:size])
|
||||
b = b[size - overlap:]
|
||||
if b:
|
||||
out.append(b)
|
||||
return out or ['']
|
||||
|
||||
|
||||
def rebuild_chunks(doc_id):
|
||||
doc = db.q('SELECT * FROM kb_documents WHERE id=?', (doc_id,), one=True)
|
||||
if not doc:
|
||||
return 0
|
||||
db.w('DELETE FROM kb_chunks WHERE doc_id=?', (doc_id,))
|
||||
n = 0
|
||||
for i, c in enumerate(_chunks(doc.get('content') or '')):
|
||||
db.w('INSERT INTO kb_chunks (doc_id, idx, content, tokens) VALUES (?,?,?,?)',
|
||||
(doc_id, i, c, json.dumps(_tok(c))))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def search(q, top_k=6):
|
||||
"""BM25 风格检索:返回 [{doc_id,title,content,score}](按命中 token 数 + IDF 加权)"""
|
||||
q_tokens = _tok(q)
|
||||
if not q_tokens:
|
||||
return []
|
||||
rows = db.q('SELECT * FROM kb_chunks ORDER BY doc_id, idx')
|
||||
if not rows:
|
||||
return []
|
||||
docs = {d['id']: d for d in db.q('SELECT id,title FROM kb_documents')}
|
||||
df = {}
|
||||
for c in rows:
|
||||
for t in set(json.loads(c['tokens'] or '[]')):
|
||||
df[t] = df.get(t, 0) + 1
|
||||
n_docs = max(1, len(set(r['doc_id'] for r in rows)))
|
||||
scored = []
|
||||
for c in rows:
|
||||
toks = json.loads(c['tokens'] or '[]')
|
||||
tf = {}
|
||||
for t in toks:
|
||||
tf[t] = tf.get(t, 0) + 1
|
||||
score = 0.0
|
||||
for t in q_tokens:
|
||||
if t in tf:
|
||||
score += (1 + tf[t]) * max(0.1, (n_docs - df.get(t, 0) + 0.5) / (df.get(t, 0) + 0.5))
|
||||
if score > 0:
|
||||
scored.append({'doc_id': c['doc_id'], 'idx': c['idx'],
|
||||
'content': c['content'], 'score': round(score, 3),
|
||||
'title': docs.get(c['doc_id'], {}).get('title', '')})
|
||||
scored.sort(key=lambda x: -x['score'])
|
||||
return scored[:top_k]
|
||||
|
||||
|
||||
def build_context(q, top_k=4):
|
||||
"""把检索结果拼成可注入的上下文(用于对话/任务),返回 (ctx, hits)"""
|
||||
hits = search(q, top_k)
|
||||
if not hits:
|
||||
return '', []
|
||||
parts = []
|
||||
for i, h in enumerate(hits):
|
||||
parts.append(f"[{i + 1}]《{h['title']}》\n{h['content'][:900]}")
|
||||
ctx = ('以下是与你问题相关的【知识库参考】资料(可据此回答):\n' + '\n\n'.join(parts) + '\n\n----\n')
|
||||
return ctx, hits
|
||||
|
||||
|
||||
def extract_text(filename, raw):
|
||||
"""按扩展名抽取文本:txt/md/html/csv/json;pdf 用 pypdf(有则装)。返回 (text, ok)"""
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
name = filename or 'doc'
|
||||
if ext in ('.txt', '.md', '.markdown', '.html', '.htm', '.csv', '.json', '.log', '.py', '.js', '.css'):
|
||||
for enc in ('utf-8', 'gbk', 'utf-8-sig'):
|
||||
try:
|
||||
return raw.decode(enc), True
|
||||
except Exception:
|
||||
continue
|
||||
return raw.decode('utf-8', errors='ignore'), True
|
||||
if ext == '.pdf':
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
import io
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
text = '\n'.join((pg.extract_text() or '') for pg in reader.pages)
|
||||
return text, bool(text.strip())
|
||||
except Exception:
|
||||
return '', False
|
||||
if ext in ('.docx',):
|
||||
try:
|
||||
import io
|
||||
from docx import Document
|
||||
doc = Document(io.BytesIO(raw))
|
||||
text = '\n'.join(p.text for p in doc.paragraphs)
|
||||
return text, bool(text.strip())
|
||||
except Exception:
|
||||
return '', False
|
||||
return '', False
|
||||
+139
-10
@@ -63,6 +63,58 @@ def _endpoint_pricing_map(ep):
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模型能力标签(V3.5.2):chat/thinking/vision/audio_in/audio_out/image_gen/video_gen/embedding/rerank
|
||||
# ---------------------------------------------------------------------------
|
||||
CAP_LABELS = {
|
||||
'chat': '💬 对话', 'thinking': '🧠 思考', 'vision': '👁️ 视觉输入',
|
||||
'audio_in': '🎤 语音输入', 'audio_out': '🔊 语音输出', 'image_gen': '🎨 图片生成',
|
||||
'video_gen': '🎬 视频生成', 'embedding': '🔢 Embedding', 'rerank': '🔀 Rerank',
|
||||
}
|
||||
ALL_CAPS = list(CAP_LABELS.keys())
|
||||
|
||||
|
||||
def endpoint_model_caps(endpoint, model):
|
||||
"""某接口下某模型的 能力标签 列表(缺省按模型名启发式推断)"""
|
||||
if endpoint:
|
||||
try:
|
||||
caps_map = json.loads(endpoint.get('capabilities') or '{}') or {}
|
||||
except Exception:
|
||||
caps_map = {}
|
||||
caps = list(caps_map.get(model) or [])
|
||||
if caps:
|
||||
return caps
|
||||
# 兜底:模型名启发式
|
||||
return guess_model_caps(model)
|
||||
return guess_model_caps(model)
|
||||
|
||||
|
||||
def guess_model_caps(model):
|
||||
"""按模型名启发式推断能力(未显式配置时)"""
|
||||
m = (model or '').lower()
|
||||
caps = {'chat'}
|
||||
if any(k in m for k in ('think', 'reason', 'r1', 'deepseek-reasoner', 'o1', 'o3', 'glm-4.6')):
|
||||
caps.add('thinking')
|
||||
if any(k in m for k in ('vision', 'vl', 'omni', 'qwen2.5-vl', 'glm-4v', 'llava', 'internvl', 'mini-omni')):
|
||||
caps.add('vision')
|
||||
if any(k in m for k in ('audio', 'voice', 'tts', 'speech', 'asr', 'whisper', 'transcri', 's2t', 'funasr', 'sensevoice')):
|
||||
caps.add('audio_in')
|
||||
caps.add('audio_out')
|
||||
if any(k in m for k in ('image', 'dall', 'stable', 'flux', 'sd3', 'sora', 'wan', 'cogview', 'draw')):
|
||||
caps.add('image_gen')
|
||||
if any(k in m for k in ('video', 'sora', 'wan', 'kling', 'runway')):
|
||||
caps.add('video_gen')
|
||||
if any(k in m for k in ('embedding', 'text-embedding', 'bge')):
|
||||
caps.add('embedding')
|
||||
if any(k in m for k in ('rerank', 'bge-rerank')):
|
||||
caps.add('rerank')
|
||||
return sorted(caps)
|
||||
|
||||
|
||||
def model_has_cap(endpoint, model, cap):
|
||||
return cap in endpoint_model_caps(endpoint, model)
|
||||
|
||||
|
||||
def worker_llm_cfg(worker):
|
||||
"""解析 Worker 的大模型接口配置(V3.5):
|
||||
优先取绑定的接口库 endpoint_id(统一鉴权/计价),worker 自带 base_url/api_key 可覆盖。
|
||||
@@ -107,17 +159,23 @@ def calc_cost(model, prompt_tokens, completion_tokens):
|
||||
return round(prompt_tokens / 1e6 * pin + completion_tokens / 1e6 * pout, 6)
|
||||
|
||||
|
||||
def calc_cost_ex(model, prompt_tokens, completion_tokens, calls=1, cfg=None):
|
||||
"""按 Worker/接口库配置计价。cfg = worker_llm_cfg() 结果。"""
|
||||
def calc_cost_ex(model, prompt_tokens, completion_tokens, calls=1, cached_tokens=0, cfg=None):
|
||||
"""按 Worker/接口库配置计价。cfg = worker_llm_cfg() 结果。
|
||||
- 按调用次数计费:price_per_call 单位为 元/千次,cost = price_per_call/1000 * calls
|
||||
- 按 token 计费:逐模型 {input, input_cache, output},缓存命中 token 按 input_cache 计价"""
|
||||
if cfg and cfg.get('endpoint'):
|
||||
ep = cfg['endpoint']
|
||||
if ep.get('billing') == 'call':
|
||||
return round(float(ep.get('price_per_call') or 0) * max(1, calls), 6)
|
||||
return round(float(ep.get('price_per_call') or 0) / 1000.0 * max(1, calls), 6)
|
||||
p = (cfg.get('pricing_map') or {}).get(model)
|
||||
if p:
|
||||
pin, pout = float(p.get('input') or 0), float(p.get('output') or 0)
|
||||
pin = float(p.get('input') or 0)
|
||||
pcache = float(p.get('input_cache') if p.get('input_cache') is not None else p.get('cache') or 0)
|
||||
pout = float(p.get('output') or 0)
|
||||
if pin or pout:
|
||||
return round(prompt_tokens / 1e6 * pin + completion_tokens / 1e6 * pout, 6)
|
||||
cached = max(0, min(cached_tokens or 0, prompt_tokens))
|
||||
return round((prompt_tokens - cached) / 1e6 * pin + cached / 1e6 * pcache
|
||||
+ completion_tokens / 1e6 * pout, 6)
|
||||
pin, pout = cfg.get('in_price') or 0, cfg.get('out_price') or 0
|
||||
if pin or pout:
|
||||
return round(prompt_tokens / 1e6 * pin + completion_tokens / 1e6 * pout, 6)
|
||||
@@ -126,10 +184,10 @@ def calc_cost_ex(model, prompt_tokens, completion_tokens, calls=1, cfg=None):
|
||||
|
||||
|
||||
def worker_unit_price(worker):
|
||||
"""自动路由用:估算 Worker 单次调用成本(按 token 计费 = 输入价+0.5*输出价;按次计费 = 单价)"""
|
||||
"""自动路由用:估算 Worker 单次调用成本(按 token 计费 = 输入价+0.5*输出价;按次计费 = 单价/千次)"""
|
||||
cfg = worker_llm_cfg(worker)
|
||||
if cfg.get('endpoint') and cfg.get('billing') == 'call':
|
||||
return float(cfg.get('price_per_call') or 0)
|
||||
return float(cfg.get('price_per_call') or 0) / 1000.0
|
||||
p = (cfg.get('pricing_map') or {}).get(cfg['model'])
|
||||
if p:
|
||||
return float(p.get('input') or 0) + float(p.get('output') or 0) * 0.5
|
||||
@@ -256,12 +314,16 @@ def _chat_stream_raw(provider, model, messages, temperature=0.7, max_tokens=None
|
||||
ch = choices[0]
|
||||
delta = ch.get('delta') or {}
|
||||
piece = delta.get('content') or ''
|
||||
if not piece:
|
||||
piece = delta.get('reasoning_content') or ''
|
||||
if piece:
|
||||
if first_token_at is None:
|
||||
first_token_at = time.time()
|
||||
yield ('delta', piece)
|
||||
else:
|
||||
rp = delta.get('reasoning_content') or ''
|
||||
if rp:
|
||||
if first_token_at is None:
|
||||
first_token_at = time.time()
|
||||
yield ('reasoning', rp)
|
||||
if ch.get('finish_reason'):
|
||||
yield ('finish', ch.get('finish_reason'))
|
||||
return
|
||||
@@ -316,6 +378,7 @@ def chat_stream(provider, model, messages, temperature=0.7, max_tokens=None,
|
||||
last_err = None
|
||||
for attempt in range(retries + 1):
|
||||
parts, usage = [], None
|
||||
thinking = []
|
||||
finish_reason = None
|
||||
first_token_at = None
|
||||
t0 = time.time()
|
||||
@@ -334,6 +397,10 @@ def chat_stream(provider, model, messages, temperature=0.7, max_tokens=None,
|
||||
on_chunk(val)
|
||||
except Exception:
|
||||
pass
|
||||
elif evt == 'reasoning':
|
||||
if first_token_at is None:
|
||||
first_token_at = time.time()
|
||||
thinking.append(val)
|
||||
elif evt == 'usage':
|
||||
usage = val
|
||||
elif evt == 'finish':
|
||||
@@ -350,6 +417,7 @@ def chat_stream(provider, model, messages, temperature=0.7, max_tokens=None,
|
||||
first_ms = int((first_token_at - t0) * 1000) if first_token_at else None
|
||||
return {
|
||||
'text': text,
|
||||
'thinking': ''.join(thinking),
|
||||
'model': model,
|
||||
'prompt_tokens': pt,
|
||||
'completion_tokens': ct,
|
||||
@@ -402,13 +470,74 @@ def chat_worker(worker, messages, temperature=None, max_tokens=None, on_chunk=No
|
||||
max_tokens=max_tokens or worker.get('max_tokens') or 2000,
|
||||
base_url=cfg['base_url'] or None, api_key=cfg['api_key'] or None,
|
||||
on_chunk=on_chunk)
|
||||
r['cost'] = calc_cost_ex(cfg['model'], r['prompt_tokens'], r['completion_tokens'], 1, cfg)
|
||||
r['cost'] = calc_cost_ex(cfg['model'], r['prompt_tokens'], r['completion_tokens'], 1,
|
||||
r.get('cached_tokens', 0), cfg)
|
||||
r['worker_id'] = worker['id']
|
||||
r['provider'] = cfg['provider']
|
||||
r['model'] = cfg['model']
|
||||
return r
|
||||
|
||||
|
||||
def stream_chat(cfg, messages, temperature=0.7, max_tokens=None, extra_payload=None):
|
||||
"""对话专用的 实时流式生成器(V3.5.2)——真正边收边吐,思考与回答分开。
|
||||
cfg: {'provider','model','base_url','api_key','endpoint'}(worker 或 接口 目标解析结果)
|
||||
yield ('reasoning', piece) | ('delta', piece) | ('done', result) | ('error', msg)
|
||||
result 含 thinking/text/usage/cost(含缓存命中价与按次计费)/first_token_ms/elapsed_ms。"""
|
||||
provider = cfg['provider']
|
||||
model = cfg['model']
|
||||
if not model:
|
||||
yield ('error', '未选择模型')
|
||||
return
|
||||
tk, fk, rk = db.get_llm_timeouts()
|
||||
parts, thinking, usage = [], [], None
|
||||
finish_reason = None
|
||||
first_token_at = None
|
||||
t0 = time.time()
|
||||
tried = False
|
||||
while True:
|
||||
try:
|
||||
for evt, val in _chat_stream_raw(
|
||||
provider, model, messages, temperature=temperature, max_tokens=max_tokens,
|
||||
base_url=cfg.get('base_url') or None, api_key=cfg.get('api_key') or None,
|
||||
token_timeout=tk, first_token_timeout=fk, extra_payload=extra_payload):
|
||||
if evt == 'reasoning':
|
||||
if first_token_at is None:
|
||||
first_token_at = time.time()
|
||||
thinking.append(val)
|
||||
yield ('reasoning', val)
|
||||
elif evt == 'delta':
|
||||
if first_token_at is None:
|
||||
first_token_at = time.time()
|
||||
parts.append(val)
|
||||
yield ('delta', val)
|
||||
elif evt == 'usage':
|
||||
usage = val
|
||||
elif evt == 'finish':
|
||||
finish_reason = val
|
||||
break
|
||||
except LLMError as e:
|
||||
if parts or tried:
|
||||
yield ('error', str(e))
|
||||
return
|
||||
tried = True # 无任何输出时允许重试一次
|
||||
continue
|
||||
text = ''.join(parts)
|
||||
pt, ct, cached = _usage_fields(usage)
|
||||
usage_estimated = usage is None
|
||||
if usage is None:
|
||||
pt, ct = _estimate_prompt_tokens(messages), _estimate_completion_tokens(text)
|
||||
elapsed_ms = int((time.time() - t0) * 1000)
|
||||
first_ms = int((first_token_at - t0) * 1000) if first_token_at else None
|
||||
cost = calc_cost_ex(model, pt, ct, 1, cached, cfg) if cfg.get('endpoint') else calc_cost(model, pt, ct)
|
||||
yield ('done', {
|
||||
'text': text, 'thinking': ''.join(thinking), 'model': model,
|
||||
'prompt_tokens': pt, 'completion_tokens': ct, 'total_tokens': pt + ct,
|
||||
'cached_tokens': cached, 'cost': cost, 'calls': 1,
|
||||
'first_token_ms': first_ms, 'elapsed_ms': elapsed_ms,
|
||||
'usage_estimated': usage_estimated, 'finish_reason': finish_reason,
|
||||
})
|
||||
|
||||
|
||||
def chat_vision(provider, model, text, image_url=None, image_path=None,
|
||||
temperature=0.4, max_tokens=2000, base_url=None, api_key=None,
|
||||
retries=3):
|
||||
|
||||
+629
-131
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -11,8 +11,10 @@
|
||||
<aside id="sidebar">
|
||||
<div class="logo">🤖 <span>AI Worker</span><small>项目管理平台</small></div>
|
||||
<nav>
|
||||
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view" class="nav-chat">💬 对话 · 仪表盘</a>
|
||||
<a href="#/chat" data-route="chat" data-perm="dashboard.view" class="nav-chat">💬 对话</a>
|
||||
<a href="#/dashboard" data-route="dashboard" data-perm="dashboard.view">📊 仪表盘</a>
|
||||
<a href="#/projects" data-route="projects" data-perm="project.view">📁 项目</a>
|
||||
<a href="#/kb" data-route="kb" data-perm="project.view">📚 知识库</a>
|
||||
<a href="#/workers" data-route="workers" data-perm="worker.view">🧑💻 AI Worker</a>
|
||||
<a href="#/agents" data-route="agents" data-perm="agent.view">🤝 多 Agent 协作</a>
|
||||
<a href="#/eval" data-route="eval" data-perm="eval.view">🎯 自动评估</a>
|
||||
|
||||
@@ -277,6 +277,7 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
|
||||
|
||||
/* ---------- V3.5 对话 ---------- */
|
||||
.chat-card{display:flex;flex-direction:column;max-height:560px;padding:14px}
|
||||
.chat-card.chat-tall{max-height:72vh;margin-top:14px}
|
||||
.chat-top{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.chat-title{font-size:16px;font-weight:700}
|
||||
.chat-sess{display:flex;gap:6px;align-items:center}
|
||||
@@ -285,6 +286,7 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
|
||||
.chat-target select{width:auto;max-width:240px}
|
||||
.chat-target-label{font-size:12px;color:var(--accent);font-weight:600}
|
||||
.chat-body{flex:1;min-height:180px;max-height:330px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:12px}
|
||||
.chat-card.chat-tall .chat-body{max-height:none}
|
||||
.chat-msg{display:flex;margin-bottom:10px}
|
||||
.chat-msg.user{justify-content:flex-end}
|
||||
.chat-msg.ai{justify-content:flex-start;flex-direction:column;align-items:flex-start}
|
||||
@@ -292,8 +294,42 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
|
||||
.chat-bubble.user{background:var(--accent);color:#fff;border-bottom-right-radius:3px}
|
||||
.chat-bubble.ai{background:var(--panel2);border:1px solid var(--border);border-bottom-left-radius:3px}
|
||||
.chat-bubble.ai.err{border-color:var(--danger)}
|
||||
.chat-cursor{display:inline-block;width:8px;height:15px;background:var(--accent);margin-left:2px;vertical-align:-2px;animation:chatblink .8s steps(1) infinite}
|
||||
@keyframes chatblink{50%{opacity:0}}
|
||||
.chat-usage{font-size:11px;color:var(--muted);margin-top:4px;font-family:ui-monospace,Consolas,monospace}
|
||||
.chat-input{display:flex;gap:10px;margin-top:10px;align-items:flex-end}
|
||||
.chat-input textarea{flex:1;resize:vertical}
|
||||
.ref-card{border-left:3px solid var(--accent)}
|
||||
.ref-card:hover{border-color:var(--accent)}
|
||||
.chat-stats{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
|
||||
.chat-stat-link{cursor:pointer;transition:border .15s, transform .1s}
|
||||
.chat-stat-link:hover{border-color:var(--accent);transform:translateY(-2px)}
|
||||
@media(max-width:1100px){.chat-stats{grid-template-columns:repeat(3,1fr)}}
|
||||
|
||||
/* V3.5.2 对话:历史列表 + 主体 */
|
||||
.chat-wrap{display:grid;grid-template-columns:250px 1fr;gap:12px;min-height:0}
|
||||
.chat-hist{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:10px;overflow-y:auto;max-height:340px}
|
||||
.chat-card.chat-tall .chat-hist{max-height:56vh}
|
||||
.chat-hist-title{font-size:12px;color:var(--muted);font-weight:600;padding:2px 4px 8px;border-bottom:1px solid var(--border);margin-bottom:8px}
|
||||
.chat-hist-item{position:relative;padding:8px 10px;border-radius:8px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
|
||||
.chat-hist-item:hover{background:var(--panel2)}
|
||||
.chat-hist-item.on{background:var(--panel2);border-color:var(--accent)}
|
||||
.chat-hist-item .t{font-size:13px;font-weight:600;padding-right:56px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-hist-item .m{font-size:11px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chat-hist-item .ops{position:absolute;top:6px;right:6px;display:none;gap:2px}
|
||||
.chat-hist-item:hover .ops{display:flex}
|
||||
.chat-hist-item .op{background:var(--panel2);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:12px;padding:2px 5px;cursor:pointer}
|
||||
.chat-hist-item .op:hover{color:var(--accent);border-color:var(--accent)}
|
||||
.chat-main{display:flex;flex-direction:column;min-width:0}
|
||||
.chat-caps{display:inline-flex;gap:4px;flex-wrap:wrap}
|
||||
.chat-attach{background:var(--panel2);border:1px solid var(--accent);border-radius:8px;padding:4px 10px;font-size:12px;align-self:center;white-space:nowrap}
|
||||
.chat-thinking{background:var(--panel2);border:1px solid var(--border);border-left:3px solid var(--purple);border-radius:8px;padding:6px 10px;margin-bottom:8px;max-width:78%}
|
||||
.chat-thinking summary{cursor:pointer;font-size:12px;color:var(--purple);font-weight:600}
|
||||
.chat-thinking-body{font-size:12px;color:var(--muted);margin-top:6px;max-height:140px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
|
||||
|
||||
/* V3.5.2 大模型接口:模型编辑器 */
|
||||
.ep-models{border:1px solid var(--border);border-radius:10px;padding:8px;max-height:300px;overflow-y:auto;margin-bottom:8px}
|
||||
.ep-mrow{display:grid;grid-template-columns:1.2fr 0.8fr 1fr 0.8fr 2.6fr;gap:6px;align-items:center;padding:4px 2px;border-bottom:1px dashed var(--border)}
|
||||
.ep-mrow.ep-head{position:sticky;top:0;background:var(--panel);font-size:12px;color:var(--muted);z-index:2}
|
||||
.ep-mrow input{font-size:12px;padding:5px 7px}
|
||||
.ep-mrow .c-caps{display:flex;flex-wrap:wrap;gap:2px}
|
||||
Reference in New Issue
Block a user