Compare commits
4 Commits
72418a5321
...
54db4fc348
| Author | SHA1 | Date |
|---|---|---|
|
|
54db4fc348 | |
|
|
2066947061 | |
|
|
c655ce0a76 | |
|
|
e1d5bf6788 |
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -5,6 +5,18 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.58.27 — 2026-07-15
|
||||||
|
|
||||||
|
- 对话卡片新增「复制」按钮:鼠标悬停在任意一条消息上即出现,一键把该条正文原文(Markdown 源码,不带渲染样式)复制到剪贴板,复制成功会短暂提示「✓ 已复制」。
|
||||||
|
|
||||||
|
## 0.58.26 — 2026-07-15
|
||||||
|
|
||||||
|
- 点「停止」中断回复后,对话底部此前什么都不剩(一闪即被收尾刷新冲掉);现在会留下一条居中的「已停止 · 你中断了上次运行」提示,刷新页面、切换任务再回来都在,直到下次发消息才消失。
|
||||||
|
|
||||||
|
## 0.58.25 — 2026-07-15
|
||||||
|
|
||||||
|
- 生成 PPT 等场景此前会自动切到更擅长该任务的指定模型;现在若你已经手动选了国际旗舰模型,就尊重你的选择、不再自动切换,继续用你选的模型来做。
|
||||||
|
|
||||||
## 0.58.21 — 2026-07-13
|
## 0.58.21 — 2026-07-13
|
||||||
|
|
||||||
- 任务运行出错时不再"悄无声息":此前错误提示一闪即被刷掉,发「继续」也像没反应;现在对话里会一直显示「上次运行出错」的红色错误卡(写明原因和重试方式),刷新页面、切换任务再回来都在,直到下次运行开始才消失。
|
- 任务运行出错时不再"悄无声息":此前错误提示一闪即被刷掉,发「继续」也像没反应;现在对话里会一直显示「上次运行出错」的红色错误卡(写明原因和重试方式),刷新页面、切换任务再回来都在,直到下次运行开始才消失。
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ yaml 是手填的,probe 用真实调用对账(basic_chat/parallel_tools/thinking
|
||||||
|
|
||||||
**用户私有 skill**(2026-06-11):registry 收有序来源列表——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写)。取舍:① **user wins 同名覆盖**(核心用例是"copy 内置再改",覆盖只作用于本人会话,blast radius 锁死),覆盖显式标注不静默;② **创作走 host-side `save_skill`/`fork_skill`**——fs 工具的 base_dir 跨 backend 够不到 `user_root/.skills`,host 工具一个落点两模式通吃;③ 用户 skill 加载失败收进 `load_errors` 注入 prompt 提示修,不崩整次扫描。
|
**用户私有 skill**(2026-06-11):registry 收有序来源列表——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写)。取舍:① **user wins 同名覆盖**(核心用例是"copy 内置再改",覆盖只作用于本人会话,blast radius 锁死),覆盖显式标注不静默;② **创作走 host-side `save_skill`/`fork_skill`**——fs 工具的 base_dir 跨 backend 够不到 `user_root/.skills`,host 工具一个落点两模式通吃;③ 用户 skill 加载失败收进 `load_errors` 注入 prompt 提示修,不崩整次扫描。
|
||||||
|
|
||||||
**Skill 定向模型**(frontmatter `model:`,2026-07-06):内置 skill 声明"该工作流用这个模型最好"(如 ppt→glm.pro52)。**单一执行点**:对话中 `load_skill` 命中 → run 内热切(loop 换 `self.llm/self.caps`,下一轮生效)+ 持久化 task 模型;切失败降级原模型。取舍:**跳档位门控**(产品决策,任何档位可用);**只信内置 skill**(用户 skill 的 model 忽略,防自写 frontmatter 绕门控);**不自动切回**("skill 结束"不可判定);**不设 DB 开关、不做建 task 预切**——frontmatter 那一行本身就是热配置(删行即停,per-skill 粒度),全局开关是第二事实源、预切是第二执行点,都砍。
|
**Skill 定向模型**(frontmatter `model:`,2026-07-06):内置 skill 声明"该工作流用这个模型最好"(如 ppt→glm.pro52)。**单一执行点**:对话中 `load_skill` 命中 → run 内热切(loop 换 `self.llm/self.caps`,下一轮生效)+ 持久化 task 模型;切失败降级原模型。取舍:**跳档位门控**(产品决策,任何档位可用);**只信内置 skill**(用户 skill 的 model 忽略,防自写 frontmatter 绕门控);**不自动切回**("skill 结束"不可判定);**不设 DB 开关、不做建 task 预切**——frontmatter 那一行本身就是热配置(删行即停,per-skill 粒度),全局开关是第二事实源、预切是第二执行点,都砍;**已选国际旗舰模型(unifyllm 网关族:Claude/GPT/Gemini)豁免切换**(2026-07-15)——定向 pin 本意是给较弱的国产默认模型托底产物质量,用户已主动选了旗舰模型则尊重其选择、不降级(判据:current profile 的 family==unifyllm)。
|
||||||
|
|
||||||
### 3.6 Session 与 Task
|
### 3.6 Session 与 Task
|
||||||
Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 原样 payload);Task = 上层元数据,写 `tasks`。working_dir FS 只存 skill 产物,无 state.json。本地 + SaaS 同一份 schema/ORM,差别只在 `ZCBOT_DB_URL`。字段:`name`=显示名(独立于目录)、`working_dir`=相对 ROOT posix 串(多 task 共享)、`skill`=类型标签。working_dir 在创建入口 eager mkdir;DELETE 走软删(§7.9),FS 一律不动。原子性:PG INSERT 天然;产物走 `atomic_write_text`。
|
Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM 原样 payload);Task = 上层元数据,写 `tasks`。working_dir FS 只存 skill 产物,无 state.json。本地 + SaaS 同一份 schema/ORM,差别只在 `ZCBOT_DB_URL`。字段:`name`=显示名(独立于目录)、`working_dir`=相对 ROOT posix 串(多 task 共享)、`skill`=类型标签。working_dir 在创建入口 eager mkdir;DELETE 走软删(§7.9),FS 一律不动。原子性:PG INSERT 天然;产物走 `atomic_write_text`。
|
||||||
|
|
@ -181,7 +181,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
```
|
```
|
||||||
|
|
||||||
- working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。
|
- working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。
|
||||||
- **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok/cancelled 收回 idle,只有 error 持久。
|
- **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。
|
||||||
- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
|
- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
|
||||||
- **files API 是目录树唯一 mutation 入口,DB-FS 一致性服务端内化**:顶层目录 rename/delete 走 DB-aware 分支(事务锁关联 task、running→409、DB UPDATE 先于 FS、delete 被引用→409)。
|
- **files API 是目录树唯一 mutation 入口,DB-FS 一致性服务端内化**:顶层目录 rename/delete 走 DB-aware 分支(事务锁关联 task、running→409、DB UPDATE 先于 FS、delete 被引用→409)。
|
||||||
- **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。
|
- **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-07-15(sandbox 补 olefile/xlrd/extract-msg 老格式解析依赖,bump 0.58.23)
|
最后更新:2026-07-15(用户停止后「已停止」持久提示,补齐 cancel 终态与 error 卡对称,bump 0.58.26)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,10 @@
|
||||||
|
|
||||||
### 2026-07
|
### 2026-07
|
||||||
|
|
||||||
|
- **07-15 / 0.58.27**:**对话卡片「复制」按钮(仿 GPT/DeepSeek 悬停操作栏)**(用户提议:卡片上加复制等按钮)。`renderMessages` 给每条 user 卡、以及有正文的 assistant 卡末尾挂 `.msg-actions` 栏(纯 tool_calls 轮 / error 卡不挂),默认 `opacity:0` 悬停/`focus-within` 才现、触屏(`hover:none`)常显淡态。复制取的是**正文原文**:点击经 `#chat-stream` 既有 click 委托据卡片 `data-idx` 回 `state.loadedMessages` 取 `payload.content`(Markdown 源,**不从渲染后的 `.body` HTML 反推**),`navigator.clipboard.writeText` 主路径 + `execCommand("copy")` 降级,按钮短暂反馈「✓ 已复制/复制失败」后复位。直播卡不带此栏——流结束后 `loadMessages` 整屏重渲成持久卡自然带上,省一处直播态特判。**范围收敛的理由**:后端是纯追加式会话,无「重跑最后一轮 / 截断历史 / 编辑分叉」语义,故本期只做零后端耦合的复制;重新生成 / 编辑重发若无后端截断支持会变成「复读上一句 / 历史里又追加一条」的语义欺骗,留待后端补重跑/截断能力再单独立项。只改 `chat.js`(生成+委托+剪贴板)+ `dev.html`(CSS),对外无契约变化。
|
||||||
|
- **07-15 / 0.58.26**:**用户停止后「已停止」持久提示(补齐 cancel 终态,与 error 卡对称)**(用户报:点停止后对话底部什么都不剩)。根因同 `2a1bc25d` 案:cancel 时前端 live-append 的 `已停止` badge 会被紧随的收尾 `loadMessages` 整屏重建(`renderMessages` 先 `innerHTML=""`)冲掉——闪一下即消失;而 error 之所以稳,是因为它落 `run_status="error"` 持久态 + `renderPersistedRunError` 每次重渲重派生,cancel 却在收尾直接回 `idle` 不留痕。**方案 B(架构对称):把 cancel 提升为一等持久终态**——`run_status` 是 `Text` 列(非 enum,**无 migration**),`agent.run` 在任一取消路径都 `return "[cancelled]"`(`loop.py`),`_run_agent_bg` 据此收尾写 `run_status="cancelled"`(否则 `idle`)。前端 `renderPersistedRunError`→`renderPersistedRunTerminal` 扩成同时认 error/cancelled:cancelled 渲居中 muted 虚线条「已停止 · 你中断了上次运行」(良性态,不用错误卡的醒目样式),刷新/切任务仍在,下次起新 run(`post_message` 写 running)覆盖清掉。删掉会被冲掉的 live badge(及其死 CSS `.cancelled-badge`)。**未选方案 A(客户端临时 flag)**:它会把刚被 `2a1bc25d` 修掉的「终态挂 live-only DOM」反模式请回来(刷新即死、跨标签页不通、对 broker 的 DB 态机制不可见),且与 10 行外的 error 路径不对称。兼容性:`run_status` 加新值是纯增量(不改既有值语义),蓝绿旧实例读到 `cancelled` 落到 `in ("running","cancelling")` 判断的 else 当良性态、旧前端只认 `error`→优雅退化到今天行为、不崩;全部 run_status 消费点(起 run 409 门/reaper/SSE is_active/cancel 端点/clear_messages)逐一核对 cancelled 均按 idle/error 类良性态处理。filter allowlist + DESIGN §7.4 尾句 + models.py 注释同步。
|
||||||
|
- **07-15 / 0.58.25**:**skill 定向模型:已选国际旗舰模型时豁免切换**(用户反馈:ppt 等 skill 会 pin `glm.pro52`,但用户若已主动选了 Claude/GPT 就不该被切走)。`core/agent_builder.py::_skill_model_switch` 在算出目标 profile 后加一道判据:current profile 的 family==`unifyllm`(网关族,含 Claude/GPT/Gemini)→ 返回 `None` 不切。定向 pin 本意是给较弱的国产默认模型托底产物质量,已在旗舰模型上的用户尊重其选择、不降级。判据放在 DB 写入前(纯字符串检查,无副作用);其余取舍(只信内置 skill / 跳档位门控 / 不自动切回)不变。DESIGN §3.5 记该豁免。对外无契约变化,不影响国产默认档用户的既有热切行为。
|
||||||
|
- **07-15 / 0.58.24**:**畸形 tool_call arguments 就地抢救(salvage),命中即省一次非流式重试**(失败聚集面板同形态跨 4 个工具复发:`write` 21 次/`edit` 8 次/`run_python` 5 次/`shell` 5 次(24h),逐条核对形态一致:`Expecting value: line 1 column 1 (char 0)`——char-0 是 provider 在 wire 上把别处正文字节错标成该 tool_call 的 arguments delta 粘的垃圾前缀,尾部真 JSON 完好并跑到串尾;定层已证 provider-wire 脏、拼接层无辜,见 memory)。机制**工具无关**:`allowed_keys` 每工具从 `executor.schemas()` 的 properties 现取(write/edit/run_python/shell 的 key 集各异但同一套逻辑覆盖,新增工具自动生效)。此前 `_stream_llm` 对畸形轮整轮丢弃 + 降级非流式重 roll(自愈无终态失败,但 ~9% 轮次几十秒延迟 + 重复大生成烧 token)。现在 `_malformed_tool_calls` 命中后**先试就地抢救**:纯函数 `core/salvage.py::salvage_tool_arguments(raw, allowed_keys)` 左→右扫每个 `{` 试 `json.loads(raw[i:])`,第一个满足 (a) parse-to-end 成功 (b) 非空 dict (c) 顶层 key ⊆ 该工具 schema 参数名 的即采纳;双护栏:(a) 挡尾部截断的半个 JSON(拒→回落重试,不冒险写残缺),(c) 挡前缀里自洽的旁支 JSON。`AgentLoop._try_salvage_response` 全有或全无:本轮所有畸形 tool_call 都能抠出才就地改写 `.arguments` 当轮继续,任一抠不出则一个都不动、原样走既有 `_log_malformed_args`+ 非流式重试(零回退风险)。抢救成功记 `usage_events` kind=`tool_salvaged`(`record_salvaged_tool_call`,units={tool,len,salvaged_len,head},cost 0)——与 tool_malformed 成对区分「真丢弃」vs「抢救回」;toolfail 巡检 `where kind='tool_malformed'/'run_error'` 精确匹配,salvage 是恢复不算失败、自然不进面板/告警。纯函数单列 `core/salvage.py`(不 import litellm)是刻意的:loop.py 顶层 import litellm 在本机导入期卡死,把守卫逻辑拆出来 `tests/test_salvage.py` 才能无 litellm 依赖跑(含线上真样本夹具 + 截断/多键/旁支 JSON/尾残渣边界 10 用例)。盲区:provider-wire 抖动本机复现不出、/verify 端到端跑不了,靠单测 + 生产灰度观察。additive 内部机制,不碰对外契约。
|
||||||
- **07-15 / 0.58.23**:**sandbox 补齐老格式(OLE 复合文档)解析依赖**(toolfail 巡检:`run_python/exit` `ModuleNotFoundError: No module named 'olefile'` 3 用户/3 任务,模型在 run_python 里 `import olefile, struct` 手撸解析老 Office/Outlook 二进制件)。`markitdown[...]` 只吃现代 `xlsx/docx/pptx`,盖不到 OLE;建材院高频收到老 `.doc/.xls/.msg` 转发件。按 Dockerfile:46「装齐免 ModuleNotFoundError 摩擦」策略(同 `file` 命令准入线),`requirements.txt` 补 `olefile`(OLE 底层读)+ `xlrd`(老 .xls BIFF,openpyxl 读不了)+ `extract-msg`(Outlook .msg,省手撸)。纯 Python、无重依赖,镜像膨胀忽略;要生效需重建 sandbox 镜像(pip 大层重跑)。additive 内部依赖,不碰对外契约。
|
- **07-15 / 0.58.23**:**sandbox 补齐老格式(OLE 复合文档)解析依赖**(toolfail 巡检:`run_python/exit` `ModuleNotFoundError: No module named 'olefile'` 3 用户/3 任务,模型在 run_python 里 `import olefile, struct` 手撸解析老 Office/Outlook 二进制件)。`markitdown[...]` 只吃现代 `xlsx/docx/pptx`,盖不到 OLE;建材院高频收到老 `.doc/.xls/.msg` 转发件。按 Dockerfile:46「装齐免 ModuleNotFoundError 摩擦」策略(同 `file` 命令准入线),`requirements.txt` 补 `olefile`(OLE 底层读)+ `xlrd`(老 .xls BIFF,openpyxl 读不了)+ `extract-msg`(Outlook .msg,省手撸)。纯 Python、无重依赖,镜像膨胀忽略;要生效需重建 sandbox 镜像(pip 大层重跑)。additive 内部依赖,不碰对外契约。
|
||||||
- **07-13 / 0.58.22**:**run_python script_path 宿主预检 + 路径容错 + code 落盘回退**(失败聚集面板 `run_python/exit`「can't open file」15 次/7 天/13 task,逐条 DB 核对出三种形态):① 形态 1(13/15,没写就跑):模型调 `script_path` 但前文从未 write 过该文件(如 90c8bba9 #29),Docker 执行器原本不预检直接 `docker exec python <path>`,模型拿裸 stderr 自愈慢——现宿主侧 `is_file()` 预检(bind mount 免进容器),返带纠偏提示的 `[Error] script_path not found`(与 host 执行器行为对齐)。② 形态 2(双重拼接,0d7dd9d9 #74):根因是**展示/解析不对称**——fs 工具输出渲染 user_root 相对路径(`tools/base.py::_display` → `吴中伟科技奖/scripts/x.py`),模型原样回传,而 `_container_script_path` 按 workdir 相对拼 → `/workspace/<dir>/<dir>/…`;现 workdir 相对不存在时按 user_root 相对再试一次。③ 形态 3(a395ee0c #134,code+script_path 同给):原本 script_path 优先、文件不存在报错,整段完好 code 被扔掉;现文件不存在且 code 非空 → code 落盘到 script_path 再执行(正合工具描述「先写后跑」引导,schema 描述同步注明)。`DockerExecutor._container_script_path` 重写为 `_prepare_script`(前台+background 两路共用,code 回写在宿主侧故加 user_root 越界防护);`tools/run_python.py` host 路径同语义(`_prepare_script` helper,execute/_execute_background 共用)。`tests/test_executor_docker.py` +4 用例(not found 不起 docker / 前缀容错 / code 落盘 / 越界拒),`tests/test_run_python_script_path.py` +4(host 侧同形态 + 已存在文件不被 code 覆盖)。诊断脚本 `scripts/diag_run_python_noent{,2}.py` 留档。
|
- **07-13 / 0.58.22**:**run_python script_path 宿主预检 + 路径容错 + code 落盘回退**(失败聚集面板 `run_python/exit`「can't open file」15 次/7 天/13 task,逐条 DB 核对出三种形态):① 形态 1(13/15,没写就跑):模型调 `script_path` 但前文从未 write 过该文件(如 90c8bba9 #29),Docker 执行器原本不预检直接 `docker exec python <path>`,模型拿裸 stderr 自愈慢——现宿主侧 `is_file()` 预检(bind mount 免进容器),返带纠偏提示的 `[Error] script_path not found`(与 host 执行器行为对齐)。② 形态 2(双重拼接,0d7dd9d9 #74):根因是**展示/解析不对称**——fs 工具输出渲染 user_root 相对路径(`tools/base.py::_display` → `吴中伟科技奖/scripts/x.py`),模型原样回传,而 `_container_script_path` 按 workdir 相对拼 → `/workspace/<dir>/<dir>/…`;现 workdir 相对不存在时按 user_root 相对再试一次。③ 形态 3(a395ee0c #134,code+script_path 同给):原本 script_path 优先、文件不存在报错,整段完好 code 被扔掉;现文件不存在且 code 非空 → code 落盘到 script_path 再执行(正合工具描述「先写后跑」引导,schema 描述同步注明)。`DockerExecutor._container_script_path` 重写为 `_prepare_script`(前台+background 两路共用,code 回写在宿主侧故加 user_root 越界防护);`tools/run_python.py` host 路径同语义(`_prepare_script` helper,execute/_execute_background 共用)。`tests/test_executor_docker.py` +4 用例(not found 不起 docker / 前缀容错 / code 落盘 / 越界拒),`tests/test_run_python_script_path.py` +4(host 侧同形态 + 已存在文件不被 code 覆盖)。诊断脚本 `scripts/diag_run_python_noent{,2}.py` 留档。
|
||||||
- **07-13 / 0.58.21**:**run 级错误前端持久提示 + 进失败聚集面板 + provider 级致命错误即时告警**(用户报 task 2a1bc25d "ppt3":Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知):错误其实有 emit(SSE error 事件→前端错误卡),但 fetchSse 收尾 `loadMessages()` → `renderMessages` 先 `innerHTML=""` 整屏重建,错误不是持久化消息 → 卡瞬间被冲掉;历史打开更没有(run_error 唯一出口是任务列表红点 hover title)。admin 侧 toolfail 巡检只扫 tool 消息 + tool_malformed,LLM 请求层直接抛的 run 级错误(整轮无 tool 消息)全盲。三改:① chat.js 新增 `renderPersistedRunError()`(挂在 renderMessages 两分支末尾,所有重渲路径统一走):meta.run_status=error 且无 live run → 消息流末尾补「上次运行出错」持久卡(含重试引导),刷新/切任务都在,下次 run 起跑后端清 run_error 自然消失;selectTask 本就先赋 taskMeta 再 renderMessages,时序天然成立。② run 错误留痕:`_run_agent_bg` except 里加 `record_run_error`(usage_events kind=run_error,units={err},cost 0——run_error 列只留最后一次,此行才是完整数据源);`core/toolfail.py` 加第三段扫描聚成 kind=run / tool=(run) 的 cluster,admin 面板/巡检邮件零改动自动带上。③ provider 级致命错误(余额不足/配额/认证,正则关键词)即时邮件 `alert_provider_critical`:这类错误该 provider 上所有 run 全挂,等日巡检 5 次/2 task 阈值太慢;同归一化签名 6h 进程内冷却,SMTP 未配只打日志,全路径静默失败不反噬错误路径。`tests/test_toolfail_malformed.py` +3 用例(run_error 聚集 / 关键词命中+冷却 / 普通异常不告警),FakeSession 分流补第三段。
|
- **07-13 / 0.58.21**:**run 级错误前端持久提示 + 进失败聚集面板 + provider 级致命错误即时告警**(用户报 task 2a1bc25d "ppt3":Zai 余额不足连挂 3 次续跑,前端零提示、admin 零感知):错误其实有 emit(SSE error 事件→前端错误卡),但 fetchSse 收尾 `loadMessages()` → `renderMessages` 先 `innerHTML=""` 整屏重建,错误不是持久化消息 → 卡瞬间被冲掉;历史打开更没有(run_error 唯一出口是任务列表红点 hover title)。admin 侧 toolfail 巡检只扫 tool 消息 + tool_malformed,LLM 请求层直接抛的 run 级错误(整轮无 tool 消息)全盲。三改:① chat.js 新增 `renderPersistedRunError()`(挂在 renderMessages 两分支末尾,所有重渲路径统一走):meta.run_status=error 且无 live run → 消息流末尾补「上次运行出错」持久卡(含重试引导),刷新/切任务都在,下次 run 起跑后端清 run_error 自然消失;selectTask 本就先赋 taskMeta 再 renderMessages,时序天然成立。② run 错误留痕:`_run_agent_bg` except 里加 `record_run_error`(usage_events kind=run_error,units={err},cost 0——run_error 列只留最后一次,此行才是完整数据源);`core/toolfail.py` 加第三段扫描聚成 kind=run / tool=(run) 的 cluster,admin 面板/巡检邮件零改动自动带上。③ provider 级致命错误(余额不足/配额/认证,正则关键词)即时邮件 `alert_provider_critical`:这类错误该 provider 上所有 run 全挂,等日巡检 5 次/2 task 阈值太慢;同归一化签名 6h 进程内冷却,SMTP 未配只打日志,全路径静默失败不反噬错误路径。`tests/test_toolfail_malformed.py` +3 用例(run_error 聚集 / 关键词命中+冷却 / 普通异常不告警),FakeSession 分流补第三段。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.58.23"
|
__version__ = "0.58.27"
|
||||||
|
|
|
||||||
|
|
@ -791,6 +791,11 @@ def build_agent(
|
||||||
profile = (sk.model or "").strip()
|
profile = (sk.model or "").strip()
|
||||||
if not profile or profile == current_profile:
|
if not profile or profile == current_profile:
|
||||||
return None
|
return None
|
||||||
|
# 用户已选国际旗舰模型(unifyllm 网关:Claude / GPT / Gemini)→ 尊重用户选择,
|
||||||
|
# 不切到 skill 定向的模型。定向 pin(如 ppt→glm.pro52)本意是给较弱的国产默认
|
||||||
|
# 模型托底产物质量;已在旗舰模型上的用户无需切换。current_profile = "<family>.<variant>"。
|
||||||
|
if current_profile.split(".", 1)[0] == "unifyllm":
|
||||||
|
return None
|
||||||
new_caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
|
new_caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
|
||||||
new_llm = LLM(new_caps) # 缺 api key 会 raise → loop 捕获转 warn,不切换
|
new_llm = LLM(new_caps) # 缺 api key 会 raise → loop 捕获转 warn,不切换
|
||||||
from sqlalchemy import update as _update
|
from sqlalchemy import update as _update
|
||||||
|
|
|
||||||
79
core/loop.py
79
core/loop.py
|
|
@ -25,8 +25,13 @@ from .context import CHARS_PER_TOKEN, COMPACT_CONTEXT_RATIO, prepare_messages_wi
|
||||||
from .context_fold import maybe_fold
|
from .context_fold import maybe_fold
|
||||||
from .executor import ExecCtx, Executor
|
from .executor import ExecCtx, Executor
|
||||||
from .llm import LLM
|
from .llm import LLM
|
||||||
|
from .salvage import salvage_tool_arguments
|
||||||
from .session import Session
|
from .session import Session
|
||||||
from .storage import record_chat_usage, record_malformed_tool_call
|
from .storage import (
|
||||||
|
record_chat_usage,
|
||||||
|
record_malformed_tool_call,
|
||||||
|
record_salvaged_tool_call,
|
||||||
|
)
|
||||||
from . import pptx_guard
|
from . import pptx_guard
|
||||||
|
|
||||||
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
|
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
|
||||||
|
|
@ -527,6 +532,11 @@ class AgentLoop:
|
||||||
bad = _malformed_tool_calls(response)
|
bad = _malformed_tool_calls(response)
|
||||||
if not bad:
|
if not bad:
|
||||||
return response, False
|
return response, False
|
||||||
|
# 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire),
|
||||||
|
# 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试;
|
||||||
|
# 任一抠不出则一个都不动,原样走下面的丢弃 + 重试(零回退风险)。
|
||||||
|
if self._try_salvage_response(response):
|
||||||
|
return response, False
|
||||||
_log_malformed_args(
|
_log_malformed_args(
|
||||||
self.session.task_id, self.user_id,
|
self.session.task_id, self.user_id,
|
||||||
f"{self.caps.family}.{self.caps.variant}", response,
|
f"{self.caps.family}.{self.caps.variant}", response,
|
||||||
|
|
@ -543,6 +553,73 @@ class AgentLoop:
|
||||||
# 优雅返错给模型,而非在此死循环。
|
# 优雅返错给模型,而非在此死循环。
|
||||||
return response, False
|
return response, False
|
||||||
|
|
||||||
|
def _try_salvage_response(self, response: Any) -> bool:
|
||||||
|
"""尝试就地抢救本轮所有畸形 tool_call 的 arguments;成功才改写并返回 True。
|
||||||
|
|
||||||
|
全有或全无:任一畸形 tool_call 抠不出干净 JSON 就一个都不改、返回 False,让调用方
|
||||||
|
原样走丢弃 + 非流式重试(半抢救 = 一部分执行一部分丢弃,状态不一致,不如整轮重来)。
|
||||||
|
allowed_keys 从各工具 schema 的 properties 现取(单一事实源,新增工具自动生效)。
|
||||||
|
改写用 ensure_ascii=False 保留中文原字节,_execute_tool_call 会正常 json.loads 回来。
|
||||||
|
每条成功抢救记一行 kind=tool_salvaged 留痕 + 发 warn 让前端重建占位段。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
msg = response.choices[0].message
|
||||||
|
tool_calls = list(getattr(msg, "tool_calls", None) or [])
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
allowed_by_tool = {
|
||||||
|
sc["function"]["name"]: set(
|
||||||
|
(sc["function"].get("parameters") or {}).get("properties", {}).keys()
|
||||||
|
)
|
||||||
|
for sc in self.executor.schemas()
|
||||||
|
}
|
||||||
|
# 第一遍:只算,不改。任一失败即整轮放弃。
|
||||||
|
# pending 项:(tc, new_json, orig_len, salvaged_len, head) —— head=原始损坏前 300 字
|
||||||
|
# 过 ascii 转义(消费端编码不可控),留痕用于事后核验前缀形态是否仍是 char-0 型。
|
||||||
|
pending: List[Tuple[Any, str, int, int, str]] = []
|
||||||
|
for tc in tool_calls:
|
||||||
|
raw = (getattr(tc.function, "arguments", None) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
json.loads(raw)
|
||||||
|
continue # 本就合法,不动
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
allowed = allowed_by_tool.get(tc.function.name)
|
||||||
|
if not allowed:
|
||||||
|
return False # 未知工具无白名单可校验,不冒险
|
||||||
|
obj = salvage_tool_arguments(raw, allowed)
|
||||||
|
if obj is None:
|
||||||
|
return False # 抠不出 → 整轮放弃,回落重试
|
||||||
|
new_json = json.dumps(obj, ensure_ascii=False)
|
||||||
|
pending.append((tc, new_json, len(raw), len(new_json), ascii(raw[:300])))
|
||||||
|
if not pending:
|
||||||
|
return False # 无可抢救的畸形(理论上不会到这:调用前已确认 bad 非空)
|
||||||
|
# 第二遍:全部确认可抢救,统一改写 + 留痕。
|
||||||
|
for tc, new_json, orig_len, salvaged_len, head in pending:
|
||||||
|
tc.function.arguments = new_json
|
||||||
|
try:
|
||||||
|
record_salvaged_tool_call(
|
||||||
|
task_id=self.session.task_id,
|
||||||
|
user_id=self.user_id,
|
||||||
|
model_profile=f"{self.caps.family}.{self.caps.variant}",
|
||||||
|
tool=tc.function.name,
|
||||||
|
arg_len=orig_len,
|
||||||
|
salvaged_len=salvaged_len,
|
||||||
|
head=head,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # 留痕失败绝不能打断:arguments 已改好,当轮照常执行
|
||||||
|
self._emit({
|
||||||
|
"type": "warn",
|
||||||
|
"msg": (
|
||||||
|
f"工具调用参数损坏但已就地抢救 "
|
||||||
|
f"{[f'{tc.function.name}({o}->{s})' for tc, _, o, s, _h in pending]},本轮继续"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
def _collect_stream_once(self, llm_messages: List[dict]) -> Tuple[Optional[Any], bool]:
|
def _collect_stream_once(self, llm_messages: List[dict]) -> Tuple[Optional[Any], bool]:
|
||||||
"""跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。
|
"""跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。
|
||||||
返回 (response, cancelled_mid_stream)。"""
|
返回 (response, cancelled_mid_stream)。"""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""从损坏的 tool_call arguments 里抢救完好 JSON 的纯逻辑。
|
||||||
|
|
||||||
|
单独成模块(不 import litellm)是刻意的:core/loop.py 顶层 import litellm 在部分
|
||||||
|
开发机导入期会拉 GitHub cost map 卡死(见 memory),使 import core.loop 无法跑;而
|
||||||
|
本机制的正确性盲区(线上真垃圾前缀本机复现不出)恰恰只能靠单测兜底。把纯函数放这里,
|
||||||
|
tests 可无 litellm 依赖直接 import 校验守卫逻辑。
|
||||||
|
|
||||||
|
定层结论(见 project_deepseek_malformed_toolcall_salvage / DESIGN):畸形 arguments 是
|
||||||
|
provider 在 wire 上把别处正文字节错标成本 tool_call 的 arguments delta,粘在**开头**
|
||||||
|
(char-0 垃圾前缀),尾部真 JSON 完好并跑到串尾。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def salvage_tool_arguments(raw: str, allowed_keys: set) -> Optional[dict]:
|
||||||
|
"""从损坏的 arguments 里抢救出完好的 JSON 对象;抢不出返回 None。
|
||||||
|
|
||||||
|
左→右扫每个 `{`,试 `json.loads(raw[i:])`——第一个满足三条的即采纳:
|
||||||
|
(a) parse-to-end 成功(json.loads 要求整个后缀是合法 JSON,尾部有残渣就失败);
|
||||||
|
(b) 结果是非空 dict;
|
||||||
|
(c) 顶层 key ⊆ 该工具 schema 的参数名。
|
||||||
|
双护栏缺一不可:(a) 挡尾部被截断的半个 JSON(截断则拒绝、回落既有重试,不冒险执行
|
||||||
|
残缺写入);(c) 挡垃圾前缀里恰好自洽的旁支 JSON(如模型正文里引用的 `{...}` 片段)。
|
||||||
|
都不满足返回 None。纯函数、无副作用,便于单测(线上真前缀样本当夹具)。
|
||||||
|
"""
|
||||||
|
# 只可能出现在 '{' 处;非 '{' 直接跳过,省下大部分 json.loads 尝试。
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
i = raw.find("{", start)
|
||||||
|
if i < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
obj = json.loads(raw[i:])
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
start = i + 1
|
||||||
|
continue
|
||||||
|
if isinstance(obj, dict) and obj and set(obj.keys()) <= allowed_keys:
|
||||||
|
return obj
|
||||||
|
# 解析成功但不是想要的 dict(如前缀里一段自洽 JSON):继续往后找,真 JSON 可能在更后面。
|
||||||
|
start = i + 1
|
||||||
|
|
@ -12,7 +12,11 @@ from .engine import (
|
||||||
get_engine,
|
get_engine,
|
||||||
session_scope,
|
session_scope,
|
||||||
)
|
)
|
||||||
from .usage import record_chat_usage, record_malformed_tool_call
|
from .usage import (
|
||||||
|
record_chat_usage,
|
||||||
|
record_malformed_tool_call,
|
||||||
|
record_salvaged_tool_call,
|
||||||
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
NoSubtaskError,
|
NoSubtaskError,
|
||||||
check_no_subtask,
|
check_no_subtask,
|
||||||
|
|
@ -30,6 +34,7 @@ __all__ = [
|
||||||
"get_task",
|
"get_task",
|
||||||
"record_chat_usage",
|
"record_chat_usage",
|
||||||
"record_malformed_tool_call",
|
"record_malformed_tool_call",
|
||||||
|
"record_salvaged_tool_call",
|
||||||
"session_scope",
|
"session_scope",
|
||||||
"update_task",
|
"update_task",
|
||||||
"upsert_task",
|
"upsert_task",
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,9 @@ class Task(Base):
|
||||||
tokens_completion: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
tokens_completion: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
cost_cny: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0)
|
cost_cny: Mapped[Decimal] = mapped_column(Numeric(12, 6), nullable=False, default=0)
|
||||||
# 当前 in-flight 状态(原 runs 表合并入,DESIGN §7.4 简化 / 0004 migration):
|
# 当前 in-flight 状态(原 runs 表合并入,DESIGN §7.4 简化 / 0004 migration):
|
||||||
# idle / running / cancelling / error;ok / cancelled 收尾直接回 idle,
|
# idle / running / cancelling / error / cancelled;ok 收尾回 idle,
|
||||||
# 只有 error 是持久终态(下次起新 run 时由 post_message 清掉)
|
# error(出错)与 cancelled(用户停止)都是持久终态(前端据此补持久卡,
|
||||||
|
# 扛过收尾重渲),下次起新 run 时由 post_message 写 running 覆盖清掉
|
||||||
run_status: Mapped[str] = mapped_column(Text, nullable=False, default="idle")
|
run_status: Mapped[str] = mapped_column(Text, nullable=False, default="idle")
|
||||||
run_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
run_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
# in-flight run 归属实例(0020,蓝绿双实例 RUN.md B 档):起 run 时写 ZCBOT_INSTANCE
|
# in-flight run 归属实例(0020,蓝绿双实例 RUN.md B 档):起 run 时写 ZCBOT_INSTANCE
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,41 @@ def record_malformed_tool_call(
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def record_salvaged_tool_call(
|
||||||
|
*,
|
||||||
|
task_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
model_profile: str,
|
||||||
|
tool: str,
|
||||||
|
arg_len: int,
|
||||||
|
salvaged_len: int,
|
||||||
|
head: str,
|
||||||
|
) -> None:
|
||||||
|
"""记一次从畸形 arguments 里抢救成功的 tool_call(kind=tool_salvaged)。
|
||||||
|
|
||||||
|
与 record_malformed_tool_call 是一对:畸形轮本会整轮丢弃 + 非流式重 roll,salvage
|
||||||
|
命中时改成就地抠出尾部完好 JSON、当轮照常执行,省掉一次重试。此行是留痕(cost 恒 0,
|
||||||
|
tokens 不入 kind=chat 汇总),供「工具失败聚集」面板 / 事后核对区分「真丢弃」与「抢救回」:
|
||||||
|
arg_len=损坏前缀+JSON 的总长,salvaged_len=抠出 JSON 的长,差值即被丢弃的 wire 垃圾前缀长。
|
||||||
|
head 存前 300 字(过 ascii 转义,消费端编码不可控)供核验前缀形态是否仍是 char-0 型。
|
||||||
|
"""
|
||||||
|
with session_scope() as s:
|
||||||
|
s.add(UsageEvent(
|
||||||
|
user_id=user_id,
|
||||||
|
task_id=task_id,
|
||||||
|
message_id=None, # 抢救出的 tool_call 正常执行并入 messages,但此留痕行不与之关联
|
||||||
|
kind="tool_salvaged",
|
||||||
|
model_profile=model_profile,
|
||||||
|
units={
|
||||||
|
"tool": tool,
|
||||||
|
"len": int(arg_len),
|
||||||
|
"salvaged_len": int(salvaged_len),
|
||||||
|
"head": head,
|
||||||
|
},
|
||||||
|
cost_cny=Decimal("0"),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def record_run_error(
|
def record_run_error(
|
||||||
*,
|
*,
|
||||||
task_id: UUID,
|
task_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""salvage_tool_arguments 守卫逻辑单测。
|
||||||
|
|
||||||
|
盲区:线上真垃圾前缀本机复现不出(定层为 provider-wire 抖动),/verify 端到端跑不了 ——
|
||||||
|
这份单测是该机制的主要正确性保障。夹具含 admin「工具失败聚集」面板里的真样本形态
|
||||||
|
(2026-07-15 scheduled 简报 write:char-0 中文正文前缀 + 尾部完好 {"path","content"})。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from core.salvage import salvage_tool_arguments # noqa: E402
|
||||||
|
|
||||||
|
WRITE_KEYS = {"path", "content"}
|
||||||
|
# 与各工具 schema properties 一致(tools/fs.py / run_python.py / shell.py):salvage 线上
|
||||||
|
# 由 executor.schemas() 现取,这里手抄一份钉死"多工具 key 集各异但机制通用"。
|
||||||
|
EDIT_KEYS = {"path", "old_str", "new_str"}
|
||||||
|
RUN_PYTHON_KEYS = {"code", "script_path", "timeout", "background"}
|
||||||
|
SHELL_KEYS = {"command", "timeout", "background"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSalvage(unittest.TestCase):
|
||||||
|
def test_real_char0_prefix_sample(self):
|
||||||
|
"""线上真形态:一段中文正文垃圾前缀 + 尾部完好 write JSON → 抠出干净 dict。"""
|
||||||
|
good = {"path": "/workspace/scheduled-f57075f9/2026-07-15-AI重点新闻简报.txt",
|
||||||
|
"content": "═══ AI 重点新闻简报 ═══\n[1] OpenAI 发布 GPT-5。"}
|
||||||
|
raw = ("到轻量场景。\n\n[2] Claude Opus 4-7-thinking登顶代码榜 | IT之家 | 07/13\n"
|
||||||
|
" Arena平台" + json.dumps(good, ensure_ascii=False))
|
||||||
|
# 前置条件:整串确实解析不了(char-0 是中文,不是 '{')
|
||||||
|
with self.assertRaises(json.JSONDecodeError):
|
||||||
|
json.loads(raw)
|
||||||
|
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), good)
|
||||||
|
|
||||||
|
def test_edit_char0_prefix_sample(self):
|
||||||
|
"""同形态发生在 edit(2026-07-15 面板真前缀):key 集 {path,old_str,new_str} 也命中。"""
|
||||||
|
good = {"path": "/workspace/十五五KGJ项目/sections/03_关键技术研究内容及技术路线.md",
|
||||||
|
"old_str": "补充实验的迭代执行机制:分三批滚动执行。",
|
||||||
|
"new_str": "补充实验的迭代执行机制:序贯实验设计,分三批滚动执行。"}
|
||||||
|
raw = '描述符向量,为后续正向预测模型提供"宏观性能' + json.dumps(good, ensure_ascii=False)
|
||||||
|
self.assertEqual(salvage_tool_arguments(raw, EDIT_KEYS), good)
|
||||||
|
|
||||||
|
def test_run_python_char0_prefix_sample(self):
|
||||||
|
"""同形态发生在 run_python(真前缀是 python list repr 碎片):key 集 {code,...} 命中。
|
||||||
|
code 值里含 import/换行/引号,parse-to-end 照样抠回(全在 JSON 字符串转义内)。"""
|
||||||
|
good = {"code": "import olefile, struct\n\npath = '/workspace/GB16911/水泥.doc'\n"
|
||||||
|
"ole = olefile.OleFileIO(path)\nprint(ole.listdir())"}
|
||||||
|
raw = "性质', '选择除尘', '测试孔', 'HJ 1045', '合理选择']\n" + json.dumps(good, ensure_ascii=False)
|
||||||
|
self.assertEqual(salvage_tool_arguments(raw, RUN_PYTHON_KEYS), good)
|
||||||
|
|
||||||
|
def test_shell_char0_prefix_sample(self):
|
||||||
|
"""同形态发生在 shell(真前缀含上下标/全角括号):key 集 {command,...} 命中。
|
||||||
|
command 值里含 heredoc `<< 'PYEOF'` + 多行 python,全在 JSON 字符串内不破坏解析。"""
|
||||||
|
good = {"command": "cd /workspace/申报书 && python3 << 'PYEOF'\n"
|
||||||
|
"from docx import Document\ndoc = Document('x.docx')\nPYEOF"}
|
||||||
|
raw = "变参数——对于d⁹和d⁷构型的离子(如Cu²⁺、" + json.dumps(good, ensure_ascii=False)
|
||||||
|
self.assertEqual(salvage_tool_arguments(raw, SHELL_KEYS), good)
|
||||||
|
|
||||||
|
def test_wrong_tool_keys_dont_crosstalk(self):
|
||||||
|
"""key 白名单按工具隔离:write 的 {path,content} 拿 shell 白名单校验 → 拒(content 不在)。
|
||||||
|
证明 _try_salvage_response 必须用"该 tool_call 自己工具"的白名单,不能混用。"""
|
||||||
|
raw = '前缀{"path": "/a", "content": "x"}'
|
||||||
|
self.assertIsNone(salvage_tool_arguments(raw, SHELL_KEYS))
|
||||||
|
|
||||||
|
def test_truncated_tail_rejected(self):
|
||||||
|
"""尾部 JSON 被截断(content 未闭合)→ parse-to-end 失败 → None,回落重试不冒险写残缺。"""
|
||||||
|
raw = '垃圾前缀{"path": "/a.txt", "content": "只写了一半没有结束引号'
|
||||||
|
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
|
||||||
|
|
||||||
|
def test_prefix_has_selfconsistent_wrongkey_json(self):
|
||||||
|
"""垃圾前缀里恰好有一段自洽但 key 不对的 JSON → 跳过,继续找到后面真 JSON。"""
|
||||||
|
good = {"path": "/b.txt", "content": "正文"}
|
||||||
|
raw = ('模型正文里引用了 {"foo": 1, "bar": 2} 这段配置,然后'
|
||||||
|
+ json.dumps(good, ensure_ascii=False))
|
||||||
|
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), good)
|
||||||
|
|
||||||
|
def test_only_wrongkey_json_returns_none(self):
|
||||||
|
"""通篇只有 key 不在白名单的 JSON(无真 tool 参数)→ None,绝不误采。"""
|
||||||
|
raw = '前缀{"foo": 1, "bar": {"path": "x"}}'
|
||||||
|
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
|
||||||
|
|
||||||
|
def test_superset_keys_rejected(self):
|
||||||
|
"""顶层多出 schema 外的 key(非纯 tool 参数)→ 拒绝(key ⊆ allowed 不成立)。"""
|
||||||
|
raw = '前缀{"path": "/a", "content": "x", "unexpected": 1}'
|
||||||
|
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
|
||||||
|
|
||||||
|
def test_subset_keys_accepted(self):
|
||||||
|
"""只给了部分参数(path 无 content)但都在白名单 → 采纳;缺必填交 executor 报错。"""
|
||||||
|
raw = '前缀{"path": "/a"}'
|
||||||
|
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), {"path": "/a"})
|
||||||
|
|
||||||
|
def test_trailing_garbage_after_json_rejected(self):
|
||||||
|
"""已知局限:JSON 后面还挂垃圾 → parse-to-end 失败 → None(观测形态为 JSON 在尾部,
|
||||||
|
本例是防御性记录:宁可回落重试也不用 raw_decode 容忍尾残渣误采半个对象)。"""
|
||||||
|
raw = '前缀{"path": "/a", "content": "x"}尾部残渣'
|
||||||
|
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
|
||||||
|
|
||||||
|
def test_clean_json_at_char0(self):
|
||||||
|
"""char-0 就是完好 JSON(理论上不会进 salvage,但函数应幂等返回它)。"""
|
||||||
|
raw = '{"path": "/a", "content": "x"}'
|
||||||
|
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS),
|
||||||
|
{"path": "/a", "content": "x"})
|
||||||
|
|
||||||
|
def test_no_brace_returns_none(self):
|
||||||
|
"""整串无 '{' → None。"""
|
||||||
|
self.assertIsNone(salvage_tool_arguments("纯文本没有大括号", WRITE_KEYS))
|
||||||
|
|
||||||
|
def test_empty_dict_rejected(self):
|
||||||
|
"""抠出的是空 {} → 视为无内容,不采纳(非空 dict 才算)。"""
|
||||||
|
raw = '前缀{}'
|
||||||
|
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
17
web/app.py
17
web/app.py
|
|
@ -435,7 +435,8 @@ def _run_agent_bg(
|
||||||
cancel 延迟 ~ 单 chunk 间隔(100ms 级);seedance 轮询间也读这个 cancel_check 用于
|
cancel 延迟 ~ 单 chunk 间隔(100ms 级);seedance 轮询间也读这个 cancel_check 用于
|
||||||
用户停止按钮(必须在 build_agent 阶段就传进去,因为 SeedanceTool ctor 持有它,
|
用户停止按钮(必须在 build_agent 阶段就传进去,因为 SeedanceTool ctor 持有它,
|
||||||
不能像以前那样 build_agent 返回后再赋 agent.cancel_check)。
|
不能像以前那样 build_agent 返回后再赋 agent.cancel_check)。
|
||||||
`ok / cancelled` 收尾直接回 `idle`(不留持久标记);只有 error 是持久终态。
|
`ok` 收尾回 `idle`;`cancelled`(用户停止)与 `error` 一样落持久终态 —— 前端据此
|
||||||
|
补「已停止」/ 错误卡(扛过收尾重渲),下次起新 run(post_message 写 running)覆盖清掉。
|
||||||
|
|
||||||
image_variant / video_variant:本 run 用哪个 image/video variant 装 tool(空 → yaml 第一个)。
|
image_variant / video_variant:本 run 用哪个 image/video variant 装 tool(空 → yaml 第一个)。
|
||||||
随消息 POST 传进来,不入 DB —— UI 下拉的选择就跟在这一条消息上生效。
|
随消息 POST 传进来,不入 DB —— UI 下拉的选择就跟在这一条消息上生效。
|
||||||
|
|
@ -452,13 +453,17 @@ def _run_agent_bg(
|
||||||
scheduled_run=scheduled,
|
scheduled_run=scheduled,
|
||||||
)
|
)
|
||||||
agent.sink = WebEventSink(broker, task_id)
|
agent.sink = WebEventSink(broker, task_id)
|
||||||
agent.run(user_message)
|
result = agent.run(user_message)
|
||||||
sync_task_tokens(task_state)
|
sync_task_tokens(task_state)
|
||||||
# cancel 命中或正常完成 → run_status 回 idle(error 才持久)
|
# 收尾终态:agent.run 在任一取消路径都 return "[cancelled]"(loop.py)——
|
||||||
|
# 用户停止 → 落持久 cancelled(前端 renderPersistedRunTerminal 据此补「已停止」卡,
|
||||||
|
# 扛过收尾 loadMessages 整屏重建);正常完成 → 回 idle。两者都清 run_error。
|
||||||
|
# cancelled 与 error 同为持久终态,下次起新 run(post_message 写 running)自然覆盖。
|
||||||
|
final_status = "cancelled" if result == "[cancelled]" else "idle"
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
s.execute(
|
s.execute(
|
||||||
update(Task).where(Task.task_id == task_id).values(
|
update(Task).where(Task.task_id == task_id).values(
|
||||||
run_status="idle", run_error=None,
|
run_status=final_status, run_error=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -2164,7 +2169,7 @@ def create_app() -> FastAPI:
|
||||||
- `skill` 精确匹配(空忽略)
|
- `skill` 精确匹配(空忽略)
|
||||||
- `working_dir` 末段目录名(如 `水泥申报`);后端自动拼 `workspace/users/<uid>/<name>` 比对
|
- `working_dir` 末段目录名(如 `水泥申报`);后端自动拼 `workspace/users/<uid>/<name>` 比对
|
||||||
- `q` 模糊搜索 name + description(ILIKE,大小写不敏感)
|
- `q` 模糊搜索 name + description(ILIKE,大小写不敏感)
|
||||||
- `run_status` 逗号分隔,allowlist `idle/running/cancelling/error`;非法值静默忽略
|
- `run_status` 逗号分隔,allowlist `idle/running/cancelling/error/cancelled`;非法值静默忽略
|
||||||
(dev SPA 拉同 wd 活跃 task 用,通常 `running,cancelling`)
|
(dev SPA 拉同 wd 活跃 task 用,通常 `running,cancelling`)
|
||||||
- `ordering` DRF 风格,逗号分隔,`-field` 倒序;allowlist `created_at/updated_at/name/status`;
|
- `ordering` DRF 风格,逗号分隔,`-field` 倒序;allowlist `created_at/updated_at/name/status`;
|
||||||
非法字段静默忽略;**默认 `-created_at`**(创建时间倒序)
|
非法字段静默忽略;**默认 `-created_at`**(创建时间倒序)
|
||||||
|
|
@ -2177,7 +2182,7 @@ def create_app() -> FastAPI:
|
||||||
skill = (skill or "").strip() or None
|
skill = (skill or "").strip() or None
|
||||||
wd_name = (working_dir or "").strip() or None
|
wd_name = (working_dir or "").strip() or None
|
||||||
q_text = (q or "").strip() or None
|
q_text = (q or "").strip() or None
|
||||||
rs_allowed = ("idle", "running", "cancelling", "error")
|
rs_allowed = ("idle", "running", "cancelling", "error", "cancelled")
|
||||||
run_status_set = {
|
run_status_set = {
|
||||||
s.strip() for s in (run_status or "").split(",") if s.strip() in rs_allowed
|
s.strip() for s in (run_status or "").split(",") if s.strip() in rs_allowed
|
||||||
} or None
|
} or None
|
||||||
|
|
|
||||||
|
|
@ -706,7 +706,6 @@
|
||||||
.msg.user { background: var(--user-bg); align-self: flex-end; max-width: min(92%, 36rem); }
|
.msg.user { background: var(--user-bg); align-self: flex-end; max-width: min(92%, 36rem); }
|
||||||
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
|
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
|
||||||
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
|
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
|
||||||
.cancelled-badge { margin-top: 8px; padding: 4px 10px; font-size: 12px; color: var(--accent); background: var(--accent-soft); border: 1px dashed var(--accent); border-radius: var(--r-md); display: inline-block; }
|
|
||||||
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: var(--mono); }
|
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: var(--mono); }
|
||||||
.msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; }
|
.msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; }
|
||||||
.msg.assistant.live-run { border-color: rgba(220, 38, 38, 0.28); box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.08), 0 8px 24px rgba(220, 38, 38, 0.08); }
|
.msg.assistant.live-run { border-color: rgba(220, 38, 38, 0.28); box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.08), 0 8px 24px rgba(220, 38, 38, 0.08); }
|
||||||
|
|
@ -764,6 +763,19 @@
|
||||||
.msg .body a, .md-render a { color: var(--accent); }
|
.msg .body a, .md-render a { color: var(--accent); }
|
||||||
.msg .body img, .md-render img { max-width: 100%; }
|
.msg .body img, .md-render img { max-width: 100%; }
|
||||||
.msg .body hr, .md-render hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
.msg .body hr, .md-render hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||||
|
/* 卡片底部操作栏(仿 GPT/DeepSeek):默认隐,悬停/聚焦现;触屏无 hover → 常显淡态 */
|
||||||
|
.msg-actions { display: flex; gap: 6px; margin-top: 6px; opacity: 0; transition: opacity .12s; }
|
||||||
|
.msg:hover .msg-actions, .msg:focus-within .msg-actions { opacity: 1; }
|
||||||
|
.msg.user .msg-actions { justify-content: flex-end; }
|
||||||
|
.msg-actions button {
|
||||||
|
font: inherit; font-size: 11px; line-height: 1; color: var(--muted);
|
||||||
|
background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||||
|
padding: 3px 8px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
transition: background .12s, color .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.msg-actions button:hover { color: var(--text); background: var(--code-bg); border-color: var(--muted); }
|
||||||
|
.msg-actions button.copied { color: #16a34a; border-color: #16a34a; background: transparent; }
|
||||||
|
@media (hover: none) { .msg-actions { opacity: .55; } }
|
||||||
.tool-call { margin-top: 6px; font-family: var(--mono); font-size: 12px; }
|
.tool-call { margin-top: 6px; font-family: var(--mono); font-size: 12px; }
|
||||||
.tool-call summary {
|
.tool-call summary {
|
||||||
cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: var(--r-sm);
|
cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: var(--r-sm);
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,7 @@ export async function selectTask(tid) {
|
||||||
refreshProcs(),
|
refreshProcs(),
|
||||||
]);
|
]);
|
||||||
state.taskMeta = meta;
|
state.taskMeta = meta;
|
||||||
renderMessages(state.loadedMessages); // 内部含 renderPersistedRunError(须在 taskMeta 赋值后)
|
renderMessages(state.loadedMessages); // 内部含 renderPersistedRunTerminal(须在 taskMeta 赋值后)
|
||||||
renderChatMeta();
|
renderChatMeta();
|
||||||
applyChannelComposerLock(meta);
|
applyChannelComposerLock(meta);
|
||||||
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
if (meta.run_status === "running" || meta.run_status === "cancelling") {
|
||||||
|
|
@ -1105,6 +1105,47 @@ function setTaskProgress(taskId, steps) {
|
||||||
if (state.taskId === taskId) renderTaskProgressDock(normalized);
|
if (state.taskId === taskId) renderTaskProgressDock(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卡片底部「复制」按钮 HTML。复制的是正文原文(Markdown 源),点击时经事件委托据
|
||||||
|
// 卡片 data-idx 回 state.loadedMessages 取 payload.content —— 不从渲染后的 .body HTML 反推。
|
||||||
|
// 直播卡不带此栏:流结束后 loadMessages 整屏重渲成持久卡,自然带上。
|
||||||
|
function msgActionsHtml() {
|
||||||
|
return `<div class="msg-actions"><button type="button" class="msg-copy" title="复制正文">复制</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制文本到剪贴板:优先 async Clipboard API(需 https/localhost),失败降级 execCommand。
|
||||||
|
// 按钮态短暂反馈「✓ 已复制 / 复制失败」后复位。
|
||||||
|
async function copyTextToClipboard(text, btn) {
|
||||||
|
if (!text) return;
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch (e) { /* 落降级路径 */ }
|
||||||
|
if (!ok) {
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0;";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
ok = document.execCommand("copy");
|
||||||
|
ta.remove();
|
||||||
|
} catch (e) { ok = false; }
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
if (!btn.dataset.label) btn.dataset.label = btn.textContent;
|
||||||
|
btn.textContent = ok ? "✓ 已复制" : "复制失败";
|
||||||
|
btn.classList.toggle("copied", ok);
|
||||||
|
clearTimeout(btn._copyTimer);
|
||||||
|
btn._copyTimer = setTimeout(() => {
|
||||||
|
btn.textContent = btn.dataset.label;
|
||||||
|
btn.classList.remove("copied");
|
||||||
|
}, 1400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderMessages(msgs) {
|
function renderMessages(msgs) {
|
||||||
const wrap = $("chat-stream");
|
const wrap = $("chat-stream");
|
||||||
_msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe
|
_msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe
|
||||||
|
|
@ -1114,7 +1155,7 @@ function renderMessages(msgs) {
|
||||||
state.msgHasMore = false;
|
state.msgHasMore = false;
|
||||||
setTaskProgress(state.taskId, []);
|
setTaskProgress(state.taskId, []);
|
||||||
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
||||||
renderPersistedRunError();
|
renderPersistedRunTerminal();
|
||||||
renderLiveRunIfVisible();
|
renderLiveRunIfVisible();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1242,6 +1283,10 @@ function renderMessages(msgs) {
|
||||||
}
|
}
|
||||||
// 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress)
|
// 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 dock(见 setTaskProgress)
|
||||||
}
|
}
|
||||||
|
// 复制栏:user 恒有正文;assistant 仅在有正文文本时挂(纯 tool_calls 轮无正文可复制)
|
||||||
|
const hasCopy = role === "user"
|
||||||
|
|| (role === "assistant" && typeof p.content === "string" && p.content.trim());
|
||||||
|
if (hasCopy) html += msgActionsHtml();
|
||||||
card.innerHTML = html;
|
card.innerHTML = html;
|
||||||
highlightIn(card);
|
highlightIn(card);
|
||||||
wrap.appendChild(card);
|
wrap.appendChild(card);
|
||||||
|
|
@ -1257,7 +1302,7 @@ function renderMessages(msgs) {
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
setTaskProgress(state.taskId, currentProgressSteps);
|
setTaskProgress(state.taskId, currentProgressSteps);
|
||||||
upgradeMediaArtifacts(wrap);
|
upgradeMediaArtifacts(wrap);
|
||||||
renderPersistedRunError(); // 上次 run error 终态 → 末尾补持久错误卡(所有重渲路径统一走这)
|
renderPersistedRunTerminal(); // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这)
|
||||||
renderLiveRunIfVisible();
|
renderLiveRunIfVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1856,6 +1901,15 @@ document.addEventListener("keydown", (e) => {
|
||||||
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
|
||||||
// 弹个 modal 反而打断播放,不如交给浏览器。
|
// 弹个 modal 反而打断播放,不如交给浏览器。
|
||||||
$("chat-stream").addEventListener("click", (e) => {
|
$("chat-stream").addEventListener("click", (e) => {
|
||||||
|
const copyBtn = e.target.closest && e.target.closest(".msg-copy");
|
||||||
|
if (copyBtn) {
|
||||||
|
const card = copyBtn.closest(".msg[data-idx]");
|
||||||
|
const idx = card && card.dataset.idx;
|
||||||
|
const m = idx != null && state.loadedMessages.find((x) => String(x.idx) === String(idx));
|
||||||
|
const text = m && m.payload && typeof m.payload.content === "string" ? m.payload.content : "";
|
||||||
|
copyTextToClipboard(text, copyBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const askBtn = e.target.closest && e.target.closest(".ask-option");
|
const askBtn = e.target.closest && e.target.closest(".ask-option");
|
||||||
if (askBtn) {
|
if (askBtn) {
|
||||||
if (askBtn.disabled || isCurrentTaskStreaming()) return;
|
if (askBtn.disabled || isCurrentTaskStreaming()) return;
|
||||||
|
|
@ -2369,10 +2423,9 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
asstCard.appendChild(w);
|
asstCard.appendChild(w);
|
||||||
}
|
}
|
||||||
} else if (t === "cancelled") {
|
} else if (t === "cancelled") {
|
||||||
const badge = document.createElement("div");
|
// 「已停止」不在此 live-append —— 会被紧随的收尾 loadMessages 整屏重建冲掉(旧行为:
|
||||||
badge.className = "cancelled-badge";
|
// 闪一下即消失)。后端已把 run_status 落成持久 cancelled,收尾重渲时
|
||||||
badge.textContent = "已停止";
|
// renderPersistedRunTerminal 据此补居中「已停止」条,刷新/切任务后仍在。
|
||||||
asstCard.appendChild(badge);
|
|
||||||
} else if (t === "error") {
|
} else if (t === "error") {
|
||||||
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
|
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
|
||||||
appendRunError(ctx, msg);
|
appendRunError(ctx, msg);
|
||||||
|
|
@ -2380,21 +2433,32 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久错误卡:上次 run 以 error 终态收场(tasks.run_error)→ 消息流末尾补一张卡。
|
// 持久终态卡:上次 run 以 error(tasks.run_error)或 cancelled(用户停止)收场
|
||||||
// live 的错误卡(handleSseEvent → appendErrorCard)会被收尾 loadMessages 的整屏重建
|
// → 消息流末尾补一张卡。live 追加的卡(错误卡 / 「已停止」badge)会被收尾 loadMessages
|
||||||
// 冲掉(renderMessages 先 innerHTML="",错误不是持久化消息,重建即消失 —— 2a1bc25d
|
// 的整屏重建冲掉(renderMessages 先 innerHTML="",非持久化消息重建即消失 —— 2a1bc25d
|
||||||
// 案"发了继续啥也没发生"的根因);这张以 meta.run_status 为准的卡在每次重渲后补,
|
// 案"发了继续啥也没发生"的根因);这张以 meta.run_status 为准的卡在每次重渲后补,
|
||||||
// 刷新/切换任务也在。下次 run 起跑时后端清 run_error,meta 刷新后自然消失。
|
// 刷新/切换任务也在。下次 run 起跑时后端把 run_status 写回 running,meta 刷新后自然消失。
|
||||||
function renderPersistedRunError() {
|
function renderPersistedRunTerminal() {
|
||||||
document.querySelectorAll("#chat-stream .run-error-persist").forEach((el) => el.remove());
|
document.querySelectorAll("#chat-stream .run-error-persist, #chat-stream .run-cancelled-persist")
|
||||||
|
.forEach((el) => el.remove());
|
||||||
const t = state.taskMeta;
|
const t = state.taskMeta;
|
||||||
if (!t || state.taskId !== t.task_id) return;
|
if (!t || state.taskId !== t.task_id || getLiveRun(state.taskId)) return;
|
||||||
if (t.run_status !== "error" || getLiveRun(state.taskId)) return;
|
if (t.run_status === "error") {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "msg error run-error-persist";
|
card.className = "msg error run-error-persist";
|
||||||
card.innerHTML = `<div class="role">上次运行出错</div><div class="body">${escapeHtml(t.run_error || "(未知错误)")}`
|
card.innerHTML = `<div class="role">上次运行出错</div><div class="body">${escapeHtml(t.run_error || "(未知错误)")}`
|
||||||
+ `<div class="muted" style="margin-top:4px;font-size:12px;">可直接重发消息或回复「继续」重试。</div></div>`;
|
+ `<div class="muted" style="margin-top:4px;font-size:12px;">可直接重发消息或回复「继续」重试。</div></div>`;
|
||||||
$("chat-stream").appendChild(card);
|
$("chat-stream").appendChild(card);
|
||||||
|
} else if (t.run_status === "cancelled") {
|
||||||
|
// 用户主动停止 —— 良性终态,渲成居中 muted 虚线小条(同 model-switch 分隔风格),
|
||||||
|
// 不用错误卡那种醒目样式。
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "run-cancelled-persist muted";
|
||||||
|
card.textContent = "已停止 · 你中断了上次运行";
|
||||||
|
card.style.cssText = "align-self:center;margin:8px 0;padding:4px 12px;font-size:12px;"
|
||||||
|
+ "border:1px dashed var(--border);border-radius:var(--r-md);";
|
||||||
|
$("chat-stream").appendChild(card);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendErrorCard(msg) {
|
function appendErrorCard(msg) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue