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:
2026-09-10 18:22:20 +08:00
parent 1c2b440dce
commit 4b5b836d31
8 changed files with 477 additions and 31 deletions
+14 -2
View File
@@ -1186,15 +1186,19 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
```json ```json
{"ok": true, "state": "running"|"idle"|"done", "run_id": "...", "prompt": "...", {"ok": true, "state": "running"|"idle"|"done", "run_id": "...", "prompt": "...",
"serial": "...", "started": "10:00:01", "answer": "...", "error": "", "serial": "...", "started": "10:00:01", "answer": "...", "error": "",
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "calls": 0},
"history": [{"role": "user", "content": "..."}]} "history": [{"role": "user", "content": "..."}]}
``` ```
`usage` 为本轮累计 token 用量(`calls` = 模型调用次数;运行中实时增长,失败也保留已花费的)。
### GET /api/agent/stream?run_id= ### GET /api/agent/stream?run_id=
订阅事件流(SSE,EventSource)。事件: 订阅事件流(SSE,EventSource)。事件:
- `event: delta` `{text, kind: content|reasoning}` — 流式文本增量 - `event: delta` `{text, kind: content|reasoning}` — 流式文本增量
- `event: step` `{tool, args, image?}` — 工具调用完成(MCP 步骤,image 为缩略截图)。另有三类伪卡片:`tool="🧠 经验记忆"` 表示命中任务级经验(args 形如「命中 N 条同类历史经验,已注入参考:<配方摘要>」)或本轮已写入经验库;`tool="🧠 动作经验"` 表示命中**可复用动作**(「命中 N 个可复用动作,已注入参考:<动作名>」,执行前注入)或本轮已沉淀动作(「已沉淀 N 个可复用动作」,含元素定位、禁坐标) - `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`) - `event: error` `{message}` — 失败(若因 MCP Server 未启动/不可达,message 为明确文案「MCP server(8033) 不可达 …」,不再是 SDK 原始的 `Server returned an error response`)
- 空闲时每 15s 发一行 `: keepalive` 注释防超时;`done`/`error` 后关流 - 空闲时每 15s 发一行 `: keepalive` 注释防超时;`done`/`error` 后关流
@@ -1234,10 +1238,18 @@ AI 可用设备列表(在线状态 + 是否有任务运行,前端据此把 b
**响应**: **响应**:
```json ```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": "..."} "created_at": "...", "updated_at": "..."}
``` ```
> assistant 消息可带 `usage`(本轮 token 用量)与 `reasoning`(推理链,上限 `_REASONING_KEEP`=6000 字符)。
> 两者**仅供前端展示/回看**,回灌模型上下文时只取 `role`/`content`(见 `_agent_thread`)。
### DELETE /api/agent/conversations/<conv_id> ### DELETE /api/agent/conversations/<conv_id>
删除会话(消息一并删除,不可恢复)。 删除会话(消息一并删除,不可恢复)。
+13
View File
@@ -346,6 +346,19 @@ AI 控制台(顶级 Tab)右上角两个模态框,管理自进化记忆:
- **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。 - **🎬 动作库**:动作级经验(`agent_action`)——命名动作(可含 1~N 步)+ 编辑器 schema 步骤 + **元素定位(禁坐标)**;由任务成功后从**成功步骤**自动蒸馏,执行前按名/别名召回注入;面板支持查看/编辑/删除/手动新建(保存经服务端校验,坐标步骤被拒)。
- **会话列表显示会话 ID**(前 8 位,等宽小字),点击即复制完整 ID——便于反馈问题时引用 `conv=<id>`。 - **会话列表显示会话 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`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略: > **蒸馏健壮性(2026-09-10)**:经验/动作靠**模型蒸馏**落库。推理型模型会把 token 预算烧在 `reasoning` 上,导致 `content` 为空或被截断(`finish_reason=length`)→ 早期只读 `content`,经验/动作被**静默丢弃**("小红书·苏州饭店"案例)。现策略:
> 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复; > 1. 蒸馏调用**关闭推理**:`"thinking": {"type": "disabled"}`(该代理支持;实测关掉后 reasoning=0、正文正常,配方 3/3 合格)——这是关键修复;
> 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试); > 2. 配方用**纯文本问法**(不要放可照抄的占位示例,否则模型会原样当配方存下来)+ 质量门槛 `_recipe_ok`(过短/含省略号占位 → 丢弃并重试);
+2 -2
View File
@@ -108,7 +108,7 @@
- **任务参数放各自 `tasks/<app>/task.py` 顶部,不放 `config.py`** - **任务参数放各自 `tasks/<app>/task.py` 顶部,不放 `config.py`**
- **生产环境(220)默认只读**:任何写操作(改文件/重启容器/部署)都必须先经负责人确认 - **生产环境(220)默认只读**:任何写操作(改文件/重启容器/部署)都必须先经负责人确认
- **数据库是 SQLite**(`data/users.db`,WAL 模式):运行时数据不提交 git - **数据库是 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 行/页,不要移除分页逻辑 - **监控页/大列表已加分页**:100 台设备也只渲染 10 行/页,不要移除分页逻辑
- **任务批量触发已错峰**(`_START_STAGGER_SEC`):避免大量设备同时启动造成 adb 连接风暴,不要移除 - **任务批量触发已错峰**(`_START_STAGGER_SEC`):避免大量设备同时启动造成 adb 连接风暴,不要移除
@@ -196,7 +196,7 @@ tasks/<app>/
| 文件 | 内容 | | 文件 | 内容 |
|------|------| |------|------|
| `templates/admin/monitor.html` | HTML 结构 + CSS + `<script src>` 引用 | | `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。 改完**强刷浏览器**(Cmd/Ctrl+Shift+R),必要时重启 web_server。
+84 -2
View File
@@ -8,6 +8,8 @@
- content/reasoning_content 增量逐 chunk 回调(kind 区分) - content/reasoning_content 增量逐 chunk 回调(kind 区分)
- tool_calls 分片累积(arguments 按 index 拼接),流结束后统一执行 - tool_calls 分片累积(arguments 按 index 拼接),流结束后统一执行
- 截图(de_screenshot)图像转 image_url 追加下一轮,同时 on_tool 回调带缩略 - 截图(de_screenshot)图像转 image_url 追加下一轮,同时 on_tool 回调带缩略
- token 用量:请求带 stream_options.include_usage,按「每次模型调用」累计,
on_usage 回调吐出累计值(Web 控制台展示)
""" """
import base64 import base64
import json import json
@@ -22,6 +24,10 @@ _log = logging.getLogger("agent")
S = AgentSettings() S = AgentSettings()
class _UsageUnsupported(RuntimeError):
"""模型/网关不认 stream_options.include_usage(400/422 或报错点名该字段)——降级重试用。"""
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。 SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
工作规范: 工作规范:
@@ -56,8 +62,15 @@ class Agent:
# 回调(Web 展示用,均可选): # 回调(Web 展示用,均可选):
# on_delta(text, kind) kind: content | reasoning —— 流式文本增量 # on_delta(text, kind) kind: content | reasoning —— 流式文本增量
# on_tool(step) step: {tool, args, result, image} —— 工具调用完成 # 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_delta = None
self.on_tool = 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 self._mcp = None
# ---------- MCP 工具桥 ---------- # ---------- MCP 工具桥 ----------
@@ -88,7 +101,23 @@ class Agent:
# ---------- 模型调用(流式) ---------- # ---------- 模型调用(流式) ----------
async def _chat_stream(self): 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 = { body = {
"model": self.s.model, "model": self.s.model,
"messages": self.messages, "messages": self.messages,
@@ -96,6 +125,8 @@ class Agent:
"max_tokens": 4096, "max_tokens": 4096,
"stream": True, "stream": True,
} }
if with_usage:
body["stream_options"] = {"include_usage": True}
headers = {"Authorization": f"Bearer {self.s.api_key}", headers = {"Authorization": f"Bearer {self.s.api_key}",
"Content-Type": "application/json"} "Content-Type": "application/json"}
url = f"{self.s.api_base.rstrip('/')}/chat/completions" 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: async with client.stream("POST", url, json=body, headers=headers) as r:
if r.status_code != 200: if r.status_code != 200:
text = (await r.aread()).decode(errors="replace") 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]}") raise RuntimeError(f"模型 API HTTP {r.status_code}: {text[:300]}")
async for line in r.aiter_lines(): async for line in r.aiter_lines():
if not line.startswith("data:"): if not line.startswith("data:"):
@@ -115,6 +151,37 @@ class Agent:
except json.JSONDecodeError: except json.JSONDecodeError:
continue 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): async def _execute_tool(self, name, arguments):
"""执行 MCP 工具,返回 (文本结果, image_data_or_None)。""" """执行 MCP 工具,返回 (文本结果, image_data_or_None)。"""
@@ -161,7 +228,7 @@ class Agent:
# ---------- 主循环(流式) ---------- # ---------- 主循环(流式) ----------
async def run_stream(self, prompt: str, serial: str = "", async def run_stream(self, prompt: str, serial: str = "",
history=None, on_delta=None, on_tool=None, 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": 文本}] 列表, history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
@@ -170,9 +237,15 @@ class Agent:
on_tool(step):工具调用完成(实时显示 MCP 步骤) on_tool(step):工具调用完成(实时显示 MCP 步骤)
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用) should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
extra_context:附加文本(经验记忆注入,放在 system prompt 末尾) extra_context:附加文本(经验记忆注入,放在 system prompt 末尾)
on_usage(usage):每完成一次模型调用回调一次(累计值,见 self.usage)
本轮累计 token 用量同时留在 self.usage(调用方可直接读)。
""" """
self.on_delta = on_delta self.on_delta = on_delta
self.on_tool = on_tool 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 target = serial or self.s.default_serial
sys_txt = SYSTEM_PROMPT sys_txt = SYSTEM_PROMPT
if target: if target:
@@ -193,9 +266,13 @@ class Agent:
tool_acc = {} # index -> {id, name, args} tool_acc = {} # index -> {id, name, args}
has_tool = False has_tool = False
retried = False retried = False
call_usage = None # 本次模型调用的 usage(末尾 chunk 带)
while True: while True:
call_usage = None # 重试时丢弃上一次(未完成)的用量
try: try:
async for chunk in self._chat_stream(): async for chunk in self._chat_stream():
if chunk.get("usage"):
call_usage = chunk["usage"]
choice = (chunk.get("choices") or [{}])[0] choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {} delta = choice.get("delta") or {}
text = delta.get("content") text = delta.get("content")
@@ -228,6 +305,7 @@ class Agent:
continue continue
raise raise
self._accumulate_usage(call_usage)
full_content = "".join(content_parts) full_content = "".join(content_parts)
if has_tool: if has_tool:
@@ -272,13 +350,17 @@ class Agent:
"已完成的部分、当前设备状态、未能完成的原因与下一步建议。" "已完成的部分、当前设备状态、未能完成的原因与下一步建议。"
"不要调用任何工具。"}) "不要调用任何工具。"})
parts = [] parts = []
call_usage = None
async for chunk in self._chat_stream(): async for chunk in self._chat_stream():
if chunk.get("usage"):
call_usage = chunk["usage"]
delta = (chunk.get("choices") or [{}])[0].get("delta") or {} delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
text = delta.get("content") text = delta.get("content")
if text: if text:
parts.append(text) parts.append(text)
if on_delta: if on_delta:
on_delta(text, "content") on_delta(text, "content")
self._accumulate_usage(call_usage)
self.tools_schema = saved_tools self.tools_schema = saved_tools
summary = "".join(parts) summary = "".join(parts)
return summary or "(已达步骤上限,模型未能生成总结)" return summary or "(已达步骤上限,模型未能生成总结)"
+131 -17
View File
@@ -120,6 +120,11 @@ function pollRunState(){
? '将操作:' + document.getElementById('agent-target-select').value : ''); ? '将操作:' + document.getElementById('agent-target-select').value : '');
} }
document.getElementById('btn-agent-send').disabled = running; 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); } // 流异常丢失时复位 if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
}); });
} }
@@ -173,11 +178,91 @@ function newAssistantMsg(){
clearEmpty(); clearEmpty();
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'agent-msg assistant'; 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); chat.appendChild(div);
scrollChat(); scrollChat();
return div; 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(){ function clearEmpty(){
const empty = document.querySelector('#agent-chat .agent-empty'); const empty = document.querySelector('#agent-chat .agent-empty');
if(empty) empty.remove(); if(empty) empty.remove();
@@ -197,6 +282,9 @@ function bindChatScroll(){
} }
function clearAgentChat(){ function clearAgentChat(){
document.getElementById('agent-chat').innerHTML = ''; document.getElementById('agent-chat').innerHTML = '';
_convUsage = {}; // 换会话:token 累计重新算
_liveUsage = {};
updateSessionTokens();
} }
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ================== // ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
@@ -293,15 +381,26 @@ function loadCurrentConvMessages(){
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{ apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
if(!r||!r.ok)return; if(!r||!r.ok)return;
chat.innerHTML = ''; chat.innerHTML = '';
_convUsage = {};
_liveUsage = {};
(r.messages||[]).forEach(m=>{ (r.messages||[]).forEach(m=>{
const txt = (m.content||'').trim(); const txt = (m.content||'').trim();
if(!txt) return; if(m.role==='user'){ if(txt) addUserMsg(txt); return; }
if(m.role==='user'){ addUserMsg(txt); } if(!txt && !m.reasoning && !(m.usage && m.usage.total_tokens)) return;
else{ const div = newAssistantMsg();
const div = newAssistantMsg(); if(txt){ div._md = txt; _renderMd(div); }
div.querySelector('.agent-text').textContent = txt; 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){ if(!chat.children.length){
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令' chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
+ '(先选 🎯 目标设备)…</div>'; + '(先选 🎯 目标设备)…</div>';
@@ -379,21 +478,29 @@ function listenStream(runId){
es.addEventListener('delta', ev=>{ es.addEventListener('delta', ev=>{
if(!msgEl) msgEl = newAssistantMsg(); if(!msgEl) msgEl = newAssistantMsg();
const d = JSON.parse(ev.data); const d = JSON.parse(ev.data);
const textEl = msgEl.querySelector('.agent-text');
if(d.kind === 'reasoning'){ if(d.kind === 'reasoning'){
let r = msgEl.querySelector('.reasoning'); _appendReasoning(msgEl, d.text || '');
if(!r){
r = document.createElement('div');
r.className = 'reasoning';
msgEl.insertBefore(r, textEl);
}
r.textContent += d.text;
}else{ }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(); 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=>{ es.addEventListener('step', ev=>{
const d = JSON.parse(ev.data); const d = JSON.parse(ev.data);
followSerialFromArgs(d.args); followSerialFromArgs(d.args);
@@ -417,10 +524,16 @@ function listenStream(runId){
es.addEventListener('done', ev=>{ es.addEventListener('done', ev=>{
const d = JSON.parse(ev.data); const d = JSON.parse(ev.data);
if(d.answer){ if(d.answer || d.usage || !msgEl){
if(!msgEl) msgEl = newAssistantMsg(); 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(); endRun();
renderConvList(); // 落库已完成:刷新标题/时间/轮数 renderConvList(); // 落库已完成:刷新标题/时间/轮数
}); });
@@ -432,6 +545,7 @@ function listenStream(runId){
}catch(e){} }catch(e){}
if(!msgEl) msgEl = newAssistantMsg(); if(!msgEl) msgEl = newAssistantMsg();
msgEl.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg); msgEl.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg);
_liveUsage = {}; // 本轮可能已花费 token,但结果无效——不并入会话累计
endRun(); endRun();
}); });
+163
View File
@@ -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('');
}
+28 -1
View File
@@ -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.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{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 .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-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{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} .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 id="tab-agent" class="tab-panel">
<div class="agent-shell"> <div class="agent-shell">
<div class="agent-topbar"> <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"> <div class="agent-top-actions">
<span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span> <span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button> <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(含初始化)) --> <!-- 前端模块(按依赖顺序加载:base → list → 页面模块 → admin(含初始化)) -->
<script src="/static/admin/base.js"></script> <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/list.js"></script>
<script src="/static/admin/monitor.js"></script> <script src="/static/admin/monitor.js"></script>
<script src="/static/admin/editor.js"></script> <script src="/static/admin/editor.js"></script>
+42 -7
View File
@@ -5,7 +5,9 @@
GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅): GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅):
event: delta {text, kind: content|reasoning} 流式文本增量 event: delta {text, kind: content|reasoning} 流式文本增量
event: step {tool, args, image?} 工具调用完成(MCP 步骤) 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} 失败 event: error {message} 失败
GET/POST /api/agent/config → 配置读写(key 打码回显) GET/POST /api/agent/config → 配置读写(key 打码回显)
@@ -38,9 +40,13 @@ _CFG_KEYS = {"api_base": "agent_api_base",
"default_serial": "agent_default_serial", "default_serial": "agent_default_serial",
"max_steps": "agent_max_steps"} "max_steps": "agent_max_steps"}
# 推理链落库上限(字符):只留够回看的量,避免会话消息无限膨胀
_REASONING_KEEP = 6000
# ---------- 运行状态(单实例 + 事件队列) ---------- # ---------- 运行状态(单实例 + 事件队列) ----------
_run = {"id": None, "state": "idle", "prompt": "", "serial": "", _run = {"id": None, "state": "idle", "prompt": "", "serial": "",
"answer": "", "error": "", "answer": "", "error": "", "usage": {},
"history": []} # 多轮对话历史 [{role: user|assistant, content}] "history": []} # 多轮对话历史 [{role: user|assistant, content}]
_queues = {} # run_id -> queue.Queue(SSE 消费者读取) _queues = {} # run_id -> queue.Queue(SSE 消费者读取)
_stop_events = {} # run_id -> threading.Event(用户中断) _stop_events = {} # run_id -> threading.Event(用户中断)
@@ -1192,7 +1198,7 @@ def agent_run():
_run.update(id=run_id, state="running", prompt=prompt, _run.update(id=run_id, state="running", prompt=prompt,
serial=serial, conv_id=conv_id, serial=serial, conv_id=conv_id,
started=_dt.now().strftime("%H:%M:%S"), started=_dt.now().strftime("%H:%M:%S"),
answer="", error="") answer="", error="", usage={})
# history 保留(多轮上下文),由会话/「新建会话」管理 # history 保留(多轮上下文),由会话/「新建会话」管理
_queues[run_id] = queue.Queue() _queues[run_id] = queue.Queue()
_stop_events[run_id] = threading.Event() _stop_events[run_id] = threading.Event()
@@ -1244,6 +1250,7 @@ def agent_run_status():
"started": _run.get("started", ""), "started": _run.get("started", ""),
"answer": (_run.get("answer") or "")[:4000], "answer": (_run.get("answer") or "")[:4000],
"error": (_run.get("error") or "")[:400], "error": (_run.get("error") or "")[:400],
"usage": _run.get("usage") or {},
"history": hist[-16:]}) "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__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcp_agent.agent import Agent from mcp_agent.agent import Agent
# 推理链(reasoning_content)累计——单纯流式展示会随页面刷新丢失,
# 落库后可在会话里折叠回看(只存文本,不回灌给模型)
reason_parts = []
reason_len = 0
def on_delta(text, kind): 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})) q.put(("delta", {"text": text, "kind": kind}))
def on_usage(usage):
"""每次模型调用完成 → 推累计用量(前端实时刷新 token 计数)。"""
q.put(("usage", dict(usage)))
tool_seq = [] # 本轮工具序列(任务级配方提炼用) tool_seq = [] # 本轮工具序列(任务级配方提炼用)
tool_trace = [] # 结构化轨迹:{tool, args, result}(动作经验提炼用,判成败) 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, on_delta=on_delta, on_tool=on_tool,
should_stop=lambda: bool( should_stop=lambda: bool(
stop_evt and stop_evt.is_set()), stop_evt and stop_evt.is_set()),
extra_context=recall_ctx) extra_context=recall_ctx,
on_usage=on_usage)
except Exception as e: except Exception as e:
if _mcp_unreachable(e): if _mcp_unreachable(e):
raise RuntimeError( raise RuntimeError(
@@ -1460,13 +1481,22 @@ def _agent_thread(run_id, prompt, serial, cfg):
# 整体超时保护:卡死时结束,释放单实例 # 整体超时保护:卡死时结束,释放单实例
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900)) answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
usage = dict(getattr(agent, "usage", None) or {})
with _lock: with _lock:
_run["state"] = "done" _run["state"] = "done"
_run["answer"] = answer _run["answer"] = answer
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀) _run["usage"] = usage
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)。
# usage/reasoning 仅用于前端展示与落库,不进模型上下文(读回时只取 role/content)
hist = _run.setdefault("history", []) hist = _run.setdefault("history", [])
hist.append({"role": "user", "content": prompt[:2000]}) 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:] _run["history"] = hist[-24:]
# 会话落库:本轮追加写回(新会话自动用首条消息作标题)。 # 会话落库:本轮追加写回(新会话自动用首条消息作标题)。
# 后台线程 db 访问需 app context。 # 后台线程 db 访问需 app context。
@@ -1513,7 +1543,7 @@ def _agent_thread(run_id, prompt, serial, cfg):
"image": None})) "image": None}))
except Exception as e: except Exception as e:
_log.warning(f"经验保存异常: {e}") _log.warning(f"经验保存异常: {e}")
q.put(("done", {"answer": answer})) q.put(("done", {"answer": answer, "usage": usage}))
except Exception as e: except Exception as e:
_log.warning(f"Agent 运行异常: {e}") _log.warning(f"Agent 运行异常: {e}")
# 诊断:打印消息结构(tool_calls 与 tool 消息配对检查) # 诊断:打印消息结构(tool_calls 与 tool 消息配对检查)
@@ -1529,6 +1559,11 @@ def _agent_thread(run_id, prompt, serial, cfg):
with _lock: with _lock:
_run["state"] = "error" _run["state"] = "error"
_run["error"] = f"{type(e).__name__}: {str(e)[:200]}" _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]})) q.put(("error", {"message": str(e)[:200]}))
finally: finally:
q.put(None) # 关闭 SSE q.put(None) # 关闭 SSE