ui(dev SPA): 菜单 / 按钮 / 状态 / 弹窗文案全部中文化

login / header / 三栏 label / chat 按钮 / new task modal 静态文案 +
renderTaskList / renderChatMeta / fetchSse / 弹窗等动态文案全套本地化。
状态码 active/completed/abandoned 显示为「进行中/已完成/已废弃」,
role user/assistant/error → 我/助手/错误。

技术字段(user_id / platform_key / UUID / tok / CSS class / SSE event 名)
保持原状,不影响 UI 中文。Smoke 13 个中文标签全在 + 8 个英文按钮无残留。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-18 15:44:50 +08:00
parent 0d127a7261
commit f9311b069c
2 changed files with 77 additions and 71 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(`cli.py` 改名 `main.py`(入口);原 `main.py` 挪到 `core/agent_builder.py`(装配 lib);CLI REPL `chat / tasks / export` 删,入口只剩 `web / db / probe`;§7 E CLI 双模式路线撤) 最后更新:2026-05-18(dev SPA 全套 UI 中文化:菜单/按钮/状态提示/role 标签/弹窗文案全部本地化;状态码 `active/completed/abandoned` 显示为「进行中/已完成/已废弃」)
--- ---
@ -21,6 +21,7 @@
## 已完成关键能力 ## 已完成关键能力
- **05-18 / dev SPA 全套 UI 中文化**:用户反馈"web 页面菜单按钮啥的改为中文"。`web/static/dev.html` 静态部分(login overlay / header / 三栏 pane-head label / chat 操作按钮 / new task modal)+ JS 动态部分(状态文案 / role 标签 / confirm/alert 文案 / 状态 badge / SSE 流式提示)全面本地化。**静态文案**:`zcbot dev login → zcbot 登录` / `+ new task → + 新建任务` / `logout → 退出登录` / `tasks/chat/files → 任务/对话/文件` / 状态 select `(all)/active/completed/abandoned → (全部)/进行中/已完成/已废弃` / `export .docx/done/abandon/delete → 导出 docx/完成/废弃/删除` / `stop/send → 停止/发送` / `ready/sending/streaming/cancelling → 就绪/发送中/接收中/停止中` / `(no task selected) → (未选中任务)` / `select a task on the left → 请在左侧选一个任务` / `loading… → 加载中…` / `load failed → 加载失败` / `(no tasks) → (暂无任务)` / `(no messages yet) → (暂无消息 · 在下方输入开始对话)` / `(unnamed) → (未命名)` / `(user root) → (根目录)`。**动态文案**:`renderTaskList` / `renderChatMeta``statusLabels` map(`active→进行中`等),task list 计数 `msg → 条`;消息卡 role 标签 `user/assistant/error → 我/助手/错误`,`tool · name → 工具调用 · name`,`result (N chars) → 结果(N 字符)`,SSE 流式 `tool_call:/tool_result → 工具调用:/工具结果`;`cancelled` badge `已停止(stopped by user) → 已停止`(更简洁)。**弹窗 / 错文案**:`确认置为 status? → 确认置为「中文 label」?` / `delete failed → 删除失败:` / `download failed → 下载失败:` / `upload failed → 上传失败:` / `export failed → 导出失败:` / 删 task confirm 文案改"任务「项目名」(N 条消息)" / `任务名 必填 → 任务名为必填项`。**modal**:`新建 task → 新建任务` / 各 label "必填"/"可选" 加括号统一 / `留空 fallback 用任务名 → 留空则用任务名` / `N 个 task → N 个任务`。**Smoke**(in-process TestClient 拉 `/static/dev.html`):assert 13 个中文标签全在 + 8 个原英文按钮文案全无残留。**没动**:技术字段(`user_id` / `platform_key` / `UUID` / `tok` token 简称)、CSS class(`badge active` 等仍是英文 class,但显示文本走 statusLabels)、SSE event 名(`text/tool_call/tool_result/done/error/cancelled`)、API 字段名 — 都是 schema 层,不影响 UI 中文。
- **05-18 / 入口归位:`cli.py` 改名 `main.py`、原 `main.py` 挪 `core/agent_builder.py`,删 CLI REPL `chat/tasks/export`,§7 E 双模式路线撤**:接 0004 schema 大瘦身后又一轮架构清理。用户复盘"§7 E `--remote` 是不是可以移除""有 dev SPA 后 CLI REPL 还需要吗""统一到 main.py 是否合理"——一连串问题指向同一个底层:`cli.py`(CLI 入口)+ `main.py`(装配 lib)+ `chat / tasks / export` REPL 子命令是历史多形态共存遗留,在"UI 由 platform 实现 + dev SPA 是开发主路径"的新形态下都是冗余。**架构判断**:`main.py` 此前混三角色(装配 lib + 路径/验证 utility + 被 cli+web 共同 import 的事实入口),按 §5 Less Scaffolding + SoC 应该拆;直接答案是 `cli.py` 改名 `main.py`(入口),原 `main.py``core/agent_builder.py`(装配 lib),单一职责对齐 Python 社区惯例(入口叫 main.py,lib 在子模块)。**改动**:① `git mv main.py core/agent_builder.py`;② `git mv cli.py main.py`(覆盖);③ 5 处 `web/app.py::from main import xxx``from core.agent_builder import xxx`(`build_agent / sync_task_tokens / working_dir_from_name / resolve_workspace / user_root / InvalidTaskName / validate_task_name`);④ 新 `main.py` 自指 `from main import``from core.agent_builder import`;⑤ 删 `chat / tasks / export` 三个 click 命令 + REPL 内部 helpers(`_cleanup_if_empty / _delete_task_db_row / _task_has_messages / _list_task_rows` 共 ~110 行)+ REPL 主循环(`/exit /reset /new /resume /id /status /done /abandon /desc /export` 共 ~200 行)+ `--name --working-dir --skill --desc --resume --model` CLI 选项 + `tasks` 列表渲染 + `export` 命令 — 共 ~400 行;新 `main.py` ~180 行(`db {upgrade,downgrade,current}` + `probe` + `web` 三命令组);⑥ `core/agent_builder.py` 顺手清:删 `_resolve_uuid_or_prefix` 函数(web 端只传完整 UUID,前缀匹配无 caller)+ `resolve_task_id``task_id_arg in (None, "", "last")` 分支(web 不传 "last"),resume 直接 `UUID(task_id_arg)`;模块 docstring "本地 CLI user_id = SENTINEL" → "所有入口走 web `/v1` + JWT;dev SPA 默认填 SENTINEL 走同一条路径"。**Smoke 6 case 全绿**(in-process TestClient + 子进程跑 `python main.py db current`):① `/healthz` 200 ② POST /v1/tasks → GET → POST messages(返 `events_url` 无 run_id)→ cancel → DELETE 全链路 ③ `/v1/folders``core.agent_builder.user_root` 路径 ④ `/v1/files``_load_user_root``resolve_task_id` 完整 UUID resume(去前缀匹配后用 `UUID(...)` 直接解析;非 UUID 字符串 ValueError;ghost UUID `empty working_dir` ValueError)⑥ `subprocess.run([sys.executable, "main.py", "db", "current"])` 子进程跑通 + stdout 含 `0004 (head)`(验证 click 入口、alembic config 路径、ROOT 解析都没坏)。**文档同步**:DESIGN §1 形态兼容(删 `--remote`,讲"无 CLI / in-process 分叉")/ §2 目录树(`{main.py, cli.py}` → `core/agent_builder.py + main.py`)/ §3.3 `cli.py probe``main.py probe` / §3.6 "REPL 内 task 切换"段改"Task 切换 / 软删 走 dev SPA + /v1" + "入口"段讲 `python main.py web` / §7.0 共享差别表入口列改 `python main.py web` + auth 行讲"dev SPA 填 sentinel + 本地 key" / §7.6 #8 标"已撤" / §7.7 E 阶段标"撤" / §7.8 风险表"CLI 双模式分叉"行融合进"过早抽象" / §7.9 新增"CLI REPL 撤,入口统一 main.py"取舍说明 + 删原"CLI 双模式共存"段;RUN 顶 / 一次性初始化 / 日常命令 / 故障兜底 / 关键路径全部 `cli.py``main.py`,且日常命令段重写"只剩 `web / db / probe` + 所有 task 交互走 main.py web 后浏览器或 /v1`";PROGRESS 文件清单 / 状态表 / 下一步候选同步(去掉 E 路线)。**净效果**:总代码 -360 行(`cli.py` 558 行删 → `main.py` 180 行 + `core/agent_builder.py` ~320 行 = ~500;原 `main.py` 337 + `cli.py` 558 = 895;净减 -395);入口文件数 2 → 1;维护面 -1 套 task 切换语义(REPL `/new /resume /done /abandon` 全归到 `/v1/tasks*`);测试面 -1 套(原 cli build_agent 调用链 smoke 全归到 web TestClient)。 - **05-18 / 入口归位:`cli.py` 改名 `main.py`、原 `main.py` 挪 `core/agent_builder.py`,删 CLI REPL `chat/tasks/export`,§7 E 双模式路线撤**:接 0004 schema 大瘦身后又一轮架构清理。用户复盘"§7 E `--remote` 是不是可以移除""有 dev SPA 后 CLI REPL 还需要吗""统一到 main.py 是否合理"——一连串问题指向同一个底层:`cli.py`(CLI 入口)+ `main.py`(装配 lib)+ `chat / tasks / export` REPL 子命令是历史多形态共存遗留,在"UI 由 platform 实现 + dev SPA 是开发主路径"的新形态下都是冗余。**架构判断**:`main.py` 此前混三角色(装配 lib + 路径/验证 utility + 被 cli+web 共同 import 的事实入口),按 §5 Less Scaffolding + SoC 应该拆;直接答案是 `cli.py` 改名 `main.py`(入口),原 `main.py``core/agent_builder.py`(装配 lib),单一职责对齐 Python 社区惯例(入口叫 main.py,lib 在子模块)。**改动**:① `git mv main.py core/agent_builder.py`;② `git mv cli.py main.py`(覆盖);③ 5 处 `web/app.py::from main import xxx``from core.agent_builder import xxx`(`build_agent / sync_task_tokens / working_dir_from_name / resolve_workspace / user_root / InvalidTaskName / validate_task_name`);④ 新 `main.py` 自指 `from main import``from core.agent_builder import`;⑤ 删 `chat / tasks / export` 三个 click 命令 + REPL 内部 helpers(`_cleanup_if_empty / _delete_task_db_row / _task_has_messages / _list_task_rows` 共 ~110 行)+ REPL 主循环(`/exit /reset /new /resume /id /status /done /abandon /desc /export` 共 ~200 行)+ `--name --working-dir --skill --desc --resume --model` CLI 选项 + `tasks` 列表渲染 + `export` 命令 — 共 ~400 行;新 `main.py` ~180 行(`db {upgrade,downgrade,current}` + `probe` + `web` 三命令组);⑥ `core/agent_builder.py` 顺手清:删 `_resolve_uuid_or_prefix` 函数(web 端只传完整 UUID,前缀匹配无 caller)+ `resolve_task_id``task_id_arg in (None, "", "last")` 分支(web 不传 "last"),resume 直接 `UUID(task_id_arg)`;模块 docstring "本地 CLI user_id = SENTINEL" → "所有入口走 web `/v1` + JWT;dev SPA 默认填 SENTINEL 走同一条路径"。**Smoke 6 case 全绿**(in-process TestClient + 子进程跑 `python main.py db current`):① `/healthz` 200 ② POST /v1/tasks → GET → POST messages(返 `events_url` 无 run_id)→ cancel → DELETE 全链路 ③ `/v1/folders``core.agent_builder.user_root` 路径 ④ `/v1/files``_load_user_root``resolve_task_id` 完整 UUID resume(去前缀匹配后用 `UUID(...)` 直接解析;非 UUID 字符串 ValueError;ghost UUID `empty working_dir` ValueError)⑥ `subprocess.run([sys.executable, "main.py", "db", "current"])` 子进程跑通 + stdout 含 `0004 (head)`(验证 click 入口、alembic config 路径、ROOT 解析都没坏)。**文档同步**:DESIGN §1 形态兼容(删 `--remote`,讲"无 CLI / in-process 分叉")/ §2 目录树(`{main.py, cli.py}` → `core/agent_builder.py + main.py`)/ §3.3 `cli.py probe``main.py probe` / §3.6 "REPL 内 task 切换"段改"Task 切换 / 软删 走 dev SPA + /v1" + "入口"段讲 `python main.py web` / §7.0 共享差别表入口列改 `python main.py web` + auth 行讲"dev SPA 填 sentinel + 本地 key" / §7.6 #8 标"已撤" / §7.7 E 阶段标"撤" / §7.8 风险表"CLI 双模式分叉"行融合进"过早抽象" / §7.9 新增"CLI REPL 撤,入口统一 main.py"取舍说明 + 删原"CLI 双模式共存"段;RUN 顶 / 一次性初始化 / 日常命令 / 故障兜底 / 关键路径全部 `cli.py``main.py`,且日常命令段重写"只剩 `web / db / probe` + 所有 task 交互走 main.py web 后浏览器或 /v1`";PROGRESS 文件清单 / 状态表 / 下一步候选同步(去掉 E 路线)。**净效果**:总代码 -360 行(`cli.py` 558 行删 → `main.py` 180 行 + `core/agent_builder.py` ~320 行 = ~500;原 `main.py` 337 + `cli.py` 558 = 895;净减 -395);入口文件数 2 → 1;维护面 -1 套 task 切换语义(REPL `/new /resume /done /abandon` 全归到 `/v1/tasks*`);测试面 -1 套(原 cli build_agent 调用链 smoke 全归到 web TestClient)。
- **05-18 / 0004 schema 大瘦身:删 runs / usage_events 表,run_status / run_error 合入 tasks;路由从 run_id 维度改 task 维度**:用户复盘"为什么 cancel 接口要带 run_id?现在不是一个 task 一个 run 吗",顺手把 runs / usage_events 表也重新审视 — `usage_events` 全代码库零引用、零写入、零读取,纯死代码(为未来计费预付的架构成本);`runs` 表 `tokens_p/c` 写但从未被读(tokens 累计走 tasks 列),`started_at / finished_at / error` 也只写不读,`run_id` 唯二实用是 broker 频道键 + cancel 参数 — 但 §7.1 已选定**单活 run** 形态,同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够,run_id 完全冗余。按"开发期不写兼容层"心智一把切干净。**alembic 0004**:`DROP TABLE usage_events / runs`,`tasks` 加 `run_status text not null default 'idle'`(idle / running / cancelling / error)+ `run_error text null`。**ORM** `models.py``Run` / `UsageEvent` 两 class + 删 `BigInteger` import;`Task` 加两列;`storage/__init__.py` 文档示例同步;`Task.run_status` 终态语义:`ok / cancelled` 收尾都回 `idle`(用户视角"跑完 / 停了"等价不留持久标记),只有 `error` 是持久终态,起新 run 时清。**Broker**(`web/broker.py`)全面 task_id 索引:`_subs / _done / _cancel_flags` 三个 dict key 从 run_id 换 task_id;加 `start(task_id)` 入口在新 run 起来前清 `_done` 标记(避免上一轮 done 让新订阅者立刻断流)。**Sink**(`web/sinks.py`)绑 task_id 替代 run_id。**`web/app.py`**:① `_run_agent_bg(task_id, user_id, content)` 去掉 run_id 参数;装 `agent.cancel_check = lambda tid=task_id: broker.is_cancelled(tid)`;终态写 `tasks.run_status = "idle"`(原 `Run.status = "ok"/"cancelled"`)或 `"error"`(`run_error = err`);finally `broker.clear_cancel(tid) + broker.close(tid)`。② `POST /v1/tasks/{tid}/messages` 改:`SELECT Task.run_status … FOR UPDATE` 替代 `select(Run.run_id) … running/cancelling`;同事务 `UPDATE Task SET run_status='running', run_error=NULL`(error 也算可重启视为清);commit 后 `broker.start(tid)` 清 done;返 `{"events_url": "/v1/tasks/{tid}/events"}` 去掉 `run_id`。③ `POST /v1/tasks/{tid}/cancel` 取代 `POST /v1/tasks/{tid}/runs/{rid}/cancel`,只校验 task 归属 user;`run_status != 'running'` → 409。④ `GET /v1/tasks/{tid}/events` 取代 `/runs/{rid}/events`,broker.subscribe(tid)。⑤ lifespan reaper `UPDATE Task SET run_status='error' WHERE run_status IN ('running','cancelling')`,文案不变。⑥ `_task_dict` 暴露 `run_status` / `run_error` 字段给前端。**dev SPA**(`web/static/dev.html`):`state.currentRunId` 改 `state.streaming` bool;`POST /messages` 拿到 `events_url` 直接订阅,不再保存 run_id;cancel 按钮 click → `POST /v1/tasks/{tid}/cancel`(去掉 `/runs/{rid}/`)。**Migration 跑通**:本地 PG `db upgrade 0003 → 0004 (head)` 一把过(用户授权清旧数据,无 backfill)。**Smoke 18 case 全绿**(in-process TestClient + BG mock):POST /messages 返 `events_url` 无 run_id / tasks.run_status='running' / gate when running 409 / POST /cancel 202 + run_status='cancelling' + broker flag set / double cancel 409(状态非 running)/ gate during cancelling 也 409 / cancel idle 409 / cancel error 409 / error 状态可发新消息(error 不挂 gate + 清 run_error) / ghost task 404 / invalid UUID 404 / cross-user 404 / no auth 401 / GET /events 路由注册(SSE 流式跑会挂 30s 心跳,smoke 只验路径 + headers) / GET /tasks 返回 run_status / run_error 字段 / stale reaper 扫 running+cancelling 标 error / broker.start 清 _done / broker.subscribe + emit + close + late subscriber 立刻收 done / broker.request_cancel + is_cancelled + clear_cancel。**净增量**:核心代码 -200 行(删表 ORM + 两路由层简化),broker 加 21 行 start/cancel API,dev.html 几行字段重命名;DB 表 5 → 3,路由 `/runs/{rid}/{events,cancel}``/{events,cancel}`,前端 SPA 不再需要先拿 run_id 才能 cancel / 订阅 — 客户端只需 task_id。**文档同步**:DESIGN §7.2 路由表 messages 路由返 `events_url`(去 `run_id`)+ cancel / events 改 task-level + lead-in 注 0004 简化 + SSE schema text event 字段 `delta`(实际就是 delta,文档原 `content` 笔误);§7.4 schema 块 tasks 加两列 + 注 0004 合并;§7.9 hard cascade 行注 "原 usage_events 0004 删" + 加专项取舍说明"0004 删 runs + usage_events 表";§7.7 风险表两行同步 / 改 task-level 路由名;RUN 路由表三路由全改 + 故障兜底 cancel 409 文案改 + db upgrade head 改 0004;PROGRESS 已完成 + 状态表 + 文件清单。 - **05-18 / 0004 schema 大瘦身:删 runs / usage_events 表,run_status / run_error 合入 tasks;路由从 run_id 维度改 task 维度**:用户复盘"为什么 cancel 接口要带 run_id?现在不是一个 task 一个 run 吗",顺手把 runs / usage_events 表也重新审视 — `usage_events` 全代码库零引用、零写入、零读取,纯死代码(为未来计费预付的架构成本);`runs` 表 `tokens_p/c` 写但从未被读(tokens 累计走 tasks 列),`started_at / finished_at / error` 也只写不读,`run_id` 唯二实用是 broker 频道键 + cancel 参数 — 但 §7.1 已选定**单活 run** 形态,同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够,run_id 完全冗余。按"开发期不写兼容层"心智一把切干净。**alembic 0004**:`DROP TABLE usage_events / runs`,`tasks` 加 `run_status text not null default 'idle'`(idle / running / cancelling / error)+ `run_error text null`。**ORM** `models.py``Run` / `UsageEvent` 两 class + 删 `BigInteger` import;`Task` 加两列;`storage/__init__.py` 文档示例同步;`Task.run_status` 终态语义:`ok / cancelled` 收尾都回 `idle`(用户视角"跑完 / 停了"等价不留持久标记),只有 `error` 是持久终态,起新 run 时清。**Broker**(`web/broker.py`)全面 task_id 索引:`_subs / _done / _cancel_flags` 三个 dict key 从 run_id 换 task_id;加 `start(task_id)` 入口在新 run 起来前清 `_done` 标记(避免上一轮 done 让新订阅者立刻断流)。**Sink**(`web/sinks.py`)绑 task_id 替代 run_id。**`web/app.py`**:① `_run_agent_bg(task_id, user_id, content)` 去掉 run_id 参数;装 `agent.cancel_check = lambda tid=task_id: broker.is_cancelled(tid)`;终态写 `tasks.run_status = "idle"`(原 `Run.status = "ok"/"cancelled"`)或 `"error"`(`run_error = err`);finally `broker.clear_cancel(tid) + broker.close(tid)`。② `POST /v1/tasks/{tid}/messages` 改:`SELECT Task.run_status … FOR UPDATE` 替代 `select(Run.run_id) … running/cancelling`;同事务 `UPDATE Task SET run_status='running', run_error=NULL`(error 也算可重启视为清);commit 后 `broker.start(tid)` 清 done;返 `{"events_url": "/v1/tasks/{tid}/events"}` 去掉 `run_id`。③ `POST /v1/tasks/{tid}/cancel` 取代 `POST /v1/tasks/{tid}/runs/{rid}/cancel`,只校验 task 归属 user;`run_status != 'running'` → 409。④ `GET /v1/tasks/{tid}/events` 取代 `/runs/{rid}/events`,broker.subscribe(tid)。⑤ lifespan reaper `UPDATE Task SET run_status='error' WHERE run_status IN ('running','cancelling')`,文案不变。⑥ `_task_dict` 暴露 `run_status` / `run_error` 字段给前端。**dev SPA**(`web/static/dev.html`):`state.currentRunId` 改 `state.streaming` bool;`POST /messages` 拿到 `events_url` 直接订阅,不再保存 run_id;cancel 按钮 click → `POST /v1/tasks/{tid}/cancel`(去掉 `/runs/{rid}/`)。**Migration 跑通**:本地 PG `db upgrade 0003 → 0004 (head)` 一把过(用户授权清旧数据,无 backfill)。**Smoke 18 case 全绿**(in-process TestClient + BG mock):POST /messages 返 `events_url` 无 run_id / tasks.run_status='running' / gate when running 409 / POST /cancel 202 + run_status='cancelling' + broker flag set / double cancel 409(状态非 running)/ gate during cancelling 也 409 / cancel idle 409 / cancel error 409 / error 状态可发新消息(error 不挂 gate + 清 run_error) / ghost task 404 / invalid UUID 404 / cross-user 404 / no auth 401 / GET /events 路由注册(SSE 流式跑会挂 30s 心跳,smoke 只验路径 + headers) / GET /tasks 返回 run_status / run_error 字段 / stale reaper 扫 running+cancelling 标 error / broker.start 清 _done / broker.subscribe + emit + close + late subscriber 立刻收 done / broker.request_cancel + is_cancelled + clear_cancel。**净增量**:核心代码 -200 行(删表 ORM + 两路由层简化),broker 加 21 行 start/cancel API,dev.html 几行字段重命名;DB 表 5 → 3,路由 `/runs/{rid}/{events,cancel}``/{events,cancel}`,前端 SPA 不再需要先拿 run_id 才能 cancel / 订阅 — 客户端只需 task_id。**文档同步**:DESIGN §7.2 路由表 messages 路由返 `events_url`(去 `run_id`)+ cancel / events 改 task-level + lead-in 注 0004 简化 + SSE schema text event 字段 `delta`(实际就是 delta,文档原 `content` 笔误);§7.4 schema 块 tasks 加两列 + 注 0004 合并;§7.9 hard cascade 行注 "原 usage_events 0004 删" + 加专项取舍说明"0004 删 runs + usage_events 表";§7.7 风险表两行同步 / 改 task-level 路由名;RUN 路由表三路由全改 + 故障兜底 cancel 409 文案改 + db upgrade head 改 0004;PROGRESS 已完成 + 状态表 + 文件清单。
- **05-18 / cancel run endpoint + AgentLoop 协作式 cancel + dev SPA stop 按钮**:用户反馈"等待回复或 LLM 操作时没有停止接口"。落地 DESIGN §7.2 原标"待"的 `POST /v1/tasks/{id}/runs/{rid}/cancel`。**Broker**(`web/broker.py`):加 `request_cancel(rid)` / `is_cancelled(rid)` / `clear_cancel(rid)` 三方法,内部 `dict[UUID, threading.Event]` per-run;`setdefault` 保证 BG 还没 register 也能 set。**Loop**(`core/loop.py`):`AgentLoop` 加 `cancel_check: Optional[Callable[[], bool]]` 字段(CLI 路径不传 = None 永不 cancel),`_is_cancelled()` helper + `_fill_cancelled_tool_results(remaining)` 给未执行的 tool_call 全部 append `[cancelled by user]` tool message —— LiteLLM 协议要求每个 assistant tool_call 必须有匹配 tool result,否则 resume 时 LLM 报错。Check 点:每轮 LLM 前 + tool_calls 之间。命中 emit `cancelled` event + return `[cancelled]`。**LLM 同步 call 本身不可中断**(litellm 同步阻塞,无原生 cancel)—— 接受最坏等当前一轮跑完(通常几十秒),注释里讲清楚。**Endpoint**(`web/app.py::cancel_run`):校验 task 归属 user + run 归属 task(else 404),`run.status` 必须是 `running`(else 409 含具体 status);标 `cancelling`(过渡态)+ `broker.request_cancel(rid)`;202。`_run_agent_bg` 装配时 `agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid)`,run 完时判 `broker.is_cancelled` 写终态 `cancelled` vs `ok`;finally `broker.clear_cancel + broker.close`。**Gate 同步扩**:`post_message` 单活 run 检查从 `status == 'running'``status in ('running', 'cancelling')`,确保 cancel 后旧 BG 还没退出时新 POST 仍 409(避免新旧 run 撞 messages.idx)。**Reaper 同步扩**:lifespan 启动也扫 `cancelling`(进程 crash 时 BG 来不及写终态 cancelled,反正没线程在跑就清掉)。**dev SPA**(`web/static/dev.html`):chat 表单加 `<button id="chat-cancel" class="small danger">stop</button>`(常态 hidden);state 加 `currentRunId`;sendMessage 拿到 run_id 后 show stop,fetchSse `try/finally` 收尾时一并 hide stop / 清 currentRunId / 复原 send button(确保 SSE 失败路径 UI 也 reset 不卡死)。cancel 按钮 click → `POST /runs/{rid}/cancel`;409 静默忽略(并发 done 不算错)。`handleSseEvent` 加 `cancelled` case → 在当前 assistant 卡贴一个虚线红框 "已停止(stopped by user)" badge。CSS 加 `.cancelled-badge`。**Smoke 15 case 全绿**:HTTP 层 11 case(cancel happy + 双 cancel 409 + cancelling 期间 POST messages 409 + ghost run 404 + invalid UUID 404 + cross-task 404 + cross-user 404 + cancel 已 ok 409 + cancel 已 error 409 + no auth 401 + stale reaper 扫 cancelling);Loop 层 4 case(cancel before first iter 不调 LLM / cancel between tool_calls 补 cancelled placeholder 3 个 + 保协议 + emit cancelled / 正常 done 不 emit cancelled / CLI 路径 cancel_check=None 默永不 cancel)。**没动 SSE handler 的 break list**(`("done", "error")`):cancelled 在 SSE 里走流给前端看,broker.close 之后立即跟 done 收流。**文档同步**:DESIGN §7.2 路由表 cancel 行从"待"扩成完整描述 + SSE 事件加 `cancelled{}` 行 + §7.7 风险表加"Run 跑太久 / 用户想中断"行;RUN 路由表加 cancel 行 + POST /messages 409 文案改 "running / cancelling" + 故障兜底加三行(cancel 409 / 点 stop 没立刻停 / reaper 扫 cancelling);PROGRESS 已完成 + 下一步重排(去掉 cancel,留 OIDC / C Executor / E CLI 双模式)。 - **05-18 / cancel run endpoint + AgentLoop 协作式 cancel + dev SPA stop 按钮**:用户反馈"等待回复或 LLM 操作时没有停止接口"。落地 DESIGN §7.2 原标"待"的 `POST /v1/tasks/{id}/runs/{rid}/cancel`。**Broker**(`web/broker.py`):加 `request_cancel(rid)` / `is_cancelled(rid)` / `clear_cancel(rid)` 三方法,内部 `dict[UUID, threading.Event]` per-run;`setdefault` 保证 BG 还没 register 也能 set。**Loop**(`core/loop.py`):`AgentLoop` 加 `cancel_check: Optional[Callable[[], bool]]` 字段(CLI 路径不传 = None 永不 cancel),`_is_cancelled()` helper + `_fill_cancelled_tool_results(remaining)` 给未执行的 tool_call 全部 append `[cancelled by user]` tool message —— LiteLLM 协议要求每个 assistant tool_call 必须有匹配 tool result,否则 resume 时 LLM 报错。Check 点:每轮 LLM 前 + tool_calls 之间。命中 emit `cancelled` event + return `[cancelled]`。**LLM 同步 call 本身不可中断**(litellm 同步阻塞,无原生 cancel)—— 接受最坏等当前一轮跑完(通常几十秒),注释里讲清楚。**Endpoint**(`web/app.py::cancel_run`):校验 task 归属 user + run 归属 task(else 404),`run.status` 必须是 `running`(else 409 含具体 status);标 `cancelling`(过渡态)+ `broker.request_cancel(rid)`;202。`_run_agent_bg` 装配时 `agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid)`,run 完时判 `broker.is_cancelled` 写终态 `cancelled` vs `ok`;finally `broker.clear_cancel + broker.close`。**Gate 同步扩**:`post_message` 单活 run 检查从 `status == 'running'``status in ('running', 'cancelling')`,确保 cancel 后旧 BG 还没退出时新 POST 仍 409(避免新旧 run 撞 messages.idx)。**Reaper 同步扩**:lifespan 启动也扫 `cancelling`(进程 crash 时 BG 来不及写终态 cancelled,反正没线程在跑就清掉)。**dev SPA**(`web/static/dev.html`):chat 表单加 `<button id="chat-cancel" class="small danger">stop</button>`(常态 hidden);state 加 `currentRunId`;sendMessage 拿到 run_id 后 show stop,fetchSse `try/finally` 收尾时一并 hide stop / 清 currentRunId / 复原 send button(确保 SSE 失败路径 UI 也 reset 不卡死)。cancel 按钮 click → `POST /runs/{rid}/cancel`;409 静默忽略(并发 done 不算错)。`handleSseEvent` 加 `cancelled` case → 在当前 assistant 卡贴一个虚线红框 "已停止(stopped by user)" badge。CSS 加 `.cancelled-badge`。**Smoke 15 case 全绿**:HTTP 层 11 case(cancel happy + 双 cancel 409 + cancelling 期间 POST messages 409 + ghost run 404 + invalid UUID 404 + cross-task 404 + cross-user 404 + cancel 已 ok 409 + cancel 已 error 409 + no auth 401 + stale reaper 扫 cancelling);Loop 层 4 case(cancel before first iter 不调 LLM / cancel between tool_calls 补 cancelled placeholder 3 个 + 保协议 + emit cancelled / 正常 done 不 emit cancelled / CLI 路径 cancel_check=None 默永不 cancel)。**没动 SSE handler 的 break list**(`("done", "error")`):cancelled 在 SSE 里走流给前端看,broker.close 之后立即跟 done 收流。**文档同步**:DESIGN §7.2 路由表 cancel 行从"待"扩成完整描述 + SSE 事件加 `cancelled{}` 行 + §7.7 风险表加"Run 跑太久 / 用户想中断"行;RUN 路由表加 cancel 行 + POST /messages 409 文案改 "running / cancelling" + 故障兜底加三行(cancel 409 / 点 stop 没立刻停 / reaper 扫 cancelling);PROGRESS 已完成 + 下一步重排(去掉 cancel,留 OIDC / C Executor / E CLI 双模式)。

