Show task progress above composer

This commit is contained in:
caoqianming 2026-06-08 09:04:43 +08:00
parent 8616ba2b56
commit 4ee09976ee
5 changed files with 167 additions and 53 deletions

View File

@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyProgressAction,
progressActionsFromToolCalls,
} from "../web/static/js/progress.js";
test("update_step without title preserves title from the existing plan", () => {
const initial = applyProgressAction([], {
action: "set_plan",
steps: [
{ id: "s1", title: "理解需求", status: "in_progress" },
{ id: "s2", title: "实现功能", status: "pending" },
],
});
const updated = applyProgressAction(initial, {
action: "update_step",
step: { id: "s1", status: "completed" },
});
assert.deepEqual(updated, [
{ id: "s1", title: "理解需求", status: "completed" },
{ id: "s2", title: "实现功能", status: "pending" },
]);
});
test("tool calls can apply progress updates on top of previous task progress", () => {
const previous = [
{ id: "s1", title: "理解需求", status: "in_progress" },
{ id: "s2", title: "实现功能", status: "pending" },
];
const toolCalls = [{
function: {
name: "task_progress",
arguments: JSON.stringify({
action: "update_step",
step: { id: "s1", status: "completed" },
}),
},
}];
const result = progressActionsFromToolCalls(toolCalls, previous);
assert.equal(result.sawProgress, true);
assert.deepEqual(result.steps, [
{ id: "s1", title: "理解需求", status: "completed" },
{ id: "s2", title: "实现功能", status: "pending" },
]);
});

View File

@ -466,6 +466,16 @@
}
.task-progress .tp-text { overflow-wrap: anywhere; line-height: 1.45; }
.task-progress .tp-step.completed .tp-text { color: var(--muted); text-decoration: line-through; }
#task-progress-dock {
flex-shrink: 0; display: none;
padding: 8px 12px; border-top: 1px solid var(--border);
background: #fff;
}
#task-progress-dock.show { display: block; }
#task-progress-dock .task-progress {
margin-top: 0; border-color: rgba(192,57,43,0.22);
background: linear-gradient(180deg, #fff, #fffafa);
}
/* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */
.tool-banner {
display: inline-flex; flex-wrap: wrap; gap: 6px;
@ -1030,6 +1040,7 @@
<div id="chat-meta"><span class="muted">(未选中任务)</span></div>
<div id="wd-concurrent-warn" style="display:none;"></div>
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
<div id="task-progress-dock"></div>
<form id="chat-form" style="display:none;">
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
<div class="row">

View File

@ -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 = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
}
}
@ -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 = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
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 = `<span class="muted">(未选中任务)</span>`;
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
renderTaskProgressDock([]);
$("chat-form").style.display = "none";
$("btn-done").disabled = true;
$("btn-abandon").disabled = true;

73
web/static/js/progress.js Normal file
View File

@ -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 };
}

View File

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