From 10d95c4a5e43c754b42e0bc93400332c6e1b064e Mon Sep 17 00:00:00 2001 From: kyugao Date: Mon, 3 Aug 2026 16:17:15 +0800 Subject: [PATCH] feat: aggregate task progress updates --- tauri/README.md | 6 ++ tauri/src/main.ts | 12 ++++ tauri/src/protocol.ts | 26 ++++++- tauri/src/runtime/conversation-store.test.ts | 13 ++++ tauri/src/runtime/conversation-store.ts | 38 ++++++++--- tauri/src/runtime/interaction-kernel.test.ts | 17 +++++ tauri/src/runtime/interaction-kernel.ts | 17 +++++ tauri/src/runtime/task-state.test.ts | 26 +++++++ tauri/src/runtime/task-state.ts | 71 ++++++++++++++++++++ tauri/src/runtime/trusted-dom-renderers.ts | 58 ++++++++++++++++ tauri/src/style.css | 2 +- 11 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 tauri/src/runtime/task-state.test.ts create mode 100644 tauri/src/runtime/task-state.ts diff --git a/tauri/README.md b/tauri/README.md index e5bf9ca..de90c14 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -68,6 +68,12 @@ M0-01~M0-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 功能。 diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 6d62fb9..26dc6df 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -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(); rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); @@ -186,6 +193,9 @@ async function syncLoop(): Promise { 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 @@ $("#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; diff --git a/tauri/src/protocol.ts b/tauri/src/protocol.ts index e304148..90ad285 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/protocol.ts @@ -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(["choice", "confirm", "input"]); const TOOL_FINAL_STATUSES = new Set(["completed", "failed", "cancelled", "expired"]); const ACTION_GROUP_MODES = new Set(["single-choice", "multi-choice", "button"]); const INPUT_FIELD_TYPES = new Set(["text", "textarea", "number"]); +const TASK_STATUSES = new Set(["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); diff --git a/tauri/src/runtime/conversation-store.test.ts b/tauri/src/runtime/conversation-store.test.ts index e2c1b89..88b1334 100644 --- a/tauri/src/runtime/conversation-store.test.ts +++ b/tauri/src/runtime/conversation-store.test.ts @@ -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 })]); + }); }); diff --git a/tauri/src/runtime/conversation-store.ts b/tauri/src/runtime/conversation-store.ts index 8465452..dfb3476 100644 --- a/tauri/src/runtime/conversation-store.ts +++ b/tauri/src/runtime/conversation-store.ts @@ -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>; + tasks: readonly TaskRecord[]; }; type PersistedConversation = { - version: 4; + version: 5; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; tool_calls: ToolCallRecord[]; tool_drafts: Record; + 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 { diff --git a/tauri/src/runtime/interaction-kernel.test.ts b/tauri/src/runtime/interaction-kernel.test.ts index 854c0c8..110e973 100644 --- a/tauri/src/runtime/interaction-kernel.test.ts +++ b/tauri/src/runtime/interaction-kernel.test.ts @@ -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" })]); + }); }); diff --git a/tauri/src/runtime/interaction-kernel.ts b/tauri/src/runtime/interaction-kernel.ts index 8fb7d04..1ee7f3f 100644 --- a/tauri/src/runtime/interaction-kernel.ts +++ b/tauri/src/runtime/interaction-kernel.ts @@ -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(); 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}`; diff --git a/tauri/src/runtime/task-state.test.ts b/tauri/src/runtime/task-state.test.ts new file mode 100644 index 0000000..d3fffac --- /dev/null +++ b/tauri/src/runtime/task-state.test.ts @@ -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"); + }); +}); diff --git a/tauri/src/runtime/task-state.ts b/tauri/src/runtime/task-state.ts new file mode 100644 index 0000000..ed3b3d1 --- /dev/null +++ b/tauri/src/runtime/task-state.ts @@ -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(); + + 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; + 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(["running", "waiting", "completed", "failed", "cancelled"]); +function copy(record: TaskRecord): TaskRecord { return { ...record }; } diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/runtime/trusted-dom-renderers.ts index 05537e1..9490382 100644 --- a/tauri/src/runtime/trusted-dom-renderers.ts +++ b/tauri/src/runtime/trusted-dom-renderers.ts @@ -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(); + private readonly taskCards = new Map(); private readonly expirationTimers = new Set(); 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): 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"; diff --git a/tauri/src/style.css b/tauri/src/style.css index 8b2ef5b..3e7b4b3 100644 --- a/tauri/src/style.css +++ b/tauri/src/style.css @@ -1 +1 @@ -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button,.action-group-card button,.tool-card button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.action-group-card,.tool-card{display:grid;justify-self:start;gap:.7rem;width:min(100%,32rem);padding:1rem;border:1px solid #537362;border-radius:.9rem;background:#1c2c24}.action-group-card>p,.tool-card>p{margin:0;color:#bfd1c6;line-height:1.45}.action-group-actions{display:grid;gap:.5rem}.action-group-actions.multi-choice{grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}.action-group-actions.button{grid-template-columns:repeat(auto-fit,minmax(7rem,1fr))}.action-group-action{min-height:2.7rem;text-align:left}.action-group-actions.button .action-group-action{text-align:center}.action-group-action[aria-pressed="true"]{outline:2px solid #e0f8ea;background:#d0f0db}.action-group-submit{justify-self:start}.action-group-status,.tool-card-status{color:#aac5b5}.action-group-card[data-status="submitted"],.tool-card[data-status="submitted"]{border-color:#89c7a6}.tool-card[data-status="cancelled"],.tool-card[data-status="expired"]{border-color:#587064}.tool-card form{display:grid;gap:.75rem}.tool-card form label{display:grid;gap:.35rem;color:#c7d9ce;font-size:.85rem}.tool-card form input,.tool-card form textarea{width:100%;border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.7rem;font:inherit}.tool-card form textarea{min-height:5rem;resize:vertical}.tool-card-actions{display:flex;flex-wrap:wrap;gap:.5rem}.tool-card-actions .secondary-action{background:#395447;color:#e2f1e8}.tool-card-error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.82rem}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled,.action-group-card button:disabled,.tool-card button:disabled,.tool-card input:disabled,.tool-card textarea:disabled{opacity:.5;cursor:not-allowed}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button,.action-group-card button,.tool-card button,.task-card button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.action-group-card,.tool-card,.task-card{display:grid;justify-self:start;gap:.7rem;width:min(100%,32rem);padding:1rem;border:1px solid #537362;border-radius:.9rem;background:#1c2c24}.action-group-card>p,.tool-card>p{margin:0;color:#bfd1c6;line-height:1.45}.action-group-actions{display:grid;gap:.5rem}.action-group-actions.multi-choice{grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}.action-group-actions.button{grid-template-columns:repeat(auto-fit,minmax(7rem,1fr))}.action-group-action{min-height:2.7rem;text-align:left}.action-group-actions.button .action-group-action{text-align:center}.action-group-action[aria-pressed="true"]{outline:2px solid #e0f8ea;background:#d0f0db}.action-group-submit{justify-self:start}.action-group-status,.tool-card-status,.task-card-status{color:#aac5b5}.action-group-card[data-status="submitted"],.tool-card[data-status="submitted"],.task-card[data-status="completed"]{border-color:#89c7a6}.tool-card[data-status="cancelled"],.tool-card[data-status="expired"],.task-card[data-status="cancelled"],.task-card[data-status="failed"]{border-color:#587064}.task-card progress{width:100%;height:.7rem;accent-color:#a9dfc4}.task-card .secondary-action{justify-self:start;background:#395447;color:#e2f1e8}.tool-card form{display:grid;gap:.75rem}.tool-card form label{display:grid;gap:.35rem;color:#c7d9ce;font-size:.85rem}.tool-card form input,.tool-card form textarea{width:100%;border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.7rem;font:inherit}.tool-card form textarea{min-height:5rem;resize:vertical}.tool-card-actions{display:flex;flex-wrap:wrap;gap:.5rem}.tool-card-actions .secondary-action{background:#395447;color:#e2f1e8}.tool-card-error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.82rem}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled,.action-group-card button:disabled,.tool-card button:disabled,.tool-card input:disabled,.tool-card textarea:disabled,.task-card button:disabled{opacity:.5;cursor:not-allowed}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}}