Compare commits

..

4 Commits

Author SHA1 Message Date
caoqianming 54db4fc348 feat(web): 对话卡片悬停「复制」按钮,取正文 Markdown 原文(bump 0.58.27)
renderMessages 给 user 卡与有正文的 assistant 卡挂 .msg-actions 栏
(纯 tool_calls 轮/error 卡不挂),默认隐、hover/focus-within 现、
触屏常显淡态。复制经 #chat-stream 既有 click 委托据 data-idx 回
state.loadedMessages 取 payload.content(非渲染 HTML),Clipboard API
主路径 + execCommand 降级,按钮短暂反馈「✓ 已复制」。直播卡不带此栏
——流结束 loadMessages 整屏重渲成持久卡自然带上。

本期只做零后端耦合的复制;重新生成/编辑重发需后端截断语义,另立项。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:51:28 +08:00
caoqianming 2066947061 fix(web): 用户停止后补持久「已停止」提示,cancel 落一等终态(bump 0.58.26)
点停止中断回复后对话底部什么都不剩:live-append 的「已停止」badge 被收尾
loadMessages 整屏重建冲掉(同 2a1bc25d 案)。补齐 cancel 终态,与 error 卡对称:

- 后端: agent.run 取消路径 return "[cancelled]",_run_agent_bg 据此收尾写
  run_status="cancelled"(否则 idle)。run_status 是 Text 列,加新值零 migration。
- 前端: renderPersistedRunError → renderPersistedRunTerminal,同时认 error/cancelled;
  cancelled 渲居中 muted 虚线「已停止 · 你中断了上次运行」(良性态,不用错误卡样式),
  刷新/切任务仍在,下次起新 run 覆盖清掉。删掉会被冲掉的 live badge 及死 CSS。
- 兼容: run_status 加新值纯增量,蓝绿旧实例读到 cancelled 当良性态优雅退化不崩;
  全部 run_status 消费点(409 门/reaper/SSE is_active/cancel 端点/clear)已核对。
- 同步 DESIGN §7.4 / models.py 注释 / filter allowlist / CHANGELOG / PROGRESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:38:54 +08:00
caoqianming c655ce0a76 feat(skill): 已选国际旗舰模型时豁免 skill 定向模型切换(bump 0.58.25)
ppt 等内置 skill 会 pin glm.pro52,但用户若已主动选了国际旗舰模型
(unifyllm 网关族:Claude/GPT/Gemini)不应被切走。_skill_model_switch
在算出目标 profile 后加判据:current profile 的 family==unifyllm → 返回
None 不切。定向 pin 本意是给较弱的国产默认模型托底产物质量,已在旗舰
模型上的用户尊重其选择、不降级。判据在 DB 写入前(纯字符串检查,无副作用),
其余取舍(只信内置 skill / 跳档位门控 / 不自动切回)不变。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:22:02 +08:00
caoqianming e1d5bf6788 feat(loop): 畸形 tool_call arguments 就地抢救(salvage)省一次非流式重试(bump 0.58.24)
失败聚集面板同形态跨 write/edit/run_python/shell 复发(err=Expecting value char 0):
char-0 是 provider 在 wire 上把别处正文字节错标成该 tool_call 的 arguments delta 粘的
垃圾前缀,尾部真 JSON 完好并跑到串尾(定层已证 provider-wire 脏、拼接层无辜)。

命中畸形后先试就地抢救,而非直接整轮丢弃 + 降级非流式重 roll:
- core/salvage.py::salvage_tool_arguments(raw, allowed_keys) 纯函数,左→右扫每个 '{'
  试 json.loads(raw[i:]),第一个 parse-to-end 成功 + 非空 dict + 顶层 key ⊆ 该工具
  schema 参数名的即采纳。双护栏:parse-to-end 挡尾部截断的半个 JSON,key 白名单挡前缀
  里自洽的旁支 JSON。工具无关——allowed_keys 每工具从 executor.schemas() 现取。
- AgentLoop._try_salvage_response 全有或全无:本轮所有畸形都能抠出才就地改写 .arguments
  当轮继续,任一抠不出则一个都不动、原样走既有 _log_malformed_args + 非流式重试(零回退)。
- 抢救成功记 usage_events kind=tool_salvaged(record_salvaged_tool_call,cost 0),与
  tool_malformed 成对;toolfail 精确匹配 tool_malformed/run_error,salvaged 不进失败面板。

纯函数单列 core/salvage.py(不 import litellm)以便 tests/test_salvage.py 无依赖本机可跑
(loop.py 顶层 import litellm 本机导入期卡死);14 用例含 write/edit/run_python/shell 四工具
真前缀 + 截断/多键/旁支/尾残渣/空{}/跨工具白名单隔离等边界。盲区:provider-wire 抖动本机
复现不出、/verify 端到端跑不了,靠单测 + 生产灰度观察 tool_salvaged/tool_malformed 比。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:20:38 +08:00
14 changed files with 416 additions and 35 deletions

View File

