From 2066947061e95ad6678152bdb25842ed01181d34 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 15 Jul 2026 09:38:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E7=94=A8=E6=88=B7=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E5=90=8E=E8=A1=A5=E6=8C=81=E4=B9=85=E3=80=8C=E5=B7=B2?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E3=80=8D=E6=8F=90=E7=A4=BA,cancel=20?= =?UTF-8?q?=E8=90=BD=E4=B8=80=E7=AD=89=E7=BB=88=E6=80=81(bump=200.58.26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点停止中断回复后对话底部什么都不剩: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) --- CHANGELOG.md | 4 ++++ DESIGN.md | 2 +- PROGRESS.md | 3 ++- core/__init__.py | 2 +- core/storage/models.py | 5 +++-- web/app.py | 17 +++++++++----- web/static/dev.html | 1 - web/static/js/chat.js | 50 +++++++++++++++++++++++++----------------- 8 files changed, 52 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b0184..ddd86bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.58.26 — 2026-07-15 + +- 点「停止」中断回复后,对话底部此前什么都不剩(一闪即被收尾刷新冲掉);现在会留下一条居中的「已停止 · 你中断了上次运行」提示,刷新页面、切换任务再回来都在,直到下次发消息才消失。 + ## 0.58.25 — 2026-07-15 - 生成 PPT 等场景此前会自动切到更擅长该任务的指定模型;现在若你已经手动选了国际旗舰模型,就尊重你的选择、不再自动切换,继续用你选的模型来做。 diff --git a/DESIGN.md b/DESIGN.md index 149a954..0cb725a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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。 diff --git a/PROGRESS.md b/PROGRESS.md index 7e32a05..ce77ff6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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 +- **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 内部依赖,不碰对外契约。 diff --git a/core/__init__.py b/core/__init__.py index 5af78c0..e44779d 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.25" +__version__ = "0.58.26" diff --git a/core/storage/models.py b/core/storage/models.py index dc6669c..b8fc5cb 100644 --- a/core/storage/models.py +++ b/core/storage/models.py @@ -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 diff --git a/web/app.py b/web/app.py index 430e1dc..2783f57 100644 --- a/web/app.py +++ b/web/app.py @@ -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//` 比对 - `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 diff --git a/web/static/dev.html b/web/static/dev.html index 559f587..c52b2dd 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -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); } diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 97c3bd4..10b72b5 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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") { @@ -1114,7 +1114,7 @@ function renderMessages(msgs) { state.msgHasMore = false; setTaskProgress(state.taskId, []); wrap.innerHTML = `
(暂无消息 · 在下方输入开始对话)
`; - renderPersistedRunError(); + renderPersistedRunTerminal(); renderLiveRunIfVisible(); return; } @@ -1257,7 +1257,7 @@ function renderMessages(msgs) { wrap.scrollTop = wrap.scrollHeight; setTaskProgress(state.taskId, currentProgressSteps); upgradeMediaArtifacts(wrap); - renderPersistedRunError(); // 上次 run error 终态 → 末尾补持久错误卡(所有重渲路径统一走这) + renderPersistedRunTerminal(); // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这) renderLiveRunIfVisible(); } @@ -2369,10 +2369,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 +2379,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 = `
上次运行出错
${escapeHtml(t.run_error || "(未知错误)")}` - + `
可直接重发消息或回复「继续」重试。
`; - $("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 = `
上次运行出错
${escapeHtml(t.run_error || "(未知错误)")}` + + `
可直接重发消息或回复「继续」重试。
`; + $("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) {