Compare commits

..
2 Commits
Author SHA1 Message Date
hz4th_coder 07f597b57e V3.5.2 真流式对话+思考折叠/历史会话/能力标签/模型库/语音/知识库/按千次计费
1. 修复假流式:对话接口边收边吐(stream_chat 生成器直驱 _chat_stream_raw);思考模型先流式思考内容(折叠)再流式回答;回答块显示 tok/s
2. 历史会话列表:重命名/置顶/删除(pinned/use_kb 列);📝Markdown 一键开关,默认 Markdown 渲染
3. 模型能力标签(chat/thinking/vision/audio_in/audio_out/image_gen/video_gen/embedding/rerank):接口库每模型勾选;
   对话按能力适配:视觉→传图、语音入→🎤录音(默认ASR转写)、语音出→🔊朗读(默认TTS)、思考→思考折叠;不匹配提前提示
4. 模型库标签:能力矩阵 + 系统默认模型(语音识别/合成/生图/生视频/embedding/rerank)
5. 计费修正:按次=元/千次;逐模型定价=模型名 输入价 缓存输入价 输出价(缓存价计缓存命中token)
6. 弹窗未保存提醒(点外部/X 时 confirm,全弹窗通用)
7. 知识库导航:#/kb 全局文档 CRUD + txt/md/pdf/docx 上传解析 + jieba BM25 检索;对话可一键注入知识库
2026-09-06 00:27:33 +08:00
hz4th_coder 5428a33a0c V3.5.1 优化:项目存为参考/修复标签切换/对话独立首页/报表按日期
1. 项目一键保存为参考:项目卡片「存为参考」按钮,复制项目+任务到从参考项目中新建;
   参考列表显示来源标记,非内置可删除;内置参考受保护(ref_builtin),种子自愈补标记
2. 修复AI Worker页 大模型接口库/团队 标签点击无反应(workersTab被pageWorkers重置的bug)
3. 对话独立首页:导航改为 对话/仪表盘 并列;首页=上部仪表盘摘要(可点击跳仪表盘)+
   中下对话主体(更高面板、流式光标、每条消息带用量)
4. 成本报表新增「按日期」维度(近7/30天/全部),按天统计调用/输入/输出/缓存/成本
2026-09-05 19:49:47 +08:00
8 changed files with 1544 additions and 222 deletions
+26 -1
View File
@@ -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天/全部 筛选 |
---
+491 -75
View File
@@ -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 部署 / 打包 / 邮件送达
# ---------------------------------------------------------------------------
+80 -4
View File
@@ -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.2chat_messages 补 thinking/imagechat_sessions 补 pinned/use_kbendpoints 补 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()
+140
View File
@@ -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/jsonpdf 用 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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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>
+36
View File
@@ -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}