V3.5.3 对话底部撑满+历史即时显示/模型管理独立页/对话附件/Worker精简
1. 修复首页对话历史列表初始为空(pageChat 渲染后补调 chatRenderHistory) 2. 对话区撑满浏览器底部(height calc(100vh-155px)):内容超长自动滚动、输入框固定底部; 去掉右上角会话下拉/新建/删除,左侧历史列表顶部加「+新建对话」 3. 新增侧边栏「🧠模型管理」独立页(#/models):📋模型列表(全部接口全部模型含能力/价格/测试) + ⚙️模型配置(接口CRUD+系统默认模型);AI Worker 页移除 接口库/模型库 两个标签 4. AI Worker 弹窗去掉 Base URL/API Key/自定义模型 等接口配置项,模型统一从模型管理选择 5. 对话📎附件:支持图片与可读文本(txt/md/pdf/docx/csv/json 等自动解析注入), chat_messages 加 doc 列;/api/chat/upload_doc;_chat_gen 注入文档文本并提示
This commit is contained in:
@@ -3,11 +3,23 @@
|
||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||
|
||||
**当前版本:V3.5.2**(真流式对话+思考折叠 / 历史会话管理 / 模型能力标签 / 模型库+系统默认模型 / 语音输入输出 / 知识库导航 / 按千次计费)
|
||||
**当前版本:V3.5.3**(对话底部撑满+输入固定 / 历史列表即时显示 / 模型管理独立页 / 对话附件 / Worker 简化)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 V3.5.2 优化(本轮)
|
||||
## 🚀 V3.5.3 优化(本轮)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 🕘 历史列表即时显示 | 修复首页对话左侧历史会话列表初始为空的 bug(渲染后未触发列表刷新),现在一进来就能看到全部历史会话 |
|
||||
| 📐 对话区撑满底部 | 对话主体高度自动撑到浏览器底部:内容超长自动滚动,**输入框固定在底部**;右上角的选择会话/新建/删除下拉去掉,**「+ 新建对话」移入左侧历史列表顶部** |
|
||||
| 🧠 模型管理独立页 | 新增侧边栏「🧠 模型管理」,把原 AI Worker 里的「大模型接口库」和「模型库」合并迁入,分两部分:**📋 模型列表**(全部接口的全部模型:大模型/生图/生视频/embedding 等,含能力、价格、测试)与 **⚙️ 模型配置**(接口 CRUD + 系统默认模型);AI Worker 页只保留 Worker 与团队 |
|
||||
| 🧑💻 AI Worker 精简 | Worker 弹窗去掉 Base URL / API Key / 自定义模型等接口配置项——**模型统一从模型管理中选择**,Worker 只管角色 |
|
||||
| 📎 对话附件 | 输入框新增 📎 附件按钮:**支持上传图片(视觉模型)与可读文本**(txt/md/pdf/docx/csv/json 等自动解析注入,发送前显示附件标签,消息记录保留附件引用) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 V3.5.2 优化(上一轮)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
|
||||
@@ -1227,14 +1227,19 @@ def _chat_target_cfg(session_row):
|
||||
|
||||
|
||||
def _chat_history(sid, limit=24):
|
||||
"""取会话历史(含图片消息),返回 [{role, content, image}]"""
|
||||
rows = db.q('SELECT role, content, image FROM chat_messages WHERE session_id=? AND error="" '
|
||||
"""取会话历史(含图片/文档附件),返回 [{role, content, image, doc}],doc 文本已注入 content"""
|
||||
rows = db.q('SELECT role, content, image, doc FROM chat_messages WHERE session_id=? AND error="" '
|
||||
'ORDER BY id DESC LIMIT ?', (sid, limit))
|
||||
rows.reverse()
|
||||
out = []
|
||||
for r in rows:
|
||||
c = (r['content'] or '').strip()
|
||||
img = _resolve_image(r.get('image') or '')
|
||||
doc = r.get('doc') or ''
|
||||
if doc:
|
||||
dtext, dname = _doc_text(doc)
|
||||
if dtext:
|
||||
c = (c + ('\n' if c else '') + f'\n\n【参考文档:{dname}】\n{dtext}').strip()
|
||||
if c or img:
|
||||
out.append({'role': r['role'], 'content': c[:8000], 'image': img})
|
||||
return out
|
||||
@@ -1330,6 +1335,52 @@ def chat_attachment(name):
|
||||
return send_from_directory(CHAT_UPLOAD_DIR, os.path.basename(name))
|
||||
|
||||
|
||||
def _doc_text(doc_ref):
|
||||
"""从文档附件引用读取文本内容(用于注入消息)"""
|
||||
if not doc_ref:
|
||||
return '', ''
|
||||
if doc_ref.startswith('/api/chat/attachments/'):
|
||||
p = os.path.join(CHAT_UPLOAD_DIR, os.path.basename(doc_ref))
|
||||
else:
|
||||
p = doc_ref
|
||||
if not os.path.isfile(p):
|
||||
return '', os.path.basename(doc_ref)
|
||||
try:
|
||||
with open(p, 'rb') as f:
|
||||
raw = f.read()
|
||||
ext = os.path.splitext(p)[1].lower()
|
||||
if ext == '.txt' or raw[:4] == b'%PDF':
|
||||
text, ok = kb.extract_text(os.path.basename(p), raw)
|
||||
else:
|
||||
text = raw.decode('utf-8', errors='ignore')
|
||||
return (text or '')[:60000], os.path.basename(doc_ref)
|
||||
except Exception:
|
||||
return '', os.path.basename(doc_ref)
|
||||
|
||||
|
||||
@app.route('/api/chat/upload_doc', methods=['POST'])
|
||||
@require_auth
|
||||
def chat_upload_doc():
|
||||
"""对话文本附件上传:txt/md/pdf/docx/csv/json 等可读文本,解析后供消息注入"""
|
||||
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
|
||||
raw = fs.read()
|
||||
if not raw:
|
||||
return jsonify({'ok': False, 'error': '文件内容为空'}), 400
|
||||
text, ok = kb.extract_text(fs.filename, raw)
|
||||
if not ok or not (text or '').strip():
|
||||
return jsonify({'ok': False, 'error': '无法解析该文件为可读文本(支持 txt/md/pdf/docx/csv/json/html 等)'}), 400
|
||||
ext = os.path.splitext(fs.filename)[1].lower()
|
||||
name = f'{db.now()}_{secrets.token_hex(4)}{ext}'
|
||||
with open(os.path.join(CHAT_UPLOAD_DIR, name), 'wb') as f:
|
||||
f.write(raw)
|
||||
return jsonify({'ok': True, 'url': f'/api/chat/attachments/{name}',
|
||||
'name': fs.filename, 'text_len': len(text), 'text_preview': text[:200]})
|
||||
|
||||
|
||||
@app.route('/api/chat/transcribe', methods=['POST'])
|
||||
@require_auth
|
||||
def chat_transcribe():
|
||||
@@ -1460,9 +1511,9 @@ def chat_session_detail(sid):
|
||||
return jsonify({'ok': True, 'data': {'session': s, 'messages': msgs}})
|
||||
|
||||
|
||||
def _chat_gen(sid, user_content, image=None, use_kb=False):
|
||||
def _chat_gen(sid, user_content, image=None, doc=None, use_kb=False):
|
||||
"""SSE 生成器(V3.5.2 真流式):边收边吐;思考模型先流式思考内容再流式回答;
|
||||
支持知识库注入与图片消息。事件:start/reasoning/delta/done/error"""
|
||||
支持知识库注入、图片与文档附件。事件:start/reasoning/delta/done/error"""
|
||||
def sse(obj):
|
||||
return f'data: {_json.dumps(obj, ensure_ascii=False)}\n\n'
|
||||
s = db.q('SELECT * FROM chat_sessions WHERE id=?', (sid,), one=True)
|
||||
@@ -1483,6 +1534,14 @@ def _chat_gen(sid, user_content, image=None, use_kb=False):
|
||||
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
|
||||
# 文档附件:解析文本并注入
|
||||
doc_text = ''
|
||||
doc_name = ''
|
||||
if doc:
|
||||
doc_text, doc_name = _doc_text(doc)
|
||||
if not doc_text:
|
||||
yield sse({'type': 'error', 'message': f'无法读取文档附件「{doc_name or doc}」的文本内容'}); return
|
||||
yield sse({'type': 'note', 'message': f'📄 已注入参考文档「{doc_name}」({len(doc_text)} 字)'})
|
||||
# 组装消息(历史 + 知识库 + 本条)
|
||||
history = _chat_history(sid, 24)
|
||||
if use_kb:
|
||||
@@ -1492,11 +1551,15 @@ def _chat_gen(sid, user_content, image=None, use_kb=False):
|
||||
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})
|
||||
user_msg_text = user_content
|
||||
if doc_text:
|
||||
user_msg_text = (user_msg_text + ('\n' if user_msg_text else '') +
|
||||
f'\n\n【参考文档:{doc_name}】\n{doc_text}').strip()
|
||||
if img_url:
|
||||
history.append({'role': 'user', 'content': [{'type': 'text', 'text': user_content},
|
||||
history.append({'role': 'user', 'content': [{'type': 'text', 'text': user_msg_text},
|
||||
{'type': 'image_url', 'image_url': {'url': img_url}}]})
|
||||
else:
|
||||
history.append({'role': 'user', 'content': user_content})
|
||||
history.append({'role': 'user', 'content': user_msg_text})
|
||||
chunks, thinking = [], []
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -1514,10 +1577,10 @@ def _chat_gen(sid, user_content, image=None, use_kb=False):
|
||||
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, '
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, thinking, image, doc, 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,
|
||||
'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': {
|
||||
@@ -1528,16 +1591,16 @@ def _chat_gen(sid, user_content, image=None, use_kb=False):
|
||||
'has_thinking': bool(thinking)}})
|
||||
except llm_gateway.LLMError as e:
|
||||
partial = ''.join(chunks)
|
||||
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 '',
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, thinking, image, doc, 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, thinking, image, model, worker_id, error, created_at) '
|
||||
'VALUES (?,?,?,?,?,?,?,?,?)',
|
||||
(sid, 'assistant', ''.join(chunks), ''.join(thinking), '', model or '',
|
||||
db.w('INSERT INTO chat_messages (session_id, role, content, thinking, image, doc, 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)})
|
||||
|
||||
@@ -1551,18 +1614,19 @@ def chat_send(sid):
|
||||
return jsonify({'ok': False, 'error': '会话不存在'}), 404
|
||||
d = request.get_json(force=True)
|
||||
content = (d.get('content') or '').strip()
|
||||
if not content and not d.get('image'):
|
||||
if not content and not d.get('image') and not d.get('doc'):
|
||||
return jsonify({'ok': False, 'error': '消息内容为空'}), 400
|
||||
if len(content) > 60000:
|
||||
content = content[:60000]
|
||||
image = (d.get('image') or '').strip()
|
||||
doc = (d.get('doc') 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('INSERT INTO chat_messages (session_id, role, content, image, doc, created_at) VALUES (?,?,?,?,?,?)',
|
||||
(sid, 'user', content, image, doc, db.now()))
|
||||
db.w('UPDATE chat_sessions SET updated_at=? WHERE id=?', (db.now(), sid))
|
||||
return Response(_chat_gen(sid, content, image, use_kb), mimetype='text/event-stream',
|
||||
return Response(_chat_gen(sid, content, image, doc, use_kb), mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
|
||||
|
||||
|
||||
@@ -441,6 +441,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
content TEXT DEFAULT '',
|
||||
thinking TEXT DEFAULT '', -- 思考模型的过程内容
|
||||
image TEXT DEFAULT '', -- 用户消息附带的图片(路径/数据URL)
|
||||
doc TEXT DEFAULT '', -- 用户消息附带的文档附件(路径)
|
||||
model TEXT DEFAULT '',
|
||||
worker_id INTEGER,
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
@@ -593,6 +594,8 @@ def _migrate():
|
||||
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 ''")
|
||||
if 'doc' not in mcols:
|
||||
conn.execute("ALTER TABLE chat_messages ADD COLUMN doc 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 '{}'")
|
||||
@@ -652,6 +655,7 @@ def _migrate():
|
||||
for col, ddl in (
|
||||
('thinking', "ALTER TABLE chat_messages ADD COLUMN thinking TEXT DEFAULT ''"),
|
||||
('image', "ALTER TABLE chat_messages ADD COLUMN image TEXT DEFAULT ''"),
|
||||
('doc', "ALTER TABLE chat_messages ADD COLUMN doc TEXT DEFAULT ''"),
|
||||
):
|
||||
if col not in mcols:
|
||||
conn.execute(ddl)
|
||||
|
||||
+133
-84
@@ -216,7 +216,7 @@ setInterval(refreshAlertBadge, 30000);
|
||||
/* ---------- Router ---------- */
|
||||
const routes = {
|
||||
'dashboard': pageDashboard, 'chat': pageChat, 'projects': pageProjects, 'project': pageProject,
|
||||
'workers': pageWorkers, 'reports': pageReports, 'logs': pageLogs,
|
||||
'workers': pageWorkers, 'models': pageModels, 'reports': pageReports, 'logs': pageLogs,
|
||||
'alerts': pageAlerts, 'api': pageApiTokens, 'settings': pageSettings,
|
||||
'agents': pageAgents, 'eval': pageEval, 'templates': pageTemplates,
|
||||
'enterprise': pageEnterprise, 'kb': pageKb
|
||||
@@ -230,7 +230,7 @@ function router() {
|
||||
// 离开项目页时丢弃项目缓存,避免下次进入用旧数据
|
||||
if (name !== 'project') projCtx = null;
|
||||
const fn = routes[name] || pageChat;
|
||||
const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts', chat:'chat', kb:'kb'};
|
||||
const navMap = {project:'projects', api:'api', settings:'settings', alerts:'alerts', chat:'chat', kb:'kb', models:'models'};
|
||||
$$('#sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.route === (navMap[name] || name)));
|
||||
$('#main').innerHTML = '<div class="empty"><span class="spin"></span>加载中…</div>';
|
||||
fn(parts.slice(1)).catch(e => { $('#main').innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
|
||||
@@ -279,7 +279,6 @@ function chatPanelHtml(tall = false) {
|
||||
const eps = (c.options?.endpoints || []).map(e => `<option value="${e.id}" data-caps="${esc(JSON.stringify(e.caps || {}))}">${esc(e.name)}</option>`).join('') || '<option value="">(无可用接口)</option>';
|
||||
const ws = (c.options?.workers || []).map(w => `<option value="${w.id}" ${String(w.id) === String(c.tid) ? 'selected' : ''}>${esc(w.name)}${String(w.id) === c.options?.main_worker_id ? ' ⭐主力' : ''}</option>`).join('') || '<option value="">(无 Worker)</option>';
|
||||
const ts = (c.options?.teams || []).map(t => `<option value="${t.id}">${esc(t.name)}(${t.worker_count} 人)</option>`).join('') || '<option value="">(无团队)</option>';
|
||||
const sessOpts = (c.sessions || []).map(s => `<option value="${s.id}" ${String(s.id) === String(c.sid) ? 'selected' : ''}>${s.pinned ? '📌 ' : ''}${esc(s.title || ('会话#' + s.id))}</option>`).join('');
|
||||
const caps = chatCaps();
|
||||
return `
|
||||
<div class="card chat-card${tall ? ' chat-tall' : ''}">
|
||||
@@ -288,13 +287,15 @@ function chatPanelHtml(tall = false) {
|
||||
<div class="chat-sess">
|
||||
<button class="btn sm ${c.md ? 'primary' : ''}" onclick="chatCtx.md=!chatCtx.md;chatRenderMessages()" title="Markdown / 原文 一键切换">📝 Markdown</button>
|
||||
<button class="btn sm ${c.use_kb ? 'primary' : ''}" onclick="chatKbToggle()" title="发送时自动注入知识库参考">📚 知识库</button>
|
||||
<select id="chat-session" onchange="chatSwitchSession()" title="切换历史会话"><option value="">— 新会话 —</option>${sessOpts}</select>
|
||||
<button class="btn sm" onclick="chatNewSession()" title="新建会话">+</button>
|
||||
${c.sid ? `<button class="btn sm danger" onclick="chatDelSession(${c.sid})" title="删除当前会话">🗑</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-wrap">
|
||||
<div class="chat-hist" id="chat-history"></div>
|
||||
<div class="chat-hist" id="chat-history">
|
||||
<div class="chat-hist-title">💬 历史会话
|
||||
<button class="btn sm primary" style="float:right;padding:2px 10px" onclick="chatNewSession()" title="新建对话">+ 新建对话</button>
|
||||
</div>
|
||||
<div class="chat-hist-list" id="chat-history-list"></div>
|
||||
</div>
|
||||
<div class="chat-main">
|
||||
<div class="chat-target">
|
||||
<select id="chat-ttype" onchange="chatChangeType()">
|
||||
@@ -311,10 +312,11 @@ function chatPanelHtml(tall = false) {
|
||||
</div>
|
||||
<div class="chat-body" id="chat-body"><div class="empty">👆 选择对话目标,开始输入吧(新会话需先发送第一条消息)</div></div>
|
||||
<div class="chat-input">
|
||||
${hasCap('vision') ? `<button class="btn sm" id="chat-img-btn" onclick="chatPickImage()" title="上传图片(视觉模型)">🖼️</button>
|
||||
<input type="file" id="chat-image-input" accept="image/*" style="display:none" onchange="chatImageChosen(this)">` : ''}
|
||||
<button class="btn sm" onclick="chatPickAttach()" title="上传附件(图片 / 可读文本)">📎</button>
|
||||
<input type="file" id="chat-attach-input" accept="image/*,.txt,.md,.markdown,.pdf,.docx,.csv,.json,.html,.htm,.log,.py,.js" style="display:none" onchange="chatAttachChosen(this)">
|
||||
${hasCap('vision') ? `<button class="btn sm" id="chat-img-btn" onclick="chatPickImage()" title="上传图片(视觉模型)">🖼️</button>` : ''}
|
||||
${hasCap('audio_in') ? `<button class="btn sm" id="chat-mic-btn" onclick="chatMic()" title="语音输入">🎤</button>` : ''}
|
||||
${c.attach ? `<span class="chat-attach">🖼️ ${esc(c.attach.name)} <a class="copy-btn" onclick="chatRemoveImage()" title="移除">✕</a></span>` : ''}
|
||||
${c.attach ? `<span class="chat-attach">${esc(c.attach.name)} <a class="copy-btn" onclick="chatRemoveAttach()" title="移除">✕</a></span>` : ''}
|
||||
<textarea id="chat-input" rows="2" placeholder="输入你的问题,Enter 发送 / Shift+Enter 换行" onkeydown="chatKeydown(event)"></textarea>
|
||||
<button class="btn primary" id="chat-send" onclick="chatSend()">发送 🚀</button>
|
||||
</div>
|
||||
@@ -329,10 +331,9 @@ function chatRenderTarget() {
|
||||
const caps = chatCaps();
|
||||
const capsEl = $('.chat-caps');
|
||||
if (capsEl) capsEl.innerHTML = caps.map(x => `<span class="tag">${x}</span>`).join('');
|
||||
// 按能力显示/隐藏工具按钮
|
||||
const imgBtn = $('#chat-img-btn'), imgInp = $('#chat-image-input');
|
||||
const imgBtn = $('#chat-img-btn');
|
||||
const micBtn = $('#chat-mic-btn');
|
||||
if (imgBtn && imgInp) { const on = hasCap('vision'); imgBtn.style.display = on ? '' : 'none'; imgInp.style.display = 'none'; if (!on) chatRemoveImage(); }
|
||||
if (imgBtn) imgBtn.style.display = hasCap('vision') ? '' : 'none';
|
||||
if (micBtn) micBtn.style.display = hasCap('audio_in') ? '' : 'none';
|
||||
}
|
||||
|
||||
@@ -354,11 +355,10 @@ async function chatRefreshSessions() {
|
||||
}
|
||||
|
||||
function chatRenderHistory() {
|
||||
const box = $('#chat-history');
|
||||
const box = $('#chat-history-list');
|
||||
if (!box) return;
|
||||
const c = chatCtx;
|
||||
box.innerHTML = `<div class="chat-hist-title">💬 历史会话</div>` +
|
||||
(c.sessions || []).map(s => `
|
||||
box.innerHTML = (c.sessions || []).map(s => `
|
||||
<div class="chat-hist-item ${String(s.id) === String(c.sid) ? 'on' : ''}" onclick="chatOpenSession(${s.id})">
|
||||
<div class="t">${s.pinned ? '📌 ' : ''}${esc(s.title || ('会话#' + s.id))}</div>
|
||||
<div class="m">${esc((s.last_message || '…').slice(0, 22))}</div>
|
||||
@@ -368,9 +368,6 @@ function chatRenderHistory() {
|
||||
<button class="op" title="删除" onclick="event.stopPropagation();chatDelSession(${s.id})">🗑</button>
|
||||
</div>
|
||||
</div>`).join('') || '<div class="empty" style="padding:20px 0">暂无历史会话</div>';
|
||||
const sel = $('#chat-session');
|
||||
if (sel) sel.innerHTML = '<option value="">— 新会话 —</option>' + (c.sessions || []).map(s =>
|
||||
`<option value="${s.id}" ${String(s.id) === String(c.sid) ? 'selected' : ''}>${s.pinned ? '📌 ' : ''}${esc(s.title || ('会话#' + s.id))}</option>`).join('');
|
||||
}
|
||||
|
||||
async function chatOpenSession(id) {
|
||||
@@ -493,7 +490,11 @@ function chatRenderMessages() {
|
||||
if (!c.msgs.length) { body.innerHTML = '<div class="empty">新会话已建立,开始提问吧 ✍️</div>'; return; }
|
||||
body.innerHTML = c.msgs.map((m, i) => {
|
||||
if (m.role === 'user') {
|
||||
return `<div class="chat-msg user"><div class="chat-bubble user">${m.image ? `<img src="${esc(m.image)}" style="max-width:200px;max-height:160px;border-radius:8px;margin-bottom:6px;display:block">` : ''}${esc(m.content)}</div></div>`;
|
||||
const docName = m.doc ? (m.doc.split('/').pop() || '附件') : '';
|
||||
return `<div class="chat-msg user"><div class="chat-bubble user">
|
||||
${m.image ? `<img src="${esc(m.image)}" style="max-width:200px;max-height:160px;border-radius:8px;margin-bottom:6px;display:block">` : ''}
|
||||
${m.doc ? `<div class="chat-doc">📄 ${esc(docName)}</div>` : ''}
|
||||
${esc(m.content)}</div></div>`;
|
||||
}
|
||||
if (m.error) {
|
||||
return `<div class="chat-msg ai"><div class="chat-bubble ai err"><b style="color:var(--danger)">❌ 出错了:</b>${esc(m.error)}</div>${m.content ? `<div class="chat-bubble ai">${chatMd(m.content)}</div>` : ''}</div>`;
|
||||
@@ -511,23 +512,36 @@ function chatKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); }
|
||||
}
|
||||
|
||||
/* --- 图片 / 语音 / 知识库工具 --- */
|
||||
function chatPickImage() { $('#chat-image-input')?.click(); }
|
||||
function chatRemoveImage() { chatCtx.attach = null; const inp = $('#chat-image-input'); if (inp) inp.value = ''; chatRenderTarget(); }
|
||||
async function chatImageChosen(input) {
|
||||
/* --- 附件(图片 / 可读文本)/ 语音 / 知识库工具 --- */
|
||||
function chatPickAttach() {
|
||||
const inp = $('#chat-attach-input');
|
||||
if (inp) { inp.accept = 'image/*,.txt,.md,.markdown,.pdf,.docx,.csv,.json,.html,.htm,.log,.py,.js'; inp.value = ''; inp.click(); }
|
||||
}
|
||||
function chatPickImage() {
|
||||
const inp = $('#chat-attach-input');
|
||||
if (inp) { inp.accept = 'image/*'; inp.value = ''; inp.click(); }
|
||||
}
|
||||
function chatRemoveAttach() {
|
||||
chatCtx.attach = null;
|
||||
const inp = $('#chat-attach-input'); if (inp) inp.value = '';
|
||||
chatRenderTarget();
|
||||
}
|
||||
async function chatAttachChosen(input) {
|
||||
const f = input.files && input.files[0];
|
||||
if (!f) return;
|
||||
const isImg = f.type.startsWith('image/');
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
try {
|
||||
toast('上传图片中…');
|
||||
const res = await fetch('/api/chat/upload_image', {method: 'POST', body: fd});
|
||||
toast(isImg ? '上传图片中…' : '上传并解析文档中…');
|
||||
const res = await fetch(isImg ? '/api/chat/upload_image' : '/api/chat/upload_doc', {method: 'POST', body: fd});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.ok === false) throw new Error(data.error || '上传失败');
|
||||
chatCtx.attach = {url: data.url, name: f.name};
|
||||
chatCtx.attach = {url: data.url, name: isImg ? f.name : (data.name || f.name), kind: isImg ? 'image' : 'doc',
|
||||
text_len: data.text_len};
|
||||
chatRenderTarget();
|
||||
toast('✅ 图片已就绪,可随消息发送', 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
toast(`✅ 附件已就绪(${isImg ? '图片' : '文档' + (data.text_len ? ',' + data.text_len + ' 字' : '')}),可随消息发送`, 'ok');
|
||||
} catch (e) { toast(e.message, 'err'); input.value = ''; }
|
||||
}
|
||||
|
||||
let chatRec = null;
|
||||
@@ -612,7 +626,8 @@ async function chatSend() {
|
||||
userWrap.className = 'chat-msg user';
|
||||
const userBubble = document.createElement('div');
|
||||
userBubble.className = 'chat-bubble user';
|
||||
if (chatCtx.attach) userBubble.innerHTML = `<img src="${esc(chatCtx.attach.url)}" style="max-width:200px;max-height:160px;border-radius:8px;margin-bottom:6px;display:block">`;
|
||||
if (chatCtx.attach && chatCtx.attach.kind === 'image') userBubble.innerHTML = `<img src="${esc(chatCtx.attach.url)}" style="max-width:200px;max-height:160px;border-radius:8px;margin-bottom:6px;display:block">`;
|
||||
if (chatCtx.attach && chatCtx.attach.kind === 'doc') userBubble.insertAdjacentHTML('beforeend', `<div class="chat-doc">📄 ${esc(chatCtx.attach.name)}</div>`);
|
||||
userBubble.appendChild(document.createTextNode(content));
|
||||
userWrap.appendChild(userBubble);
|
||||
// AI 占位:思考块 + 回答 + usage
|
||||
@@ -644,15 +659,16 @@ async function chatSend() {
|
||||
body.appendChild(userWrap);
|
||||
body.appendChild(msgWrap);
|
||||
body.scrollTop = body.scrollHeight;
|
||||
const imageRef = chatCtx.attach ? chatCtx.attach.url : '';
|
||||
const imageRef = chatCtx.attach && chatCtx.attach.kind === 'image' ? chatCtx.attach.url : '';
|
||||
const docRef = chatCtx.attach && chatCtx.attach.kind === 'doc' ? chatCtx.attach.url : '';
|
||||
input.value = '';
|
||||
chatRemoveImage();
|
||||
chatRemoveAttach();
|
||||
let acc = '', think = '';
|
||||
const mdOn = chatCtx.md;
|
||||
try {
|
||||
const res = await fetch(`/api/chat/sessions/${chatCtx.sid}/messages`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({content, image: imageRef, use_kb: chatCtx.use_kb})
|
||||
body: JSON.stringify({content, image: imageRef, doc: docRef, use_kb: chatCtx.use_kb})
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const reader = res.body.getReader();
|
||||
@@ -781,6 +797,7 @@ async function pageChat() {
|
||||
${stat('调用次数', s.total_calls ?? 0, `缓存命中 ${(s.total_cached ?? 0)/1e6 >= 0.001 ? (s.total_cached/1e6).toFixed(2)+'M' : (s.total_cached ?? 0)} tokens`)}
|
||||
</div>
|
||||
${chatPanelHtml(true)}`;
|
||||
chatRenderHistory(); // 初始即渲染历史会话列表
|
||||
}
|
||||
|
||||
/* ---------- 项目列表 ---------- */
|
||||
@@ -2340,14 +2357,11 @@ async function pageWorkers(args = []) {
|
||||
const tabBar = `
|
||||
<div class="tabs">
|
||||
<a class="${workersTab === 'list' ? 'active' : ''}" onclick="pageWorkers(['list'])">🧑💻 AI Worker</a>
|
||||
<a class="${workersTab === 'endpoints' ? 'active' : ''}" onclick="pageWorkers(['endpoints'])">🔌 大模型接口库</a>
|
||||
<a class="${workersTab === 'models' ? 'active' : ''}" onclick="pageWorkers(['models'])">🧠 模型库</a>
|
||||
<a class="${workersTab === 'teams' ? 'active' : ''}" onclick="pageWorkers(['teams'])">👥 团队</a>
|
||||
<a class="${workersTab === 'models' ? 'active' : ''}" href="#/models/config" style="color:var(--accent)">🧠 模型配置 → 模型管理</a>
|
||||
</div>`;
|
||||
$('#main').innerHTML = `<h1 class="page-title">AI Worker<small>模型接口 + 能力标签 + 角色提示词 + 成本上限 = 虚拟员工档案</small></h1>${tabBar}<div id="workers-body"></div>`;
|
||||
if (workersTab === 'endpoints') renderEndpoints(eps.data, canManage);
|
||||
else if (workersTab === 'teams') renderTeams(teams.data, ws.data, canManage);
|
||||
else if (workersTab === 'models') renderModels(eps.data, canManage);
|
||||
$('#main').innerHTML = `<h1 class="page-title">AI Worker<small>角色提示词 + 成本上限 + 成本上限 = 虚拟员工档案(模型统一在「🧠 模型管理」配置)</small></h1>${tabBar}<div id="workers-body"></div>`;
|
||||
if (workersTab === 'teams') renderTeams(teams.data, ws.data, canManage);
|
||||
else renderWorkersList(ws.data, ps.data, eps.data, canManage);
|
||||
}
|
||||
|
||||
@@ -2437,14 +2451,7 @@ function openWorkerModal(w, providers, eps) {
|
||||
${(curEp?.models || []).map(m => `<option value="${esc(m)}" ${m === w.model ? 'selected' : ''}>${esc(m)}</option>`).join('') || '<option value="">(该接口未配置模型列表)</option>'}
|
||||
${w.model && !(curEp?.models || []).includes(w.model) ? `<option value="${esc(w.model)}" selected>${esc(w.model)}(自定义)</option>` : ''}
|
||||
</select>
|
||||
<div class="row">
|
||||
<div><label>或自定义模型名(填了优先用这个,如 vllm 上的自定义模型)</label><input id="wm-model-custom" value="" placeholder="留空则用上面选择的模型"></div>
|
||||
</div>
|
||||
<div class="tab-note" id="wm-price-info" style="margin-top:4px">${w.id && w.endpoint_id ? '计价由接口库「' + esc(curEp?.name || '') + '」统一管理' : ''}</div>
|
||||
<div class="row">
|
||||
<div><label>自定义 Base URL(覆盖接口库,留空用接口库)</label><input id="wm-base" value="${esc(w.base_url || '')}"></div>
|
||||
<div><label>自定义 API Key(覆盖接口库,留空用接口库)</label><input id="wm-key" value="${esc(w.api_key || '')}"></div>
|
||||
</div>
|
||||
<div class="tab-note" id="wm-price-info" style="margin-top:4px">${w.id && w.endpoint_id ? '计价由接口库「' + esc(curEp?.name || '') + '」统一管理(地址/密钥/价格均在模型管理中配置)' : '💡 接口地址、密钥、能力、价格统一在「🧠 模型管理」中配置,Worker 只负责角色'}</div>
|
||||
<label>角色提示词(System Prompt)</label><textarea id="wm-prompt" placeholder="你是资深文案专家,擅长小红书种草文案…">${esc(w.system_prompt || '')}</textarea>
|
||||
<label>角色描述</label><input id="wm-desc" value="${esc(w.description || '')}" placeholder="一句话描述这个虚拟员工的职责">
|
||||
<div class="row">
|
||||
@@ -2484,14 +2491,11 @@ function openWorkerModal(w, providers, eps) {
|
||||
};
|
||||
$('#wm-save').addEventListener('click', async () => {
|
||||
const endpoint_id = Number($('#wm-endpoint').value || 0);
|
||||
const customModel = $('#wm-model-custom')?.value.trim() || '';
|
||||
const body = {
|
||||
name: $('#wm-name').value.trim(),
|
||||
endpoint_id: endpoint_id || null,
|
||||
provider: endpoint_id ? (eps.find(e => e.id === endpoint_id)?.provider || 'custom') : (w.provider || 'custom'),
|
||||
model: customModel || $('#wm-model').value.trim(),
|
||||
base_url: $('#wm-base').value.trim(),
|
||||
api_key: $('#wm-key').value.trim(),
|
||||
model: $('#wm-model').value.trim(),
|
||||
system_prompt: $('#wm-prompt').value,
|
||||
description: $('#wm-desc').value.trim(),
|
||||
temperature: parseFloat($('#wm-temp').value || 0.7),
|
||||
@@ -2510,14 +2514,14 @@ function openWorkerModal(w, providers, eps) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 🔌 大模型接口库(V3.5) ---------- */
|
||||
function renderEndpoints(eps, canManage) {
|
||||
$('#workers-body').innerHTML = `
|
||||
/* ---------- 🔌 大模型接口库(V3.5:接口 CRUD,模型管理中展示) ---------- */
|
||||
function renderEndpoints(eps, canManage, target = '#workers-body') {
|
||||
$(target).innerHTML = `
|
||||
${canManage ? `<div class="toolbar"><button class="btn primary" onclick="openEndpointModal()">+ 添加大模型接口</button>
|
||||
<span class="pager-tip">专门配置大模型接口(地址/密钥/模型/定价),创建 AI Worker 时直接选用;支持按 token 或按调用次数计费</span></div>` : ''}
|
||||
<span class="pager-tip">专门配置大模型接口(地址/密钥/模型/能力/定价),创建 AI Worker 时直接选用;支持按 token 或按调用次数计费</span></div>` : ''}
|
||||
<table><thead><tr><th>ID</th><th>名称</th><th>提供商</th><th>Base URL</th><th>模型</th><th>计费方式</th><th>价格</th><th>状态</th><th>关联 Worker</th><th>操作</th></tr></thead><tbody>
|
||||
${eps.map(e => {
|
||||
const billingTxt = e.billing === 'call' ? `按次 ¥${e.price_per_call}/次` : '按 token';
|
||||
const billingTxt = e.billing === 'call' ? `按次 ¥${e.price_per_call}/千次` : '按 token';
|
||||
return `<tr>
|
||||
<td class="mono">#${e.id}</td>
|
||||
<td><b>${esc(e.name)}</b><br><span style="color:var(--muted);font-size:12px">${esc(e.description || '')}</span></td>
|
||||
@@ -2536,35 +2540,66 @@ function renderEndpoints(eps, canManage) {
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
/* ---------- 🧠 模型库(V3.5.2:全能力模型矩阵 + 系统默认模型) ---------- */
|
||||
const MODEL_CAP_META = {
|
||||
'chat': {label: '💬 对话', desc: '默认对话大模型(AI Worker 对话用)'},
|
||||
'asr': {label: '🎤 语音识别', desc: '识别语音输入内容(对话中的语音输入按钮)'},
|
||||
'tts': {label: '🔊 语音合成', desc: '支持回答的语音生成(对话中的 🔊 播放)'},
|
||||
'image': {label: '🎨 图片生成', desc: '文生图'},
|
||||
'video': {label: '🎬 视频生成', desc: '文生视频'},
|
||||
'embedding': {label: '🔢 Embedding', desc: '向量化'},
|
||||
'rerank': {label: '🔀 Rerank', desc: '重排'},
|
||||
};
|
||||
let _modelDefaults = null;
|
||||
/* ---------- 🧠 模型管理(V3.5.3:模型列表 + 模型配置 两部分) ---------- */
|
||||
let modelsTab = 'list';
|
||||
|
||||
async function renderModels(eps, canManage) {
|
||||
let m;
|
||||
try { m = (await api('/api/models/defaults')).data; } catch (e) { m = {defaults: {}, endpoints: eps}; }
|
||||
_modelDefaults = m;
|
||||
const CAPS = [['chat','💬'],['thinking','🧠'],['vision','👁️'],['audio_in','🎤'],['audio_out','🔊'],['image_gen','🎨'],['video_gen','🎬'],['embedding','🔢'],['rerank','🔀']];
|
||||
// 能力 → 模型 矩阵
|
||||
async function pageModels(args = []) {
|
||||
modelsTab = args[0] || 'list';
|
||||
const eps = await api('/api/endpoints');
|
||||
const canManage = CURRENT_ROLE === 'admin';
|
||||
$('#main').innerHTML = `
|
||||
<h1 class="page-title">模型管理<small>大模型 / 生成 / Embedding 等全类型模型 · 接口配置 · 系统默认模型</small></h1>
|
||||
<div class="tabs">
|
||||
<a class="${modelsTab === 'list' ? 'active' : ''}" href="#/models">📋 模型列表</a>
|
||||
<a class="${modelsTab === 'config' ? 'active' : ''}" href="#/models/config">⚙️ 模型配置</a>
|
||||
</div>
|
||||
<div id="models-body"></div>`;
|
||||
if (modelsTab === 'config') renderModelsConfig(eps.data, canManage);
|
||||
else renderModelList(eps.data, canManage);
|
||||
}
|
||||
|
||||
/* 模型列表:所有接口下的全部模型(含大模型、生成、embedding 等) */
|
||||
const CAP_LABEL_JS = {chat: '💬对话', thinking: '🧠思考', vision: '👁️视觉', audio_in: '🎤语音入', audio_out: '🔊语音出', image_gen: '🎨生图', video_gen: '🎬生视频', embedding: '🔢向量', rerank: '🔀重排'};
|
||||
function renderModelList(eps, canManage) {
|
||||
const rows = [];
|
||||
eps.forEach(e => {
|
||||
(e.models || []).forEach(md => {
|
||||
const caps = (e.capabilities && e.capabilities[md]) || llm_gateway_guess(md);
|
||||
rows.push({endpoint: e, model: md, caps});
|
||||
const p = (e.pricing && e.pricing[md]) || {};
|
||||
const priceTxt = e.billing === 'call'
|
||||
? `按次 ¥${e.price_per_call}/千次`
|
||||
: `输入 ¥${p.input ?? e.input_price ?? 0} · 缓存 ${p.input_cache ?? '同输入'} · 输出 ¥${p.output ?? e.output_price ?? 0}(/1M)`;
|
||||
rows.push({e, md, caps, priceTxt});
|
||||
});
|
||||
});
|
||||
const optOf = val => val ? `<option value="${esc(val)}" selected>${esc(val.split(':')[1])}(#${val.split(':')[0]})</option>` : '';
|
||||
$('#workers-body').innerHTML = `
|
||||
$('#models-body').innerHTML = `
|
||||
<div class="toolbar">
|
||||
<span class="pager-tip">共 ${rows.length} 个模型(来自 ${eps.length} 个接口)· 能力/价格/密钥均在此管理,AI Worker 只负责角色定义</span>
|
||||
${canManage ? `<button class="btn primary" onclick="openEndpointModal()">+ 添加接口/模型</button>` : ''}
|
||||
</div>
|
||||
<table><thead><tr><th>接口</th><th>模型</th><th>能力标签</th><th>计费 / 价格</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
${rows.map(r => `<tr>
|
||||
<td>${esc(r.e.name)} <span class="tag">${esc(r.e.provider)}</span></td>
|
||||
<td class="mono"><b>${esc(r.md)}</b></td>
|
||||
<td>${r.caps.map(c => `<span class="tag">${CAP_LABEL_JS[c] || c}</span>`).join('') || '—'}</td>
|
||||
<td style="font-size:12px;color:var(--muted)">${r.priceTxt}</td>
|
||||
<td><span class="badge ${r.e.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${r.e.status === 'enabled' ? '启用' : '停用'}</span></td>
|
||||
<td><button class="btn sm" onclick="testEndpointModel(${r.e.id}, '${esc(r.md)}')">测试</button>
|
||||
<button class="btn sm" onclick="openEndpointModal(${JSON.stringify(r.e).replace(/"/g, '"')})">编辑</button></td>
|
||||
</tr>`).join('') || '<tr><td colspan="6" class="empty">暂无模型,先点右上角添加接口/模型</td></tr>'}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
/* 模型配置:接口库 CRUD + 系统默认模型 */
|
||||
async function renderModelsConfig(eps, canManage) {
|
||||
let m;
|
||||
try { m = (await api('/api/models/defaults')).data; } catch (e) { m = {defaults: {}, endpoints: eps}; }
|
||||
_modelDefaults = m;
|
||||
$('#models-body').innerHTML = `<div id="mcfg-defaults"></div><div id="mcfg-endpoints"></div>`;
|
||||
// 系统默认模型
|
||||
$('#mcfg-defaults').innerHTML = `
|
||||
<div class="card">
|
||||
<div class="sec-title" style="margin-top:0">⚙️ 系统默认模型(V3.5.2)</div>
|
||||
<div class="sec-title" style="margin-top:0">⚙️ 系统默认模型</div>
|
||||
<div class="tab-note">配置后全局生效:如配置「语音识别」模型后,对话中即可用 🎤 语音输入转写;配置「语音合成」模型后,回答旁显示 🔊 可播放语音。</div>
|
||||
<table><thead><tr><th>能力</th><th>说明</th><th>默认模型</th></tr></thead><tbody>
|
||||
${Object.entries(MODEL_CAP_META).map(([cap, meta]) => {
|
||||
@@ -2576,16 +2611,22 @@ async function renderModels(eps, canManage) {
|
||||
}).join('')}
|
||||
</tbody></table>
|
||||
<button class="btn primary" onclick="saveModelDefaults()">保存默认模型配置</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:14px">
|
||||
<div class="sec-title" style="margin-top:0">模型能力矩阵(在「🔌 大模型接口库」编辑接口时可勾选能力)</div>
|
||||
<table><thead><tr><th>接口</th><th>模型</th>${CAPS.map(([, lab]) => `<th>${lab}</th>`).join('')}</tr></thead><tbody>
|
||||
${rows.map(r => `<tr><td>${esc(r.endpoint.name)}</td><td class="mono">${esc(r.model)}</td>
|
||||
${CAPS.map(([cap]) => `<td style="text-align:center">${r.caps.includes(cap) ? '✅' : '—'}</td>`).join('')}</tr>`).join('') || '<tr><td colspan="11" class="empty">暂无模型,先在大模型接口库配置</td></tr>'}
|
||||
</tbody></table>
|
||||
</div>`;
|
||||
// 接口库 CRUD
|
||||
renderEndpoints(eps, canManage, '#mcfg-endpoints');
|
||||
}
|
||||
|
||||
const MODEL_CAP_META = {
|
||||
'chat': {label: '💬 对话', desc: '默认对话大模型(AI Worker 对话用)'},
|
||||
'asr': {label: '🎤 语音识别', desc: '识别语音输入内容(对话中的语音输入按钮)'},
|
||||
'tts': {label: '🔊 语音合成', desc: '支持回答的语音生成(对话中的 🔊 播放)'},
|
||||
'image': {label: '🎨 图片生成', desc: '文生图'},
|
||||
'video': {label: '🎬 视频生成', desc: '文生视频'},
|
||||
'embedding': {label: '🔢 Embedding', desc: '向量化'},
|
||||
'rerank': {label: '🔀 Rerank', desc: '重排'},
|
||||
};
|
||||
let _modelDefaults = null;
|
||||
|
||||
function llm_gateway_guess(model) {
|
||||
const m = (model || '').toLowerCase();
|
||||
const caps = ['chat'];
|
||||
@@ -2599,6 +2640,14 @@ function llm_gateway_guess(model) {
|
||||
return caps;
|
||||
}
|
||||
|
||||
async function testEndpointModel(eid, model) {
|
||||
toast('正在测试连通性…');
|
||||
try {
|
||||
const r = await api(`/api/endpoints/${eid}/test`, {method: 'POST', body: {model}});
|
||||
toast(`✅ 连通正常:${r.data.latency_ms}ms(首字 ${r.data.first_token_ms ?? '—'}ms),回复「${r.data.reply}」,成本 ¥${r.data.cost}`, 'ok');
|
||||
} catch (e) { toast('❌ ' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function saveModelDefaults() {
|
||||
const body = {};
|
||||
$$('.md-default').forEach(sel => { body[sel.dataset.cap] = sel.value; });
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<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="#/models" data-route="models" data-perm="worker.view">🧠 模型管理</a>
|
||||
<a href="#/agents" data-route="agents" data-perm="agent.view">🤝 多 Agent 协作</a>
|
||||
<a href="#/eval" data-route="eval" data-perm="eval.view">🎯 自动评估</a>
|
||||
<a href="#/templates" data-route="templates" data-perm="template.view">🧩 模板市场</a>
|
||||
|
||||
+13
-4
@@ -308,9 +308,9 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
|
||||
|
||||
/* 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{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:10px;overflow:hidden;display:flex;flex-direction:column;max-height:340px}
|
||||
.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;flex:none}
|
||||
.chat-hist-list{overflow-y:auto;flex:1;min-height:0}
|
||||
.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)}
|
||||
@@ -320,13 +320,22 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
|
||||
.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-main{display:flex;flex-direction:column;min-width:0;min-height: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-doc{display:inline-block;background:var(--panel2);border:1px solid var(--accent);border-radius:6px;padding:2px 8px;font-size:12px;margin-bottom:4px;color:var(--accent)}
|
||||
.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.3 对话底部撑满:主体滚动、输入框固定底部 */
|
||||
.chat-card.chat-tall{height:calc(100vh - 155px);max-height:none;margin-top:14px}
|
||||
.chat-card.chat-tall .chat-wrap{flex:1;min-height:0}
|
||||
.chat-card.chat-tall .chat-hist{max-height:none;height:100%}
|
||||
.chat-card.chat-tall .chat-main{flex:1}
|
||||
.chat-card.chat-tall .chat-body{flex:1;min-height:0;max-height:none;overflow-y:auto}
|
||||
.chat-input{flex:none}
|
||||
|
||||
/* 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)}
|
||||
|
||||
Reference in New Issue
Block a user