diff --git a/web/static/js/chat.js b/web/static/js/chat.js
index 6988fd4..0e35412 100644
--- a/web/static/js/chat.js
+++ b/web/static/js/chat.js
@@ -14,6 +14,7 @@ import { logout } from "./auth.js";
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
+import { applyProgressAction, cloneProgressSteps, progressActionsFromToolCalls } from "./progress.js";
export async function loadModels() {
try {
@@ -237,6 +238,7 @@ export async function selectTask(tid) {
refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
} catch (e) {
if (e.status === 401) { logout(); return; }
+ renderTaskProgressDock([]);
$("chat-stream").innerHTML = `
加载失败:${escapeHtml(e.message)}
`;
}
}
@@ -415,6 +417,7 @@ function renderLiveRunIfVisible() {
const wrap = $("chat-stream");
const card = run.card || createLiveAssistantCard(run);
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
+ renderProgressInto(card, run.progressSteps || []);
if (card.parentElement !== wrap) wrap.appendChild(card);
wrap.scrollTop = wrap.scrollHeight;
setActionMode(run.cancelling ? "cancelling" : "streaming");
@@ -434,6 +437,7 @@ function ensureRunningTaskSubscribed(taskId, url) {
body: null,
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
workingDir: state.taskMeta && state.taskMeta.working_dir,
+ progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
};
state.liveRuns.set(taskId, run);
state.streaming = true;
@@ -445,54 +449,6 @@ function setRunHint(run, text) {
if (state.taskId === run.taskId) $("chat-hint").textContent = text;
}
-function normalizeProgressStatus(status) {
- return ["pending", "in_progress", "completed"].includes(status) ? status : "pending";
-}
-
-function normalizeProgressStep(step) {
- if (!step || typeof step !== "object") return null;
- const id = String(step.id || "").trim();
- const title = String(step.title || "").trim();
- const status = normalizeProgressStatus(step.status);
- if (!id || !title) return null;
- return { id, title, status };
-}
-
-function applyProgressAction(progress, args) {
- if (!args || typeof args !== "object") return progress;
- const action = args.action || "";
- if (action === "clear") return [];
- if (action === "set_plan") {
- const steps = Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : [];
- return steps;
- }
- if (action === "update_step") {
- const raw = args.step;
- if (!raw || typeof raw !== "object") return progress;
- const id = String(raw.id || "").trim();
- if (!id) return progress;
- let found = false;
- const next = progress.map((s) => {
- if (s.id !== id) return s;
- found = true;
- return {
- id: s.id,
- title: String(raw.title || s.title || "").trim(),
- status: normalizeProgressStatus(raw.status || s.status),
- };
- }).filter(s => s.title);
- if (!found && raw.title) {
- next.push({
- id,
- title: String(raw.title).trim(),
- status: normalizeProgressStatus(raw.status),
- });
- }
- return next;
- }
- return progress;
-}
-
function renderProgressHtml(steps) {
if (!Array.isArray(steps) || !steps.length) return "";
const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : "");
@@ -519,10 +475,29 @@ function renderProgressInto(card, steps) {
}
}
+function renderTaskProgressDock(steps) {
+ const dock = $("task-progress-dock");
+ if (!dock) return;
+ if (!Array.isArray(steps) || !steps.length) {
+ dock.innerHTML = "";
+ dock.classList.remove("show");
+ return;
+ }
+ dock.innerHTML = renderProgressHtml(steps);
+ 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);
+}
+
function renderMessages(msgs) {
const wrap = $("chat-stream");
wrap.innerHTML = "";
if (!msgs.length) {
+ setTaskProgress(state.taskId, []);
wrap.innerHTML = `
(暂无消息 · 在下方输入开始对话)
`;
renderLiveRunIfVisible();
return;
@@ -533,6 +508,7 @@ function renderMessages(msgs) {
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
const seenRels = new Set();
+ let currentProgressSteps = [];
const pickFresh = (rels) => {
const fresh = [];
for (const r of rels) {
@@ -591,8 +567,8 @@ function renderMessages(msgs) {
}
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
- let progressSteps = [];
- let sawProgress = false;
+ const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps);
+ currentProgressSteps = progressResult.steps;
for (const tc of p.tool_calls) {
const fn = (tc.function && tc.function.name) || "?";
let argsObj = {};
@@ -602,8 +578,6 @@ function renderMessages(msgs) {
args = JSON.stringify(argsObj, null, 2);
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
if (fn === "task_progress") {
- progressSteps = applyProgressAction(progressSteps, argsObj);
- sawProgress = true;
continue;
}
const label = toolActivityLabel(fn, argsObj);
@@ -614,13 +588,14 @@ function renderMessages(msgs) {
${renderArtifactBarHtml(rels, isProducer)}
`;
}
- if (sawProgress) html += renderProgressHtml(progressSteps);
+ if (progressResult.sawProgress) html += renderProgressHtml(currentProgressSteps);
}
card.innerHTML = html;
highlightIn(card);
wrap.appendChild(card);
}
wrap.scrollTop = wrap.scrollHeight;
+ setTaskProgress(state.taskId, currentProgressSteps);
upgradeMediaArtifacts(wrap);
renderLiveRunIfVisible();
}
@@ -839,6 +814,7 @@ async function sendMessage() {
body: asstCard.querySelector(".body"),
cancelling: false,
workingDir: state.taskMeta && state.taskMeta.working_dir,
+ progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
};
state.liveRuns.set(taskId, run);
state.streaming = true;
@@ -1027,6 +1003,7 @@ function handleSseEvent(ev, asstCard, ctx) {
const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
if (fn === "task_progress") {
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
+ setTaskProgress(ctx.taskId, ctx.progressSteps);
renderProgressInto(asstCard, ctx.progressSteps);
return;
}
@@ -1145,6 +1122,7 @@ async function deleteTask(tid, name, nMsg) {
renderConcurrentWarning();
$("chat-meta").innerHTML = `
(未选中任务)`;
$("chat-stream").innerHTML = `
请在左侧选一个任务
`;
+ renderTaskProgressDock([]);
$("chat-form").style.display = "none";
$("btn-done").disabled = true;
$("btn-abandon").disabled = true;
diff --git a/web/static/js/progress.js b/web/static/js/progress.js
new file mode 100644
index 0000000..12a5c34
--- /dev/null
+++ b/web/static/js/progress.js
@@ -0,0 +1,73 @@
+// Task progress state helpers. Kept DOM-free so Node tests can cover the
+// "update_step without title" merge behavior used by the Web UI.
+
+export function cloneProgressSteps(steps) {
+ return Array.isArray(steps) ? steps.map(s => ({ ...s })) : [];
+}
+
+export function normalizeProgressStatus(status) {
+ return ["pending", "in_progress", "completed"].includes(status) ? status : "pending";
+}
+
+export function normalizeProgressStep(step) {
+ if (!step || typeof step !== "object") return null;
+ const id = String(step.id || "").trim();
+ const title = String(step.title || "").trim();
+ const status = normalizeProgressStatus(step.status);
+ if (!id || !title) return null;
+ return { id, title, status };
+}
+
+export function applyProgressAction(progress, args) {
+ const current = cloneProgressSteps(progress);
+ if (!args || typeof args !== "object") return current;
+ const action = args.action || "";
+ if (action === "clear") return [];
+ if (action === "set_plan") {
+ return Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : [];
+ }
+ if (action === "update_step") {
+ const raw = args.step;
+ if (!raw || typeof raw !== "object") return current;
+ const id = String(raw.id || "").trim();
+ if (!id) return current;
+ let found = false;
+ const next = current.map((s) => {
+ if (s.id !== id) return s;
+ found = true;
+ return {
+ id: s.id,
+ title: String(raw.title || s.title || "").trim(),
+ status: normalizeProgressStatus(raw.status || s.status),
+ };
+ }).filter(s => s.title);
+ if (!found && raw.title) {
+ next.push({
+ id,
+ title: String(raw.title).trim(),
+ status: normalizeProgressStatus(raw.status),
+ });
+ }
+ return next;
+ }
+ return current;
+}
+
+export function progressActionsFromToolCalls(toolCalls, baseSteps = []) {
+ let steps = cloneProgressSteps(baseSteps);
+ let sawProgress = false;
+ if (!Array.isArray(toolCalls)) return { steps, sawProgress };
+ for (const tc of toolCalls) {
+ const fn = (tc && tc.function && tc.function.name) || "";
+ if (fn !== "task_progress") continue;
+ let args = {};
+ try {
+ args = JSON.parse((tc.function && tc.function.arguments) || "{}");
+ } catch (e) {
+ args = {};
+ }
+ steps = applyProgressAction(steps, args);
+ sawProgress = true;
+ }
+ return { steps, sawProgress };
+}
diff --git a/web/static/js/state.js b/web/static/js/state.js
index 06813fb..cdfbdea 100644
--- a/web/static/js/state.js
+++ b/web/static/js/state.js
@@ -28,6 +28,7 @@ export const state = {
evtSrc: null,
streaming: false, // 兼容旧判断:任一 task 是否在流式中
liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本
+ taskProgressByTask: new Map(), // task_id -> 历史消息重放后的当前进度步骤
// task list 滚动加载 + 筛选
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
taskPageSize: 20,