feat: aggregate task progress updates

This commit is contained in:
2026-08-03 16:17:15 +08:00
parent 9312b09e76
commit 10d95c4a5e
11 changed files with 273 additions and 13 deletions
+6
View File
@@ -68,6 +68,12 @@ M0-01M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1
确认、取消、表单本地校验、提交与超时都更新同一 `call_id` 的状态,并在终态禁用控件。Conversation Store v4 额外持久化 Tool Call projection 及未提交表单草稿:刷新或重新登录时,待处理卡片与草稿会恢复;离线期间到期的 call 会恢复为不可操作的 expired。提交仍不发送网络请求或 `tool.result`,也不处理 Agent echo 或 Hermes Adapter。`npm test` 当前为 3 个文件 / 18 个测试通过,`npm run build` 通过。
### M1-04 任务与进度聚合(已完成)
已有的不带 `operation_id``agent.progress` 仍显示为兼容性系统提示;带 `operation_id` 的更新必须使用严格的 task schema,并聚合到同一张可信原生任务卡片。每次进度、状态或 Agent `cancelled` 更新只更新卡片,不会无限新增聊天消息。可取消任务提供“请求取消”入口;本阶段它只记录本地请求并禁用入口,等待后续 Agent 状态确认。
Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显示任务最新状态。尚未创建取消 outbox、尚未发送任何网络事件、尚未实现 result echo 或 Hermes Adapter 契约。`npm test` 当前为 4 个文件 / 22 个测试通过,`npm run build` 通过。
### 当前结构债务(M0 的改造对象)
`src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`。在 M0 准入前不新增 Surface 或 Capability 功能。
+12
View File
@@ -116,6 +116,13 @@ const rendererContext = new TrustedDOMRendererContext(messages, presence, {
loadDraft(callID) { return conversationStore.getToolDraft(callID); },
saveDraft(callID, values) { conversationStore.saveToolDraft(callID, values); },
clearDraft(callID) { conversationStore.clearToolDraft(callID); },
}, {
requestCancel(operationID) {
const transition = interactionKernel.requestTaskCancel(operationID, new Date().toISOString());
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
return transition.disposition === "accepted";
},
read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); },
});
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
@@ -186,6 +193,9 @@ async function syncLoop(): Promise<void> {
if (result.items.some(item => item.kind === "tool-call" || item.kind === "tool-result" || item.kind === "tool-cancel")) {
conversationStore.replaceToolCalls(result.snapshot.tool_calls);
}
if (result.items.some(item => item.kind === "progress" && item.task)) {
conversationStore.replaceTasks(result.snapshot.tasks);
}
for (const item of result.items) {
if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item);
}
@@ -233,7 +243,9 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
const snapshot = conversationStore.open(currentConversationKey());
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
session.lastSeq = snapshot.cursor;
messages.replaceChildren(); rendererContext.reset();
loginView.hidden = true; chatView.hidden = false;
+24 -2
View File
@@ -106,6 +106,16 @@ export type ToolCallCancel = {
reason: string;
};
export type TaskStatus = "running" | "waiting" | "completed" | "failed" | "cancelled";
export type TaskProgress = {
operation_id: string;
title: string;
percent: number;
status: TaskStatus;
cancellable: boolean;
};
/**
* A user-originated action awaiting delivery through the Transport Adapter.
* It is data only: the Store owns retries and the Kernel owns state changes.
@@ -172,7 +182,7 @@ export type ConversationItem =
| (ConversationItemBase & { kind: "user-message"; text: string; delivery: DeliveryState })
| (ConversationItemBase & { kind: "markdown"; markdown: string })
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string })
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress })
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
@@ -312,6 +322,7 @@ const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
const TASK_STATUSES = new Set<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
function optionalTimestamp(value: unknown): string | undefined {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
@@ -416,6 +427,15 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
};
}
function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | undefined {
const operationID = identifier(payload.operation_id);
const status = text(payload.status) as TaskStatus;
if (!operationID || !TASK_STATUSES.has(status)) return undefined;
const title = text(payload.title).trim();
if (!title || title.length > 200) return undefined;
return { operation_id: operationID, title, percent, status, cancellable: payload.cancellable === true };
}
function parseToolResult(payload: JsonObject): ToolCallResult | undefined {
const callID = identifier(payload.call_id);
const status = text(payload.status) as ToolCallFinalStatus;
@@ -477,7 +497,9 @@ export function decodeConversationItem(raw: string): ConversationItem {
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
return fallbackItem({ code: "invalid_payload", detail: "Agent progress payload requires percent between 0 and 100.", envelope });
}
return { kind: "progress", title: text(payload.title), percent, status: text(payload.status), ...base };
const task = payload.operation_id === undefined ? undefined : parseTaskProgress(payload, percent);
if (payload.operation_id !== undefined && !task) return fallbackItem({ code: "invalid_payload", detail: "Task progress requires operation_id, title, supported status, and valid cancellation flag.", envelope });
return { kind: "progress", title: text(payload.title), percent, status: text(payload.status), ...(task ? { task } : {}), ...base };
}
case "lineup.v1.error": {
const message = text(payload.message);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
import { ConversationStore } from "./conversation-store";
class MemoryStorage implements Storage {
@@ -97,4 +98,16 @@ describe("ConversationStore", () => {
expect(reopened.tool_calls).toEqual([expect.objectContaining({ call_id: "call_form_001", status: "pending" })]);
expect(reopened.tool_drafts).toEqual({ call_form_001: { version: "1.2.3" } });
});
it("persists the latest aggregated task record per conversation", () => {
const storage = new MemoryStorage();
const task: TaskRecord = {
operation_id: "task_store_001", conversation_id: conversationID, title: "Render preview", percent: 70, status: "running", cancellable: true, updated_at: createdAt,
};
const first = new ConversationStore(storage);
first.open(conversationID);
first.replaceTasks([task]);
expect(new ConversationStore(storage).open(conversationID).tasks).toEqual([expect.objectContaining({ operation_id: "task_store_001", percent: 70 })]);
});
});
+28 -10
View File
@@ -1,5 +1,6 @@
import type { ConversationItem, DeliveryState, JsonObject } from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
export type StoredConversationItem = {
local_id: string;
@@ -21,15 +22,17 @@ export type ConversationSnapshot = {
outbox: readonly OutboxEntry[];
tool_calls: readonly ToolCallRecord[];
tool_drafts: Readonly<Record<string, JsonObject>>;
tasks: readonly TaskRecord[];
};
type PersistedConversation = {
version: 4;
version: 5;
cursor: number;
items: StoredConversationItem[];
outbox: OutboxEntry[];
tool_calls: ToolCallRecord[];
tool_drafts: Record<string, JsonObject>;
tasks: TaskRecord[];
};
const STORE_PREFIX = "lineup.conversation.v1";
@@ -44,7 +47,7 @@ const STORE_PREFIX = "lineup.conversation.v1";
*/
export class ConversationStore {
private activeConversationID: string | undefined;
private state: PersistedConversation = { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} };
private state: PersistedConversation = emptyConversation();
public constructor(private readonly storage: Storage) {}
@@ -56,7 +59,7 @@ export class ConversationStore {
public close(): void {
this.activeConversationID = undefined;
this.state = { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} };
this.state = emptyConversation();
}
public snapshot(): ConversationSnapshot {
@@ -67,6 +70,7 @@ export class ConversationStore {
outbox: this.state.outbox.map(entry => ({ ...entry })),
tool_calls: clone(this.state.tool_calls),
tool_drafts: clone(this.state.tool_drafts),
tasks: clone(this.state.tasks),
};
}
@@ -92,6 +96,11 @@ export class ConversationStore {
this.persist();
}
public replaceTasks(records: readonly TaskRecord[]): void {
this.state.tasks = records.map(record => clone(record));
this.persist();
}
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
const stored: StoredConversationItem = {
@@ -188,26 +197,35 @@ export class ConversationStore {
try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown };
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown };
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
return emptyConversation();
}
if (candidate.version !== 4) {
if (candidate.version !== 5) {
// v1 advanced its cursor from a send acknowledgement. Some browsers
// had already been upgraded to v2 before that correction, retaining a
// high cursor with holes for their own echoes. Preserve every valid
// local item but replay the server history once to repair those holes;
// the next persist upgrades this record to v4.
return { version: 4, cursor: 0, items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[], tool_calls: [], tool_drafts: {} };
// the next persist upgrades this record to v5.
return {
version: 5,
cursor: candidate.version === 4 ? Math.max(0, candidate.cursor) : 0,
items: candidate.items as StoredConversationItem[],
outbox: candidate.outbox as OutboxEntry[],
tool_calls: candidate.version === 4 && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
tool_drafts: candidate.version === 4 && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
tasks: [],
};
}
return {
version: 4,
version: 5,
cursor: Math.max(0, candidate.cursor),
items: candidate.items as StoredConversationItem[],
outbox: candidate.outbox as OutboxEntry[],
tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
};
} catch {
return emptyConversation();
@@ -229,7 +247,7 @@ export class ConversationStore {
}
function emptyConversation(): PersistedConversation {
return { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} };
return { version: 5, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [] };
}
function isDraftMap(value: unknown): value is Record<string, JsonObject> {
@@ -178,4 +178,21 @@ describe("InteractionKernel", () => {
expect(form.items).toEqual([expect.objectContaining({ kind: "tool-call", call: expect.objectContaining({ form: expect.objectContaining({ fields: expect.arrayContaining([expect.objectContaining({ id: "version", required: true })]) }) }) })]);
expect(invalid.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
});
it("aggregates structured task progress by operation_id and exposes a local cancellation request", () => {
const kernel = new InteractionKernel();
const first = kernel.ingest({
raw: envelope({ id: "evt_task_first", type: "lineup.v1.agent.progress", payload: { operation_id: "task_kernel_001", title: "Build report", percent: 15, status: "running", cancellable: true } }),
source: "sync",
});
const update = kernel.ingest({
raw: envelope({ id: "evt_task_update", type: "lineup.v1.agent.progress", payload: { operation_id: "task_kernel_001", title: "Build report", percent: 65, status: "running", cancellable: true } }),
source: "live",
});
expect(first.snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task_kernel_001", percent: 15 })]);
expect(update.snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task_kernel_001", percent: 65 })]);
expect(kernel.requestTaskCancel("task_kernel_001", "2026-08-03T09:05:00.000Z")).toMatchObject({ disposition: "accepted" });
expect(kernel.snapshot().tasks).toEqual([expect.objectContaining({ cancel_requested_at: "2026-08-03T09:05:00.000Z" })]);
});
});
+17
View File
@@ -6,6 +6,7 @@ import {
type ToolCallFinalStatus,
} from "../protocol";
import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state";
import { TaskStateMachine, type TaskRecord } from "./task-state";
/** Transport metadata is intentionally opaque to the protocol and renderer. */
export type IncomingMessage = {
@@ -27,6 +28,7 @@ export type KernelSnapshot = {
seen_envelope_ids: number;
agent_presence: readonly AgentPresence[];
tool_calls: readonly ToolCallRecord[];
tasks: readonly TaskRecord[];
};
export type KernelResult =
@@ -44,6 +46,7 @@ export class InteractionKernel {
private readonly seenOrder: string[] = [];
private readonly presenceByActor = new Map<string, AgentPresence>();
private readonly toolCalls = new ToolCallStateMachine();
private readonly tasks = new TaskStateMachine();
public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) {
if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) {
@@ -67,6 +70,7 @@ export class InteractionKernel {
seen_envelope_ids: this.seenIDs.size,
agent_presence: [...this.presenceByActor.values()].map(presence => ({ ...presence })),
tool_calls: this.toolCalls.snapshot(),
tasks: this.tasks.snapshot(),
};
}
@@ -90,12 +94,21 @@ export class InteractionKernel {
this.toolCalls.restore(records, now);
}
public requestTaskCancel(operationID: string, updatedAt: string) {
return this.tasks.requestCancel(operationID, updatedAt);
}
public restoreTasks(records: readonly TaskRecord[]): void {
this.tasks.restore(records);
}
/** Clear only the in-memory projection when the active conversation changes. */
public reset(): void {
this.seenIDs.clear();
this.seenOrder.length = 0;
this.presenceByActor.clear();
this.toolCalls.reset();
this.tasks.reset();
}
private remember(id: string): void {
@@ -120,6 +133,10 @@ export class InteractionKernel {
this.toolCalls.complete(item.result, updatedAt);
return;
}
if (item.kind === "progress" && item.task && item.envelope) {
this.tasks.receive(item.task, item.envelope.conversation_id, updatedAt);
return;
}
if (item.kind !== "agent-status" || !item.envelope) return;
const actor = item.envelope.sender;
const actorKey = `${actor.kind}:${actor.id}`;
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { TaskStateMachine } from "./task-state";
const TIME = "2026-08-03T09:00:00.000Z";
describe("TaskStateMachine", () => {
it("projects repeated progress updates into one task record", () => {
const tasks = new TaskStateMachine();
tasks.receive({ operation_id: "task_001", title: "Generate report", percent: 20, status: "running", cancellable: true }, "conversation_001", TIME);
tasks.receive({ operation_id: "task_001", title: "Generate report", percent: 80, status: "running", cancellable: true }, "conversation_001", "2026-08-03T09:01:00.000Z");
expect(tasks.snapshot()).toEqual([expect.objectContaining({ operation_id: "task_001", percent: 80, status: "running" })]);
});
it("records one local cancellation request and lets the Agent close it as cancelled", () => {
const tasks = new TaskStateMachine();
tasks.receive({ operation_id: "task_002", title: "Export", percent: 40, status: "running", cancellable: true }, "conversation_001", TIME);
expect(tasks.requestCancel("task_002", "2026-08-03T09:01:00.000Z")).toMatchObject({ disposition: "accepted", record: { cancel_requested_at: "2026-08-03T09:01:00.000Z" } });
expect(tasks.requestCancel("task_002", "2026-08-03T09:02:00.000Z").disposition).toBe("invalid_transition");
tasks.receive({ operation_id: "task_002", title: "Export", percent: 40, status: "cancelled", cancellable: false }, "conversation_001", "2026-08-03T09:03:00.000Z");
expect(tasks.get("task_002")).toMatchObject({ status: "cancelled" });
expect(tasks.get("task_002")).not.toHaveProperty("cancel_requested_at");
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { TaskProgress, TaskStatus } from "../protocol";
export type TaskRecord = TaskProgress & {
conversation_id: string;
updated_at: string;
cancel_requested_at?: string;
};
export type TaskTransition =
| { disposition: "accepted"; record: TaskRecord }
| { disposition: "missing" | "invalid_transition"; record?: TaskRecord };
/**
* Platform-neutral task projection. It has no DOM, storage, transport, or
* side-effect handler. M1-04 records a user cancellation request locally;
* M1-06 later owns durable delivery to the Agent.
*/
export class TaskStateMachine {
private readonly tasks = new Map<string, TaskRecord>();
public receive(update: TaskProgress, conversationID: string, updatedAt: string): TaskRecord {
const current = this.tasks.get(update.operation_id);
const next: TaskRecord = {
...update,
conversation_id: conversationID,
updated_at: updatedAt,
...(current?.cancel_requested_at && update.status !== "cancelled" ? { cancel_requested_at: current.cancel_requested_at } : {}),
};
this.tasks.set(next.operation_id, next);
return copy(next);
}
public requestCancel(operationID: string, updatedAt: string): TaskTransition {
const record = this.tasks.get(operationID);
if (!record) return { disposition: "missing" };
if (!record.cancellable || isTerminal(record.status) || record.cancel_requested_at) return { disposition: "invalid_transition", record: copy(record) };
record.cancel_requested_at = updatedAt;
record.updated_at = updatedAt;
return { disposition: "accepted", record: copy(record) };
}
public get(operationID: string): TaskRecord | undefined {
const record = this.tasks.get(operationID);
return record ? copy(record) : undefined;
}
public snapshot(): readonly TaskRecord[] { return [...this.tasks.values()].map(copy); }
public reset(): void { this.tasks.clear(); }
public restore(records: readonly TaskRecord[]): void {
this.tasks.clear();
for (const record of records) {
if (!isRestoreable(record) || this.tasks.has(record.operation_id)) continue;
this.tasks.set(record.operation_id, copy(record));
}
}
}
function isTerminal(status: TaskStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; }
function isRestoreable(value: unknown): value is TaskRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Partial<TaskRecord>;
return typeof record.operation_id === "string" && Boolean(record.operation_id)
&& typeof record.conversation_id === "string" && Boolean(record.conversation_id)
&& typeof record.title === "string" && typeof record.percent === "number" && Number.isFinite(record.percent)
&& TASK_STATUS_VALUES.has(record.status as TaskStatus) && typeof record.cancellable === "boolean"
&& typeof record.updated_at === "string" && !Number.isNaN(Date.parse(record.updated_at));
}
const TASK_STATUS_VALUES = new Set<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
function copy(record: TaskRecord): TaskRecord { return { ...record }; }
@@ -10,6 +10,7 @@ import type {
ToolCallRequest,
} from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
export type ToolInteractionHandlers = {
submit: (callID: string, submission: JsonObject) => boolean;
@@ -21,6 +22,11 @@ export type ToolInteractionHandlers = {
clearDraft: (callID: string) => void;
};
export type TaskInteractionHandlers = {
requestCancel: (operationID: string) => boolean;
read: (operationID: string) => TaskRecord | undefined;
};
/**
* Trusted presentation context for the first-party chat shell. It owns only
* DOM nodes and display-local state; it deliberately has no Store, Transport,
@@ -29,17 +35,20 @@ export type ToolInteractionHandlers = {
export class TrustedDOMRendererContext {
private thinking: HTMLElement | undefined;
private readonly userRows = new Map<string, HTMLElement>();
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
private readonly expirationTimers = new Set<number>();
public constructor(
private readonly messages: HTMLElement,
private readonly presence: HTMLElement,
private readonly toolInteractions?: ToolInteractionHandlers,
private readonly taskInteractions?: TaskInteractionHandlers,
) {}
public reset(): void {
this.thinking = undefined;
this.userRows.clear();
this.taskCards.clear();
for (const timer of this.expirationTimers) window.clearTimeout(timer);
this.expirationTimers.clear();
}
@@ -63,6 +72,14 @@ export class TrustedDOMRendererContext {
}
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
if (item.task) {
this.renderTaskProgress(this.taskInteractions?.read(item.task.operation_id) ?? {
...item.task,
conversation_id: item.envelope?.conversation_id ?? "",
updated_at: new Date().toISOString(),
});
return;
}
const percent = Math.max(0, Math.min(100, item.percent));
this.renderSystemMessage(`${item.title || "Agent 任务"}${percent}%${item.status ? ` · ${item.status}` : ""}`);
}
@@ -164,6 +181,47 @@ export class TrustedDOMRendererContext {
return row;
}
private renderTaskProgress(task: TaskRecord): void {
const existing = this.taskCards.get(task.operation_id);
if (existing) {
this.updateTaskCard(existing, task);
return;
}
const card = document.createElement("article");
card.className = "task-card";
card.dataset.operationId = task.operation_id;
const title = document.createElement("strong");
const progress = document.createElement("progress");
progress.max = 100;
const status = document.createElement("small");
status.className = "task-card-status";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "secondary-action";
cancel.textContent = "请求取消";
cancel.addEventListener("click", () => {
if (!this.taskInteractions?.requestCancel(task.operation_id)) return;
const current = this.taskInteractions.read(task.operation_id);
if (current) this.updateTaskCard(this.taskCards.get(task.operation_id)!, current);
});
const entry = { card, title, progress, status, cancel };
card.append(title, progress, status, cancel);
this.taskCards.set(task.operation_id, entry);
this.updateTaskCard(entry, task);
this.messages.append(card);
this.scrollLatest();
}
private updateTaskCard(entry: { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }, task: TaskRecord): void {
entry.card.dataset.status = task.status;
entry.title.textContent = task.title;
entry.progress.value = Math.max(0, Math.min(100, task.percent));
const requested = task.cancel_requested_at !== undefined && task.status !== "cancelled";
entry.status.textContent = requested ? `取消请求已记录 · ${task.percent}%` : `${task.status} · ${task.percent}%`;
entry.cancel.hidden = !task.cancellable || task.status === "completed" || task.status === "failed" || task.status === "cancelled";
entry.cancel.disabled = requested;
}
private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void {
const card = document.createElement("article");
card.className = "action-group-card";
+1 -1
View File
File diff suppressed because one or more lines are too long