Show task progress above composer
This commit is contained in:
parent
8616ba2b56
commit
4ee09976ee
|
|
@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -466,6 +466,16 @@
|
||||||
}
|
}
|
||||||
.task-progress .tp-text { overflow-wrap: anywhere; line-height: 1.45; }
|
.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 .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,折叠态也可见) */
|
/* media tool 摘要 banner(model / size / cost / elapsed,折叠态也可见) */
|
||||||
.tool-banner {
|
.tool-banner {
|
||||||
display: inline-flex; flex-wrap: wrap; gap: 6px;
|
display: inline-flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
|
@ -1030,6 +1040,7 @@
|
||||||
<div id="chat-meta"><span class="muted">(未选中任务)</span></div>
|
<div id="chat-meta"><span class="muted">(未选中任务)</span></div>
|
||||||
<div id="wd-concurrent-warn" style="display:none;"></div>
|
<div id="wd-concurrent-warn" style="display:none;"></div>
|
||||||
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
|
<div id="chat-stream"><div class="empty">请在左侧选一个任务</div></div>
|
||||||
|
<div id="task-progress-dock"></div>
|
||||||
<form id="chat-form" style="display:none;">
|
<form id="chat-form" style="display:none;">
|
||||||
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
|
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,Ctrl+V 可粘贴文件)"></textarea>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { logout } from "./auth.js";
|
||||||
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
|
import { openFilePreview, openPasteFilePreview, closePreviewIfShowing } from "./preview.js";
|
||||||
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
|
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
|
||||||
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.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() {
|
export async function loadModels() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -237,6 +238,7 @@ export async function selectTask(tid) {
|
||||||
refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
|
refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) { logout(); return; }
|
if (e.status === 401) { logout(); return; }
|
||||||
|
renderTaskProgressDock([]);
|
||||||
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
$("chat-stream").innerHTML = `<div class="empty">加载失败:${escapeHtml(e.message)}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -415,6 +417,7 @@ function renderLiveRunIfVisible() {
|
||||||
const wrap = $("chat-stream");
|
const wrap = $("chat-stream");
|
||||||
const card = run.card || createLiveAssistantCard(run);
|
const card = run.card || createLiveAssistantCard(run);
|
||||||
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
|
if (run.body && run.acc) run.body.innerHTML = renderMd(run.acc);
|
||||||
|
renderProgressInto(card, run.progressSteps || []);
|
||||||
if (card.parentElement !== wrap) wrap.appendChild(card);
|
if (card.parentElement !== wrap) wrap.appendChild(card);
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
setActionMode(run.cancelling ? "cancelling" : "streaming");
|
setActionMode(run.cancelling ? "cancelling" : "streaming");
|
||||||
|
|
@ -434,6 +437,7 @@ function ensureRunningTaskSubscribed(taskId, url) {
|
||||||
body: null,
|
body: null,
|
||||||
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
|
cancelling: state.taskMeta && state.taskMeta.run_status === "cancelling",
|
||||||
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
||||||
|
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
||||||
};
|
};
|
||||||
state.liveRuns.set(taskId, run);
|
state.liveRuns.set(taskId, run);
|
||||||
state.streaming = true;
|
state.streaming = true;
|
||||||
|
|
@ -445,54 +449,6 @@ function setRunHint(run, text) {
|
||||||
if (state.taskId === run.taskId) $("chat-hint").textContent = 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) {
|
function renderProgressHtml(steps) {
|
||||||
if (!Array.isArray(steps) || !steps.length) return "";
|
if (!Array.isArray(steps) || !steps.length) return "";
|
||||||
const mark = (status) => status === "completed" ? "✓" : (status === "in_progress" ? "…" : "");
|
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) {
|
function renderMessages(msgs) {
|
||||||
const wrap = $("chat-stream");
|
const wrap = $("chat-stream");
|
||||||
wrap.innerHTML = "";
|
wrap.innerHTML = "";
|
||||||
if (!msgs.length) {
|
if (!msgs.length) {
|
||||||
|
setTaskProgress(state.taskId, []);
|
||||||
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
wrap.innerHTML = `<div class="empty">(暂无消息 · 在下方输入开始对话)</div>`;
|
||||||
renderLiveRunIfVisible();
|
renderLiveRunIfVisible();
|
||||||
return;
|
return;
|
||||||
|
|
@ -533,6 +508,7 @@ function renderMessages(msgs) {
|
||||||
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
|
// chip 去重:同一路径在 tool 结果里挂过 inline 图后,assistant 正文 echo 同路径不再重挂。
|
||||||
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
|
// chronological 遍历,首次出现保留(tool 结果常在前),后续重复过滤掉。
|
||||||
const seenRels = new Set();
|
const seenRels = new Set();
|
||||||
|
let currentProgressSteps = [];
|
||||||
const pickFresh = (rels) => {
|
const pickFresh = (rels) => {
|
||||||
const fresh = [];
|
const fresh = [];
|
||||||
for (const r of rels) {
|
for (const r of rels) {
|
||||||
|
|
@ -591,8 +567,8 @@ function renderMessages(msgs) {
|
||||||
}
|
}
|
||||||
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
|
if (Array.isArray(p.tool_calls) && p.tool_calls.length) {
|
||||||
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
const wd = _workingDirName(state.taskMeta && state.taskMeta.working_dir);
|
||||||
let progressSteps = [];
|
const progressResult = progressActionsFromToolCalls(p.tool_calls, currentProgressSteps);
|
||||||
let sawProgress = false;
|
currentProgressSteps = progressResult.steps;
|
||||||
for (const tc of p.tool_calls) {
|
for (const tc of p.tool_calls) {
|
||||||
const fn = (tc.function && tc.function.name) || "?";
|
const fn = (tc.function && tc.function.name) || "?";
|
||||||
let argsObj = {};
|
let argsObj = {};
|
||||||
|
|
@ -602,8 +578,6 @@ function renderMessages(msgs) {
|
||||||
args = JSON.stringify(argsObj, null, 2);
|
args = JSON.stringify(argsObj, null, 2);
|
||||||
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
|
} catch (e) { args = (tc.function && tc.function.arguments) || ""; }
|
||||||
if (fn === "task_progress") {
|
if (fn === "task_progress") {
|
||||||
progressSteps = applyProgressAction(progressSteps, argsObj);
|
|
||||||
sawProgress = true;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const label = toolActivityLabel(fn, argsObj);
|
const label = toolActivityLabel(fn, argsObj);
|
||||||
|
|
@ -614,13 +588,14 @@ function renderMessages(msgs) {
|
||||||
${renderArtifactBarHtml(rels, isProducer)}
|
${renderArtifactBarHtml(rels, isProducer)}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
if (sawProgress) html += renderProgressHtml(progressSteps);
|
if (progressResult.sawProgress) html += renderProgressHtml(currentProgressSteps);
|
||||||
}
|
}
|
||||||
card.innerHTML = html;
|
card.innerHTML = html;
|
||||||
highlightIn(card);
|
highlightIn(card);
|
||||||
wrap.appendChild(card);
|
wrap.appendChild(card);
|
||||||
}
|
}
|
||||||
wrap.scrollTop = wrap.scrollHeight;
|
wrap.scrollTop = wrap.scrollHeight;
|
||||||
|
setTaskProgress(state.taskId, currentProgressSteps);
|
||||||
upgradeMediaArtifacts(wrap);
|
upgradeMediaArtifacts(wrap);
|
||||||
renderLiveRunIfVisible();
|
renderLiveRunIfVisible();
|
||||||
}
|
}
|
||||||
|
|
@ -839,6 +814,7 @@ async function sendMessage() {
|
||||||
body: asstCard.querySelector(".body"),
|
body: asstCard.querySelector(".body"),
|
||||||
cancelling: false,
|
cancelling: false,
|
||||||
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
workingDir: state.taskMeta && state.taskMeta.working_dir,
|
||||||
|
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)),
|
||||||
};
|
};
|
||||||
state.liveRuns.set(taskId, run);
|
state.liveRuns.set(taskId, run);
|
||||||
state.streaming = true;
|
state.streaming = true;
|
||||||
|
|
@ -1027,6 +1003,7 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
|
const argsStr = typeof args === "string" ? args : JSON.stringify(args, null, 2);
|
||||||
if (fn === "task_progress") {
|
if (fn === "task_progress") {
|
||||||
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
|
ctx.progressSteps = applyProgressAction(ctx.progressSteps || [], args);
|
||||||
|
setTaskProgress(ctx.taskId, ctx.progressSteps);
|
||||||
renderProgressInto(asstCard, ctx.progressSteps);
|
renderProgressInto(asstCard, ctx.progressSteps);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1145,6 +1122,7 @@ async function deleteTask(tid, name, nMsg) {
|
||||||
renderConcurrentWarning();
|
renderConcurrentWarning();
|
||||||
$("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
|
$("chat-meta").innerHTML = `<span class="muted">(未选中任务)</span>`;
|
||||||
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
|
$("chat-stream").innerHTML = `<div class="empty">请在左侧选一个任务</div>`;
|
||||||
|
renderTaskProgressDock([]);
|
||||||
$("chat-form").style.display = "none";
|
$("chat-form").style.display = "none";
|
||||||
$("btn-done").disabled = true;
|
$("btn-done").disabled = true;
|
||||||
$("btn-abandon").disabled = true;
|
$("btn-abandon").disabled = true;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ export const state = {
|
||||||
evtSrc: null,
|
evtSrc: null,
|
||||||
streaming: false, // 兼容旧判断:任一 task 是否在流式中
|
streaming: false, // 兼容旧判断:任一 task 是否在流式中
|
||||||
liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本
|
liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本
|
||||||
|
taskProgressByTask: new Map(), // task_id -> 历史消息重放后的当前进度步骤
|
||||||
// task list 滚动加载 + 筛选
|
// task list 滚动加载 + 筛选
|
||||||
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
|
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
|
||||||
taskPageSize: 20,
|
taskPageSize: 20,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue