diff --git a/CHANGELOG.md b/CHANGELOG.md index 8018b30..dbd342d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - 专业软件任务完成后可按用户意图仅报告产物,或自动读取图表和数据并给出分析;Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。 -- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。 +- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。 - 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。 diff --git a/DESIGN.md b/DESIGN.md index fc8cd30..64838bb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -199,7 +199,7 @@ Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/ Export GET /v1/tasks/{id}/export(docx) ``` -**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ progress_snapshot{run_id,steps}(当前 user message 即 run 边界,从 messages 投影恢复)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。`task_progress` 每次提交完整步骤快照,前端整体替换;旧 `set_plan/update_step` 仅在历史投影时兼容。fan-out:每订阅独立 queue;迟到订阅先从 PG 恢复当前 run 最新进度,终态迟到订阅立收 done。普通直播事件不持久化(messages 走 PG)。 +**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ progress_snapshot{run_id,steps,waiting}(当前 user message 即 run 边界,从 messages 投影恢复)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。`task_progress` 每次提交完整步骤快照,前端整体替换;旧 `set_plan/update_step` 仅在历史投影时兼容。进度 dock 是运行状态而非历史消息:活跃时紧凑显示,正常完成后隐藏,`ask_user` 等待确认/取消/异常时折叠保留。fan-out:每订阅独立 queue;迟到订阅先从 PG 恢复当前 run 最新进度,终态迟到订阅立收 done。普通直播事件不持久化(messages 走 PG)。 **版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。 ### 7.3 认证 diff --git a/RUN.md b/RUN.md index 37298e2..d39d196 100644 --- a/RUN.md +++ b/RUN.md @@ -384,7 +384,7 @@ $env:ZCBOT_EVAL_TOKEN = "" | `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 | | `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 | -**SSE 事件**(每帧 `event: ` + `data: `):建连时若当前 run 已发布计划,先补 `progress_snapshot{run_id,steps}` → `run_start{}` → `llm_start{}` → `text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}` → `llm_end{prompt_tokens,completion_tokens}` → `done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。`task_progress` 新协议每次携带完整 `steps`,客户端整体替换;消息分页响应也附加 `progress_snapshot`,刷新不依赖当前 30 条窗口。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。 +**SSE 事件**(每帧 `event: ` + `data: `):建连时若当前 run 已发布计划,先补 `progress_snapshot{run_id,steps,waiting}` → `run_start{}` → `llm_start{}` → `text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}` → `llm_end{prompt_tokens,completion_tokens}` → `done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。`task_progress` 新协议每次携带完整 `steps`,客户端整体替换;消息分页响应也附加 `progress_snapshot`,刷新不依赖当前 30 条窗口。`waiting=true` 表示本轮已调用 `ask_user` 等待确认;正常完成回看时隐藏进度,等待/取消/异常则折叠保留。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。 **SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query(目前不支持,避免 token 进 access log)。 diff --git a/tests/frontend_task_progress.test.mjs b/tests/frontend_task_progress.test.mjs index 3687341..ece00fc 100644 --- a/tests/frontend_task_progress.test.mjs +++ b/tests/frontend_task_progress.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { applyProgressAction, enforceMonotonicProgress, + progressPanelMode, progressActionsFromToolCalls, } from "../web/static/js/progress.js"; @@ -68,6 +69,14 @@ test("a full snapshot replaces the prior run plan instead of merging it", () => ]); }); +test("progress panel is live-only after successful completion", () => { + assert.equal(progressPanelMode({ hasLiveRun: true, runStatus: "running" }), "active"); + assert.equal(progressPanelMode({ runStatus: "idle" }), "hidden"); + assert.equal(progressPanelMode({ runStatus: "idle", waiting: true }), "waiting"); + assert.equal(progressPanelMode({ runStatus: "cancelled" }), "cancelled"); + assert.equal(progressPanelMode({ runStatus: "error" }), "error"); +}); + test("a completed step force-completes earlier dangling steps (monotonic heal)", () => { const steps = [ { id: "s1", title: "摄取素材", status: "in_progress" }, diff --git a/tests/test_task_progress_projection.py b/tests/test_task_progress_projection.py index ff0457f..90848b7 100644 --- a/tests/test_task_progress_projection.py +++ b/tests/test_task_progress_projection.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import unittest -from web.task_progress import project_progress_payloads +from web.task_progress import progress_waiting_for_user, project_progress_payloads def _call(args: dict) -> dict: @@ -47,6 +47,19 @@ class TaskProgressProjectionTests(unittest.TestCase): self.assertTrue(seen) self.assertEqual([step["status"] for step in steps], ["completed", "completed"]) + def test_ask_user_marks_the_run_as_waiting(self) -> None: + payloads = [ + _call({"steps": [ + {"id": "s1", "title": "确认方案", "status": "in_progress"}, + ]}), + { + "role": "assistant", + "tool_calls": [{"function": {"name": "ask_user", "arguments": "{}"}}], + }, + ] + + self.assertTrue(progress_waiting_for_user(payloads)) + if __name__ == "__main__": unittest.main() diff --git a/web/runs.py b/web/runs.py index b029edd..824f6e4 100644 --- a/web/runs.py +++ b/web/runs.py @@ -107,7 +107,6 @@ def run_agent_bg( }) except Exception as e: err = f"{type(e).__name__}: {e}" - broker.emit(task_id, {"type": "error", "msg": err}) mp = "" try: with session_scope() as s: @@ -120,7 +119,10 @@ def run_agent_bg( ) ) except Exception: - pass # 已 emit error 给前端,DB 写失败不放大噪声 + pass # DB 写失败不阻断后续可见 error 事件 + # error 是 SSE 终止事件,客户端收到后会立即回读 task meta;先落 DB 终态再 emit, + # 避免它抢读到旧 running,导致持久错误卡和中断进度面板偶发不显示。 + broker.emit(task_id, {"type": "error", "msg": err}) # 留痕 + 告警(0.58.21,反 2a1bc25d 教训:Zai 余额不足连挂 3 次续跑无人知): # run_error 列只留最后一次,usage_events(kind=run_error)才是聚合面板/巡检 # 邮件的完整数据源;余额/认证类 provider 级错误另走即时邮件(6h 签名冷却)。 diff --git a/web/static/js/chat.js b/web/static/js/chat.js index d68402c..842a3d4 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -34,7 +34,12 @@ import { import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js"; import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, renderArtifactChipContent, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js"; import { parseUserAttachments } from "./attachments.js"; -import { applyProgressAction, cloneProgressSteps, progressActionsFromToolCalls } from "./progress.js"; +import { + applyProgressAction, + cloneProgressSteps, + progressActionsFromToolCalls, + progressPanelMode, +} from "./progress.js"; import { refreshProcs, decorateBgprocCard, hasRunningProc, killTaskProcs } from "./procs.js"; export async function loadModels() { @@ -1433,7 +1438,7 @@ function renderLiveRunIfVisible() { // card 已持有全部文字段/工具卡 DOM(切走再切回只需重新挂载,不重渲); // 新建的重连 card 由 createLiveAssistantCard 自行渲染已累积文字。 const card = run.card || createLiveAssistantCard(run); - renderTaskProgressDock(run.progressSteps || []); + renderTaskProgressDock(run.progressSteps || [], currentProgressPanelMode(run.taskId)); if (card.parentElement !== wrap) wrap.appendChild(card); wrap.scrollTop = wrap.scrollHeight; setActionMode(run.cancelling ? "cancelling" : "streaming"); @@ -1470,19 +1475,35 @@ function setRunHint(run, text) { if (state.taskId === run.taskId) $("chat-hint").textContent = text; } -// 进度只在对话区顶部的单一 dock 里渲染(codex 式钉顶面板),不再内联进每条消息卡。 -// 进行中:展开实时显示 pending/in_progress/completed;全部完成:折叠成一行摘要,点开看清单。 -function renderTaskProgressDock(steps) { +function currentProgressPanelMode(taskId) { + const run = getLiveRun(taskId); + const meta = state.taskId === taskId ? state.taskMeta : null; + const snapshot = state.taskId === taskId ? state.taskProgressSnapshot : null; + return progressPanelMode({ + hasLiveRun: !!run, + cancelling: !!(run && run.cancelling), + runStatus: (meta && meta.run_status) || "", + waiting: !!(snapshot && snapshot.waiting), + }); +} + +// 进度是当前 run 的状态面板,不是历史消息:活跃时紧凑展示;正常完成后隐藏; +// 等待确认/取消/异常时折叠保留。用户手动展开后,同一状态内更新不强制折回。 +function renderTaskProgressDock(steps, mode = "active") { const dock = $("task-progress-dock"); if (!dock) return; - if (!Array.isArray(steps) || !steps.length) { + if (mode === "hidden" || !Array.isArray(steps) || !steps.length) { dock.innerHTML = ""; + delete dock.dataset.progressMode; dock.classList.remove("show"); return; } + const previousMode = dock.dataset.progressMode || ""; + const wasOpen = previousMode === mode && !!dock.querySelector("details.task-progress")?.open; const total = steps.length; const done = steps.filter(s => s.status === "completed").length; const allDone = done === total; + const current = steps.find(s => s.status === "in_progress") || steps.find(s => s.status === "pending"); const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : ""); const rows = steps.map((s) => `
@@ -1490,18 +1511,24 @@ function renderTaskProgressDock(steps) { ${escapeHtml(s.title)}
`).join(""); - const summary = allDone - ? `✓ 全部完成 · ${done}/${total} 步` - : `进度 · ${done}/${total} 步`; - const openAttr = allDone ? "" : " open"; // 全完成默认折叠,其余展开 + let label = current ? `正在执行:${current.title}` : "执行进度"; + if (allDone) label = "✓ 全部完成"; + else if (mode === "waiting") label = "等待你确认"; + else if (mode === "cancelled") label = "已停止"; + else if (mode === "error") label = "执行中断"; + else if (mode === "cancelling") label = "正在停止"; + const summaryClass = allDone ? "tp-summary tp-done" : "tp-summary"; + const summary = `${escapeHtml(label)} · ${done}/${total} 步`; + const openAttr = wasOpen ? " open" : ""; dock.innerHTML = `
${summary}
${rows}
`; + dock.dataset.progressMode = mode; dock.classList.add("show"); } function setTaskProgress(taskId, steps) { const normalized = cloneProgressSteps(steps); if (taskId) state.taskProgressByTask.set(taskId, normalized); - if (state.taskId === taskId) renderTaskProgressDock(normalized); + if (state.taskId === taskId) renderTaskProgressDock(normalized, currentProgressPanelMode(taskId)); } const COPY_ICON = ``; diff --git a/web/static/js/progress.js b/web/static/js/progress.js index 5a7523e..08a8288 100644 --- a/web/static/js/progress.js +++ b/web/static/js/progress.js @@ -9,6 +9,21 @@ export function normalizeProgressStatus(status) { return ["pending", "in_progress", "completed"].includes(status) ? status : "pending"; } +export function progressPanelMode({ + hasLiveRun = false, + cancelling = false, + runStatus = "", + waiting = false, +} = {}) { + if (hasLiveRun) return cancelling ? "cancelling" : "active"; + if (runStatus === "running") return "active"; + if (runStatus === "cancelling") return "cancelling"; + if (runStatus === "cancelled") return "cancelled"; + if (runStatus === "error") return "error"; + if (waiting) return "waiting"; + return "hidden"; +} + export function normalizeProgressStep(step) { if (!step || typeof step !== "object") return null; const id = String(step.id || "").trim(); diff --git a/web/task_progress.py b/web/task_progress.py index 181f830..22ad9ad 100644 --- a/web/task_progress.py +++ b/web/task_progress.py @@ -102,6 +102,17 @@ def project_progress_payloads( return steps, seen +def progress_waiting_for_user(payloads: Iterable[dict[str, Any]]) -> bool: + for payload in payloads: + if not isinstance(payload, dict) or payload.get("role") != "assistant": + continue + for call in payload.get("tool_calls") or []: + function = call.get("function") if isinstance(call, dict) else None + if isinstance(function, dict) and function.get("name") == "ask_user": + return True + return False + + def latest_run_progress(session, task_id: UUID) -> dict[str, Any] | None: """Return the latest user message id and that run's projected plan.""" run = session.execute( @@ -123,4 +134,8 @@ def latest_run_progress(session, task_id: UUID) -> dict[str, Any] | None: steps, seen = project_progress_payloads(payloads) if not seen: return None - return {"run_id": str(run.message_id), "steps": steps} + return { + "run_id": str(run.message_id), + "steps": steps, + "waiting": progress_waiting_for_user(payloads), + }