View File

@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>zcbot dev</title> <title>zcbot 控制台</title>
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) --> <!-- markdown + 防 XSS + 代码高亮(纯 CDN,失败优雅降级回 plain text) -->
@ -231,7 +231,7 @@
<!-- ───── login overlay ───── --> <!-- ───── login overlay ───── -->
<div id="login"> <div id="login">
<div class="card"> <div class="card">
<h2>zcbot dev login</h2> <h2>zcbot 登录</h2>
<label for="li-uid">user_id (UUID)</label> <label for="li-uid">user_id (UUID)</label>
<input id="li-uid" autocomplete="off" /> <input id="li-uid" autocomplete="off" />
<label for="li-key">platform_key</label> <label for="li-key">platform_key</label>
@ -252,20 +252,20 @@
<div class="title">zcbot</div> <div class="title">zcbot</div>
<div class="who" id="hd-who"></div> <div class="who" id="hd-who"></div>
<div class="spacer"></div> <div class="spacer"></div>
<button id="hd-new" class="primary">+ new task</button> <button id="hd-new" class="primary">+ 新建任务</button>
<button id="hd-logout">logout</button> <button id="hd-logout">退出登录</button>
</header> </header>
<!-- left --> <!-- left -->
<div class="pane" id="pane-left"> <div class="pane" id="pane-left">
<div class="pane-head"> <div class="pane-head">
<span class="label">tasks</span> <span class="label">任务</span>
<span class="spacer"></span> <span class="spacer"></span>
<select id="filter-status" class="small" style="width: auto;"> <select id="filter-status" class="small" style="width: auto;">
<option value="">(all)</option> <option value="">(全部)</option>
<option value="active">active</option> <option value="active">进行中</option>
<option value="completed">completed</option> <option value="completed">已完成</option>
<option value="abandoned">abandoned</option> <option value="abandoned">已废弃</option>
</select> </select>
<button id="btn-refresh-tasks" class="small" title="刷新"></button> <button id="btn-refresh-tasks" class="small" title="刷新"></button>
</div> </div>
@ -285,7 +285,7 @@
<option value="status,-created_at">状态分组(同状态按时间倒序)</option> <option value="status,-created_at">状态分组(同状态按时间倒序)</option>
</select> </select>
</div> </div>
<div id="task-list"><div class="empty">loading</div></div> <div id="task-list"><div class="empty">加载中</div></div>
<div id="task-pager" class="pane-head" style="border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); justify-content: space-between; display: none;"> <div id="task-pager" class="pane-head" style="border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); justify-content: space-between; display: none;">
<span id="pager-info"></span> <span id="pager-info"></span>
<span style="display:flex; gap: 4px;"> <span style="display:flex; gap: 4px;">
@ -298,22 +298,22 @@
<!-- middle --> <!-- middle -->
<div id="pane-mid"> <div id="pane-mid">
<div class="pane-head"> <div class="pane-head">
<span class="label">chat</span> <span class="label">对话</span>
<span class="spacer"></span> <span class="spacer"></span>
<button id="btn-export" class="small" disabled>export .docx</button> <button id="btn-export" class="small" disabled>导出 docx</button>
<button id="btn-done" class="small" disabled>done</button> <button id="btn-done" class="small" disabled>完成</button>
<button id="btn-abandon" class="small danger" disabled>abandon</button> <button id="btn-abandon" class="small danger" disabled>废弃</button>
<button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">delete</button> <button id="btn-delete-task" class="small danger" disabled title="硬删除:清 DB 行 + messages,FS 文件不动">删除</button>
</div> </div>
<div id="chat-meta"><span class="muted">(no task selected)</span></div> <div id="chat-meta"><span class="muted">(未选中任务)</span></div>
<div id="chat-stream"><div class="empty">select a task on the left</div></div> <div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<form id="chat-form" style="display:none;"> <form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea> <textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行)"></textarea>
<div class="row"> <div class="row">
<span class="hint" id="chat-hint">ready</span> <span class="hint" id="chat-hint">就绪</span>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">stop</button> <button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">停止</button>
<button type="submit" class="primary" id="chat-send">send</button> <button type="submit" class="primary" id="chat-send">发送</button>
</div> </div>
</form> </form>
</div> </div>
@ -321,13 +321,13 @@
<!-- right --> <!-- right -->
<div id="pane-right"> <div id="pane-right">
<div class="pane-head"> <div class="pane-head">
<span class="label">files</span> <span class="label">文件</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span> <span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;"></span>
<span class="spacer"></span> <span class="spacer"></span>
<button id="btn-upload" class="small" title="上传文件到当前目录"></button> <button id="btn-upload" class="small" title="上传文件到当前目录"></button>
<button id="btn-refresh-files" class="small"></button> <button id="btn-refresh-files" class="small"></button>
</div> </div>
<div id="file-crumbs" class="crumbs muted">loading</div> <div id="file-crumbs" class="crumbs muted">加载中</div>
<div id="file-list"></div> <div id="file-list"></div>
<input type="file" id="upload-input" multiple style="display:none;" /> <input type="file" id="upload-input" multiple style="display:none;" />
</div> </div>
@ -336,16 +336,16 @@
<!-- ───── new task modal ───── --> <!-- ───── new task modal ───── -->
<div id="new-task-modal"> <div id="new-task-modal">
<div class="card"> <div class="card">
<h3>新建 task</h3> <h3>新建任务</h3>
<label for="nt-name">任务名(必填)</label> <label for="nt-name">任务名(必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" /> <input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd">工作目录(可选,留空 → 用任务名;已有则复用,新名则新建)</label> <label for="nt-wd">工作目录(可选,留空 → 用任务名;已有则复用,新名则新建)</label>
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空 fallback 用任务名" /> <input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空用任务名" />
<datalist id="folders-datalist"></datalist> <datalist id="folders-datalist"></datalist>
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div> <div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
<label for="nt-desc">描述 (可选,task 长描述)</label> <label for="nt-desc">描述(可选,任务长描述)</label>
<input id="nt-desc" /> <input id="nt-desc" />
<label for="nt-skill">skill (可选,智能体类型,如 coding / ppt / proposal)</label> <label for="nt-skill">智能体类型(可选,如 coding / ppt / proposal)</label>
<input id="nt-skill" /> <input id="nt-skill" />
<div class="err" id="nt-err"></div> <div class="err" id="nt-err"></div>
<div class="actions"> <div class="actions">
@ -518,7 +518,7 @@ async function loadTaskList() {
renderPager(); renderPager();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
$("task-list").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`; $("task-list").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
$("task-pager").style.display = "none"; $("task-pager").style.display = "none";
} }
} }
@ -547,24 +547,26 @@ function resetPageAndReload() {
function renderTaskList(tasks) { function renderTaskList(tasks) {
if (!tasks.length) { if (!tasks.length) {
$("task-list").innerHTML = `<div class="empty">(no tasks)</div>`; $("task-list").innerHTML = `<div class="empty">(暂无任务)</div>`;
return; return;
} }
const statusLabels = { active: "进行中", completed: "已完成", abandoned: "已废弃" };
const html = tasks.map((t) => { const html = tasks.map((t) => {
const active = state.taskId === t.task_id ? " active" : ""; const active = state.taskId === t.task_id ? " active" : "";
// 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示) // 主行 = 任务名(必填字段);副行 = 工作目录 + description(都按需显示)
const taskName = t.name || "(unnamed)"; const taskName = t.name || "(未命名)";
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const desc = t.description || ""; const desc = t.description || "";
const statusLabel = statusLabels[t.status] || t.status;
return ` return `
<div class="task-row${active}" data-tid="${t.task_id}"> <div class="task-row${active}" data-tid="${t.task_id}">
<div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div> <div class="desc" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</div>
${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""} ${wdName ? `<div class="meta muted" title="${escapeHtml(t.working_dir || "")}">📁 ${escapeHtml(wdName)}</div>` : ""}
${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""} ${desc ? `<div class="meta muted" title="${escapeHtml(desc)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">${escapeHtml(desc)}</div>` : ""}
<div class="meta"> <div class="meta">
<span class="badge ${t.status}">${t.status}</span> <span class="badge ${t.status}">${statusLabel}</span>
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""} ${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.n_messages || 0} msg</span> <span>${t.n_messages || 0} </span>
<span>${t.tokens || 0} tok</span> <span>${t.tokens || 0} tok</span>
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span> <span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
</div> </div>
@ -620,24 +622,25 @@ async function selectTask(tid) {
await loadFiles(); await loadFiles();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
$("chat-stream").innerHTML = `<div class="empty">load failed: ${escapeHtml(e.message)}</div>`; $("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
} }
} }
function renderChatMeta() { function renderChatMeta() {
const t = state.taskMeta; const t = state.taskMeta;
if (!t) { $("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; return; } if (!t) { $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`; return; }
const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : ""; const wdName = t.working_dir ? t.working_dir.split("/").filter(Boolean).pop() : "";
const taskName = t.name || "(unnamed)"; const taskName = t.name || "(未命名)";
const statusLabel = { active: "进行中", completed: "已完成", abandoned: "已废弃" }[t.status] || t.status;
$("chat-meta").innerHTML = ` $("chat-meta").innerHTML = `
<span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span> <span style="font-weight:600;" title="${escapeHtml(taskName)}">${escapeHtml(taskName)}</span>
<span class="badge ${t.status}">${t.status}</span> <span class="badge ${t.status}">${statusLabel}</span>
${wdName ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>` : ""} ${wdName ? `<span class="muted" title="${escapeHtml(t.working_dir)}">📁 ${escapeHtml(wdName)}</span>` : ""}
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""} ${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span class="tid">${t.task_id.slice(0, 8)}</span> <span class="tid">${t.task_id.slice(0, 8)}</span>
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""} ${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span> <span class="spacer"></span>
<span class="muted small">${t.n_messages || 0} msg · ${t.tokens || 0} tok</span> <span class="muted small">${t.n_messages || 0} · ${t.tokens || 0} tok</span>
`; `;
const active = t.status === "active"; const active = t.status === "active";
$("chat-form").style.display = active ? "flex" : "none"; $("chat-form").style.display = active ? "flex" : "none";
@ -656,7 +659,7 @@ function renderMessages(msgs) {
const wrap = $("chat-stream"); const wrap = $("chat-stream");
wrap.innerHTML = ""; wrap.innerHTML = "";
if (!msgs.length) { if (!msgs.length) {
wrap.innerHTML = `<div class="empty">(no messages yet · send something below)</div>`; wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
return; return;
} }
for (const m of msgs) { for (const m of msgs) {
@ -669,15 +672,16 @@ function renderMessages(msgs) {
card.className = "msg tool"; card.className = "msg tool";
const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content); const txt = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
card.innerHTML = ` card.innerHTML = `
<div class="role">tool · ${escapeHtml(p.name || "")}</div> <div class="role">工具调用 · ${escapeHtml(p.name || "")}</div>
<details class="tool-call"><summary>result (${(txt || "").length} chars)</summary><pre>${escapeHtml(txt || "")}</pre></details> <details class="tool-call"><summary>结果(${(txt || "").length} 字符)</summary><pre>${escapeHtml(txt || "")}</pre></details>
`; `;
wrap.appendChild(card); wrap.appendChild(card);
continue; continue;
} }
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "msg " + role; card.className = "msg " + role;
let html = `<div class="role">${role}</div>`; const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
let html = `<div class="role">${roleLabel}</div>`;
if (typeof p.content === "string" && p.content) { if (typeof p.content === "string" && p.content) {
html += `<div class="body">${renderMd(p.content)}</div>`; html += `<div class="body">${renderMd(p.content)}</div>`;
} }
@ -689,7 +693,7 @@ function renderMessages(msgs) {
args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2); args = JSON.stringify(JSON.parse((tc.function && tc.function.arguments) || "{}"), null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; } } catch (e) { args = (tc.function && tc.function.arguments) || ""; }
html += ` html += `
<details class="tool-call"><summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details> <details class="tool-call"><summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(args)}</pre></details>
`; `;
} }
} }
@ -711,19 +715,19 @@ async function sendMessage() {
const content = $("chat-input").value.trim(); const content = $("chat-input").value.trim();
if (!content) return; if (!content) return;
$("chat-send").disabled = true; $("chat-send").disabled = true;
$("chat-hint").textContent = "sending…"; $("chat-hint").textContent = "发送中…";
try { try {
// 立刻渲染 user 消息卡(乐观) // 立刻渲染 user 消息卡(乐观)
const wrap = $("chat-stream"); const wrap = $("chat-stream");
const userCard = document.createElement("div"); const userCard = document.createElement("div");
userCard.className = "msg user"; userCard.className = "msg user";
userCard.innerHTML = `<div class="role">user</div><div class="body">${escapeHtml(content)}</div>`; userCard.innerHTML = `<div class="role"></div><div class="body">${escapeHtml(content)}</div>`;
wrap.appendChild(userCard); wrap.appendChild(userCard);
// assistant 流式占位卡 // assistant 流式占位卡
const asstCard = document.createElement("div"); const asstCard = document.createElement("div");
asstCard.className = "msg assistant"; asstCard.className = "msg assistant";
asstCard.innerHTML = `<div class="role">assistant</div><div class="body streaming"></div>`; asstCard.innerHTML = `<div class="role">助手</div><div class="body streaming"></div>`;
wrap.appendChild(asstCard); wrap.appendChild(asstCard);
wrap.scrollTop = wrap.scrollHeight; wrap.scrollTop = wrap.scrollHeight;
@ -736,7 +740,7 @@ async function sendMessage() {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
appendErrorCard(e.message); appendErrorCard(e.message);
$("chat-send").disabled = false; $("chat-send").disabled = false;
$("chat-hint").textContent = "ready"; $("chat-hint").textContent = "就绪";
} }
} }
@ -744,7 +748,7 @@ async function cancelCurrentTask() {
if (!state.taskId || !state.streaming) return; if (!state.taskId || !state.streaming) return;
const btn = $("chat-cancel"); const btn = $("chat-cancel");
btn.disabled = true; btn.disabled = true;
$("chat-hint").textContent = "cancelling…"; $("chat-hint").textContent = "停止中…";
try { try {
await api("POST", `/v1/tasks/${state.taskId}/cancel`); await api("POST", `/v1/tasks/${state.taskId}/cancel`);
// 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清 // 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清
@ -753,7 +757,7 @@ async function cancelCurrentTask() {
// 409 = 已结束 / 已 cancelling,不算错;其他贴 toast // 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
if (e.status !== 409) appendErrorCard("cancel: " + e.message); if (e.status !== 409) appendErrorCard("cancel: " + e.message);
btn.disabled = false; btn.disabled = false;
$("chat-hint").textContent = "ready"; $("chat-hint").textContent = "就绪";
} }
} }
@ -777,7 +781,7 @@ async function fetchSse(url, asstCard) {
const reader = r.body.getReader(); const reader = r.body.getReader();
const dec = new TextDecoder(); const dec = new TextDecoder();
let buf = ""; let buf = "";
$("chat-hint").textContent = "streaming…"; $("chat-hint").textContent = "接收中…";
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
@ -799,7 +803,7 @@ async function fetchSse(url, asstCard) {
} finally { } finally {
body.classList.remove("streaming"); body.classList.remove("streaming");
$("chat-send").disabled = false; $("chat-send").disabled = false;
$("chat-hint").textContent = "ready"; $("chat-hint").textContent = "就绪";
state.streaming = false; state.streaming = false;
const cb = $("chat-cancel"); const cb = $("chat-cancel");
cb.style.display = "none"; cb.style.display = "none";
@ -846,18 +850,18 @@ function handleSseEvent(ev, asstCard, ctx) {
const args = (ev.data && ev.data.arguments) || ""; const args = (ev.data && ev.data.arguments) || "";
const det = document.createElement("details"); const det = document.createElement("details");
det.className = "tool-call"; det.className = "tool-call";
det.innerHTML = `<summary>tool_call: ${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`; det.innerHTML = `<summary>工具调用:${escapeHtml(fn)}</summary><pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>`;
asstCard.appendChild(det); asstCard.appendChild(det);
} else if (t === "tool_result") { } else if (t === "tool_result") {
const txt = (ev.data && ev.data.result) || ""; const txt = (ev.data && ev.data.result) || "";
const det = document.createElement("details"); const det = document.createElement("details");
det.className = "tool-call"; det.className = "tool-call";
det.innerHTML = `<summary>tool_result</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`; det.innerHTML = `<summary>工具结果</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
asstCard.appendChild(det); asstCard.appendChild(det);
} else if (t === "cancelled") { } else if (t === "cancelled") {
const badge = document.createElement("div"); const badge = document.createElement("div");
badge.className = "cancelled-badge"; badge.className = "cancelled-badge";
badge.textContent = "已停止(stopped by user)"; badge.textContent = "已停止";
asstCard.appendChild(badge); 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);
@ -869,7 +873,7 @@ function handleSseEvent(ev, asstCard, ctx) {
function appendErrorCard(msg) { function appendErrorCard(msg) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "msg error"; card.className = "msg error";
card.innerHTML = `<div class="role">error</div><div class="body">${escapeHtml(msg)}</div>`; card.innerHTML = `<div class="role">错误</div><div class="body">${escapeHtml(msg)}</div>`;
$("chat-stream").appendChild(card); $("chat-stream").appendChild(card);
$("chat-stream").scrollTop = $("chat-stream").scrollHeight; $("chat-stream").scrollTop = $("chat-stream").scrollHeight;
} }
@ -881,14 +885,15 @@ $("btn-delete-task").onclick = deleteCurrentTask;
async function patchStatus(status) { async function patchStatus(status) {
if (!state.taskId) return; if (!state.taskId) return;
if (!confirm("确认置为 " + status + "?")) return; const labels = { completed: "已完成", abandoned: "已废弃" };
if (!confirm(`确认置为「${labels[status] || status}」?`)) return;
try { try {
await api("PATCH", "/v1/tasks/" + state.taskId, { status }); await api("PATCH", "/v1/tasks/" + state.taskId, { status });
await selectTask(state.taskId); await selectTask(state.taskId);
loadTaskList(); loadTaskList();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
alert("failed: " + e.message); alert("操作失败:" + e.message);
} }
} }
@ -897,15 +902,15 @@ async function deleteCurrentTask() {
const t = state.taskMeta; const t = state.taskMeta;
const projName = (t && t.working_dir) ? t.working_dir.split("/").filter(Boolean).pop() : state.taskId.slice(0, 8); const projName = (t && t.working_dir) ? t.working_dir.split("/").filter(Boolean).pop() : state.taskId.slice(0, 8);
const nMsg = (t && t.n_messages) || 0; const nMsg = (t && t.n_messages) || 0;
if (!confirm(`确认硬删除 task "${projName}" (${nMsg} 条消息)?\n\n会清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return; if (!confirm(`确认硬删除任务「${projName}」(${nMsg} 条消息)?\n\n将清掉对话历史和 DB 行,但工作目录下的文件保留。`)) return;
try { try {
await api("DELETE", "/v1/tasks/" + state.taskId); await api("DELETE", "/v1/tasks/" + state.taskId);
// 清 chat 面板,回到初始态;files 面板与 task 解耦,保留当前路径(FS 文件仍在) // 清 chat 面板,回到初始态;files 面板与 task 解耦,保留当前路径(FS 文件仍在)
if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; } if (state.evtSrc) { state.evtSrc.close(); state.evtSrc = null; }
state.taskId = null; state.taskId = null;
state.taskMeta = null; state.taskMeta = null;
$("chat-meta").innerHTML = `<span class="muted">(no task selected)</span>`; $("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
$("chat-stream").innerHTML = `<div class="empty">select a task on the left</div>`; $("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
$("chat-form").style.display = "none"; $("chat-form").style.display = "none";
$("btn-done").disabled = true; $("btn-done").disabled = true;
$("btn-abandon").disabled = true; $("btn-abandon").disabled = true;
@ -915,7 +920,7 @@ async function deleteCurrentTask() {
loadFiles(); // FS 还在,刷新当前路径(可能文件夹仍可见) loadFiles(); // FS 还在,刷新当前路径(可能文件夹仍可见)
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
alert("delete failed: " + e.message); alert("删除失败:" + e.message);
} }
} }
@ -925,7 +930,7 @@ $("btn-export").onclick = () => {
fetch("/v1/tasks/" + state.taskId + "/export", { fetch("/v1/tasks/" + state.taskId + "/export", {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) { alert("export failed: " + r.status); return; } if (!r.ok) { alert("导出失败:" + r.status); return; }
const blob = await r.blob(); const blob = await r.blob();
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
@ -956,7 +961,7 @@ function renderFiles(data) {
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
const segs = (data.current || "").split("/").filter(Boolean); const segs = (data.current || "").split("/").filter(Boolean);
const projName = segs[0] || ""; const projName = segs[0] || "";
$("files-proj").textContent = projName ? "· " + projName : "· (user root)"; $("files-proj").textContent = projName ? "· " + projName : "· (根目录)";
$("files-proj").title = data.root || ""; $("files-proj").title = data.root || "";
// crumbs root 标"我的"(user_root),更直观;其余原样 // crumbs root 标"我的"(user_root),更直观;其余原样
const cr = data.crumbs.map((c, i) => { const cr = data.crumbs.map((c, i) => {
@ -1010,7 +1015,7 @@ async function deleteFile(rel, name, isDir) {
await loadFiles(); await loadFiles();
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
alert("delete failed: " + e.message); alert("删除失败:" + e.message);
} }
} }
@ -1018,7 +1023,7 @@ function downloadFile(rel) {
fetch("/v1/files/download?path=" + encodeURIComponent(rel), { fetch("/v1/files/download?path=" + encodeURIComponent(rel), {
headers: { "Authorization": "Bearer " + state.token }, headers: { "Authorization": "Bearer " + state.token },
}).then(async (r) => { }).then(async (r) => {
if (!r.ok) { alert("download failed: " + r.status); return; } if (!r.ok) { alert("下载失败:" + r.status); return; }
const blob = await r.blob(); const blob = await r.blob();
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
@ -1043,11 +1048,11 @@ async function uploadSelected() {
}); });
if (!r.ok) { if (!r.ok) {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
throw new Error(d.detail || (r.status + " upload failed")); throw new Error(d.detail || (r.status + " 上传失败"));
} }
await loadFiles(); await loadFiles();
} catch (e) { } catch (e) {
alert("upload failed: " + e.message); alert("上传失败:" + e.message);
} finally { } finally {
inp.value = ""; // 允许重新选同名文件 inp.value = ""; // 允许重新选同名文件
} }
@ -1070,7 +1075,7 @@ $("nt-go").onclick = async () => {
const desc = $("nt-desc").value.trim(); const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value.trim(); const skill = $("nt-skill").value.trim();
$("nt-err").textContent = ""; $("nt-err").textContent = "";
if (!name) { $("nt-err").textContent = "任务名 必填"; return; } if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
try { try {
const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill }); const t = await api("POST", "/v1/tasks", { name, working_dir, description: desc, skill });
$("new-task-modal").classList.remove("show"); $("new-task-modal").classList.remove("show");
@ -1088,7 +1093,7 @@ async function loadFolderSuggestions() {
const data = await api("GET", "/v1/folders"); const data = await api("GET", "/v1/folders");
const dl = $("folders-datalist"); const dl = $("folders-datalist");
dl.innerHTML = (data.folders || []).map((f) => { dl.innerHTML = (data.folders || []).map((f) => {
const tag = f.n_tasks ? `${f.n_tasks} 个 task` : `空目录`; const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`; return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
}).join(""); }).join("");
} catch (e) { } catch (e) {
@ -1107,7 +1112,7 @@ $("nt-wd").addEventListener("input", () => {
const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`); const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`);
if (opt) { if (opt) {
const n = parseInt(opt.dataset.n) || 0; const n = parseInt(opt.dataset.n) || 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个 task`; hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
} else { } else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`; hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
} }