diff --git a/PROGRESS.md b/PROGRESS.md index ec3f1e0..e8a39c5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。 -最后更新:2026-05-20(task 级「宪法」文件命名约定 + `spec_lock` → `spec` 简化 — 解决同 working_dir 多 task 的 spec 文件冲突) +最后更新:2026-05-20(`POST /v1/tasks/{id}/clear` 清空对话 + dev SPA 清空对话按钮) --- @@ -23,6 +23,7 @@ ### 2026-05-20 +- **`POST /v1/tasks/{id}/clear` 清空对话 + dev SPA「清空对话」按钮**:用户要在同一 task 内重新开始对话。后端新路由:同事务 `SELECT … FOR UPDATE` 锁 + `run_status in (running, cancelling)` → 409(先 cancel)+ `DELETE FROM messages WHERE task_id=tid` + reset `tasks.tokens_prompt/completion/cost_usd=0` + `run_status='idle'` + `run_error=None`,返回新 task dict(`n_messages=0`)。**`usage_events` 表完全不动** — 那是用户级账户账单的 source of truth,清空对话不该影响计费;`usage_events.message_id` FK 是 `ondelete=SET NULL`(models.py:128),message_id 列变 NULL,但 task_id/model_profile/units(tokens_in/out)/cost_usd 全保留,按 task_id 聚合可重建历史累计。**reset task 三列累计 vs 保留累计**:选 reset,因为顶栏「N 条 · M tok」显示"0 条 vs 50k tok"会视觉矛盾;真正账单数据在 usage_events 完整无损。dev SPA 顶栏在「导出对话记录」后插「清空对话」按钮(紫色 hover #8e44ad,区别于完成绿/废弃橙/删除红),`renderChatMeta` 里 `running||n_messages==0 → disabled`,confirm 二次确认(显示任务名 + 消息数),clear 后 `renderMessages([])` + `renderChatMeta()` + `loadTaskList()` 同步列表。**没动**:DESIGN(无架构/schema 字段语义变化)、其他 task 写路径、FS 文件(沿用 task delete 的"FS 视图可重生"心智 — 中间产物保留,模型重起对话可继续基于已有素材推进)、SSE 协议。 - **dev SPA 对话内 tool_call/result 加 artifact chip(复用文件预览 modal)**:用户反馈"中间产物只能在右栏点,对话里不能直接预览/下载"。`web/static/dev.html` 新加两个 helper:`extractArtifactRels(text, workingDir)` 把文本里 `\` 一律归 `/`,正则锚定 `/...`(lead 边界字符类 `[\s"'\`/=:,()<>\[\]{}|]` 避免 `multi_proj_x` 误匹配,末段必须含 `.` 把目录滤掉),Set 去重;`renderArtifactBarHtml(rels)` 渲一行 `.art-chip` 小药丸(`📄 文件名`,前缀 emoji + hover 翻品牌红)。四个渲染点都插入 chip 条:① `renderMessages` 的 `role==="tool"` 历史卡;② `renderMessages` 的 assistant `tool_calls` 历史;③ `handleSseEvent` 的 `tool_call` 流式;④ `handleSseEvent` 的 `tool_result` 流式。`chat-stream` 上加点击委托 → `openFilePreview(rel)`,modal 内已带"下载"按钮所以 chip 不另开二级图标。**取舍**:路径识别限定 `working_dir/` 前缀(skill 脚本 `cd` 后只 print 纯相对路径的情况会漏抓,v1 误判控制代价);纯目录(末段无 `.`)直接跳过。**没动**:右栏文件面板、`openFilePreview` / `downloadFile` 接口(纯复用)、后端、DESIGN、RUN(对外行为零变化,纯 UI 增量)。 - **task 级「宪法」文件 (spec) 命名约定 + `spec_lock` → `spec` 简化**:同 working_dir 多 task 共享中间产物(`source/` / `sections/` / `figures/` 跨本子复用)是设计意图,但 spec 这种 task 1:1 宪法文件必须隔离 — 两本子 spec 直接撞。文件名约定 `--.spec.md`:`task_short_id`(`task_id.hex[:8]`,永不变)作主锚,glob `*--*.spec.md` 字典序最大 = current;`` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`` 写入作建时元数据,改 task.name 不 cascade(由 short_id 兜底定位)。`core/agent_builder.py::_build_system_prompt` 加 `task_id` / `today` 注入 + 命名约定段 — 所有 skill 共享一份约定文本,SKILL.md 不再重复;proposal / ppt SKILL.md 阶段一加"先 glob 检测已有 spec → 询问沿用/重定调"分支。`_lock` 后缀无信息量去掉(`templates/spec_lock.md` → `templates/spec.md` git mv 保历史)。**没动**:DB schema(无新字段)、`PATCH /v1/tasks/{id}` 改 name 入口(免 cascade)、其他中间产物扁平共享、quality_check.py(`--spec` 接路径,SKILL.md 拼对参数即可)。**反方案**(cascade rename / spec 入 PG / 物理 task 子目录)及"何时升级到 DB 化"信号见 DESIGN §7.9 取舍说明。 - **dev SPA 左 pane 折叠改 rail 模式 + 删 header 冗余按钮 + time-ago 锁宽完成跨行对齐**:用户反馈 ① "原来 zcbot 旁的折叠按钮不要了,没用处" + ② "数字对齐那块现在是不是每块内容左侧对齐?"(实际是右对齐但因 time-ago 宽度变化导致 N 条/N tok 右边界也跟着抖,跨行没真对齐)。两件套:① 折叠模式从「pane display:none」改 VS Code 范式 rail —— `body.left-collapsed #app.ready { grid-template-columns: 40px 1fr 320px }` + `#pane-left > * { display: none }`(藏全部直接子) + override 第一行 pane-head 重显且只留 `#pane-toggle-left`(`> *:not(#pane-toggle-left) { display: none }`,选择器特异性 2 ids 压 1 id);pane-head 第一行用 `position: static` 取消 sticky / `border-bottom: none` / `background: transparent` 看起来更像 rail 非"卡片"。按钮符号根据 `body.left-collapsed` 在 `applyLeftCollapsed` 里翻向(展开态 `‹` 折叠态 `›`)。彻底删 `#hd-toggle-left` + `header .icon-btn` CSS 块,header 不再背 expand 入口的债。② time-ago 加 `flex-shrink: 0; text-align: right; min-width: 64px` 锁宽,**这才是真正解决跨行对齐的关键**:此前 `.num.right-group` 用 `margin-left: auto` 把 [N 条][N tok][time] 整组推右,但 time 自身宽度浮动 30~70px(刚刚 / 10 小时前 / 2025-12-05)→ time 左边界抖 → N tok 右边界抖 → N 条 右边界抖,逐级传染。锁 time 宽后整组位置稳定,槽内 `text-align: right` 才能让"条/tok"后缀跨行真正垂直对齐。删 `.badge .time-ago { flex-shrink: 0 }` 合并里的 time-ago(已独立给规则)。**没动**:fmtTokens / 桶分级 / tabular-nums / `.num min-width: 44px`(上一轮已正确)、右 pane / chat 中列。 diff --git a/RUN.md b/RUN.md index 492d852..e60b71f 100644 --- a/RUN.md +++ b/RUN.md @@ -2,7 +2,7 @@ > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。 -最后更新:2026-05-20(加 GLM 5.1 模型档案:`.env` 加 `ZHIPUAI_API_KEY`,probe 示例加 `glm.pro`) +最后更新:2026-05-20(加 `POST /v1/tasks/{id}/clear` 清空对话路由) --- @@ -126,6 +126,7 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta | `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);UI 应 disable send 直到 SSE `done` | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: ` + `data: `);订阅 task 当前活动 | 必填 | | `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 同步 call 不可中断,最坏等当前一轮跑完 | 必填 | +| `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_usd` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 | | `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 | | `GET /v1/files/download?path=` | 下单文件 | 必填 | | `POST /v1/files/upload` | multipart 上传到 `//`;路径不存在自动 mkdir,重名覆盖 | 必填 | @@ -251,6 +252,7 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干 | platform CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头 | | `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完;等流式 done 或点 stop / `POST .../cancel`;服务异常下 `run_status` 卡 `running`/`cancelling`,启动 reaper 会清 | | `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel);dev SPA 自动忽略不报错 | +| `POST /v1/tasks/{id}/clear` 返 409 `task has an active run` | 当前 run 还没跑完;先点停止 / `POST .../cancel` 等流式 done 再清空 | | 点 stop 后流式没立刻停 | LLM 同步 call 不可中断,最坏等当前一轮跑完(几十秒);loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop | | `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 | | `kill -HUP ` 后 `/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` | diff --git a/web/app.py b/web/app.py index 70ac7ec..93ab023 100644 --- a/web/app.py +++ b/web/app.py @@ -921,6 +921,53 @@ def create_app() -> FastAPI: broker.request_cancel(tid) return {"ok": True, "task_id": str(tid), "run_status": "cancelling"} + # ───────────── Clear conversation ───────────── + + @app.post("/v1/tasks/{task_id}/clear", tags=["messages"]) + def clear_messages(task_id: str, user_id: UUID = Depends(require_user)): + """清空当前 task 全部 messages,token 累计 / cost / run_error 归零。 + + 同 working_dir 下的 FS 文件不动(沿用 task delete 的"FS 视图可重生"心智 — + 中间产物保留,模型重起对话时可继续基于已有素材推进)。 + usage_events 不动:那是用户级账户级用量记账,不该被对话清理影响。 + + - 活跃 run(running / cancelling)期间拒绝:409(先 cancel) + - error 状态可清:顺手 run_status='idle' + run_error=None + - 跨 user → 404 + """ + try: + tid = UUID(task_id) + except ValueError: + raise HTTPException(404, f"invalid task id: {task_id!r}") + from sqlalchemy import delete as _delete + with session_scope() as s: + row = s.execute( + select(Task.run_status) + .where(Task.task_id == tid, Task.user_id == user_id) + .with_for_update() + ).first() + if row is None: + raise HTTPException(404, f"task not found: {tid}") + if row.run_status in ("running", "cancelling"): + raise HTTPException( + 409, + f"task has an active run (status={row.run_status}); " + f"cancel it first", + ) + s.execute(_delete(Message).where(Message.task_id == tid)) + s.execute( + update(Task).where(Task.task_id == tid).values( + tokens_prompt=0, + tokens_completion=0, + cost_usd=0, + run_status="idle", + run_error=None, + ) + ) + task_row = s.execute(select(Task).where(Task.task_id == tid)).scalar_one() + d = _task_dict(task_row, n_messages=0) + return d + # ───────────── SSE events ───────────── @app.get("/v1/tasks/{task_id}/events", tags=["tasks"]) diff --git a/web/static/dev.html b/web/static/dev.html index 59d2c20..c2bda8e 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -187,9 +187,10 @@ background: #fff; border-bottom: 1px solid var(--border-soft); } - /* 对话顶栏按钮:常态中性 + hover 上语义色 — 完成 绿/导出 蓝/废弃 橙/删除 红 */ + /* 对话顶栏按钮:常态中性 + hover 上语义色 — 完成 绿/导出 蓝/清空 紫/废弃 橙/删除 红 */ #btn-done:hover:not(:disabled) { color: #27ae60; border-color: #a9dfbf; background: #e9f7ef; } #btn-export:hover:not(:disabled) { color: #2980b9; border-color: #aed6f1; background: #ebf5fb; } + #btn-clear-msgs:hover:not(:disabled) { color: #8e44ad; border-color: #d2b4de; background: #f5eef8; } #btn-abandon:hover:not(:disabled) { color: #e67e22; border-color: #f5cba7; background: #fef5e7; } #btn-delete-task:hover:not(:disabled) { color: #c0392b; border-color: #f5b7b1; background: #fdedec; } #pane-mid > .pane-head > button.small:disabled { opacity: 0.4; cursor: not-allowed; } @@ -623,6 +624,7 @@ 对话 + @@ -1235,6 +1237,9 @@ function renderChatMeta() { $("btn-abandon").disabled = !active; $("btn-delete-task").disabled = false; // delete 不限 status(用户显式 confirm) $("btn-export").disabled = (t.n_messages || 0) === 0; + // 清空对话:活跃 run 期间禁用(后端 409),无消息也无意义 + const running = t.run_status === "running" || t.run_status === "cancelling"; + $("btn-clear-msgs").disabled = running || (t.n_messages || 0) === 0; } function renderModelDropdown(t) { @@ -1314,6 +1319,11 @@ function renderMessages(msgs) { let html = `
${roleLabel}
`; if (typeof p.content === "string" && p.content) { html += `
${renderMd(p.content)}
`; + // assistant 正文里 echo 的 /... 路径同样挂 chip 条(只对 assistant,user 输入不抽) + if (role === "assistant") { + const wd = (state.taskMeta && state.taskMeta.working_dir) || ""; + html += renderArtifactBarHtml(extractArtifactRels(p.content, wd)); + } } if (Array.isArray(p.tool_calls) && p.tool_calls.length) { const wd = (state.taskMeta && state.taskMeta.working_dir) || ""; @@ -1537,6 +1547,28 @@ $("btn-delete-task").onclick = () => { deleteTask(state.taskId, t.name || "(未命名)", t.n_messages || 0); }; $("btn-export").onclick = () => state.taskId && exportTask(state.taskId); +$("btn-clear-msgs").onclick = () => { + if (!state.taskId) return; + const t = state.taskMeta || {}; + clearMessages(state.taskId, t.name || "(未命名)", t.n_messages || 0); +}; + +async function clearMessages(tid, name, nMsg) { + if (!confirm(`确认清空「${name}」的对话(${nMsg} 条消息)?\n\n将删除全部对话历史并重置 token 计数;工作目录下的文件保留。`)) return; + try { + const updated = await api("POST", "/v1/tasks/" + tid + "/clear"); + if (state.taskId === tid) { + state.taskMeta = updated; + renderChatMeta(); + renderMessages([]); + $("chat-hint").textContent = "对话已清空"; + } + loadTaskList(); + } catch (e) { + if (e.status === 401) { logout(); return; } + alert("清空失败:" + e.message); + } +} async function setTaskStatus(tid, status, name) { const labels = { completed: "已完成", abandoned: "已废弃" }; @@ -1566,6 +1598,7 @@ async function deleteTask(tid, name, nMsg) { $("btn-abandon").disabled = true; $("btn-delete-task").disabled = true; $("btn-export").disabled = true; + $("btn-clear-msgs").disabled = true; } loadTaskList(); loadFiles();