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:
@@ -3,11 +3,22 @@
|
||||
> 以「项目」为中心、以「AI Worker」为执行单元的项目管理平台。
|
||||
> 把大模型团队变成一支可指挥、可审计、可控成本的"虚拟团队"。
|
||||
|
||||
**当前版本:V3.5.3**(对话底部撑满+输入固定 / 历史列表即时显示 / 模型管理独立页 / 对话附件 / Worker 简化)
|
||||
**当前版本:V3.5.4**(首页一行实时动态 / 模型按服务合并去重 / 多知识库 / 全站自适应宽高)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 V3.5.3 优化(本轮)
|
||||
## 🚀 V3.5.4 优化(本轮)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| 🏠 首页对话精简 | 首页去掉仪表盘统计卡片,只保留**一行最紧急的动态提示**(🚨 未读告警 > 待审核/执行中/超期任务),**实时刷新(30s)、可点 ✕ 手动叉掉**;其余全部为对话内容,对话区撑满视口 |
|
||||
| 🧠 模型管理合并去重 | 模型列表页:**同模型服务(同供应商同模型)多接口时合并为一行显示,不再重复**;点「编辑」弹窗把该模型在**所有接口中的能力/定价一并编辑**。大模型接口的**「添加大模型接口」按钮与「接口列表」从模型配置页移入模型列表页顶部**;模型配置页改为独立的「⚙️ 系统默认模型」 |
|
||||
| 📚 多知识库 | 知识库页左侧为**知识库列表**:可**新建/重命名/删除多个知识库**,每个库独立管理文档(新建文档/上传/搜索均限定在当前库);对话中开启 📚 后新增**知识库下拉选择**,可指定注入哪个库 |
|
||||
| 📐 全站自适应宽高 | 去掉主区固定最大宽度 1400px,**宽度随屏幕自适应**;所有页面高度限定在视口内(页面超高时在主区内滚动,不再撑破页面),各页不超自适应高度 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 V3.5.3 优化(上一轮)
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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, '"')})">编辑</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
@@ -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}}
|
||||
Reference in New Issue
Block a user