diff --git a/static/admin/agent.js b/static/admin/agent.js index 2a64793..8790483 100644 --- a/static/admin/agent.js +++ b/static/admin/agent.js @@ -19,7 +19,9 @@ function initAgentChat(){ loadLiveDevices(); loadAgentTargetDevices(); startRunPoll(); - restoreAgentView(); + renderConvList(); // 会话列表(决定当前会话) + loadCurrentConvMessages(); // 渲染当前会话历史 + restoreRunningFlow(); // 若任务运行中:重订阅事件流 // 输入框快捷键 const inp = document.getElementById('agent-input'); inp.addEventListener('keydown', ev=>{ @@ -30,39 +32,22 @@ function initAgentChat(){ }); } -// ================== 页面刷新/重进恢复 ================== -// 刷新后聊天区是空的,但后端任务可能还在跑、历史还在——从这里恢复: -// 1) 渲染会话历史(user/assistant 文本轮) -// 2) 任务运行中 → 重新订阅事件流(服务端队列保留积压,重连后补发) -function restoreAgentView(){ - const chat = document.getElementById('agent-chat'); - if(!chat) return; - const hasContent = chat.children.length > 0 && !chat.querySelector('.agent-empty'); - if(hasContent) return; // 页面未刷新(切 Tab 回来 DOM 还在),无需恢复 +// ================== 页面刷新/重进恢复(运行中任务续流) ================== +// 历史轮次由 loadCurrentConvMessages 从会话渲染;这里只处理「任务仍在后台跑」: +// 重新订阅事件流(服务端队列保留积压,重连后补发 delta/step/done)。 +function restoreRunningFlow(){ apiGet('/api/agent/run').then(r=>{ if(!r||!r.ok) return; - const hist = r.history || []; - hist.forEach(h=>{ - const txt = (h.content || '').trim(); - if(!txt) return; - if(h.role === 'user'){ addUserMsg(txt); } - else{ - const div = newAssistantMsg(); - div.querySelector('.agent-text').textContent = txt; - } - }); if(r.state === 'running' && r.run_id){ - const div = newAssistantMsg(); - div.querySelector('.agent-text').textContent = '↻ 已恢复连接,任务仍在后台运行(此前的内容可在上方查看)…'; setRunning(true); listenStream(r.run_id); - }else if(r.state === 'error' && r.error){ - const div = newAssistantMsg(); - div.querySelector('.agent-text').textContent = '⚠ ' + r.error; - }else if(!hist.length && r.state === 'idle'){ - clearEmpty(); - document.getElementById('agent-chat').innerHTML = - '
👋 给 AI 下达指令…(先选 🎯 目标设备)
'; + }else if(r.state === 'error' && r.error && _currentConvId){ + // 错误提示仅当本会话相关时展示 + const chat = document.getElementById('agent-chat'); + if(chat && !chat.children.length){ + const div = newAssistantMsg(); + div.querySelector('.agent-text').textContent = '⚠ ' + r.error; + } } }); } @@ -200,18 +185,95 @@ function bindChatScroll(){ }); } function clearAgentChat(){ - document.getElementById('agent-chat').innerHTML = - '
对话已清空。给 AI 下达新指令…
'; + document.getElementById('agent-chat').innerHTML = ''; } -// 新建会话:清后端历史 + 清前端对话区(像 DeepSeek 的新建会话) -function newAgentSession(){ + +// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ================== +let _currentConvId = localStorage.getItem('agent_conv_id') || ''; +function saveConvId(id){ + _currentConvId = id || ''; + if(id) localStorage.setItem('agent_conv_id', id); + else localStorage.removeItem('agent_conv_id'); +} +function createAgentSession(){ if(_agentBusy){showToast('运行中不能新建会话','error');return;} - if(!confirm('新建会话将清空当前对话历史,确定?'))return; - apiPost('/api/agent/clear',{}).then(r=>{ + apiPost('/api/agent/conversations',{}).then(r=>{ if(r&&r.ok){ + saveConvId(r.id); clearAgentChat(); showToast('已新建会话','success'); - }else showToast('操作失败','error'); + renderConvList(); + }else showToast('新建失败: '+((r&&r.error)||''),'error'); + }); +} +function selectAgentSession(id){ + if(_agentBusy){showToast('运行中不能切换会话','error');return;} + if(id === _currentConvId) return; + saveConvId(id); + clearAgentChat(); + loadCurrentConvMessages(); + renderConvList(); +} +function deleteAgentSession(id){ + if(!confirm('删除该会话?历史消息将不可恢复。')) return; + apiDelete('/api/agent/conversations/'+id).then(r=>{ + if(r&&r.ok){ + if(_currentConvId === id) saveConvId(''); + showToast('会话已删除','success'); + renderConvList(); + loadCurrentConvMessages(); + }else showToast('删除失败: '+((r&&r.error)||''),'error'); + }); +} +function renderConvList(){ + apiGet('/api/agent/conversations').then(r=>{ + if(!r||!r.ok)return; + const list = document.getElementById('agent-sess-list'); + if(!list)return; + const convs = r.conversations || []; + let cur = _currentConvId; + if(!convs.some(c=>c.id===cur)) cur = convs.length ? convs[0].id : ''; + if(cur !== _currentConvId){ saveConvId(cur); clearAgentChat(); loadCurrentConvMessages(); } + if(!convs.length){ + list.innerHTML = '
' + + '暂无历史会话

点「+ 新建会话」开始
'; + return; + } + list.innerHTML = convs.map(c=>{ + const t = c.title || '新会话'; + return '
' + +'' + +'
'+esc(t)+'
' + +'
'+esc(c.updated_at||'')+(c.count ? ' · '+c.count+' 轮' : '')+'
'; + }).join(''); + }); +} +function loadCurrentConvMessages(){ + const chat = document.getElementById('agent-chat'); + if(!chat) return; + if(!_currentConvId){ + chat.innerHTML = '
👋 点左侧「+ 新建会话」开始,' + + '给 AI 下达指令(先选 🎯 目标设备)。
'; + return; + } + apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{ + if(!r||!r.ok)return; + chat.innerHTML = ''; + (r.messages||[]).forEach(m=>{ + const txt = (m.content||'').trim(); + if(!txt) return; + if(m.role==='user'){ addUserMsg(txt); } + else{ + const div = newAssistantMsg(); + div.querySelector('.agent-text').textContent = txt; + } + }); + if(!chat.children.length){ + chat.innerHTML = '
新会话。给 AI 下达指令' + + '(先选 🎯 目标设备)…
'; + } }); } @@ -244,17 +306,36 @@ function sendAgentMsg(){ inp.value = ''; addUserMsg(text); - apiPost('/api/agent/run', {prompt: text, serial: serial}).then(r=>{ - if(!r||!r.ok){ - const msg = r ? (r.error||'启动失败') : '请求失败'; - const div = newAssistantMsg(); - div.querySelector('.agent-text').textContent = '⚠ ' + msg; - showToast(msg,'error'); - return; - } - setRunning(true); - listenStream(r.run_id); - }); + // 会话:无当前会话时自动新建(DeepSeek 式:消息总归属于一个会话) + const doRun = (convId)=>{ + apiPost('/api/agent/run', {prompt: text, serial: serial, + conversation_id: convId}).then(r=>{ + if(!r||!r.ok){ + const msg = r ? (r.error||'启动失败') : '请求失败'; + const div = newAssistantMsg(); + div.querySelector('.agent-text').textContent = '⚠ ' + msg; + showToast(msg,'error'); + return; + } + setRunning(true); + listenStream(r.run_id); + }); + }; + if(_currentConvId){ + doRun(_currentConvId); + }else{ + apiPost('/api/agent/conversations',{}).then(r=>{ + if(r&&r.ok){ + saveConvId(r.id); + renderConvList(); + doRun(r.id); + }else{ + const div = newAssistantMsg(); + div.querySelector('.agent-text').textContent = '⚠ 新建会话失败'; + showToast('新建会话失败','error'); + } + }); + } } function listenStream(runId){ @@ -309,6 +390,7 @@ function listenStream(runId){ msgEl.querySelector('.agent-text').textContent = d.answer; } endRun(); + renderConvList(); // 落库已完成:刷新标题/时间/轮数 }); es.addEventListener('error', ev=>{ diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 31e37a8..7350eb2 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -306,6 +306,17 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 /* ===== AI 控制台(聊天界面,浅色内容区——DeepSeek 风格,图片文字清晰) ===== */ .agent-body{display:flex;flex:1;min-height:0} +.agent-col-sessions{width:236px;border-right:1px solid #e5e7eb;background:#f6f7f9;display:flex;flex-direction:column;min-width:0} +.agent-sess-head{padding:10px;border-bottom:1px solid #e5e7eb} +.agent-sess-list{flex:1;overflow-y:auto;padding:6px} +.agent-sess-item{padding:8px 10px;border-radius:9px;cursor:pointer;margin-bottom:2px;border:1px solid transparent;position:relative} +.agent-sess-item:hover{background:#eceff3} +.agent-sess-item.active{background:#e0f2fe;border-color:#bae6fd} +.agent-sess-item .t{font-size:12.5px;color:#1f2937;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:20px} +.agent-sess-item .m{font-size:10.5px;color:#9ca3af;margin-top:2px} +.agent-sess-del{position:absolute;right:6px;top:6px;visibility:hidden;background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:12px;padding:2px 5px;border-radius:5px} +.agent-sess-item:hover .agent-sess-del{visibility:visible} +.agent-sess-del:hover{background:#fecaca;color:#b91c1c} .agent-col-chat{flex:1;display:flex;flex-direction:column;min-width:0} .agent-col-live{width:300px;border-left:1px solid #e5e7eb;background:#fff;display:flex;flex-direction:column;padding:10px} .agent-live-head{font-size:12.5px;font-weight:600;color:#374151;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} @@ -467,12 +478,17 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
🤖 AI 控制台
-
+
+
+ +
+
+
diff --git a/web/agent_api.py b/web/agent_api.py index 7e80371..be0449c 100644 --- a/web/agent_api.py +++ b/web/agent_api.py @@ -460,39 +460,156 @@ def agent_experience_keep(): db.session.commit() return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已保留"}) -@bp.route("/api/agent/config", methods=["GET"]) -@admin_required -def agent_config_get(): - """读 Agent 配置(key 打码返回)。""" - cfg = _read_cfg() - if cfg["api_key"]: - k = cfg["api_key"] - cfg["api_key_masked"] = k[:6] + "***" + k[-4:] - return jsonify({"ok": True, **cfg}) +# ================== 历史会话(DeepSeek 式:多会话持久化) ================== +# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。 +# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。 +_CONV_TABLE = """ +CREATE TABLE IF NOT EXISTS agent_conversation ( + id VARCHAR(20) PRIMARY KEY, + title VARCHAR(100) DEFAULT '', + messages TEXT DEFAULT '[]', + created_at VARCHAR(20) DEFAULT '', + updated_at VARCHAR(20) DEFAULT '')""" -@bp.route("/api/agent/config", methods=["POST"]) +def _ensure_conv_table(): + try: + db.session.execute(db.text(_CONV_TABLE)) + db.session.commit() + except Exception: + pass + + +def _conv_msgs(conv_id): + """读会话消息列表 [{role, content}]。会话不存在返回 None。""" + try: + row = db.session.execute(db.text( + "SELECT messages FROM agent_conversation WHERE id=:i"), + {"i": conv_id}).fetchone() + except Exception: + return None + if not row: + return None + try: + return json.loads(row[0] or "[]") + except Exception: + return [] + + +def _conv_save_messages(conv_id, messages, title=None): + """整段写回会话消息 + 时间戳(尽力而为,失败不影响主流程)。""" + try: + from datetime import datetime + now = datetime.now().strftime("%Y-%m-%d %H:%M") + msgs_json = json.dumps(messages[-60:], ensure_ascii=False) + if title: + db.session.execute(db.text( + "UPDATE agent_conversation SET messages=:m, title=:t, " + "updated_at=:u WHERE id=:i"), + {"m": msgs_json, "t": title[:100], "u": now, "i": conv_id}) + else: + db.session.execute(db.text( + "UPDATE agent_conversation SET messages=:m, updated_at=:u " + "WHERE id=:i"), + {"m": msgs_json, "u": now, "i": conv_id}) + db.session.commit() + return True + except Exception as e: + _log.warning(f"会话保存失败: {e}") + return False + + +@bp.route("/api/agent/conversations", methods=["GET"]) @admin_required -def agent_config_save(): - """保存 Agent 配置:{api_base?, model?, api_key?, default_serial?} 部分更新。""" - data = request.json or {} - for key, meta_key in _CFG_KEYS.items(): - if key in data and data[key] is not None: - _meta_put(meta_key, str(data[key]).strip()) +def agent_conversations_list(): + """会话列表(按最近更新倒序):{id, title, updated_at, count}。""" + _ensure_conv_table() + rows = db.session.execute(db.text( + "SELECT id, title, messages, updated_at FROM agent_conversation " + "ORDER BY updated_at DESC, created_at DESC")).fetchall() + out = [] + for cid, title, messages, updated_at in rows: + try: + count = len(json.loads(messages or "[]")) // 2 + except Exception: + count = 0 + out.append({"id": cid, "title": title or "新会话", + "updated_at": updated_at or "", "count": count}) + return jsonify({"ok": True, "conversations": out}) + + +@bp.route("/api/agent/conversations", methods=["POST"]) +@admin_required +def agent_conversations_create(): + """新建会话(空消息)。返回 {id}。""" + _ensure_conv_table() + from datetime import datetime + cid = uuid.uuid4().hex[:10] + now = datetime.now().strftime("%Y-%m-%d %H:%M") + db.session.execute(db.text( + "INSERT INTO agent_conversation(id, title, messages, created_at, updated_at) " + "VALUES(:i, '', '[]', :c, :c)"), {"i": cid, "c": now}) db.session.commit() - return jsonify({"ok": True, "msg": "已保存"}) + return jsonify({"ok": True, "id": cid, "title": "新会话"}) + + +@bp.route("/api/agent/conversations/", methods=["GET"]) +@admin_required +def agent_conversations_detail(conv_id): + """会话详情(全部消息文本)。""" + _ensure_conv_table() + msgs = _conv_msgs(conv_id) + if msgs is None: + return jsonify({"ok": False, "error": "会话不存在"}), 404 + row = db.session.execute(db.text( + "SELECT title, created_at, updated_at FROM agent_conversation " + "WHERE id=:i"), {"i": conv_id}).fetchone() + return jsonify({"ok": True, "id": conv_id, + "title": (row[0] if row else "") or "新会话", + "messages": msgs, + "created_at": row[1] if row else "", + "updated_at": row[2] if row else ""}) + + +@bp.route("/api/agent/conversations/", methods=["DELETE"]) +@admin_required +def agent_conversations_delete(conv_id): + """删除会话(消息一并删除,不可恢复)。""" + _ensure_conv_table() + db.session.execute(db.text( + "DELETE FROM agent_conversation WHERE id=:i"), {"i": conv_id}) + db.session.commit() + _log.info(f"删除会话 {conv_id}") + return jsonify({"ok": True, "msg": "会话已删除"}) + + +@bp.route("/api/agent/conversations//rename", methods=["POST"]) +@admin_required +def agent_conversations_rename(conv_id): + """重命名会话。""" + data = request.json or {} + title = (data.get("title") or "").strip()[:100] + if not title: + return jsonify({"ok": False, "error": "标题不能为空"}), 400 + _ensure_conv_table() + db.session.execute(db.text( + "UPDATE agent_conversation SET title=:t WHERE id=:i"), + {"t": title, "i": conv_id}) + db.session.commit() + return jsonify({"ok": True, "msg": "已重命名"}) # ================== 运行 ================== @bp.route("/api/agent/run", methods=["POST"]) @admin_required def agent_run(): - """启动 Agent:{prompt, serial?}。运行中返回 409。""" + """启动 Agent:{prompt, serial?, conversation_id?}。运行中返回 409。""" data = request.json or {} prompt = (data.get("prompt") or "").strip() if not prompt: return jsonify({"ok": False, "error": "请输入指令"}), 400 serial = (data.get("serial") or "").strip() + conv_id = (data.get("conversation_id") or "").strip() cfg = _read_cfg() if not cfg.get("api_key"): return jsonify({"ok": False, "error": "请先在配置区填写 API Key"}), 400 @@ -517,6 +634,20 @@ def agent_run(): f"AI 不与任务抢设备,任务结束后才能操作(或在任务页先停止)"}), 409 except Exception: pass # 状态服务异常不阻塞(MCP busy 锁兜底) + # 会话校验:不存在则自动新建(标题=首条消息截断) + if conv_id: + _ensure_conv_table() + if _conv_msgs(conv_id) is None: + try: + from datetime import datetime as _dt2 + now = _dt2.now().strftime("%Y-%m-%d %H:%M") + db.session.execute(db.text( + "INSERT OR IGNORE INTO agent_conversation(id, messages, " + "created_at, updated_at) VALUES(:i, '[]', :c, :c)"), + {"i": conv_id, "c": now}) + db.session.commit() + except Exception: + pass with _lock: if _run["state"] == "running": return jsonify({"ok": False, "error": @@ -525,17 +656,38 @@ def agent_run(): run_id = uuid.uuid4().hex[:8] from datetime import datetime as _dt _run.update(id=run_id, state="running", prompt=prompt, - serial=serial, + serial=serial, conv_id=conv_id, started=_dt.now().strftime("%H:%M:%S"), answer="", error="") - # history 保留(同会话多轮对话),由前端「清空对话」调用 clear 重置 + # history 保留(多轮上下文),由会话/「新建会话」管理 _queues[run_id] = queue.Queue() _stop_events[run_id] = threading.Event() - _log.info(f"Agent 启动: {prompt[:60]} @ {serial or 'default'}") + _log.info(f"Agent 启动: {prompt[:60]} @ {serial or 'default'} conv={conv_id or '-'}") threading.Thread(target=_agent_thread, args=(run_id, prompt, serial, cfg), daemon=True).start() return jsonify({"ok": True, "run_id": run_id}) +@bp.route("/api/agent/config", methods=["GET"]) +@admin_required +def agent_config_get(): + """读 Agent 配置(key 打码返回)。""" + cfg = _read_cfg() + if cfg["api_key"]: + k = cfg["api_key"] + cfg["api_key_masked"] = k[:6] + "***" + k[-4:] + return jsonify({"ok": True, **cfg}) + + +@bp.route("/api/agent/config", methods=["POST"]) +@admin_required +def agent_config_save(): + """保存 Agent 配置:{api_base?, model?, api_key?, default_serial?} 部分更新。""" + data = request.json or {} + for key, meta_key in _CFG_KEYS.items(): + if key in data and data[key] is not None: + _meta_put(meta_key, str(data[key]).strip()) + db.session.commit() + return jsonify({"ok": True, "msg": "已保存"}) @bp.route("/api/agent/run", methods=["GET"]) @@ -691,7 +843,19 @@ def _agent_thread(run_id, prompt, serial, cfg): pass # 非法值用默认 40 with _lock: - history = list(_run.get("history") or []) + conv_id = _run.get("conv_id") or "" + # 历史来源:绑定了会话 → 从会话读(多轮上下文延续,DeepSeek 式); + # 无会话(CLI/兼容)→ 用运行态 history + if conv_id: + msgs = _conv_msgs(conv_id) + history = [{"role": m.get("role"), "content": m.get("content", "")[:4000]} + for m in (msgs or []) if m.get("role") in ("user", "assistant")] + history = history[-24:] + with _lock: + _run["history"] = list(history) + else: + with _lock: + history = list(_run.get("history") or []) target = serial or cfg.get("default_serial") or "" stop_evt = _stop_events.get(run_id) @@ -722,6 +886,19 @@ def _agent_thread(run_id, prompt, serial, cfg): hist.append({"role": "user", "content": prompt[:2000]}) hist.append({"role": "assistant", "content": (answer or "")[:4000]}) _run["history"] = hist[-24:] + # 会话落库:本轮追加写回(新会话自动用首条消息作标题) + if conv_id: + try: + saved = list(_run["history"]) + title = None + if not db.session.execute(db.text( + "SELECT title FROM agent_conversation WHERE id=:i"), + {"i": conv_id}).scalar(): + title = "".join(prompt.split())[:30] or "新会话" + _conv_save_messages(conv_id, saved, title=title) + _log.info(f"会话已保存: {conv_id}({len(saved)} 条消息)") + except Exception as e: + _log.warning(f"会话落库失败: {e}") # 自进化:成功执行过工具则提炼配方写入经验。必须在 done 之前完成—— # done 发出后 SSE 关流,用户就看不到「已写入经验」的提示了。