feat: AI 控制台回答支持 Markdown 渲染 + 推理链可折叠 + token 用量显示
- markdown.js(新增,无 CDN 依赖):轻量 Markdown 渲染(标题/列表含嵌套/表格/ 代码块/引用/链接…),先 esc() 转义再套标记,模型输出的 HTML 只当文本显示 - agent.js:回答改走 Markdown;推理链改为 <details> 可折叠(流式时展开、正文开始 自动收起、手动点过后不再自动改);单条消息 token 脚注 + 顶栏「本会话累计」 - monitor.html:消息结构加 .reasoning/.agent-usage、顶栏 token 徽标、md 相关样式, 引入 markdown.js(base.js 之后、agent.js 之前) - mcp_agent/agent.py:请求带 stream_options.include_usage,按「每次模型调用」累计 usage(末尾 chunk),on_usage 回调吐累计值;网关不认该参数(400/422/点名)时 自动降级重试一次 - web/agent_api.py:SSE 新增 usage 事件、done 带 usage;推理链与用量随会话落库 (_REASONING_KEEP=6000 截断),回灌模型时只取 role/content - 文档:API.md(usage 事件/done/会话消息字段)、ARCHITECTURE §5.4.1、DEVELOPMENT 前端 JS 清单 自测:假模型端点单测 3/3(正常/降级/多轮累加);Edge headless 全链路 27 项全通过 (真实 Flask+SSE+SQLite,含 XSS 转义、刷新后回看);Markdown 渲染器 18 用例全通过
This commit is contained in:
+14
-2
@@ -1186,15 +1186,19 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
|
||||
```json
|
||||
{"ok": true, "state": "running"|"idle"|"done", "run_id": "...", "prompt": "...",
|
||||
"serial": "...", "started": "10:00:01", "answer": "...", "error": "",
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "calls": 0},
|
||||
"history": [{"role": "user", "content": "..."}]}
|
||||
```
|
||||
|
||||
`usage` 为本轮累计 token 用量(`calls` = 模型调用次数;运行中实时增长,失败也保留已花费的)。
|
||||
|
||||
### GET /api/agent/stream?run_id=
|
||||
|
||||
订阅事件流(SSE,EventSource)。事件:
|
||||
- `event: delta` `{text, kind: content|reasoning}` — 流式文本增量
|
||||
- `event: step` `{tool, args, image?}` — 工具调用完成(MCP 步骤,image 为缩略截图)。另有三类伪卡片:`tool="🧠 经验记忆"` 表示命中任务级经验(args 形如「命中 N 条同类历史经验,已注入参考:<配方摘要>」)或本轮已写入经验库;`tool="🧠 动作经验"` 表示命中**可复用动作**(「命中 N 个可复用动作,已注入参考:<动作名>」,执行前注入)或本轮已沉淀动作(「已沉淀 N 个可复用动作」,含元素定位、禁坐标)
|
||||
- `event: done` `{answer}` — 完成
|
||||
- `event: usage` `{prompt_tokens, completion_tokens, total_tokens, calls}` — **本轮累计** token 用量,每完成一次模型调用推一次(前端实时刷新计数与费用感)
|
||||
- `event: done` `{answer, usage}` — 完成(`usage` 同上一节,最终累计)
|
||||
- `event: error` `{message}` — 失败(若因 MCP Server 未启动/不可达,message 为明确文案「MCP server(8033) 不可达 …」,不再是 SDK 原始的 `Server returned an error response`)
|
||||
- 空闲时每 15s 发一行 `: keepalive` 注释防超时;`done`/`error` 后关流
|
||||
|
||||
@@ -1234,10 +1238,18 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "id": "...", "title": "...", "messages": [{"role": "user", "content": "..."}],
|
||||
{"ok": true, "id": "...", "title": "...",
|
||||
"messages": [{"role": "user", "content": "..."},
|
||||
{"role": "assistant", "content": "...",
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0,
|
||||
"total_tokens": 0, "calls": 0},
|
||||
"reasoning": "模型推理链(≤6000 字符,可空)"}],
|
||||
"created_at": "...", "updated_at": "..."}
|
||||
```
|
||||
|
||||
> assistant 消息可带 `usage`(本轮 token 用量)与 `reasoning`(推理链,上限 `_REASONING_KEEP`=6000 字符)。
|
||||
> 两者**仅供前端展示/回看**,回灌模型上下文时只取 `role`/`content`(见 `_agent_thread`)。
|
||||
|
||||
### DELETE /api/agent/conversations/<conv_id>
|
||||
|
||||
删除会话(消息一并删除,不可恢复)。
|
||||
|
||||
@@ -346,6 +346,19 @@ AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆:
|
||||
- **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。
|
||||
- **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=<id>`。
|
||||
|
||||
#### 5.4.1 回答渲染与 token(2026-09-10)
|
||||
|
||||
| 能力 | 落点 | 说明 |
|
||||
|------|------|------|
|
||||
| **Markdown 渲染** | `static/admin/markdown.js`(`renderMarkdown()`) | 自研轻量渲染器,**不引 CDN**(生产 220 在内网):标题/段落/软换行/粗斜体/删除线/行内代码/围栏代码块/有序无序列表(含嵌套)/引用/表格/分隔线/链接。**先 `esc()` 转义再套标记**,模型输出里的 HTML 只显示为文本(防注入) |
|
||||
| **推理链可折叠** | `agent.js` `_appendReasoning()` + `<details class="reasoning">` | 流式思考时自动展开、正文开始时自动收起;用户手动点过 `summary` 后不再自动改(`dataset.touched`);摘要显示「思考过程(N 字)」 |
|
||||
| **token 显示** | `mcp_agent/agent.py` `_accumulate_usage()` + `agent_api` SSE `usage` 事件 | 每次模型调用完成后推**本轮累计**(`prompt/completion/total/calls`);单条消息脚注 + 顶栏「本会话累计」(历史 + 运行中) |
|
||||
| **推理链/用量落库** | `_agent_thread` 把 `usage`、`reasoning` 写进会话 assistant 消息 | 刷新页面后仍可回看;**回灌模型上下文时只取 `role`/`content`**(不污染 token) |
|
||||
|
||||
> **token 采集的兼容性**:请求带 `stream_options: {"include_usage": true}`,按「每次模型调用」取末尾 chunk 的 `usage` 累加(多轮工具调用会多次累加)。个别网关不认该参数会直接 **HTTP 400** → `_UsageUnsupported` 捕获后**自动关掉并重试一次**(`self._include_usage=False`),不影响主流程。
|
||||
>
|
||||
> **推理链体积**:只保留前 `_REASONING_KEEP`=6000 字符落库(会话消息上限 60 条),避免历史无限膨胀。
|
||||
|
||||
> **蒸馏健壮性(2026-09-10)**:经验/动作靠**模型蒸馏**落库。推理型模型会把 token 预算烧在 `reasoning` 上,导致 `content` 为空或被截断(`finish_reason=length`)→ 早期只读 `content`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略:
|
||||
> 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复;
|
||||
> 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试);
|
||||
|
||||
+2
-2
@@ -108,7 +108,7 @@
|
||||
- **任务参数放各自 `tasks/<app>/task.py` 顶部,不放 `config.py`**
|
||||
- **生产环境(220)默认只读**:任何写操作(改文件/重启容器/部署)都必须先经负责人确认
|
||||
- **数据库是 SQLite**(`data/users.db`,WAL 模式):运行时数据不提交 git
|
||||
- **前端 JS 已拆分多文件**(均位于 `static/admin/`):`monitor.html` 按 `base.js → list.js → monitor.js → editor.js → tasks.js → tools.js → apps.js → admin.js → agent.js → system.js` 的顺序用 `<script src>` 加载
|
||||
- **前端 JS 已拆分多文件**(均位于 `static/admin/`):`monitor.html` 按 `base.js → markdown.js → list.js → monitor.js → editor.js → tasks.js → tools.js → apps.js → admin.js → agent.js → system.js` 的顺序用 `<script src>` 加载(`markdown.js` 提供 `renderMarkdown()`,AI 控制台回答渲染用;依赖 `base.js` 的 `esc()`,故排在其后、agent.js 之前)
|
||||
- **监控页/大列表已加分页**:100 台设备也只渲染 10 行/页,不要移除分页逻辑
|
||||
- **任务批量触发已错峰**(`_START_STAGGER_SEC`):避免大量设备同时启动造成 adb 连接风暴,不要移除
|
||||
|
||||
@@ -196,7 +196,7 @@ tasks/<app>/
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `templates/admin/monitor.html` | HTML 结构 + CSS + `<script src>` 引用 |
|
||||
| `static/admin/`(base/list/monitor/editor/tasks/tools/apps/admin/agent/system.js) | 前端 JS(按 monitor.html 中 `<script src>` 顺序拆分加载,功能归属见各文件) |
|
||||
| `static/admin/`(base/markdown/list/monitor/editor/tasks/tools/apps/admin/agent/system.js) | 前端 JS(按 monitor.html 中 `<script src>` 顺序拆分加载,功能归属见各文件;`markdown.js` = 轻量 Markdown 渲染器,AI 控制台回答用) |
|
||||
|
||||
改完**强刷浏览器**(Cmd/Ctrl+Shift+R),必要时重启 web_server。
|
||||
|
||||
|
||||
+84
-2
@@ -8,6 +8,8 @@
|
||||
- content/reasoning_content 增量逐 chunk 回调(kind 区分)
|
||||
- tool_calls 分片累积(arguments 按 index 拼接),流结束后统一执行
|
||||
- 截图(de_screenshot)图像转 image_url 追加下一轮,同时 on_tool 回调带缩略
|
||||
- token 用量:请求带 stream_options.include_usage,按「每次模型调用」累计,
|
||||
on_usage 回调吐出累计值(Web 控制台展示)
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
@@ -22,6 +24,10 @@ _log = logging.getLogger("agent")
|
||||
|
||||
S = AgentSettings()
|
||||
|
||||
|
||||
class _UsageUnsupported(RuntimeError):
|
||||
"""模型/网关不认 stream_options.include_usage(400/422 或报错点名该字段)——降级重试用。"""
|
||||
|
||||
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
|
||||
|
||||
工作规范:
|
||||
@@ -56,8 +62,15 @@ class Agent:
|
||||
# 回调(Web 展示用,均可选):
|
||||
# on_delta(text, kind) kind: content | reasoning —— 流式文本增量
|
||||
# on_tool(step) step: {tool, args, result, image} —— 工具调用完成
|
||||
# on_usage(usage) usage: {prompt_tokens, completion_tokens,
|
||||
# total_tokens, calls} —— 累计 token 用量
|
||||
self.on_delta = None
|
||||
self.on_tool = None
|
||||
self.on_usage = None
|
||||
# 本轮累计用量(run_stream 开始时重置)
|
||||
self.usage = {"prompt_tokens": 0, "completion_tokens": 0,
|
||||
"total_tokens": 0, "calls": 0}
|
||||
self._include_usage = True # 模型不认 stream_options 时自动置 False
|
||||
self._mcp = None
|
||||
|
||||
# ---------- MCP 工具桥 ----------
|
||||
@@ -88,7 +101,23 @@ class Agent:
|
||||
|
||||
# ---------- 模型调用(流式) ----------
|
||||
async def _chat_stream(self):
|
||||
"""流式 chat/completions:逐 chunk 产出 JSON(async generator)。"""
|
||||
"""流式 chat/completions:逐 chunk 产出 JSON(async generator)。
|
||||
|
||||
默认要求服务端在末尾 chunk 带 usage(token 统计);个别网关不认
|
||||
`stream_options` 会直接 400,此时自动降级重试一次(不影响主流程)。
|
||||
"""
|
||||
if self._include_usage:
|
||||
try:
|
||||
async for chunk in self._chat_stream_once(True):
|
||||
yield chunk
|
||||
return
|
||||
except _UsageUnsupported as e:
|
||||
_log.warning("模型不支持 stream_options.include_usage,降级重试:%s", e)
|
||||
self._include_usage = False
|
||||
async for chunk in self._chat_stream_once(False):
|
||||
yield chunk
|
||||
|
||||
async def _chat_stream_once(self, with_usage):
|
||||
body = {
|
||||
"model": self.s.model,
|
||||
"messages": self.messages,
|
||||
@@ -96,6 +125,8 @@ class Agent:
|
||||
"max_tokens": 4096,
|
||||
"stream": True,
|
||||
}
|
||||
if with_usage:
|
||||
body["stream_options"] = {"include_usage": True}
|
||||
headers = {"Authorization": f"Bearer {self.s.api_key}",
|
||||
"Content-Type": "application/json"}
|
||||
url = f"{self.s.api_base.rstrip('/')}/chat/completions"
|
||||
@@ -103,6 +134,11 @@ class Agent:
|
||||
async with client.stream("POST", url, json=body, headers=headers) as r:
|
||||
if r.status_code != 200:
|
||||
text = (await r.aread()).decode(errors="replace")
|
||||
# 本次开了 include_usage 却被打回(400/422,或报错里点名这个字段)
|
||||
# → 视作网关不支持,交给上层降级重试(401/余额等真错误照常抛出)
|
||||
if with_usage and (r.status_code in (400, 422)
|
||||
or "stream_options" in text):
|
||||
raise _UsageUnsupported(text[:200])
|
||||
raise RuntimeError(f"模型 API HTTP {r.status_code}: {text[:300]}")
|
||||
async for line in r.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
@@ -115,6 +151,37 @@ class Agent:
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# ---------- token 用量 ----------
|
||||
@staticmethod
|
||||
def _read_usage(raw):
|
||||
"""把一次模型调用返回的 usage 规整为 {prompt, completion, total};无效返回 None。"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
pt = int(raw.get("prompt_tokens") or 0)
|
||||
ct = int(raw.get("completion_tokens") or 0)
|
||||
tt = int(raw.get("total_tokens") or 0) or (pt + ct)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (pt or ct or tt):
|
||||
return None
|
||||
return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}
|
||||
|
||||
def _accumulate_usage(self, raw):
|
||||
"""把一次模型调用的 usage 累加进本轮总量,并回调 on_usage(累计值)。"""
|
||||
u = self._read_usage(raw)
|
||||
if not u:
|
||||
return
|
||||
self.usage["prompt_tokens"] += u["prompt_tokens"]
|
||||
self.usage["completion_tokens"] += u["completion_tokens"]
|
||||
self.usage["total_tokens"] += u["total_tokens"]
|
||||
self.usage["calls"] += 1
|
||||
if self.on_usage:
|
||||
try:
|
||||
self.on_usage(dict(self.usage))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- 工具执行 ----------
|
||||
async def _execute_tool(self, name, arguments):
|
||||
"""执行 MCP 工具,返回 (文本结果, image_data_or_None)。"""
|
||||
@@ -161,7 +228,7 @@ class Agent:
|
||||
# ---------- 主循环(流式) ----------
|
||||
async def run_stream(self, prompt: str, serial: str = "",
|
||||
history=None, on_delta=None, on_tool=None,
|
||||
should_stop=None, extra_context=None):
|
||||
should_stop=None, extra_context=None, on_usage=None):
|
||||
"""流式执行一轮指令,返回最终完整文本。
|
||||
|
||||
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
|
||||
@@ -170,9 +237,15 @@ class Agent:
|
||||
on_tool(step):工具调用完成(实时显示 MCP 步骤)
|
||||
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
|
||||
extra_context:附加文本(经验记忆注入,放在 system prompt 末尾)
|
||||
on_usage(usage):每完成一次模型调用回调一次(累计值,见 self.usage)
|
||||
|
||||
本轮累计 token 用量同时留在 self.usage(调用方可直接读)。
|
||||
"""
|
||||
self.on_delta = on_delta
|
||||
self.on_tool = on_tool
|
||||
self.on_usage = on_usage
|
||||
self.usage = {"prompt_tokens": 0, "completion_tokens": 0,
|
||||
"total_tokens": 0, "calls": 0}
|
||||
target = serial or self.s.default_serial
|
||||
sys_txt = SYSTEM_PROMPT
|
||||
if target:
|
||||
@@ -193,9 +266,13 @@ class Agent:
|
||||
tool_acc = {} # index -> {id, name, args}
|
||||
has_tool = False
|
||||
retried = False
|
||||
call_usage = None # 本次模型调用的 usage(末尾 chunk 带)
|
||||
while True:
|
||||
call_usage = None # 重试时丢弃上一次(未完成)的用量
|
||||
try:
|
||||
async for chunk in self._chat_stream():
|
||||
if chunk.get("usage"):
|
||||
call_usage = chunk["usage"]
|
||||
choice = (chunk.get("choices") or [{}])[0]
|
||||
delta = choice.get("delta") or {}
|
||||
text = delta.get("content")
|
||||
@@ -228,6 +305,7 @@ class Agent:
|
||||
continue
|
||||
raise
|
||||
|
||||
self._accumulate_usage(call_usage)
|
||||
full_content = "".join(content_parts)
|
||||
|
||||
if has_tool:
|
||||
@@ -272,13 +350,17 @@ class Agent:
|
||||
"已完成的部分、当前设备状态、未能完成的原因与下一步建议。"
|
||||
"不要调用任何工具。"})
|
||||
parts = []
|
||||
call_usage = None
|
||||
async for chunk in self._chat_stream():
|
||||
if chunk.get("usage"):
|
||||
call_usage = chunk["usage"]
|
||||
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
|
||||
text = delta.get("content")
|
||||
if text:
|
||||
parts.append(text)
|
||||
if on_delta:
|
||||
on_delta(text, "content")
|
||||
self._accumulate_usage(call_usage)
|
||||
self.tools_schema = saved_tools
|
||||
summary = "".join(parts)
|
||||
return summary or "(已达步骤上限,模型未能生成总结)"
|
||||
|
||||
+131
-17
@@ -120,6 +120,11 @@ function pollRunState(){
|
||||
? '将操作:' + document.getElementById('agent-target-select').value : '');
|
||||
}
|
||||
document.getElementById('btn-agent-send').disabled = running;
|
||||
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
|
||||
if(running && !_agentStream && r.usage && r.usage.total_tokens){
|
||||
_liveUsage = r.usage;
|
||||
updateSessionTokens();
|
||||
}
|
||||
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
|
||||
});
|
||||
}
|
||||
@@ -173,11 +178,91 @@ function newAssistantMsg(){
|
||||
clearEmpty();
|
||||
const div = document.createElement('div');
|
||||
div.className = 'agent-msg assistant';
|
||||
div.innerHTML = '<div class="agent-toolcards"></div><div class="agent-text"></div>';
|
||||
// 结构:工具卡片 → 可折叠推理链 → 正文(Markdown)→ token 用量脚注
|
||||
div.innerHTML =
|
||||
'<div class="agent-toolcards"></div>'
|
||||
+ '<details class="reasoning" style="display:none"><summary>💭 思考过程</summary>'
|
||||
+ '<div class="reasoning-text"></div></details>'
|
||||
+ '<div class="agent-text"></div>'
|
||||
+ '<div class="agent-usage" style="display:none"></div>';
|
||||
chat.appendChild(div);
|
||||
scrollChat();
|
||||
return div;
|
||||
}
|
||||
|
||||
// ---------- 推理链(可折叠) ----------
|
||||
function _appendReasoning(msgEl, text){
|
||||
const box = msgEl.querySelector('.reasoning');
|
||||
if(!box) return;
|
||||
const body = box.querySelector('.reasoning-text');
|
||||
body.textContent += text;
|
||||
box.querySelector('summary').textContent =
|
||||
'💭 思考过程(' + body.textContent.length + ' 字)';
|
||||
box.style.display = 'block';
|
||||
_bindReasoning(box);
|
||||
if(!box.open) box.open = true; // 流式思考时展开,正文开始时自动收起
|
||||
}
|
||||
// 用户手动点过 summary 后不再自动收起/展开(尊重手动状态)
|
||||
function _bindReasoning(box){
|
||||
const sum = box.querySelector('summary');
|
||||
if(sum && !sum.dataset.bound){
|
||||
sum.dataset.bound = '1';
|
||||
sum.addEventListener('click', ()=>{ box.dataset.touched = '1'; });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 正文 Markdown 渲染(渲染器见 markdown.js) ----------
|
||||
function _renderMd(msgEl){
|
||||
const el = msgEl.querySelector('.agent-text');
|
||||
if(el) el.innerHTML = renderMarkdown(msgEl._md || '');
|
||||
}
|
||||
function _scheduleMd(msgEl){
|
||||
if(msgEl._mdRaf) return; // 流式增量按帧合并渲染,避免每个 token 重排
|
||||
msgEl._mdRaf = requestAnimationFrame(()=>{
|
||||
msgEl._mdRaf = 0;
|
||||
_renderMd(msgEl);
|
||||
scrollChat();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- token 用量 ----------
|
||||
let _convUsage = {}; // 当前会话历史累计(读会话时算)
|
||||
let _liveUsage = {}; // 本轮运行中累计(SSE usage 事件)
|
||||
function _fmtInt(n){ return String(n || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||||
function _usageSum(a, b){
|
||||
const o = {prompt_tokens:0, completion_tokens:0, total_tokens:0, calls:0};
|
||||
[a, b].forEach(u=>{
|
||||
if(!u) return;
|
||||
o.prompt_tokens += (u.prompt_tokens || 0);
|
||||
o.completion_tokens += (u.completion_tokens || 0);
|
||||
o.total_tokens += (u.total_tokens || 0);
|
||||
o.calls += (u.calls || 0);
|
||||
});
|
||||
return o;
|
||||
}
|
||||
// 单条消息脚注
|
||||
function renderUsage(msgEl, u){
|
||||
const el = msgEl.querySelector('.agent-usage');
|
||||
if(!el) return;
|
||||
if(!u || !u.total_tokens){ el.style.display = 'none'; return; }
|
||||
el.style.display = 'block';
|
||||
el.title = '输入 ' + _fmtInt(u.prompt_tokens) + ' + 输出 ' + _fmtInt(u.completion_tokens)
|
||||
+ ' = ' + _fmtInt(u.total_tokens) + ' tokens,共 ' + (u.calls || 0) + ' 次模型调用';
|
||||
el.textContent = '🪙 ' + _fmtInt(u.total_tokens) + ' tokens'
|
||||
+ '(↑' + _fmtInt(u.prompt_tokens) + ' ↓' + _fmtInt(u.completion_tokens)
|
||||
+ ' · ' + (u.calls || 0) + ' 次调用)';
|
||||
}
|
||||
// 顶栏「本会话累计」= 已落库历史 + 运行中本轮
|
||||
function updateSessionTokens(){
|
||||
const el = document.getElementById('agent-token-total');
|
||||
if(!el) return;
|
||||
const t = _usageSum(_convUsage, _liveUsage);
|
||||
if(!t.total_tokens){ el.textContent = ''; el.title = ''; return; }
|
||||
el.textContent = '🪙 本会话 ' + _fmtInt(t.total_tokens) + ' tokens';
|
||||
el.title = '本会话累计:输入 ' + _fmtInt(t.prompt_tokens)
|
||||
+ ' + 输出 ' + _fmtInt(t.completion_tokens)
|
||||
+ ' = ' + _fmtInt(t.total_tokens) + ' tokens,共 ' + t.calls + ' 次模型调用';
|
||||
}
|
||||
function clearEmpty(){
|
||||
const empty = document.querySelector('#agent-chat .agent-empty');
|
||||
if(empty) empty.remove();
|
||||
@@ -197,6 +282,9 @@ function bindChatScroll(){
|
||||
}
|
||||
function clearAgentChat(){
|
||||
document.getElementById('agent-chat').innerHTML = '';
|
||||
_convUsage = {}; // 换会话:token 累计重新算
|
||||
_liveUsage = {};
|
||||
updateSessionTokens();
|
||||
}
|
||||
|
||||
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
|
||||
@@ -293,15 +381,26 @@ function loadCurrentConvMessages(){
|
||||
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
|
||||
if(!r||!r.ok)return;
|
||||
chat.innerHTML = '';
|
||||
_convUsage = {};
|
||||
_liveUsage = {};
|
||||
(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(m.role==='user'){ if(txt) addUserMsg(txt); return; }
|
||||
if(!txt && !m.reasoning && !(m.usage && m.usage.total_tokens)) return;
|
||||
const div = newAssistantMsg();
|
||||
if(txt){ div._md = txt; _renderMd(div); }
|
||||
if(m.reasoning){
|
||||
_appendReasoning(div, m.reasoning);
|
||||
const box = div.querySelector('.reasoning');
|
||||
_bindReasoning(box);
|
||||
box.open = false; // 历史消息默认折叠推理链
|
||||
}
|
||||
if(m.usage){
|
||||
renderUsage(div, m.usage);
|
||||
_convUsage = _usageSum(_convUsage, m.usage);
|
||||
}
|
||||
});
|
||||
updateSessionTokens();
|
||||
if(!chat.children.length){
|
||||
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
|
||||
+ '(先选 🎯 目标设备)…</div>';
|
||||
@@ -379,21 +478,29 @@ function listenStream(runId){
|
||||
es.addEventListener('delta', ev=>{
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
const d = JSON.parse(ev.data);
|
||||
const textEl = msgEl.querySelector('.agent-text');
|
||||
if(d.kind === 'reasoning'){
|
||||
let r = msgEl.querySelector('.reasoning');
|
||||
if(!r){
|
||||
r = document.createElement('div');
|
||||
r.className = 'reasoning';
|
||||
msgEl.insertBefore(r, textEl);
|
||||
}
|
||||
r.textContent += d.text;
|
||||
_appendReasoning(msgEl, d.text || '');
|
||||
}else{
|
||||
textEl.textContent += d.text;
|
||||
// 正文开始 → 收起推理链(用户手动开过的保持原样)
|
||||
const r = msgEl.querySelector('.reasoning');
|
||||
if(r && r.open && !r.dataset.touched) r.open = false;
|
||||
msgEl._md = (msgEl._md || '') + (d.text || '');
|
||||
msgEl._mdPending = false;
|
||||
_scheduleMd(msgEl);
|
||||
}
|
||||
scrollChat();
|
||||
});
|
||||
|
||||
// 每次模型调用完成 → 实时刷新 token 计数(本轮累计)
|
||||
es.addEventListener('usage', ev=>{
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
let d = {};
|
||||
try{ d = JSON.parse(ev.data) || {}; }catch(e){}
|
||||
_liveUsage = d;
|
||||
renderUsage(msgEl, d);
|
||||
updateSessionTokens();
|
||||
});
|
||||
|
||||
es.addEventListener('step', ev=>{
|
||||
const d = JSON.parse(ev.data);
|
||||
followSerialFromArgs(d.args);
|
||||
@@ -417,10 +524,16 @@ function listenStream(runId){
|
||||
|
||||
es.addEventListener('done', ev=>{
|
||||
const d = JSON.parse(ev.data);
|
||||
if(d.answer){
|
||||
if(d.answer || d.usage || !msgEl){
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
msgEl.querySelector('.agent-text').textContent = d.answer;
|
||||
if(d.answer){ msgEl._md = d.answer; _renderMd(msgEl); }
|
||||
if(d.usage) renderUsage(msgEl, d.usage);
|
||||
}
|
||||
// 本轮用量并入会话累计(脚注已展示,顶栏不该因此变小),再清空「运行中」
|
||||
_convUsage = _usageSum(_convUsage, _liveUsage);
|
||||
_liveUsage = {};
|
||||
updateSessionTokens();
|
||||
scrollChat();
|
||||
endRun();
|
||||
renderConvList(); // 落库已完成:刷新标题/时间/轮数
|
||||
});
|
||||
@@ -432,6 +545,7 @@ function listenStream(runId){
|
||||
}catch(e){}
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
msgEl.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg);
|
||||
_liveUsage = {}; // 本轮可能已花费 token,但结果无效——不并入会话累计
|
||||
endRun();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// 轻量 Markdown 渲染(AI 控制台回答用)——不引外部库/CDN(生产 220 在内网)。
|
||||
// 安全:**先 esc() 转义,再套标记**——模型输出里的原始 HTML 只会以文本显示,
|
||||
// 不会变成可执行标签。
|
||||
// 支持:标题 / 段落 / 换行 / 粗体 / 斜体 / 删除线 / 行内代码 / 围栏代码块 /
|
||||
// 有序与无序列表(含一层以上嵌套)/ 引用 / 表格 / 分隔线 / 链接。
|
||||
// 依赖:esc()(base.js,本文件须在其后加载)
|
||||
|
||||
// 行内代码占位符(控制字符,正文几乎不可能出现)
|
||||
const _MD_PH = '\u0001';
|
||||
|
||||
function _mdInline(s){
|
||||
const codes = [];
|
||||
// 行内代码先摘出,避免其中的 ** * ~~ 被继续当标记解析
|
||||
s = s.replace(/`([^`]+)`/g, (m, c)=>{
|
||||
codes.push(c);
|
||||
return _MD_PH + (codes.length - 1) + _MD_PH;
|
||||
});
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return s.replace(new RegExp(_MD_PH + '(\\d+)' + _MD_PH, 'g'),
|
||||
(m, i)=>'<code>' + codes[+i] + '</code>');
|
||||
}
|
||||
|
||||
// 表格行 → 单元格数组
|
||||
function _mdCells(line){
|
||||
return line.trim().replace(/^\|/, '').replace(/\|$/, '')
|
||||
.split('|').map(c=>c.trim());
|
||||
}
|
||||
|
||||
// 列表项递归渲染(缩进 = 层级;同类标记合并,类型变了外层另起一个列表)
|
||||
function _mdList(items, out, idx, indent){
|
||||
const ordered = items[idx].ordered;
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
out.push('<' + tag + ' class="md-list">');
|
||||
while(idx < items.length && items[idx].indent >= indent){
|
||||
const it = items[idx];
|
||||
if(it.indent > indent) break; // 更深缩进块由外层另起列表(避免非法嵌套)
|
||||
if(it.ordered !== ordered) break; // 同级但标记类型变了 → 交回外层
|
||||
out.push('<li>' + _mdInline(esc(it.text)));
|
||||
if(idx + 1 < items.length && items[idx + 1].indent > indent){
|
||||
idx = _mdList(items, out, idx + 1, items[idx + 1].indent);
|
||||
out.push('</li>');
|
||||
continue;
|
||||
}
|
||||
out.push('</li>');
|
||||
idx++;
|
||||
}
|
||||
out.push('</' + tag + '>');
|
||||
return idx;
|
||||
}
|
||||
|
||||
// 主入口:Markdown 文本 → HTML 字符串
|
||||
function renderMarkdown(src){
|
||||
const lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
|
||||
const out = [];
|
||||
let para = [];
|
||||
let i = 0;
|
||||
|
||||
const flushPara = ()=>{
|
||||
if(!para.length) return;
|
||||
out.push('<p>' + para.map(l=>_mdInline(esc(l))).join('<br>') + '</p>');
|
||||
para = [];
|
||||
};
|
||||
|
||||
while(i < lines.length){
|
||||
const ln = lines[i];
|
||||
let m;
|
||||
|
||||
// 围栏代码块 ```lang … ```
|
||||
if(/^\s*```/.test(ln)){
|
||||
flushPara();
|
||||
const body = [];
|
||||
i++;
|
||||
while(i < lines.length && !/^\s*```/.test(lines[i])){ body.push(lines[i]); i++; }
|
||||
if(i < lines.length) i++; // 跳过结束围栏
|
||||
out.push('<pre class="md-code"><code>' + esc(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 表格:表头行 + 分隔行(|---|---|)
|
||||
if(/^\s*\|.*\|\s*$/.test(ln) && i + 1 < lines.length
|
||||
&& /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])){
|
||||
flushPara();
|
||||
const head = _mdCells(ln);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while(i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])){
|
||||
rows.push(_mdCells(lines[i])); i++;
|
||||
}
|
||||
out.push('<table class="md-table"><thead><tr>'
|
||||
+ head.map(c=>'<th>' + _mdInline(esc(c)) + '</th>').join('') + '</tr></thead><tbody>'
|
||||
+ rows.map(r=>'<tr>' + r.map(c=>'<td>' + _mdInline(esc(c)) + '</td>').join('')
|
||||
+ '</tr>').join('')
|
||||
+ '</tbody></table>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 标题 #~######
|
||||
m = ln.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
|
||||
if(m){
|
||||
flushPara();
|
||||
const lv = m[1].length;
|
||||
out.push('<h' + lv + ' class="md-h">' + _mdInline(esc(m[2])) + '</h' + lv + '>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 分隔线 --- / *** / ___
|
||||
if(/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(ln)){
|
||||
flushPara(); out.push('<hr>'); i++; continue;
|
||||
}
|
||||
|
||||
// 引用 >
|
||||
if(/^\s{0,3}>\s?/.test(ln)){
|
||||
flushPara();
|
||||
const body = [];
|
||||
while(i < lines.length && /^\s{0,3}>\s?/.test(lines[i])){
|
||||
body.push(lines[i].replace(/^\s{0,3}>\s?/, '')); i++;
|
||||
}
|
||||
out.push('<blockquote class="md-quote">'
|
||||
+ body.map(l=>_mdInline(esc(l))).join('<br>') + '</blockquote>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 列表(- * + / 1. 1))——含缩进续行
|
||||
m = ln.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||
if(m){
|
||||
flushPara();
|
||||
const items = [];
|
||||
while(i < lines.length){
|
||||
const mm = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||
if(!mm) break;
|
||||
items.push({indent: mm[1].replace(/\t/g, ' ').length,
|
||||
ordered: /\d/.test(mm[2]), text: mm[3]});
|
||||
i++;
|
||||
// 紧跟的纯缩进行算上一条的续行
|
||||
while(i < lines.length && /^\s+\S/.test(lines[i])
|
||||
&& !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])){
|
||||
items[items.length - 1].text += ' ' + lines[i].trim();
|
||||
i++;
|
||||
}
|
||||
}
|
||||
let k = 0;
|
||||
while(k < items.length){
|
||||
const n = _mdList(items, out, k, items[k].indent);
|
||||
if(n <= k) break; // 防死循环
|
||||
k = n;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 空行 = 段落分隔
|
||||
if(!ln.trim()){ flushPara(); i++; continue; }
|
||||
|
||||
para.push(ln);
|
||||
i++;
|
||||
}
|
||||
flushPara();
|
||||
return out.join('');
|
||||
}
|
||||
@@ -338,6 +338,32 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
.agent-msg.user{align-self:flex-end;background:#0e7490;color:#fff;border-bottom-right-radius:3px}
|
||||
.agent-msg.assistant{align-self:flex-start;background:#fff;border:1px solid #e5e7eb;border-bottom-left-radius:3px;color:#1f2937;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.agent-msg.assistant .reasoning{color:#6b7280;font-size:12.5px;border-left:3px solid #d1d5db;padding-left:10px;margin:8px 0}
|
||||
/* 推理链(可折叠) */
|
||||
.agent-msg.assistant details.reasoning{margin:6px 0 10px;background:#f9fafb;border:1px solid #eef0f3;border-radius:9px;padding:6px 10px}
|
||||
.agent-msg.assistant details.reasoning>summary{cursor:pointer;font-size:12px;color:#6b7280;font-weight:600;user-select:none;outline:none}
|
||||
.agent-msg.assistant details.reasoning>summary:hover{color:#0e7490}
|
||||
.agent-msg.assistant .reasoning-text{margin-top:6px;color:#6b7280;font-size:12.5px;line-height:1.7;white-space:pre-wrap;word-break:break-word;max-height:320px;overflow-y:auto}
|
||||
/* token 用量脚注 */
|
||||
.agent-usage{margin-top:8px;padding-top:7px;border-top:1px dashed #e5e7eb;font-size:11px;color:#9ca3af;font-family:var(--mono)}
|
||||
.agent-token-total{margin-left:8px;font-size:11px;color:#6b7280;font-family:var(--mono);background:#f3f4f6;padding:2px 8px;border-radius:10px}
|
||||
/* 正文 Markdown(.agent-msg 默认 pre-wrap,正文改由标签排版) */
|
||||
.agent-msg.assistant .agent-text{white-space:normal}
|
||||
.agent-text p{margin:0 0 8px}
|
||||
.agent-text p:last-child{margin-bottom:0}
|
||||
.agent-text .md-h{margin:12px 0 6px;font-weight:700;line-height:1.4}
|
||||
.agent-text h1.md-h{font-size:17px}.agent-text h2.md-h{font-size:16px}.agent-text h3.md-h{font-size:15px}
|
||||
.agent-text h4.md-h,.agent-text h5.md-h,.agent-text h6.md-h{font-size:13.5px}
|
||||
.agent-text .md-list{margin:0 0 8px;padding-left:22px}
|
||||
.agent-text .md-list li{margin:2px 0}
|
||||
.agent-text .md-code{background:#0f172a;color:#e2e8f0;border-radius:8px;padding:9px 12px;overflow-x:auto;font-size:12px;font-family:var(--mono);margin:8px 0;white-space:pre}
|
||||
.agent-text code{background:#f1f5f9;border:1px solid #e2e8f0;border-radius:4px;padding:0 4px;font-size:12px;font-family:var(--mono);color:#0f766e}
|
||||
.agent-text .md-code code{background:none;border:none;color:inherit;padding:0}
|
||||
.agent-text .md-quote{margin:8px 0;padding:2px 0 2px 11px;border-left:3px solid #d1d5db;color:#4b5563}
|
||||
.agent-text .md-table{border-collapse:collapse;margin:8px 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}
|
||||
.agent-text .md-table th,.agent-text .md-table td{border:1px solid #e5e7eb;padding:5px 10px;text-align:left;white-space:nowrap}
|
||||
.agent-text .md-table th{background:#f3f4f6;font-weight:600}
|
||||
.agent-text hr{border:none;border-top:1px solid #e5e7eb;margin:12px 0}
|
||||
.agent-text a{color:#0e7490;text-decoration:underline;word-break:break-all}
|
||||
.agent-toolcards{display:flex;flex-direction:column;gap:5px;margin-bottom:9px}
|
||||
.agent-toolcard{display:flex;align-items:flex-start;gap:8px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:9px;padding:6px 11px;font-size:12px;font-family:var(--mono);color:#374151;flex-wrap:wrap}
|
||||
.agent-toolcard .dot{width:7px;height:7px;border-radius:50%;background:#10b981;flex:none;margin-top:5px}
|
||||
@@ -476,7 +502,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<div id="tab-agent" class="tab-panel">
|
||||
<div class="agent-shell">
|
||||
<div class="agent-topbar">
|
||||
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span></div>
|
||||
<div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span><span id="agent-token-total" class="agent-token-total" title=""></span></div>
|
||||
<div class="agent-top-actions">
|
||||
<span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
|
||||
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
|
||||
@@ -972,6 +998,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
|
||||
<!-- 前端模块(按依赖顺序加载:base → list → 页面模块 → admin(含初始化)) -->
|
||||
<script src="/static/admin/base.js"></script>
|
||||
<script src="/static/admin/markdown.js"></script>
|
||||
<script src="/static/admin/list.js"></script>
|
||||
<script src="/static/admin/monitor.js"></script>
|
||||
<script src="/static/admin/editor.js"></script>
|
||||
|
||||
+42
-7
@@ -5,7 +5,9 @@
|
||||
GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅):
|
||||
event: delta {text, kind: content|reasoning} 流式文本增量
|
||||
event: step {tool, args, image?} 工具调用完成(MCP 步骤)
|
||||
event: done {answer} 完成
|
||||
event: usage {prompt_tokens, completion_tokens,
|
||||
total_tokens, calls} 本轮累计 token 用量
|
||||
event: done {answer, usage} 完成
|
||||
event: error {message} 失败
|
||||
GET/POST /api/agent/config → 配置读写(key 打码回显)
|
||||
|
||||
@@ -38,9 +40,13 @@ _CFG_KEYS = {"api_base": "agent_api_base",
|
||||
"default_serial": "agent_default_serial",
|
||||
"max_steps": "agent_max_steps"}
|
||||
|
||||
# 推理链落库上限(字符):只留够回看的量,避免会话消息无限膨胀
|
||||
_REASONING_KEEP = 6000
|
||||
|
||||
|
||||
# ---------- 运行状态(单实例 + 事件队列) ----------
|
||||
_run = {"id": None, "state": "idle", "prompt": "", "serial": "",
|
||||
"answer": "", "error": "",
|
||||
"answer": "", "error": "", "usage": {},
|
||||
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
|
||||
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
|
||||
_stop_events = {} # run_id -> threading.Event(用户中断)
|
||||
@@ -1192,7 +1198,7 @@ def agent_run():
|
||||
_run.update(id=run_id, state="running", prompt=prompt,
|
||||
serial=serial, conv_id=conv_id,
|
||||
started=_dt.now().strftime("%H:%M:%S"),
|
||||
answer="", error="")
|
||||
answer="", error="", usage={})
|
||||
# history 保留(多轮上下文),由会话/「新建会话」管理
|
||||
_queues[run_id] = queue.Queue()
|
||||
_stop_events[run_id] = threading.Event()
|
||||
@@ -1244,6 +1250,7 @@ def agent_run_status():
|
||||
"started": _run.get("started", ""),
|
||||
"answer": (_run.get("answer") or "")[:4000],
|
||||
"error": (_run.get("error") or "")[:400],
|
||||
"usage": _run.get("usage") or {},
|
||||
"history": hist[-16:]})
|
||||
|
||||
|
||||
@@ -1345,9 +1352,22 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from mcp_agent.agent import Agent
|
||||
|
||||
# 推理链(reasoning_content)累计——单纯流式展示会随页面刷新丢失,
|
||||
# 落库后可在会话里折叠回看(只存文本,不回灌给模型)
|
||||
reason_parts = []
|
||||
reason_len = 0
|
||||
|
||||
def on_delta(text, kind):
|
||||
nonlocal reason_len
|
||||
if kind == "reasoning" and reason_len < _REASONING_KEEP:
|
||||
reason_parts.append(text)
|
||||
reason_len += len(text)
|
||||
q.put(("delta", {"text": text, "kind": kind}))
|
||||
|
||||
def on_usage(usage):
|
||||
"""每次模型调用完成 → 推累计用量(前端实时刷新 token 计数)。"""
|
||||
q.put(("usage", dict(usage)))
|
||||
|
||||
tool_seq = [] # 本轮工具序列(任务级配方提炼用)
|
||||
tool_trace = [] # 结构化轨迹:{tool, args, result}(动作经验提炼用,判成败)
|
||||
|
||||
@@ -1451,7 +1471,8 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
on_delta=on_delta, on_tool=on_tool,
|
||||
should_stop=lambda: bool(
|
||||
stop_evt and stop_evt.is_set()),
|
||||
extra_context=recall_ctx)
|
||||
extra_context=recall_ctx,
|
||||
on_usage=on_usage)
|
||||
except Exception as e:
|
||||
if _mcp_unreachable(e):
|
||||
raise RuntimeError(
|
||||
@@ -1460,13 +1481,22 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
|
||||
# 整体超时保护:卡死时结束,释放单实例
|
||||
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
|
||||
usage = dict(getattr(agent, "usage", None) or {})
|
||||
with _lock:
|
||||
_run["state"] = "done"
|
||||
_run["answer"] = answer
|
||||
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)
|
||||
_run["usage"] = usage
|
||||
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)。
|
||||
# usage/reasoning 仅用于前端展示与落库,不进模型上下文(读回时只取 role/content)
|
||||
hist = _run.setdefault("history", [])
|
||||
hist.append({"role": "user", "content": prompt[:2000]})
|
||||
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
|
||||
turn = {"role": "assistant", "content": (answer or "")[:4000]}
|
||||
if usage:
|
||||
turn["usage"] = usage
|
||||
reason = "".join(reason_parts).strip()
|
||||
if reason:
|
||||
turn["reasoning"] = reason[:_REASONING_KEEP]
|
||||
hist.append(turn)
|
||||
_run["history"] = hist[-24:]
|
||||
# 会话落库:本轮追加写回(新会话自动用首条消息作标题)。
|
||||
# 后台线程 db 访问需 app context。
|
||||
@@ -1513,7 +1543,7 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
"image": None}))
|
||||
except Exception as e:
|
||||
_log.warning(f"经验保存异常: {e}")
|
||||
q.put(("done", {"answer": answer}))
|
||||
q.put(("done", {"answer": answer, "usage": usage}))
|
||||
except Exception as e:
|
||||
_log.warning(f"Agent 运行异常: {e}")
|
||||
# 诊断:打印消息结构(tool_calls 与 tool 消息配对检查)
|
||||
@@ -1529,6 +1559,11 @@ def _agent_thread(run_id, prompt, serial, cfg):
|
||||
with _lock:
|
||||
_run["state"] = "error"
|
||||
_run["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
||||
# 失败也保留已花费的 token(前端仍能展示本轮用量;agent 可能未建出来)
|
||||
try:
|
||||
_run["usage"] = dict(agent.usage or {})
|
||||
except Exception:
|
||||
pass
|
||||
q.put(("error", {"message": str(e)[:200]}))
|
||||
finally:
|
||||
q.put(None) # 关闭 SSE
|
||||
|
||||
Reference in New Issue
Block a user