V3.5.4 首页一行动态+模型合并去重+多知识库+自适应宽高

1) 首页对话: 去掉仪表盘统计卡, 只留一行最紧急动态/通知(/api/home/urgent, 30s轮询, 可✕叉掉存localStorage), 其余全是对话, 对话区flex撑满视口
2) 模型管理: 模型列表页顶部=大模型接口(添加接口按钮+接口列表从配置页移入), 下方模型按模型服务分组合并去重(同供应商同模型多接口一行显示, 编辑弹窗一并改所有接口能力/定价); 模型配置页只留系统默认模型
3) 多知识库: kb_bases表+kb_documents.kb_id, 可新建/改名/删除多个知识库, 每个库独立管理文档(上传/搜索按库限定); 对话📚旁加知识库下拉指定注入库(chat_sessions.kb_id)
4) 全站自适应: #main去max-width 1400宽度随屏; 页面高度限定视口内, 超高主区内滚动
This commit is contained in:
2026-09-06 01:31:17 +08:00
parent fac0b653bc
commit c533626b61
6 changed files with 488 additions and 102 deletions
+13 -2
View File
@@ -3,11 +3,22 @@
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
**当前版本:V3.5.3**对话底部撑满+输入固定 / 历史列表即时显示 / 模型管理独立页 / 对话附件 / Worker 简化
**当前版本:V3.5.4**首页一行实时动态 / 模型按服务合并去重 / 多知识库 / 全站自适应宽高
---
## 🚀 V3.5.3 优化(本轮)
## 🚀 V3.5.4 优化(本轮)
| 能力 | 说明 |
|---|---|
| 🏠 首页对话精简 | 首页去掉仪表盘统计卡片,只保留**一行最紧急的动态提示**(🚨 未读告警 > 待审核/执行中/超期任务),**实时刷新(30s)、可点 ✕ 手动叉掉**;其余全部为对话内容,对话区撑满视口 |
| 🧠 模型管理合并去重 | 模型列表页:**同模型服务(同供应商同模型)多接口时合并为一行显示,不再重复**;点「编辑」弹窗把该模型在**所有接口中的能力/定价一并编辑**。大模型接口的**「添加大模型接口」按钮与「接口列表」从模型配置页移入模型列表页顶部**;模型配置页改为独立的「⚙️ 系统默认模型」 |
| 📚 多知识库 | 知识库页左侧为**知识库列表**:可**新建/重命名/删除多个知识库**,每个库独立管理文档(新建文档/上传/搜索均限定在当前库);对话中开启 📚 后新增**知识库下拉选择**,可指定注入哪个库 |
| 📐 全站自适应宽高 | 去掉主区固定最大宽度 1400px,**宽度随屏幕自适应**;所有页面高度限定在视口内(页面超高时在主区内滚动,不再撑破页面),各页不超自适应高度 |
---
## 🚀 V3.5.3 优化(上一轮)
| 能力 | 说明 |
|---|---|
+115 -11
View File
@@ -1275,10 +1275,12 @@ def chat_options():
if val:
ep, m = val
dflt[cap] = {'endpoint_id': ep['id'], 'endpoint_name': ep['name'], 'model': m}
kbases = [{'id': r['id'], 'name': r['name']} for r in db.q('SELECT id, name FROM kb_bases ORDER BY id')]
return jsonify({'ok': True, 'data': {
'endpoints': eps, 'workers': workers, 'teams': teams,
'main_worker_id': db.main_worker_id(),
'defaults': dflt,
'kbases': kbases,
'cap_labels': llm_gateway.CAP_LABELS,
}})
@@ -1488,6 +1490,9 @@ def chat_session_detail(sid):
if 'use_kb' in d:
sets.append('use_kb=?')
args.append(1 if d.get('use_kb') else 0)
if 'kb_id' in d:
sets.append('kb_id=?')
args.append(int(d.get('kb_id') or 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))
@@ -1511,9 +1516,9 @@ def chat_session_detail(sid):
return jsonify({'ok': True, 'data': {'session': s, 'messages': msgs}})
def _chat_gen(sid, user_content, image=None, doc=None, use_kb=False):
def _chat_gen(sid, user_content, image=None, doc=None, use_kb=False, kb_id=0):
"""SSE 生成器(V3.5.2 真流式):边收边吐;思考模型先流式思考内容再流式回答;
支持知识库注入、图片与文档附件。事件:start/reasoning/delta/done/error"""
支持知识库注入(可指定 kb_id,默认全库)、图片与文档附件。事件: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)
@@ -1545,7 +1550,7 @@ def _chat_gen(sid, user_content, image=None, doc=None, use_kb=False):
# 组装消息(历史 + 知识库 + 本条)
history = _chat_history(sid, 24)
if use_kb:
ctx, hits = kb.build_context(user_content, 3)
ctx, hits = kb.build_context(user_content, 3, kb_id=kb_id or None)
if ctx:
history.insert(0, {'role': 'system', 'content': ctx})
yield sse({'type': 'note', 'message': f'📚 已注入知识库参考 {len(hits)}'})
@@ -1621,12 +1626,13 @@ def chat_send(sid):
image = (d.get('image') or '').strip()
doc = (d.get('doc') or '').strip()
use_kb = bool(d.get('use_kb'))
kb_id = int(d.get('kb_id') or 0)
if use_kb:
db.w('UPDATE chat_sessions SET use_kb=1 WHERE id=?', (sid,))
db.w('UPDATE chat_sessions SET use_kb=1, kb_id=?, updated_at=? WHERE id=?', (kb_id, db.now(), sid))
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, doc, use_kb), mimetype='text/event-stream',
return Response(_chat_gen(sid, content, image, doc, use_kb, kb_id), mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@@ -2059,6 +2065,39 @@ def stats():
return jsonify({'ok': True, 'data': out})
@app.route('/api/home/urgent')
@require_auth
def home_urgent():
"""首页顶部一行实时动态:最紧急的通知或任务(未读告警优先,其次待审核/执行中/超期任务)"""
# 1) 最紧急未读告警(critical > warn,最新优先)
a = db.q("SELECT * FROM alerts WHERE read=0 AND level IN ('critical','warn') "
"ORDER BY CASE level WHEN 'critical' THEN 0 ELSE 1 END, id DESC LIMIT 1", one=True)
if a:
return jsonify({'ok': True, 'data': {
'id': 'alert_%d' % a['id'], 'level': a['level'], 'type': 'alert',
'title': a['title'] or '告警', 'detail': (a.get('detail') or '')[:120],
'time': a['created_at'], 'link': '#/alerts'}})
# 2) 任务:待审核 > 执行中 > 超期待开工,均按最近更新优先
t = db.q("SELECT t.id, t.title, t.status, t.priority, t.project_id, t.deadline, t.updated_at, p.name AS pname "
"FROM tasks t LEFT JOIN projects p ON p.id=t.project_id "
"WHERE t.deleted=0 AND t.status IN ('review','running','todo') "
"ORDER BY CASE t.status WHEN 'review' THEN 0 WHEN 'running' THEN 1 ELSE 2 END, "
"CASE WHEN t.deadline IS NOT NULL AND t.deadline != '' AND t.deadline < date('now','localtime') THEN 0 ELSE 1 END, "
"t.updated_at DESC LIMIT 1", one=True)
if t:
st = {'review': '待审核', 'running': '执行中', 'todo': '待开工'}.get(t['status'], t['status'])
lv = 'critical' if t['status'] == 'review' else ('warn' if t['status'] == 'running' else 'info')
extra = ''
if t.get('deadline') and t['deadline'] < time.strftime('%Y-%m-%d'):
extra = ' · 已超期'
return jsonify({'ok': True, 'data': {
'id': 'task_%d' % t['id'], 'level': lv, 'type': 'task',
'title': f"{st}{t['title']}",
'detail': f"项目「{t['pname'] or ''}{extra}",
'time': t['updated_at'], 'link': f'#/project/{t["project_id"]}'}})
return jsonify({'ok': True, 'data': None})
def scope_args_ph(ids):
"""生成 ? 占位符串"""
return ','.join('?' * len(ids)) if ids else 'NULL'
@@ -2485,7 +2524,58 @@ def kb_search(pid):
# ---------------------------------------------------------------------------
# V3.5.2 · 全局知识库(导航「📚 知识库」:增删改查 / 上传 / 检索)
# V3.5.4 · 多知识库:可创建多个知识库,每个库独立管理文档;对话/任务可指定注入哪个库
# ---------------------------------------------------------------------------
@app.route('/api/kb/bases', methods=['GET', 'POST'])
@require_auth
def kb_bases():
err = _check_perm_point('project.view')
if err:
return err
if request.method == 'POST':
d = request.get_json(force=True)
name = (d.get('name') or '').strip()
if not name:
return jsonify({'ok': False, 'error': '知识库名称必填'}), 400
bid = db.w(
'INSERT INTO kb_bases (name, description, created_at, updated_at) VALUES (?,?,?,?)',
(name, (d.get('description') or '').strip(), db.now(), db.now()))
enterprise.audit(enterprise.current_actor(), 'kb.create_base', f'kb_base#{bid}',
f'创建知识库「{name}', request.remote_addr or '')
return jsonify({'ok': True, 'id': bid})
rows = db.q('SELECT * FROM kb_bases ORDER BY id')
for r in rows:
r['doc_count'] = db.q('SELECT COUNT(*) c FROM kb_documents WHERE kb_id=?', (r['id'],))[0]['c']
return jsonify({'ok': True, 'data': rows})
@app.route('/api/kb/bases/<int:bid>', methods=['GET', 'PUT', 'DELETE'])
@require_auth
def kb_base_detail(bid):
b = db.q('SELECT * FROM kb_bases WHERE id=?', (bid,), one=True)
if not b:
return jsonify({'ok': False, 'error': '知识库不存在'}), 404
if request.method == 'GET':
b['doc_count'] = db.q('SELECT COUNT(*) c FROM kb_documents WHERE kb_id=?', (bid,))[0]['c']
return jsonify({'ok': True, 'data': b})
if request.method == 'DELETE':
if bid == 1:
return jsonify({'ok': False, 'error': '默认知识库不可删除,可改名'}), 400
# 删除该库全部文档及分块
for r in db.q('SELECT id FROM kb_documents WHERE kb_id=?', (bid,)):
db.w('DELETE FROM kb_chunks WHERE doc_id=?', (r['id'],))
db.w('DELETE FROM kb_documents WHERE kb_id=?', (bid,))
db.w('DELETE FROM kb_bases WHERE id=?', (bid,))
return jsonify({'ok': True})
d = request.get_json(force=True)
if 'name' in d and (d.get('name') or '').strip():
db.w('UPDATE kb_bases SET name=? WHERE id=?', (d['name'].strip(), bid))
if 'description' in d:
db.w('UPDATE kb_bases SET description=? WHERE id=?', (d.get('description') or '', bid))
db.w('UPDATE kb_bases SET updated_at=? WHERE id=?', (db.now(), bid))
return jsonify({'ok': True})
@app.route('/api/kb/docs', methods=['GET', 'POST'])
@require_auth
def kb_docs():
@@ -2497,18 +2587,25 @@ def kb_docs():
title = (d.get('title') or '').strip()
if not title:
return jsonify({'ok': False, 'error': '标题必填'}), 400
kb_id = int(d.get('kb_id') or 1)
if not db.q('SELECT id FROM kb_bases WHERE id=?', (kb_id,), one=True):
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',
'INSERT INTO kb_documents (kb_id, title, content, tags, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?)',
(kb_id, 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()
kb_id = int(request.args.get('kb_id') or 0)
page = max(int(request.args.get('page', 1)), 1)
page_size = min(int(request.args.get('page_size', 20)), 100)
where, args = [], []
if kb_id:
where.append('kb_id=?')
args.append(kb_id)
if q:
where.append('(title LIKE ? OR content LIKE ?)')
args += [f'%{q}%', f'%{q}%']
@@ -2543,6 +2640,9 @@ def kb_doc_detail(did):
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))
if 'kb_id' in body and body.get('kb_id'):
if db.q('SELECT id FROM kb_bases WHERE id=?', (int(body['kb_id']),), one=True):
db.w('UPDATE kb_documents SET kb_id=? WHERE id=?', (int(body['kb_id']), did))
db.w('UPDATE kb_documents SET updated_at=? WHERE id=?', (db.now(), did))
kb.rebuild_chunks(did)
return jsonify({'ok': True})
@@ -2554,14 +2654,18 @@ 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)
kb_id = int(request.args.get('kb_id') or 0) or None
hits = kb.search(q, top_k=8, kb_id=kb_id)
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 抽取文本入库"""
"""知识库上传:txt/md/html/csv/json/pdf/docx 抽取文本入库(可指定 kb_id"""
kb_id = int(request.form.get('kb_id') or 1)
if not db.q('SELECT id FROM kb_bases WHERE id=?', (kb_id,), one=True):
return jsonify({'ok': False, 'error': '知识库不存在'}), 400
if 'files' not in request.files:
return jsonify({'ok': False, 'error': '缺少文件字段 files'}), 400
saved = []
@@ -2575,8 +2679,8 @@ def kb_upload():
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()))
'INSERT INTO kb_documents (kb_id, title, content, tags, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?)',
(kb_id, 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})
+27
View File
@@ -454,8 +454,17 @@ CREATE TABLE IF NOT EXISTS chat_messages (
created_at INTEGER
);
CREATE TABLE IF NOT EXISTS kb_bases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS kb_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kb_id INTEGER DEFAULT 1,
title TEXT NOT NULL,
content TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
@@ -666,6 +675,8 @@ def _migrate():
):
if col not in scols:
conn.execute(ddl)
if 'kb_id' not in scols:
conn.execute('ALTER TABLE chat_sessions ADD COLUMN kb_id INTEGER DEFAULT 0')
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 '{}'")
@@ -688,6 +699,22 @@ def _migrate():
)''')
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)')
# V3.5.4:多知识库 —— kb_bases 表 + kb_documents.kb_id 列;存量文档归入默认库
conn.execute('''CREATE TABLE IF NOT EXISTS kb_bases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at INTEGER,
updated_at INTEGER
)''')
kbdoc_cols = {r['name'] for r in conn.execute('PRAGMA table_info(kb_documents)')}
if 'kb_id' not in kbdoc_cols:
conn.execute('ALTER TABLE kb_documents ADD COLUMN kb_id INTEGER DEFAULT 1')
if conn.execute('SELECT COUNT(*) FROM kb_bases').fetchone()[0] == 0:
conn.execute("INSERT INTO kb_bases (name, description, created_at, updated_at) VALUES ('默认知识库','系统默认知识库,历史文档归入此库',?,?)",
(int(time.time()), int(time.time())))
if not conn.execute('SELECT COUNT(*) FROM kb_bases WHERE id=1').fetchone()[0]:
conn.execute('UPDATE kb_bases SET id=1 WHERE id=(SELECT MIN(id) FROM kb_bases)')
conn.commit()
conn.close()
+11 -6
View File
@@ -64,12 +64,17 @@ def rebuild_chunks(doc_id):
return n
def search(q, top_k=6):
"""BM25 风格检索:返回 [{doc_id,title,content,score}](按命中 token 数 + IDF 加权)"""
def search(q, top_k=6, kb_id=None):
"""BM25 风格检索:返回 [{doc_id,title,content,score}](按命中 token 数 + IDF 加权)
kb_id 指定时只在某个知识库内检索;None 则全库检索"""
q_tokens = _tok(q)
if not q_tokens:
return []
rows = db.q('SELECT * FROM kb_chunks ORDER BY doc_id, idx')
if kb_id:
rows = db.q('SELECT c.* FROM kb_chunks c JOIN kb_documents d ON d.id=c.doc_id WHERE d.kb_id=? ORDER BY c.doc_id, c.idx',
(kb_id,))
else:
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')}
@@ -96,9 +101,9 @@ def search(q, top_k=6):
return scored[:top_k]
def build_context(q, top_k=4):
"""把检索结果拼成可注入的上下文(用于对话/任务),返回 (ctx, hits)"""
hits = search(q, top_k)
def build_context(q, top_k=4, kb_id=None):
"""把检索结果拼成可注入的上下文(用于对话/任务),返回 (ctx, hits) """
hits = search(q, top_k, kb_id=kb_id)
if not hits:
return '', []
parts = []
+278 -79
View File
@@ -241,7 +241,7 @@ window.addEventListener('hashchange', router);
/* ---------- 💬 对话 V3.5.2(历史列表 / Markdown 开关 / 思考流式 / 图片 / 语音 / 知识库 / tok/s ---------- */
let chatCtx = {
options: null, sessions: [], sid: null, ttype: 'worker', tid: null, model: '',
busy: false, msgs: [], md: true, use_kb: false, caps: [], attach: null, seq: 0
busy: false, msgs: [], md: true, use_kb: false, kb_id: 0, caps: [], attach: null, seq: 0
};
function chatCaps() {
@@ -287,6 +287,9 @@ 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-kb" class="chat-kb" style="${c.use_kb ? '' : 'display:none'}" onchange="chatKbChange()" title="选择注入哪个知识库">
${(c.options?.kbases || []).map(b => `<option value="${b.id}" ${String(b.id) === String(c.kb_id || '') ? 'selected' : ''}>📚 ${esc(b.name)}</option>`).join('') || '<option value="0">(全库)</option>'}
</select>
</div>
</div>
<div class="chat-wrap">
@@ -384,6 +387,7 @@ async function chatLoadSession(id) {
chatCtx.tid = d.session.target_id;
chatCtx.model = d.session.model || '';
chatCtx.use_kb = !!d.session.use_kb;
chatCtx.kb_id = d.session.kb_id || 0;
chatCtx.caps = d.session.caps || [];
chatCtx.msgs = d.messages;
chatCtx.attach = null;
@@ -595,12 +599,28 @@ async function chatSpeak(text) {
async function chatKbToggle() {
chatCtx.use_kb = !chatCtx.use_kb;
toast(chatCtx.use_kb ? '📚 已开启知识库注入' : '已关闭知识库注入', 'ok');
if (chatCtx.sid) { try { await api(`/api/chat/sessions/${chatCtx.sid}`, {method: 'PUT', body: {use_kb: chatCtx.use_kb}}); } catch (e) {} }
if (chatCtx.use_kb && !chatCtx.kb_id && (chatCtx.options?.kbases || []).length) {
chatCtx.kb_id = chatCtx.options.kbases[0].id; // 默认第一个知识库
}
const kbSel = $('#chat-kb');
if (kbSel) {
kbSel.style.display = chatCtx.use_kb ? '' : 'none';
if (kbSel.value !== String(chatCtx.kb_id)) kbSel.value = String(chatCtx.kb_id || '');
}
toast(chatCtx.use_kb ? `📚 已开启知识库注入(${(chatCtx.options?.kbases || []).find(b => b.id === chatCtx.kb_id)?.name || '全库'}` : '已关闭知识库注入', 'ok');
if (chatCtx.sid) { try { await api(`/api/chat/sessions/${chatCtx.sid}`, {method: 'PUT', body: {use_kb: chatCtx.use_kb, kb_id: chatCtx.kb_id}}); } catch (e) {} }
const btns = $$('.chat-sess .btn');
btns.forEach(b => { if (b.textContent.includes('📚')) b.classList.toggle('primary', chatCtx.use_kb); });
}
async function chatKbChange() {
const sel = $('#chat-kb');
chatCtx.kb_id = Number(sel?.value || 0);
const name = (chatCtx.options?.kbases || []).find(b => b.id === chatCtx.kb_id)?.name || '全库';
toast(`📚 知识库已切换为「${name}`, 'ok');
if (chatCtx.sid) { try { await api(`/api/chat/sessions/${chatCtx.sid}`, {method: 'PUT', body: {kb_id: chatCtx.kb_id}}); } catch (e) {} }
}
async function chatSend() {
const input = $('#chat-input');
const content = (input.value || '').trim();
@@ -668,7 +688,7 @@ async function chatSend() {
try {
const res = await fetch(`/api/chat/sessions/${chatCtx.sid}/messages`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content, image: imageRef, doc: docRef, use_kb: chatCtx.use_kb})
body: JSON.stringify({content, image: imageRef, doc: docRef, use_kb: chatCtx.use_kb, kb_id: chatCtx.kb_id})
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const reader = res.body.getReader();
@@ -777,27 +797,49 @@ async function pageDashboard() {
</div>`;
}
/* ---------- 💬 首页(对话):上=仪表盘摘要(可点击跳转) · 中下=对话主体 ---------- */
/* ---------- 💬 首页(对话):一行最紧急动态/通知(可手动叉掉,实时刷新) + 全屏对话 ---------- */
let urgentDismissedId = '';
try { urgentDismissedId = localStorage.getItem('urgent_dismissed') || ''; } catch (e) {}
async function pageChat() {
await chatInit();
let s = {projects:0, tasks:0, workers:0, total_cost:0, total_tokens:0, total_calls:0, total_cached:0,
by_status:{}, recent:[]};
try { s = (await api('/api/stats')).data; } catch (e) {}
const total = Object.values(s.by_status || {}).reduce((a, b) => a + b, 0);
const pct = n => total ? Math.round(n / total * 100) : 0;
const stat = (lbl, num, sub) => `<div class="stat chat-stat-link" onclick="location.hash='#/dashboard'" title="点击进入仪表盘查看详情">
<div class="lbl">${lbl}</div><div class="num">${num}</div><div class="sub">${sub}</div></div>`;
$('#main').innerHTML = `
<h1 class="page-title">首页 · 对话<small>上=运营摘要(点击跳转仪表盘) · 中下=对话界面</small></h1>
<div class="chat-stats">
${stat('项目', s.projects, `完成 ${s.by_status.done ?? 0} 个任务`)}
${stat('任务总数', s.tasks, `完成率 ${pct(s.by_status.done ?? 0)}% · 待审核 ${s.by_status.review ?? 0}`)}
${stat('AI Worker', s.workers, '虚拟员工档案数')}
${stat('累计成本', fmtMoney(s.total_cost), `${(s.total_tokens/1e6).toFixed(2)}M tokens`)}
${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)}`;
<div class="home-body">
<h1 class="page-title">首页 · 对话<small>AI Worker / 大模型 / 团队 · 顶部一行实时动态可叉掉</small></h1>
<div id="urgent-banner"></div>
${chatPanelHtml(true)}
</div>`;
chatRenderHistory(); // 初始即渲染历史会话列表
loadUrgentBanner();
setInterval(loadUrgentBanner, 30000); // 实时刷新最紧急动态
}
async function loadUrgentBanner() {
const box = $('#urgent-banner');
if (!box) return;
try {
const r = await api('/api/home/urgent');
const u = r.data;
if (!u) { box.innerHTML = ''; return; }
if (urgentDismissedId === u.id) return; // 已手动叉掉
const ico = {critical: '🚨', warn: '⚠️', info: '️'}[u.level] || '🔔';
box.innerHTML = `
<div class="urgent-banner lv-${esc(u.level)}">
<span class="ub-ico">${ico}</span>
<a class="ub-text" href="${u.link}" title="${esc(u.title)} ${esc(u.detail)}">
<b>${esc(u.title)}</b><span class="ub-detail">${esc(u.detail)}</span>
</a>
<span class="ub-time">${fmtTime(u.time)}</span>
<button class="ub-x" title="不再显示" onclick="dismissUrgent('${u.id}')">✕</button>
</div>`;
} catch (e) {}
}
function dismissUrgent(id) {
urgentDismissedId = id;
try { localStorage.setItem('urgent_dismissed', id); } catch (e) {}
const box = $('#urgent-banner');
if (box) box.innerHTML = '';
}
/* ---------- 项目列表 ---------- */
@@ -2540,7 +2582,7 @@ function renderEndpoints(eps, canManage, target = '#workers-body') {
</tbody></table>`;
}
/* ---------- 🧠 模型管理(V3.5.3:模型列表 + 模型配置 两部分 ---------- */
/* ---------- 🧠 模型管理(V3.5.4:模型列表页 = 接口管理 + 模型服务合并去重的模型表;系统默认模型独立页 ---------- */
let modelsTab = 'list';
async function pageModels(args = []) {
@@ -2551,56 +2593,134 @@ async function pageModels(args = []) {
<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>
<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: '🔀重排'};
window.__modelGroups = []; // 当前页模型服务分组,供测试/编辑按序号引用
function renderModelList(eps, canManage) {
const rows = [];
// 按模型名合并(同一模型服务多接口 → 只显示一行,编辑时一并改)
const groups = new Map();
eps.forEach(e => {
(e.models || []).forEach(md => {
const caps = (e.capabilities && e.capabilities[md]) || llm_gateway_guess(md);
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 key = String(md).trim() || md;
if (!groups.has(key)) groups.set(key, {name: md, eps: []});
const g = groups.get(key);
g.eps.push(e);
});
});
window.__modelGroups = [...groups.values()];
const rows = window.__modelGroups.map(g => {
const capsSet = new Set();
let call = 0;
g.eps.forEach(e => {
const capsM = (e.capabilities && e.capabilities[g.name]) || llm_gateway_guess(g.name);
capsM.forEach(c => capsSet.add(c));
if (e.billing === 'call') call++;
});
const priceTxt = call === g.eps.length
? `按次 ¥${g.eps[0].price_per_call}/千次`
: g.eps.map(e => { const p = (e.pricing && e.pricing[g.name]) || {}; return `¥${p.input ?? e.input_price ?? 0}${p.output ?? e.output_price ?? 0}`; }).join(' · ');
return {g, caps: [...capsSet], priceTxt};
});
$('#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 class="models-eps">
<div class="sec-title" style="margin-top:0">🔌 大模型接口</div>
<div id="models-eps-box"></div>
</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>
<div class="sec-title">🧠 模型列表 <span class="pager-tip">同一模型服务(同供应商同模型)多接口时已合并为一行,编辑一并生效;共 ${rows.length} 个模型服务(来自 ${eps.length} 个接口)</span></div>
<table><thead><tr><th>模型</th><th>能力标签</th><th>计费 / 价格</th><th>来源接口</th><th>操作</th></tr></thead><tbody>
${rows.map((r, i) => `<tr>
<td class="mono"><b>${esc(r.g.name)}</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, '&quot;')})">编辑</button></td>
</tr>`).join('') || '<tr><td colspan="6" class="empty">暂无模型,先点右上角添加接口/模型</td></tr>'}
<td class="ml-src">${r.g.eps.map(e => `<span class="pill ${e.status !== 'enabled' ? 'src-off' : ''}" title="${esc(e.base_url)}">${esc(e.name)}${e.status !== 'enabled' ? ' 🚫' : ''}</span>`).join('')}</td>
<td><button class="btn sm" onclick="testGroupModel(${i})">测试</button>
<button class="btn sm" onclick="openGroupModelModal(${i})">编辑</button></td>
</tr>`).join('') || '<tr><td colspan="5" class="empty">暂无模型,先在「大模型接口」区添加接口/模型</td></tr>'}
</tbody></table>`;
renderEndpoints(eps, canManage, '#models-eps-box');
}
/* 模型配置:接口库 CRUD + 系统默认模型 */
/* 测试分组模型:优先测已启用接口 */
function testGroupModel(i) {
const g = window.__modelGroups[i];
if (!g) return;
const e = g.eps.find(x => x.status === 'enabled') || g.eps[0];
testEndpointModel(e.id, g.name);
}
/* 编辑一个模型服务:该模型出现在哪些接口里,就一并改这些接口中的能力/定价 */
function openGroupModelModal(i) {
const g = window.__modelGroups[i];
if (!g) return;
const {name} = g, eps = g.eps;
const CAPS = [['chat','💬 对话'],['thinking','🧠 思考'],['vision','👁️ 视觉'],['audio_in','🎤 语音入'],['audio_out','🔊 语音出'],['image_gen','🎨 生图'],['video_gen','🎬 生视频'],['embedding','🔢 向量'],['rerank','🔀 重排']];
openModal(`
<h3>编辑模型服务:<span class="mono">${esc(name)}</span><button class="close" data-close>×</button></h3>
<div class="tab-note">该模型服务在 ${eps.length} 个接口中提供,以下可一并编辑每个接口里的能力标签与定价(保存后全部生效)。</div>
${eps.map(e => {
const capsM = (e.capabilities && e.capabilities[name]) || [];
const initCaps = capsM.length ? capsM : llm_gateway_guess(name); // 未显式配置时按列表显示的猜测能力预勾选
const p = (e.pricing && e.pricing[name]) || {};
return `<div class="gm-ep">
<div class="gm-ep-head">${esc(e.name)} <span class="tag">${esc(e.provider)}</span>
<span class="badge ${e.status === 'enabled' ? 'st-done' : 'st-cancelled'}">${e.status === 'enabled' ? '启用' : '停用'}</span>
<span style="margin-left:auto;font-size:11px;color:var(--muted)">${e.billing === 'call' ? '按次计费' : '按 token'}</span></div>
<div class="gm-caps">${CAPS.map(([cap, lab]) => `<label class="pick-item" style="padding:3px 9px"><input type="checkbox" class="gm-cap" data-eid="${e.id}" data-cap="${cap}" ${initCaps.includes(cap) ? 'checked' : ''}> ${lab}</label>`).join('')}</div>
${e.billing === 'call'
? `<div class="tab-note">按调用次数计费:¥${e.price_per_call}/千次(改单价请编辑接口)</div>`
: `<div class="row">
<div><label>输入价(元/1M</label><input class="gm-pin" data-eid="${e.id}" type="number" step="0.01" value="${p.input ?? ''}"></div>
<div><label>缓存输入价(元/1M</label><input class="gm-pcache" data-eid="${e.id}" type="number" step="0.01" value="${p.input_cache ?? p.cache ?? ''}" placeholder="同输入价"></div>
<div><label>输出价(元/1M</label><input class="gm-pout" data-eid="${e.id}" type="number" step="0.01" value="${p.output ?? ''}"></div>
</div>`}
</div>`;
}).join('')}
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="gm-save">保存</button>
</div>`);
$('#gm-save').addEventListener('click', async () => {
try {
for (const e of eps) {
const capsM = [...$$(`.gm-cap[data-eid="${e.id}"]:checked`)].map(x => x.dataset.cap);
const caps = {...(e.capabilities || {})};
if (capsM.length) caps[name] = capsM; else delete caps[name];
const body = {capabilities: caps};
if (e.billing !== 'call') {
const pin = parseFloat($(`.gm-pin[data-eid="${e.id}"]`).value);
const pc = parseFloat($(`.gm-pcache[data-eid="${e.id}"]`).value);
const pout = parseFloat($(`.gm-pout[data-eid="${e.id}"]`).value);
if (!isNaN(pin) || !isNaN(pout)) {
const pricing = {...(e.pricing || {})};
pricing[name] = {input: isNaN(pin) ? 0 : pin, output: isNaN(pout) ? 0 : pout};
if (!isNaN(pc) && pc !== (isNaN(pin) ? 0 : pin)) pricing[name].input_cache = pc;
body.pricing = pricing;
}
}
await api(`/api/endpoints/${e.id}`, {method: 'PUT', body});
}
toast('已保存(所有接口一并更新)', 'ok'); closeModal(); router();
} catch (err) { toast(err.message, 'err'); }
});
}
/* 模型配置:系统默认模型(接口库 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>`;
// 系统默认模型
$('#models-body').innerHTML = `<div id="mcfg-defaults"></div>`;
$('#mcfg-defaults').innerHTML = `
<div class="card">
<div class="sec-title" style="margin-top:0">⚙️ 系统默认模型</div>
<div class="tab-note">配置后全局生效:如配置「语音识别」模型后,对话中即可用 🎤 语音输入转写;配置「语音合成」模型后,回答旁显示 🔊 可播放语音。</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]) => {
const cur = m.defaults[cap]?.value || '';
@@ -2612,8 +2732,6 @@ async function renderModelsConfig(eps, canManage) {
</tbody></table>
<button class="btn primary" onclick="saveModelDefaults()">保存默认模型配置</button>
</div>`;
// 接口库 CRUD
renderEndpoints(eps, canManage, '#mcfg-endpoints');
}
const MODEL_CAP_META = {
@@ -3257,49 +3375,129 @@ function openChannelModal(c, events) {
});
}
/* ---------- 📚 知识库(V3.5.2:全局文档管理 / 上传 / 检索 ---------- */
let kbPage = 1, kbQuery = '';
/* ---------- 📚 知识库(V3.5.4:多知识库 —— 可创建/管理/操作多个知识库,每个库独立文档 ---------- */
let kbPage = 1, kbQuery = '', kbCurId = 1;
try { kbCurId = parseInt(localStorage.getItem('kb_cur') || '1') || 1; } catch (e) {}
async function pageKb() {
const q = kbQuery;
const r = await api(`/api/kb/docs?page=${kbPage}&page_size=20${q ? '&q=' + encodeURIComponent(q) : ''}`);
const [basesRes, docsRes] = await Promise.all([
api('/api/kb/bases'),
api(`/api/kb/docs?kb_id=${kbCurId}&page=${kbPage}&page_size=20${q ? '&q=' + encodeURIComponent(q) : ''}`)
]);
const bases = basesRes.data;
const cur = bases.find(b => b.id === kbCurId) || bases[0] || {id: 1, name: '默认知识库', description: ''};
if (cur.id !== kbCurId) { kbCurId = cur.id; try { localStorage.setItem('kb_cur', kbCurId); } catch (e) {} }
window.__kbCurName = cur.name;
const r = docsRes;
$('#main').innerHTML = `
<h1 class="page-title">知识库<small>全局文档 · 上传解析 · 全文检索(对话中可一键注入知识库参考)</small></h1>
<div class="toolbar">
<button class="btn primary" onclick="kbCreate()"> 新建文档</button>
<button class="btn" onclick="$('#kb-upload').click()">⬆️ 上传文件(txt/md/pdf/docx…)</button>
<input type="file" id="kb-upload" multiple style="display:none" onchange="kbUpload(this)">
<input style="max-width:260px" placeholder="🔍 搜索知识库…" value="${esc(q)}" onkeydown="if(event.key==='Enter'){kbQuery=this.value.trim();kbPage=1;pageKb()}">
${q ? `<button class="btn sm" onclick="kbQuery='';kbPage=1;pageKb()">✕ 清除</button>` : ''}
</div>
<div class="card">
<table><thead><tr><th>ID</th><th>标题</th><th>标签</th><th>内容预览</th><th>分块</th><th>来源</th><th>更新时间</th><th>操作</th></tr></thead><tbody>
${r.data.map(d => `<tr>
<td class="mono">#${d.id}</td>
<td><b>${esc(d.title)}</b></td>
<td>${(d.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join('') || '—'}</td>
<td style="max-width:300px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(d.preview)}">${esc(d.preview || '(空)')}</td>
<td>${d.chunks}</td>
<td><span class="pill">${d.source === 'file' ? '📄 上传' : '✍️ 手写'}</span></td>
<td class="mono">${fmtTime(d.updated_at)}</td>
<td><button class="btn sm" onclick="kbView(${d.id})">查看</button>
<button class="btn sm" onclick="kbEdit(${d.id})">编辑</button>
<button class="btn sm danger" onclick="kbDel(${d.id})">删除</button></td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">知识库为空,新建文档或上传文件开始积累知识</td></tr>'}
</tbody></table>
${r.total > 20 ? `<div class="pager"><button class="pbtn" ${kbPage <= 1 ? 'disabled' : ''} onclick="kbPage--;pageKb()"></button>
<span class="pg-info">第 ${kbPage} 页 / 共 ${Math.ceil(r.total / 20)} 页(${r.total} 篇)</span>
<button class="pbtn" ${kbPage >= Math.ceil(r.total / 20) ? 'disabled' : ''} onclick="kbPage++;pageKb()"></button></div>` : ''}
<h1 class="page-title">知识库<small>多知识库 · 上传解析 · 全文检索(对话中可指定知识库一键注入参考)</small></h1>
<div class="kb-layout">
<div class="card kb-side">
<div class="sec-title" style="margin-top:0">知识库列表</div>
${bases.map(b => `
<div class="kb-base-item ${b.id === kbCurId ? 'on' : ''}" onclick="kbSelect(${b.id})">
<div class="t">📚 ${esc(b.name)} <span class="pill">${b.doc_count}</span></div>
<div class="d">${esc((b.description || '').slice(0, 22))}</div>
<div class="ops">
<button class="op" title="编辑" onclick="event.stopPropagation();kbBaseEdit(${b.id})">✏️</button>
<button class="op" title="删除" onclick="event.stopPropagation();kbBaseDel(${b.id})">🗑</button>
</div>
</div>`).join('') || '<div class="empty" style="padding:14px">暂无知识库</div>'}
<button class="btn primary" style="margin-top:8px" onclick="kbBaseEdit(null)"> 新建知识库</button>
</div>
<div class="kb-main">
<div class="card">
<div class="kb-head-row">
<b>📚 ${esc(cur.name)}</b>
<span style="color:var(--muted);font-size:12px">${esc(cur.description || '')}</span>
<span style="margin-left:auto"><button class="btn sm" onclick="kbCreate()"> 新建文档</button>
<button class="btn sm" onclick="$('#kb-upload').click()">⬆️ 上传文件</button></span>
</div>
<input type="file" id="kb-upload" multiple style="display:none" onchange="kbUpload(this)">
<div class="toolbar" style="margin-top:10px;margin-bottom:6px">
<input style="max-width:300px" placeholder="🔍 搜索当前知识库…" value="${esc(q)}" onkeydown="if(event.key==='Enter'){kbQuery=this.value.trim();kbPage=1;pageKb()}">
${q ? `<button class="btn sm" onclick="kbQuery='';kbPage=1;pageKb()">✕ 清除</button>` : ''}
</div>
<table><thead><tr><th>ID</th><th>标题</th><th>标签</th><th>内容预览</th><th>分块</th><th>来源</th><th>更新时间</th><th>操作</th></tr></thead><tbody>
${r.data.map(d => `<tr>
<td class="mono">#${d.id}</td>
<td><b>${esc(d.title)}</b></td>
<td>${(d.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join('') || '—'}</td>
<td style="max-width:300px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(d.preview)}">${esc(d.preview || '(空)')}</td>
<td>${d.chunks}</td>
<td><span class="pill">${d.source === 'file' ? '📄 上传' : '✍️ 手写'}</span></td>
<td class="mono">${fmtTime(d.updated_at)}</td>
<td><button class="btn sm" onclick="kbView(${d.id})">查看</button>
<button class="btn sm" onclick="kbEdit(${d.id})">编辑</button>
<button class="btn sm danger" onclick="kbDel(${d.id})">删除</button></td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">该知识库为空,新建文档或上传文件开始积累知识</td></tr>'}
</tbody></table>
${r.total > 20 ? `<div class="pager"><button class="pbtn" ${kbPage <= 1 ? 'disabled' : ''} onclick="kbPage--;pageKb()"></button>
<span class="pg-info">第 ${kbPage} 页 / 共 ${Math.ceil(r.total / 20)} 页(${r.total} 篇)</span>
<button class="pbtn" ${kbPage >= Math.ceil(r.total / 20) ? 'disabled' : ''} onclick="kbPage++;pageKb()"></button></div>` : ''}
</div>
</div>
</div>`;
}
/* 切换知识库 */
function kbSelect(id) {
kbCurId = id; kbPage = 1; kbQuery = '';
try { localStorage.setItem('kb_cur', id); } catch (e) {}
pageKb();
}
/* 新建 / 编辑知识库 */
function kbBaseEdit(bid) {
const load = bid ? api(`/api/kb/bases/${bid}`).then(r => r.data) : Promise.resolve({name: '', description: ''});
load.then(b => {
openModal(`
<h3>${bid ? '编辑知识库' : '新建知识库'}<button class="close" data-close>×</button></h3>
<label>知识库名称 *</label><input id="kbb-name" value="${esc(b.name)}" placeholder="如:产品资料库、运营手册">
<label>说明</label><input id="kbb-desc" value="${esc(b.description)}" placeholder="这个库放什么内容?">
<div class="tab-note">💡 每个知识库独立管理文档,对话中开启 📚 后可选注入哪个库的参考。</div>
<div class="modal-actions">
<button class="btn" data-close>取消</button>
<button class="btn primary" id="kbb-save">保存</button>
</div>`);
$('#kbb-save').addEventListener('click', async () => {
const name = $('#kbb-name').value.trim();
if (!name) return toast('知识库名称必填', 'err');
const body = {name, description: $('#kbb-desc').value.trim()};
try {
if (bid) await api(`/api/kb/bases/${bid}`, {method: 'PUT', body});
else {
const r = await api('/api/kb/bases', {method: 'POST', body});
kbCurId = r.id; try { localStorage.setItem('kb_cur', r.id); } catch (e) {}
}
toast('已保存', 'ok'); closeModal(); kbPage = 1; kbQuery = ''; pageKb();
} catch (e) { toast(e.message, 'err'); }
});
});
}
async function kbBaseDel(bid) {
const bases = (await api('/api/kb/bases')).data;
const b = bases.find(x => x.id === bid);
if (!b) return;
if (bid === 1) return toast('默认知识库不可删除,可改名', 'err');
if (!confirm(`删除知识库「${b.name}」?其中的 ${b.doc_count} 篇文档将一并删除。`)) return;
try {
await api(`/api/kb/bases/${bid}`, {method: 'DELETE'});
toast('已删除', 'ok');
if (kbCurId === bid) { kbCurId = (bases.find(x => x.id !== bid) || {id: 1}).id; }
kbPage = 1; kbQuery = ''; pageKb();
} catch (e) { toast(e.message, 'err'); }
}
function kbCreate() { kbEdit(null); }
function kbEdit(did) {
const load = did ? api(`/api/kb/docs/${did}`).then(r => r.data) : Promise.resolve({title: '', content: '', tags: []});
load.then(d => {
openModal(`
<h3>${did ? '编辑知识库文档' : '新建知识库文档'}<button class="close" data-close>×</button></h3>
<h3>${did ? '编辑知识库文档' : `新建文档(${esc((window.__kbCurName) || '当前知识库')}`}<button class="close" data-close>×</button></h3>
<label>标题 *</label><input id="kb-title" value="${esc(d.title)}" placeholder="文档标题">
<label>标签(逗号分隔)</label><input id="kb-tags" value="${esc((d.tags || []).join(', '))}" placeholder="产品, 技术, 运营">
<label>内容(Markdown / 纯文本均可)</label><textarea id="kb-content" rows="12" placeholder="文档内容…">${esc(d.content)}</textarea>
@@ -3313,7 +3511,7 @@ function kbEdit(did) {
const body = {title, content: $('#kb-content').value, tags: $('#kb-tags').value.split(/[,]/).map(s => s.trim()).filter(Boolean)};
try {
if (did) await api(`/api/kb/docs/${did}`, {method: 'PUT', body});
else await api('/api/kb/docs', {method: 'POST', body});
else await api('/api/kb/docs', {method: 'POST', body: {...body, kb_id: kbCurId}});
toast('已保存并重建索引', 'ok'); closeModal(); pageKb();
} catch (e) { toast(e.message, 'err'); }
});
@@ -3340,6 +3538,7 @@ async function kbUpload(input) {
if (!files.length) return;
const fd = new FormData();
files.forEach(f => fd.append('files', f));
fd.append('kb_id', kbCurId);
toast(`正在解析 ${files.length} 个文件…`);
try {
const res = await fetch('/api/kb/docs/upload', {method: 'POST', body: fd});
+44 -4
View File
@@ -5,7 +5,7 @@
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;font-size:14px}
#app{display:flex;min-height:100vh}
#app{display:flex;height:100vh;overflow:hidden}
#sidebar{width:220px;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;top:0;bottom:0}
.logo{padding:20px 18px;font-size:18px;font-weight:700;border-bottom:1px solid var(--border)}
.logo span{color:var(--accent)}
@@ -21,7 +21,7 @@ nav a.nav-chat:hover{background:var(--panel2)}
.logout{color:var(--muted);text-decoration:none;display:block;margin-top:6px}
.logout:hover{color:var(--danger)}
#main{flex:1;margin-left:220px;padding:24px 28px;max-width:1400px}
#main{flex:1;margin-left:220px;padding:24px 28px 40px;min-width:0;height:100vh;overflow-y:auto;overflow-x:auto}
h1.page-title{font-size:20px;margin-bottom:18px}
h1.page-title small{color:var(--muted);font-weight:400;font-size:13px;margin-left:8px}
.toolbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
@@ -276,6 +276,19 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
.al-go{color:var(--accent);font-size:11px;margin-left:6px;border:1px solid var(--border);border-radius:4px;padding:0 5px}
/* ---------- V3.5 对话 ---------- */
/* V3.5.4 首页自适应:标题+一行动态+对话,整体撑满视口、不超出 */
.home-body{display:flex;flex-direction:column;height:calc(100vh - 64px);min-height:0}
.home-body .page-title{margin-bottom:10px;flex:none}
.urgent-banner{display:flex;align-items:center;gap:10px;flex:none;background:var(--panel);border:1px solid var(--border);border-left:3px solid var(--warn);border-radius:10px;padding:8px 12px;margin-bottom:12px;font-size:13px;min-height:34px}
.urgent-banner.lv-critical{border-left-color:var(--danger)}
.urgent-banner.lv-info{border-left-color:var(--accent)}
.urgent-banner .ub-ico{flex:none}
.urgent-banner .ub-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;text-decoration:none;color:var(--text);display:flex;gap:8px;min-width:0}
.urgent-banner .ub-text b{color:var(--text)}
.urgent-banner .ub-text .ub-detail{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.urgent-banner .ub-time{flex:none;color:var(--muted);font-size:11px}
.urgent-banner .ub-x{flex:none;background:none;border:none;color:var(--muted);font-size:15px;cursor:pointer;padding:2px 6px;border-radius:6px}
.urgent-banner .ub-x:hover{color:var(--danger);background:var(--panel2)}
.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}
@@ -328,13 +341,14 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
.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}
/* V3.5.3 对话底部撑满:主体滚动、输入框固定底部V3.5.4 改为 flex 撑满视口自适应) */
.chat-card.chat-tall{flex:1;max-height:none;margin-top:0;min-height:0}
.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}
.chat-kb{width:auto;min-width:120px;max-width:200px}
/* V3.5.2 大模型接口:模型编辑器 */
.ep-models{border:1px solid var(--border);border-radius:10px;padding:8px;max-height:300px;overflow-y:auto;margin-bottom:8px}
@@ -342,3 +356,29 @@ code{background:var(--panel2);border:1px solid var(--border);border-radius:6px;p
.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}
/* V3.5.4 模型列表:接口区 + 按模型服务合并的模型表 */
.models-eps{margin-bottom:6px}
.ml-src{color:var(--muted);font-size:12px}
.ml-src .src-off{text-decoration:line-through;opacity:.7}
.gm-ep{border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:10px;background:var(--panel2)}
.gm-ep-head{font-size:13px;font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.gm-caps{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
/* V3.5.4 知识库:多库管理布局 */
.kb-layout{display:grid;grid-template-columns:250px 1fr;gap:14px;align-items:start}
.kb-side{display:flex;flex-direction:column;gap:6px;max-height:calc(100vh - 150px);overflow-y:auto}
.kb-side .sec-title{margin-top:0}
.kb-base-item{position:relative;padding:9px 12px;border-radius:10px;cursor:pointer;border:1px solid var(--border);background:var(--panel2)}
.kb-base-item:hover{border-color:var(--accent)}
.kb-base-item.on{border-color:var(--accent);background:rgba(79,140,255,.12)}
.kb-base-item .t{font-size:13px;font-weight:600;padding-right:58px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.kb-base-item .d{font-size:11px;color:var(--muted);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.kb-base-item .ops{position:absolute;top:6px;right:6px;display:none;gap:2px}
.kb-base-item:hover .ops{display:flex}
.kb-base-item .op{background:var(--panel);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:12px;padding:2px 5px;cursor:pointer}
.kb-base-item .op:hover{color:var(--accent);border-color:var(--accent)}
.kb-main{min-width:0}
.kb-head-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.kb-head-row b{font-size:15px}
@media(max-width:1100px){.kb-layout{grid-template-columns:1fr}}