@ -5,6 +5,18 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `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
- 任务运行出错时不再"悄无声息":此前错误提示一闪即被刷掉,发「继续」也像没反应;现在对话里会一直显示「上次运行出错」的红色错误卡(写明原因和重试方式),刷新页面、切换任务再回来都在,直到下次运行开始才消失。

View File

@ -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 定向模型**(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
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`/`.` 起头。
- **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 允许。
- **files API 是目录树唯一 mutation 入口,DB-FS 一致性服务端内化**:顶层目录 rename/delete 走 DB-aware 分支(事务锁关联 task、running→409、DB UPDATE 先于 FS、delete 被引用→409)。
- **单一 PG ORM**(本地 + SaaS 共用):一份 schema 一份查询,无 adapter,alembic 管 migration。

View File

@ -2,7 +2,7 @@
> 配合 `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
- **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-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 分流补第三段。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.58.23"
__version__ = "0.58.27"

View File

@ -791,6 +791,11 @@ def build_agent(
profile = (sk.model or "").strip()
if not profile or profile == current_profile:
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_llm = LLM(new_caps) # 缺 api key 会 raise → loop 捕获转 warn,不切换
from sqlalchemy import update as _update

View File

@ -25,8 +25,13 @@ from .context import CHARS_PER_TOKEN, COMPACT_CONTEXT_RATIO, prepare_messages_wi
from .context_fold import maybe_fold
from .executor import ExecCtx, Executor
from .llm import LLM
from .salvage import salvage_tool_arguments
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
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
@ -527,6 +532,11 @@ class AgentLoop:
bad = _malformed_tool_calls(response)
if not bad:
return response, False
# 先尝试就地抢救:畸形是 char-0 垃圾前缀 + 尾部完好 JSON(定层已证 provider-wire),
# 全部畸形 tool_call 都能抠出干净 JSON 才改写并当轮继续,省掉一次非流式重试;
# 任一抠不出则一个都不动,原样走下面的丢弃 + 重试(零回退风险)。
if self._try_salvage_response(response):
return response, False
_log_malformed_args(
self.session.task_id, self.user_id,
f"{self.caps.family}.{self.caps.variant}", response,
@ -543,6 +553,73 @@ class AgentLoop:
# 优雅返错给模型,而非在此死循环。
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]:
"""跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。
返回 (response, cancelled_mid_stream)"""

43
core/salvage.py Normal file
View File

@ -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

View File

@ -12,7 +12,11 @@ from .engine import (
get_engine,
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 (
NoSubtaskError,
check_no_subtask,
@ -30,6 +34,7 @@ __all__ = [
"get_task",
"record_chat_usage",
"record_malformed_tool_call",
"record_salvaged_tool_call",
"session_scope",
"update_task",
"upsert_task",

View File

@ -81,8 +81,9 @@ class Task(Base):
tokens_completion: Mapped[int] = mapped_column(Integer, 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):
# idle / running / cancelling / error;ok / cancelled 收尾直接回 idle,
# 只有 error 是持久终态(下次起新 run 时由 post_message 清掉)
# idle / running / cancelling / error / cancelled;ok 收尾回 idle,
# error(出错)与 cancelled(用户停止)都是持久终态(前端据此补持久卡,
# 扛过收尾重渲),下次起新 run 时由 post_message 写 running 覆盖清掉
run_status: Mapped[str] = mapped_column(Text, nullable=False, default="idle")
run_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# in-flight run 归属实例(0020,蓝绿双实例 RUN.md B 档):起 run 时写 ZCBOT_INSTANCE

View File

@ -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(
*,
task_id: UUID,

118
tests/test_salvage.py Normal file
View File

@ -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()

View File

@ -435,7 +435,8 @@ def _run_agent_bg(
cancel 延迟 ~ chunk 间隔(100ms );seedance 轮询间也读这个 cancel_check 用于
用户停止按钮(必须在 build_agent 阶段就传进去,因为 SeedanceTool ctor 持有它,
不能像以前那样 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 第一个)
随消息 POST 传进来,不入 DB UI 下拉的选择就跟在这一条消息上生效
@ -452,13 +453,17 @@ def _run_agent_bg(
scheduled_run=scheduled,
)
agent.sink = WebEventSink(broker, task_id)
agent.run(user_message)
result = agent.run(user_message)
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:
s.execute(
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:
@ -2164,7 +2169,7 @@ def create_app() -> FastAPI:
- `skill` 精确匹配(空忽略)
- `working_dir` 末段目录名( `水泥申报`);后端自动拼 `workspace/users/<uid>/<name>` 比对
- `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`)
- `ordering` DRF 风格,逗号分隔,`-field` 倒序;allowlist `created_at/updated_at/name/status`;
非法字段静默忽略;**默认 `-created_at`**(创建时间倒序)
@ -2177,7 +2182,7 @@ def create_app() -> FastAPI:
skill = (skill or "").strip() or None
wd_name = (working_dir 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 = {
s.strip() for s in (run_status or "").split(",") if s.strip() in rs_allowed
} or None

View File

@ -706,7 +706,6 @@
.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.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 .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); }
@ -764,6 +763,19 @@
.msg .body a, .md-render a { color: var(--accent); }
.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; }
/* 卡片底部操作栏(仿 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 summary {
cursor: pointer; padding: 4px 6px; background: var(--code-bg); border-radius: var(--r-sm);

View File

@ -423,7 +423,7 @@ export async function selectTask(tid) {
refreshProcs(),
]);
state.taskMeta = meta;
renderMessages(state.loadedMessages); // 内部含 renderPersistedRunError(须在 taskMeta 赋值后)
renderMessages(state.loadedMessages); // 内部含 renderPersistedRunTerminal(须在 taskMeta 赋值后)
renderChatMeta();
applyChannelComposerLock(meta);
if (meta.run_status === "running" || meta.run_status === "cancelling") {
@ -1105,6 +1105,47 @@ function setTaskProgress(taskId, steps) {
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) {
const wrap = $("chat-stream");
_msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe
@ -1114,7 +1155,7 @@ function renderMessages(msgs) {
state.msgHasMore = false;
setTaskProgress(state.taskId, []);
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
renderPersistedRunError();
renderPersistedRunTerminal();
renderLiveRunIfVisible();
return;
}
@ -1242,6 +1283,10 @@ function renderMessages(msgs) {
}
// 进度不再内联进消息卡 —— 累积值在循环末统一喂顶部 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;
highlightIn(card);
wrap.appendChild(card);
@ -1257,7 +1302,7 @@ function renderMessages(msgs) {
wrap.scrollTop = wrap.scrollHeight;
setTaskProgress(state.taskId, currentProgressSteps);
upgradeMediaArtifacts(wrap);
renderPersistedRunError(); // 上次 run error 终态 → 末尾补持久错误卡(所有重渲路径统一走这)
renderPersistedRunTerminal(); // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这)
renderLiveRunIfVisible();
}
@ -1856,6 +1901,15 @@ document.addEventListener("keydown", (e) => {
// 视频走原生 <video controls>:点击=播放/暂停,全屏走浏览器自带按钮,不进 modal —
// 弹个 modal 反而打断播放,不如交给浏览器。
$("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");
if (askBtn) {
if (askBtn.disabled || isCurrentTaskStreaming()) return;
@ -2369,10 +2423,9 @@ function handleSseEvent(ev, asstCard, ctx) {
asstCard.appendChild(w);
}
} else if (t === "cancelled") {
const badge = document.createElement("div");
badge.className = "cancelled-badge";
badge.textContent = "已停止";
asstCard.appendChild(badge);
// 「已停止」不在此 live-append —— 会被紧随的收尾 loadMessages 整屏重建冲掉(旧行为:
// 闪一下即消失)。后端已把 run_status 落成持久 cancelled,收尾重渲时
// renderPersistedRunTerminal 据此补居中「已停止」条,刷新/切任务后仍在。
} else if (t === "error") {
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
appendRunError(ctx, msg);
@ -2380,21 +2433,32 @@ function handleSseEvent(ev, asstCard, ctx) {
if (nearBottom) stream.scrollTop = stream.scrollHeight;
}
// 持久错误卡:上次 run 以 error 终态收场(tasks.run_error)→ 消息流末尾补一张卡。
// live 的错误卡(handleSseEvent → appendErrorCard)会被收尾 loadMessages 的整屏重建
// 冲掉(renderMessages 先 innerHTML="",错误不是持久化消息,重建即消失 —— 2a1bc25d
// 持久终态卡:上次 run 以 error(tasks.run_error)或 cancelled(用户停止)收场
// → 消息流末尾补一张卡。live 追加的卡(错误卡 / 「已停止」badge)会被收尾 loadMessages
// 的整屏重建冲掉(renderMessages 先 innerHTML="",非持久化消息重建即消失 —— 2a1bc25d
// 案"发了继续啥也没发生"的根因);这张以 meta.run_status 为准的卡在每次重渲后补,
// 刷新/切换任务也在。下次 run 起跑时后端清 run_error,meta 刷新后自然消失。
function renderPersistedRunError() {
document.querySelectorAll("#chat-stream .run-error-persist").forEach((el) => el.remove());
// 刷新/切换任务也在。下次 run 起跑时后端把 run_status 写回 running,meta 刷新后自然消失。
function renderPersistedRunTerminal() {
document.querySelectorAll("#chat-stream .run-error-persist, #chat-stream .run-cancelled-persist")
.forEach((el) => el.remove());
const t = state.taskMeta;
if (!t || state.taskId !== t.task_id) return;
if (t.run_status !== "error" || getLiveRun(state.taskId)) return;
const card = document.createElement("div");
card.className = "msg error run-error-persist";
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>`;
$("chat-stream").appendChild(card);
if (!t || state.taskId !== t.task_id || getLiveRun(state.taskId)) return;
if (t.run_status === "error") {
const card = document.createElement("div");
card.className = "msg error run-error-persist";
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>`;
$("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) {