fix(web): treat progress panel as run state

This commit is contained in:
caoqianming 2026-08-17 12:14:40 +08:00
parent 927d059388
commit 548d7a2912
9 changed files with 99 additions and 18 deletions

View File

@ -10,7 +10,7 @@
- 专业软件任务完成后可按用户意图仅报告产物或自动读取图表和数据并给出分析Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。正常完成后进度面板自动收起,等待确认、停止或异常时仍可查看停留步骤。
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。

View File

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

2
RUN.md
View File

@ -384,7 +384,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
| `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 |
| `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 |
**SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):建连时若当前 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: <type>` + `data: <JSON>`):建连时若当前 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)。

View File

@ -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" },

View File

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

View File

@ -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 签名冷却)。

View File

@ -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) => `
<div class="tp-step ${escapeHtml(s.status)}">
@ -1490,18 +1511,24 @@ function renderTaskProgressDock(steps) {
<span class="tp-text">${escapeHtml(s.title)}</span>
</div>
`).join("");
const summary = allDone
? `<summary class="tp-summary tp-done">✓ 全部完成 · ${done}/${total} 步</summary>`
: `<summary class="tp-summary">进度 · ${done}/${total} 步</summary>`;
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 = `<summary class="${summaryClass}">${escapeHtml(label)} · ${done}/${total} 步</summary>`;
const openAttr = wasOpen ? " open" : "";
dock.innerHTML = `<details class="task-progress"${openAttr}>${summary}<div class="tp-list">${rows}</div></details>`;
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 = `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path></svg>`;

View File

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

View File

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