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>
This commit is contained in:
caoqianming 2026-07-15 09:38:54 +08:00
parent c655ce0a76
commit 2066947061
8 changed files with 52 additions and 32 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.58.26 — 2026-07-15
- 点「停止」中断回复后,对话底部此前什么都不剩(一闪即被收尾刷新冲掉);现在会留下一条居中的「已停止 · 你中断了上次运行」提示,刷新页面、切换任务再回来都在,直到下次发消息才消失。
## 0.58.25 — 2026-07-15 ## 0.58.25 — 2026-07-15
- 生成 PPT 等场景此前会自动切到更擅长该任务的指定模型;现在若你已经手动选了国际旗舰模型,就尊重你的选择、不再自动切换,继续用你选的模型来做。 - 生成 PPT 等场景此前会自动切到更擅长该任务的指定模型;现在若你已经手动选了国际旗舰模型,就尊重你的选择、不再自动切换,继续用你选的模型来做。

View File

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

View File

@ -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(skill 定向模型:已选国际旗舰模型时豁免切换,bump 0.58.25) 最后更新:2026-07-15(用户停止后「已停止」持久提示,补齐 cancel 终态与 error 卡对称,bump 0.58.26)
--- ---
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **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.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.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 内部依赖,不碰对外契约。

View File

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

View File

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

View File

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

View File

@ -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); }

View File

@ -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") {
@ -1114,7 +1114,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;
} }
@ -1257,7 +1257,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();
} }
@ -2369,10 +2369,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 +2379,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) {