diff --git a/tauri/src/execution-progress.css b/tauri/src/execution-progress.css new file mode 100644 index 0000000..e62a995 --- /dev/null +++ b/tauri/src/execution-progress.css @@ -0,0 +1,44 @@ +.execution-progress-card { + justify-self: start; + width: min(100%, 32rem); + border: 1px solid #577563; + border-radius: .85rem; + background: #182820; + color: #cde3d5; +} + +.execution-progress-card summary { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: .8rem; + padding: .72rem .85rem; + cursor: pointer; + list-style: none; +} + +.execution-progress-card summary::-webkit-details-marker { display: none; } +.execution-progress-card summary::before { content: "›"; font-size: 1.2rem; color: #a9dfc4; transition: transform .15s ease; } +.execution-progress-card[open] summary::before { transform: rotate(90deg); } +.execution-progress-card[data-status="running"] summary::before { content: "◌"; transform: none; } +.execution-progress-heading strong { font-size: .9rem; } +.execution-progress-duration { color: #9cbbaa; white-space: nowrap; } + +.execution-progress-stages { + display: grid; + gap: .34rem; + margin: 0; + padding: 0 .9rem .85rem 2rem; + color: #b9d1c2; + font-size: .82rem; +} + +.execution-progress-stages li::marker { color: #85caa2; } +.execution-progress-stages li[data-status="completed"] { color: #d5f0df; } +.execution-progress-stages li[data-status="running"] { color: #d7e6a4; } +.execution-progress-card[data-status="failed"] { border-color: #82625e; } +.execution-progress-card[data-status="failed"] .execution-progress-stages li[data-status="failed"] { color: #ffb2aa; } + +/* A trace belongs to the final Agent turn, not to the global message stream. */ +.message-agent-content { display: grid; gap: .45rem; min-width: 0; } +.message-agent-content > .execution-progress-card { width: min(100%, 32rem); } diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 1bbcd5f..ae5f329 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -1,4 +1,5 @@ import "./style.css"; +import "./execution-progress.css"; import { type Actor, type ConversationItem, @@ -7,6 +8,7 @@ import { parseEnvelopeJSON, } from "./protocol"; import { ConversationStore } from "./runtime/conversation-store"; +import { ExecutionProgressState } from "./runtime/execution-progress-state"; import { InteractionKernel } from "./runtime/interaction-kernel"; import { RendererRegistry } from "./runtime/renderer-registry"; import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter"; @@ -29,6 +31,7 @@ let polling = false; let flushingOutbox = false; const interactionKernel = new InteractionKernel(); const conversationStore = new ConversationStore(localStorage); +const executionProgress = new ExecutionProgressState(); const app = document.querySelector("#app"); if (!app) throw new Error("LineUp host root is missing"); @@ -136,6 +139,7 @@ const rendererRegistry = new RendererRegistry(); rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item)); rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item)); +rendererRegistry.register("execution-summary", (item, context) => context.renderExecutionTrace(item)); rendererRegistry.register("progress", (item, context) => context.renderProgress(item)); rendererRegistry.register("error", (item, context) => context.renderError(item)); rendererRegistry.register("tool-call", (item, context) => context.renderToolCall(item)); @@ -149,6 +153,34 @@ function renderIncoming(item: ConversationItem): void { if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。"); } +/** + * Status only begins/ends a turn. Public operation labels can only come from + * the separately validated adapter trace, never from Agent status/detail. + */ +function projectExecutionProgress(item: ConversationItem, now: string): void { + if (item.kind === "agent-status" && item.status === "thinking" && item.envelope?.sender.kind === "agent") { + const summary = executionProgress.begin(item.execution_id ?? newLocalID("execution"), now); + conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); + rendererContext.renderExecutionSummary(summary); + return; + } + if (item.kind === "execution-summary") { + const summary = executionProgress.applyTrace(item.trace.execution_id, item.trace.status, item.trace.steps, now); + if (summary) { + conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); + rendererContext.renderExecutionSummary(summary); + } + return; + } + const terminal = item.kind === "error" ? "failed" : (item.kind === "agent-status" && item.status === "idle" ? "completed" : undefined); + if (!terminal) return; + const executionID = item.kind === "agent-status" ? item.execution_id : undefined; + const summary = executionProgress.finish(executionID, terminal, now); + if (!summary) return; + conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); + rendererContext.renderExecutionSummary(summary); +} + function currentConversationKey(): string { return `${session.uid}:${session.channelType}:${session.channelID}`; } @@ -269,7 +301,11 @@ async function syncLoop(): Promise { conversationStore.replaceTasks(result.snapshot.tasks); } for (const item of result.items) { - if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item); + const receivedAt = new Date().toISOString(); + if (conversationStore.recordIncoming(item, message.message_seq, receivedAt)) { + projectExecutionProgress(item, receivedAt); + renderIncoming(item); + } } } } @@ -316,12 +352,19 @@ $("#login-form").addEventListener("submit", async event => { const snapshot = conversationStore.open(currentConversationKey()); interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString()); interactionKernel.restoreTasks(snapshot.tasks); + executionProgress.restore(snapshot.execution_summaries); 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; - for (const stored of snapshot.items) renderIncoming(stored.item); + // Status envelopes describe ephemeral activity. The durable M1-08 summary + // below is the only restored execution view, so historical `thinking` + // messages cannot create a fresh or expanded pseudo-live card on reload. + for (const stored of snapshot.items) { + if (stored.item.kind !== "agent-status") renderIncoming(stored.item); + } + rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop(); } catch (reason) { error.textContent = loginFailureMessage(reason); } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } diff --git a/tauri/src/protocol.ts b/tauri/src/protocol.ts index 90ad285..4d49200 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/protocol.ts @@ -116,6 +116,10 @@ export type TaskProgress = { cancellable: boolean; }; +/** Display-only, adapter-authoritative execution trace. It grants no ability. */ +export type AgentExecutionStep = { id: string; title: string; status: "running" | "completed" | "failed" }; +export type AgentExecutionTrace = { execution_id: string; status: "running" | "completed" | "failed"; steps: readonly AgentExecutionStep[] }; + /** * A user-originated action awaiting delivery through the Transport Adapter. * It is data only: the Store owns retries and the Kernel owns state changes. @@ -180,8 +184,9 @@ type ConversationItemBase = { 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: "markdown"; markdown: string; execution_id?: string }) + | (ConversationItemBase & { kind: "agent-status"; status: string; detail: string; execution_id?: string }) + | (ConversationItemBase & { kind: "execution-summary"; trace: AgentExecutionTrace }) | (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress }) | (ConversationItemBase & { kind: "error"; message: string; code?: string }) | (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest }) @@ -199,6 +204,7 @@ const ACTOR_KINDS = new Set(["human", "agent", "app", "system"]); const SUPPORTED_TYPES = new Set([ "lineup.v1.text", "lineup.v1.agent.status", + "lineup.v1.agent.execution", "lineup.v1.agent.progress", "lineup.v1.error", "lineup.v1.tool.call", @@ -323,6 +329,7 @@ const TOOL_FINAL_STATUSES = new Set(["completed", "failed", 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"]); +const EXECUTION_STATUSES = new Set(["running", "completed", "failed"]); function optionalTimestamp(value: unknown): string | undefined { return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined; @@ -436,6 +443,24 @@ function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | return { operation_id: operationID, title, percent, status, cancellable: payload.cancellable === true }; } +function parseExecutionTrace(payload: JsonObject): AgentExecutionTrace | undefined { + const executionID = identifier(payload.execution_id); + const status = text(payload.status); + if (!executionID || !EXECUTION_STATUSES.has(status) || !Array.isArray(payload.steps) || payload.steps.length > 12) return undefined; + const steps: AgentExecutionStep[] = []; + const seen = new Set(); + for (const raw of payload.steps) { + const step = object(raw); + const id = step && identifier(step.id); + const title = step && text(step.title).trim(); + const stepStatus = step && text(step.status); + if (!id || !title || title.length > 160 || !stepStatus || !EXECUTION_STATUSES.has(stepStatus) || seen.has(id)) return undefined; + seen.add(id); + steps.push({ id, title, status: stepStatus as AgentExecutionStep["status"] }); + } + return { execution_id: executionID, status: status as AgentExecutionTrace["status"], steps }; +} + function parseToolResult(payload: JsonObject): ToolCallResult | undefined { const callID = identifier(payload.call_id); const status = text(payload.status) as ToolCallFinalStatus; @@ -484,14 +509,20 @@ export function decodeConversationItem(raw: string): ConversationItem { switch (envelope.type) { case "lineup.v1.text": { const markdown = text(payload.markdown) || text(payload.text); - return markdown ? { kind: "markdown", markdown, ...base } : fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope }); + return markdown + ? { kind: "markdown", markdown, ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base } + : fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope }); } case "lineup.v1.agent.status": { const status = text(payload.status); return status - ? { kind: "agent-status", status, detail: text(payload.detail), ...base } + ? { kind: "agent-status", status, detail: text(payload.detail), ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base } : fallbackItem({ code: "invalid_payload", detail: "Agent status payload requires status.", envelope }); } + case "lineup.v1.agent.execution": { + const trace = parseExecutionTrace(payload); + return trace ? { kind: "execution-summary", trace, ...base } : fallbackItem({ code: "invalid_payload", detail: "Agent execution requires bounded public steps.", envelope }); + } case "lineup.v1.agent.progress": { const percent = payload.percent; if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) { diff --git a/tauri/src/runtime/conversation-store.test.ts b/tauri/src/runtime/conversation-store.test.ts index b688ac6..e3b5fa4 100644 --- a/tauri/src/runtime/conversation-store.test.ts +++ b/tauri/src/runtime/conversation-store.test.ts @@ -129,6 +129,42 @@ describe("ConversationStore", () => { expect(current.snapshot().outbox).toEqual([]); }); + it("persists only the already-sanitized execution progress summary projection", () => { + const storage = new MemoryStorage(); + const first = new ConversationStore(storage); + first.open(conversationID); + first.replaceExecutionSummaries([{ + id: "execution_001", + status: "completed", + started_at: createdAt, + updated_at: "2026-08-03T00:00:08.000Z", + finished_at: "2026-08-03T00:00:08.000Z", + steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }], + }]); + + const snapshot = new ConversationStore(storage).open(conversationID); + expect(snapshot.execution_summaries).toEqual([expect.objectContaining({ + id: "execution_001", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }], + })]); + expect(JSON.stringify(snapshot.execution_summaries)).not.toContain("detail"); + }); + + it("drops untrusted extra fields from persisted execution summaries", () => { + const storage = new MemoryStorage(); + const store = new ConversationStore(storage); + store.open(conversationID); + store.replaceExecutionSummaries([{ + id: "execution_safe", + status: "running", + started_at: createdAt, + updated_at: createdAt, + steps: [], + detail: "do not persist this", + } as unknown as import("./execution-progress-state").ExecutionProgressSummary]); + + expect(JSON.stringify(store.snapshot().execution_summaries)).not.toContain("do not persist this"); + }); + it("upgrades v5 plaintext outbox entries to typed text entries", () => { const storage = new MemoryStorage(); storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({ @@ -138,4 +174,33 @@ describe("ConversationStore", () => { local_id: "legacy_001", kind: "text", payload: "hello", created_at: createdAt, }]); }); + + it("preserves v6 tool state while adding the execution summary field", () => { + const storage = new MemoryStorage(); + const state = { + call_id: "call_migrate_006", tool: "confirm", status: "completed", conversation_id: conversationID, + created_at: createdAt, updated_at: createdAt, + } as unknown as ToolCallRecord; + storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({ + version: 6, cursor: 8, items: [], outbox: [], tool_calls: [state], tool_drafts: { call_migrate_006: { value: "not-lost" } }, tasks: [], + })); + + const snapshot = new ConversationStore(storage).open(conversationID); + expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_migrate_006", status: "completed" })]); + expect(snapshot.tool_drafts).toEqual({ call_migrate_006: { value: "not-lost" } }); + expect(snapshot.execution_summaries).toEqual([]); + }); + + it("upgrades v7 while retaining tool state but dropping fixed lifecycle placeholders", () => { + const storage = new MemoryStorage(); + const state = { call_id: "call_migrate_007", tool: "confirm", status: "completed", conversation_id: conversationID, created_at: createdAt, updated_at: createdAt } as unknown as ToolCallRecord; + storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({ + version: 7, cursor: 9, items: [], outbox: [], tool_calls: [state], tool_drafts: { call_migrate_007: { ignored: "draft" } }, tasks: [], + execution_summaries: [{ id: "execution_legacy", status: "completed", started_at: createdAt, updated_at: createdAt, finished_at: createdAt, stages: ["received", "completed"] }], + })); + const snapshot = new ConversationStore(storage).open(conversationID); + expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_migrate_007" })]); + expect(snapshot.tool_drafts).toEqual({ call_migrate_007: { ignored: "draft" } }); + expect(snapshot.execution_summaries).toEqual([]); + }); }); diff --git a/tauri/src/runtime/conversation-store.ts b/tauri/src/runtime/conversation-store.ts index aeab396..103f26c 100644 --- a/tauri/src/runtime/conversation-store.ts +++ b/tauri/src/runtime/conversation-store.ts @@ -1,6 +1,8 @@ import type { ConversationItem, DeliveryState, JsonObject } from "../protocol"; import type { ToolCallRecord } from "./tool-call-state"; import type { TaskRecord } from "./task-state"; +import type { ExecutionProgressSummary } from "./execution-progress-state"; +import type { SurfaceInstanceSnapshot } from "./surface-instance-manager"; export type StoredConversationItem = { local_id: string; @@ -11,7 +13,7 @@ export type StoredConversationItem = { export type OutboxEntry = { local_id: string; - kind: "text" | "tool-result" | "tool-cancel"; + kind: "text" | "tool-result" | "tool-cancel" | "surface-event"; payload: string; created_at: string; }; @@ -24,16 +26,21 @@ export type ConversationSnapshot = { tool_calls: readonly ToolCallRecord[]; tool_drafts: Readonly>; tasks: readonly TaskRecord[]; + /** Allowlisted public operation summaries; never reasoning or raw tool data. */ + execution_summaries: readonly ExecutionProgressSummary[]; + surfaces: SurfaceInstanceSnapshot; }; type PersistedConversation = { - version: 6; + version: 9; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; tool_calls: ToolCallRecord[]; tool_drafts: Record; tasks: TaskRecord[]; + execution_summaries: ExecutionProgressSummary[]; + surfaces: SurfaceInstanceSnapshot; }; const STORE_PREFIX = "lineup.conversation.v1"; @@ -72,6 +79,8 @@ export class ConversationStore { tool_calls: clone(this.state.tool_calls), tool_drafts: clone(this.state.tool_drafts), tasks: clone(this.state.tasks), + execution_summaries: clone(this.state.execution_summaries), + surfaces: clone(this.state.surfaces), }; } @@ -102,6 +111,17 @@ export class ConversationStore { this.persist(); } + /** Stores only bounded public operation labels and timestamps. */ + public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void { + this.state.execution_summaries = normalizeExecutionSummaries(records); + this.persist(); + } + + public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void { + this.state.surfaces = clone(snapshot); + this.persist(); + } + public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem { const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const stored: StoredConversationItem = { @@ -116,7 +136,7 @@ export class ConversationStore { } /** Queues a standard interaction response before any network attempt. */ - public enqueueToolAction(localID: string, kind: "tool-result" | "tool-cancel", payload: string, createdAt: string): void { + public enqueueToolAction(localID: string, kind: "tool-result" | "tool-cancel" | "surface-event", payload: string, createdAt: string): void { if (this.state.outbox.some(entry => entry.local_id === localID)) return; this.state.outbox.push({ local_id: localID, kind, payload, created_at: createdAt }); this.persist(); @@ -213,35 +233,40 @@ 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; tasks?: unknown }; - if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6) || !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; execution_summaries?: unknown; surfaces?: unknown }; + if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { return emptyConversation(); } - if (candidate.version !== 6) { + if (candidate.version !== 9) { // 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 v5. return { - version: 6, - cursor: candidate.version === 4 || candidate.version === 5 ? Math.max(0, candidate.cursor) : 0, + version: 9, + cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0, items: candidate.items as StoredConversationItem[], outbox: normalizeOutbox(candidate.outbox), - tool_calls: (candidate.version === 4 || candidate.version === 5) && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [], - tool_drafts: (candidate.version === 4 || candidate.version === 5) && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {}, - tasks: candidate.version === 5 && Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [], + tool_calls: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [], + tool_drafts: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {}, + tasks: (candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [], + // v7's fixed lifecycle placeholders are intentionally discarded. + execution_summaries: [], + surfaces: emptySurfaces(), }; } return { - version: 6, + version: 9, cursor: Math.max(0, candidate.cursor), items: candidate.items as StoredConversationItem[], outbox: normalizeOutbox(candidate.outbox), 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[] : [], + execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries), + surfaces: normalizeSurfaces(candidate.surfaces), }; } catch { return emptyConversation(); @@ -263,16 +288,19 @@ export class ConversationStore { } function emptyConversation(): PersistedConversation { - return { version: 6, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [] }; + return { version: 9, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces() }; } +function emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; } +function normalizeSurfaces(value: unknown): SurfaceInstanceSnapshot { return value && typeof value === "object" && !Array.isArray(value) ? value as SurfaceInstanceSnapshot : emptySurfaces(); } + function normalizeOutbox(value: unknown): OutboxEntry[] { if (!Array.isArray(value)) return []; return value.flatMap(entry => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; const candidate = entry as Partial; if (typeof candidate.local_id !== "string" || typeof candidate.payload !== "string" || typeof candidate.created_at !== "string") return []; - const kind = candidate.kind === "tool-result" || candidate.kind === "tool-cancel" ? candidate.kind : "text"; + const kind = candidate.kind === "tool-result" || candidate.kind === "tool-cancel" || candidate.kind === "surface-event" ? candidate.kind : "text"; return [{ local_id: candidate.local_id, kind, payload: candidate.payload, created_at: candidate.created_at }]; }); } @@ -282,6 +310,46 @@ function isDraftMap(value: unknown): value is Record { && Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft)); } +/** + * This is intentionally stricter than a generic JSON clone. Execution + * summaries are a privacy boundary: unknown fields (including a model detail, + * command, argument, URL, path, output, or tool input) are discarded first. + */ +function normalizeExecutionSummaries(value: unknown): ExecutionProgressSummary[] { + if (!Array.isArray(value)) return []; + const statuses = new Set(["running", "completed", "failed"]); + const stepStatuses = new Set(["completed", "failed"]); + return value.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const candidate = raw as Partial; + if (typeof candidate.id !== "string" || candidate.id.length < 1 || candidate.id.length > 128 + || typeof candidate.status !== "string" || !statuses.has(candidate.status) + || typeof candidate.started_at !== "string" || Number.isNaN(Date.parse(candidate.started_at)) + || typeof candidate.updated_at !== "string" || Number.isNaN(Date.parse(candidate.updated_at)) + || (candidate.finished_at !== undefined && (typeof candidate.finished_at !== "string" || Number.isNaN(Date.parse(candidate.finished_at)))) + || !Array.isArray(candidate.steps) || candidate.steps.length > 12) return []; + const seen = new Set(); + const steps = candidate.steps.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const step = raw as { id?: unknown; title?: unknown; status?: unknown }; + if (typeof step.id !== "string" || !/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(step.id) + || typeof step.title !== "string" || !step.title.trim() || step.title.length > 160 + || typeof step.status !== "string" || !stepStatuses.has(step.status) || seen.has(step.id)) return []; + seen.add(step.id); + return [{ id: step.id, title: step.title.trim(), status: step.status as "completed" | "failed" }]; + }); + if (steps.length !== candidate.steps.length) return []; + return [{ + id: candidate.id, + status: candidate.status as ExecutionProgressSummary["status"], + started_at: candidate.started_at, + updated_at: candidate.updated_at, + ...(candidate.finished_at ? { finished_at: candidate.finished_at } : {}), + steps, + }]; + }).slice(-20); +} + function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } diff --git a/tauri/src/runtime/execution-progress-state.test.ts b/tauri/src/runtime/execution-progress-state.test.ts new file mode 100644 index 0000000..ef56d14 --- /dev/null +++ b/tauri/src/runtime/execution-progress-state.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { ExecutionProgressState } from "./execution-progress-state"; + +const T0 = "2026-08-03T00:00:00.000Z"; +const T1 = "2026-08-03T00:00:18.000Z"; + +describe("ExecutionProgressState", () => { + it("starts a turn with no invented operation steps", () => { + const state = new ExecutionProgressState(); + expect(state.begin("exec_001", T0)).toEqual(expect.objectContaining({ id: "exec_001", status: "running", steps: [] })); + }); + + it("only accepts bounded, canonical execution steps for the matching turn", () => { + const state = new ExecutionProgressState(); + state.begin("exec_001", T0); + expect(state.applyTrace("exec_other", "completed", [], T1)).toBeUndefined(); + const summary = state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }], T1); + expect(summary).toEqual(expect.objectContaining({ status: "completed", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }] })); + }); + + it("updates a real running step in place before the turn ends", () => { + const state = new ExecutionProgressState(); + state.begin("exec_001", T0); + expect(state.applyTrace("exec_001", "running", [{ id: "step_01", title: "查询公开资料", status: "running" }], T1)) + .toEqual(expect.objectContaining({ status: "running", finished_at: undefined, steps: [{ id: "step_01", title: "查询公开资料", status: "running" }] })); + expect(state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "查询公开资料", status: "completed" }], T1)) + .toEqual(expect.objectContaining({ status: "completed", finished_at: T1 })); + }); + + it("finishes without a trace without fabricating a fixed lifecycle", () => { + const state = new ExecutionProgressState(); + state.begin("exec_001", T0); + expect(state.finishActive("completed", T1)).toEqual(expect.objectContaining({ steps: [] })); + }); + + it("keeps overlapping execution ids independent and finishes only the matching turn", () => { + const state = new ExecutionProgressState(); + state.begin("exec_001", T0); + state.begin("exec_002", T1); + state.finish("exec_002", "completed", T1); + expect(state.snapshot()).toEqual([ + expect.objectContaining({ id: "exec_001", status: "running" }), + expect.objectContaining({ id: "exec_002", status: "completed" }), + ]); + }); +}); diff --git a/tauri/src/runtime/execution-progress-state.ts b/tauri/src/runtime/execution-progress-state.ts new file mode 100644 index 0000000..13fd5d6 --- /dev/null +++ b/tauri/src/runtime/execution-progress-state.ts @@ -0,0 +1,114 @@ +/** + * Display-safe projection of one Agent turn. + * + * The client never turns status text, model reasoning, commands, arguments, + * URLs, paths, or tool output into a step. Steps arrive only through the + * separately validated, adapter-authoritative `agent.execution` envelope. + */ +export type ExecutionProgressStatus = "running" | "completed" | "failed"; +export type ExecutionStepStatus = "running" | "completed" | "failed"; + +export type ExecutionProgressStep = { + id: string; + title: string; + status: ExecutionStepStatus; +}; + +export type ExecutionProgressSummary = { + id: string; + status: ExecutionProgressStatus; + started_at: string; + updated_at: string; + finished_at?: string; + steps: readonly ExecutionProgressStep[]; +}; + +const MAX_SUMMARIES = 20; +const MAX_STEPS = 12; +const ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/; +const VALID_STATUSES = new Set(["running", "completed", "failed"]); +const VALID_STEP_STATUSES = new Set(["running", "completed", "failed"]); + +export class ExecutionProgressState { + private summaries: ExecutionProgressSummary[] = []; + + /** A status boundary starts its exact turn, but deliberately creates no fake step. */ + public begin(id: string, now: string): ExecutionProgressSummary { + const safeID = validID(id) ? id : `execution_${Date.parse(now) || Date.now()}`; + const existing = this.summaries.find(summary => summary.id === safeID); + if (existing) { + existing.updated_at = now; + return copy(existing); + } + const summary: ExecutionProgressSummary = { id: safeID, status: "running", started_at: now, updated_at: now, steps: [] }; + this.summaries.push(summary); + this.trim(); + return copy(summary); + } + + /** Applies a protocol-validated trace for its exact execution id. */ + public applyTrace(id: string, status: ExecutionProgressStatus, steps: readonly ExecutionProgressStep[], now: string): ExecutionProgressSummary | undefined { + const summary = this.summaries.find(entry => entry.id === id); + if (!summary) return undefined; + summary.status = status; + summary.updated_at = now; + summary.finished_at = status === "running" ? undefined : now; + summary.steps = sanitizeSteps(steps); + return copy(summary); + } + + /** Ends the one active turn without inventing an operation description. */ + public finishActive(status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined { + const active = this.summaries.find(summary => summary.status === "running"); + if (!active) return undefined; + active.status = status; + active.updated_at = now; + active.finished_at = now; + return copy(active); + } + + /** Ends only the matching running execution; never steals another turn's card. */ + public finish(id: string | undefined, status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined { + if (!id) return this.finishActive(status, now); + const summary = this.summaries.find(entry => entry.id === id && entry.status === "running"); + if (!summary) return undefined; + summary.status = status; + summary.updated_at = now; + summary.finished_at = now; + return copy(summary); + } + + public restore(summaries: readonly ExecutionProgressSummary[]): void { + this.summaries = summaries.flatMap(summary => valid(summary) ? [copy(summary)] : []).slice(-MAX_SUMMARIES); + } + + public snapshot(): readonly ExecutionProgressSummary[] { return this.summaries.map(copy); } + + private trim(): void { if (this.summaries.length > MAX_SUMMARIES) this.summaries.splice(0, this.summaries.length - MAX_SUMMARIES); } +} + +function valid(value: ExecutionProgressSummary): boolean { + return validID(value.id) && VALID_STATUSES.has(value.status) + && validTime(value.started_at) && validTime(value.updated_at) + && (value.finished_at === undefined || validTime(value.finished_at)) + && Array.isArray(value.steps) && value.steps.length <= MAX_STEPS + && value.steps.every(step => validStep(step)); +} + +function validID(value: unknown): value is string { return typeof value === "string" && ID.test(value); } +function validTime(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); } +function validStep(value: unknown): value is ExecutionProgressStep { + return Boolean(value && typeof value === "object" && !Array.isArray(value) + && validID((value as ExecutionProgressStep).id) + && typeof (value as ExecutionProgressStep).title === "string" + && (value as ExecutionProgressStep).title.trim().length > 0 + && (value as ExecutionProgressStep).title.length <= 160 + && VALID_STEP_STATUSES.has((value as ExecutionProgressStep).status)); +} +function sanitizeSteps(steps: readonly ExecutionProgressStep[]): ExecutionProgressStep[] { + const seen = new Set(); + return steps.flatMap(step => validStep(step) && !seen.has(step.id) ? (seen.add(step.id), [{ id: step.id, title: step.title.trim(), status: step.status }]) : []).slice(0, MAX_STEPS); +} +function copy(summary: ExecutionProgressSummary): ExecutionProgressSummary { + return { ...summary, steps: summary.steps.map(step => ({ ...step })) }; +} diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/runtime/trusted-dom-renderers.ts index 2a869e4..ad5086d 100644 --- a/tauri/src/runtime/trusted-dom-renderers.ts +++ b/tauri/src/runtime/trusted-dom-renderers.ts @@ -11,6 +11,7 @@ import type { } from "../protocol"; import type { ToolCallRecord } from "./tool-call-state"; import type { TaskRecord } from "./task-state"; +import type { ExecutionProgressSummary } from "./execution-progress-state"; export type ToolInteractionHandlers = { submit: (callID: string, submission: JsonObject) => boolean; @@ -33,24 +34,33 @@ export type TaskInteractionHandlers = { * Kernel, Tauri bridge, or capability reference. */ export class TrustedDOMRendererContext { - private thinking: HTMLElement | undefined; private readonly userRows = new Map(); private readonly toolCards = new Map(); private readonly taskCards = new Map(); + private readonly executionCards = new Map(); + private readonly agentReplyGroups = new Map(); + private readonly executionSummaries = new Map(); private readonly expirationTimers = new Set(); + private readonly executionTicker: number; public constructor( private readonly messages: HTMLElement, private readonly presence: HTMLElement, private readonly toolInteractions?: ToolInteractionHandlers, private readonly taskInteractions?: TaskInteractionHandlers, - ) {} + ) { + // Status envelopes are sparse. Keep elapsed time honest between them + // without persisting a ticking value or treating it as Agent progress. + this.executionTicker = window.setInterval(() => this.refreshExecutionDurations(), 1_000); + } public reset(): void { - this.thinking = undefined; this.userRows.clear(); this.toolCards.clear(); this.taskCards.clear(); + this.executionCards.clear(); + this.agentReplyGroups.clear(); + this.executionSummaries.clear(); for (const timer of this.expirationTimers) window.clearTimeout(timer); this.expirationTimers.clear(); } @@ -61,16 +71,21 @@ export class TrustedDOMRendererContext { } public renderMarkdown(item: Extract): void { - this.hideThinking(); - this.appendBubble("agent", item.markdown, true); + const row = this.appendBubble("agent", item.markdown, true); + if (item.execution_id) { + row.dataset.executionId = item.execution_id; + this.agentReplyGroups.set(item.execution_id, row); + this.attachExecutionCard(item.execution_id); + } } public renderAgentStatus(item: Extract): void { - if (item.status === "thinking") this.showThinking(item.detail || "正在思考…"); - else { - this.hideThinking(); - this.setPresence(item.detail || item.status || "在线"); - } + // Status detail is never displayed as thought content or an operation. + this.setPresence(item.status === "thinking" ? "正在处理你的请求" : "已同步,等待消息"); + } + + public renderExecutionTrace(_item: Extract): void { + // main.ts projects the validated trace into the existing summary card. } public renderProgress(item: Extract): void { @@ -87,10 +102,52 @@ export class TrustedDOMRendererContext { } public renderError(item: Extract): void { - this.hideThinking(); this.renderSystemMessage(`Agent 错误:${item.message}`); } + /** Renders only protocol-validated public steps; raw tool data never reaches this card. */ + public renderExecutionSummary(summary: ExecutionProgressSummary): void { + this.executionSummaries.set(summary.id, summary); + // A completed reply which performed no publicly summarizable operation + // must not leave a misleading empty ledger frame behind. A later genuine + // trace still recreates and attaches the card under this same reply. + if (summary.status !== "running" && summary.steps.length === 0) { + const empty = this.executionCards.get(summary.id); + empty?.remove(); + this.executionCards.delete(summary.id); + return; + } + const current = this.executionCards.get(summary.id); + if (current) { + this.updateExecutionSummary(current, summary); + return; + } + const card = document.createElement("details"); + card.className = "execution-progress-card"; + card.dataset.executionId = summary.id; + const heading = document.createElement("summary"); + heading.className = "execution-progress-heading"; + const title = document.createElement("strong"); + const duration = document.createElement("small"); + duration.className = "execution-progress-duration"; + heading.append(title, duration); + const steps = document.createElement("ul"); + steps.className = "execution-progress-stages"; + card.append(heading, steps); + this.executionCards.set(summary.id, card); + this.updateExecutionSummary(card, summary); + this.attachExecutionCard(summary.id); + if (!card.isConnected) this.messages.append(card); + this.scrollLatest(); + } + + /** Only terminal summaries survive a page restore; live animation never does. */ + public restoreExecutionSummaries(summaries: readonly ExecutionProgressSummary[]): void { + for (const summary of summaries) { + if (summary.status !== "running") this.renderExecutionSummary(summary); + } + } + public renderToolCall(item: Extract): void { const record = this.toolInteractions?.read(item.call.call_id); if (item.call.tool === "choice" && item.call.action_group) { @@ -181,7 +238,14 @@ export class TrustedDOMRendererContext { meta.className = "meta"; meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`; bubble.append(meta); - row.append(bubble); + if (role === "agent") { + const content = document.createElement("div"); + content.className = "message-agent-content"; + content.append(bubble); + row.append(content); + } else { + row.append(bubble); + } this.messages.append(row); this.scrollLatest(); return row; @@ -453,16 +517,42 @@ export class TrustedDOMRendererContext { for (const control of entry.controls) control.disabled = true; } - private showThinking(detail: string): void { - this.thinking?.remove(); - this.thinking = this.appendBubble("agent", detail); - this.thinking.classList.add("thinking"); - this.setPresence("正在思考"); + private updateExecutionSummary(card: HTMLDetailsElement, summary: ExecutionProgressSummary): void { + card.dataset.status = summary.status; + card.open = summary.status === "running"; + const elapsed = executionElapsed(summary); + const heading = card.querySelector(".execution-progress-heading strong"); + const duration = card.querySelector(".execution-progress-duration"); + const steps = card.querySelector(".execution-progress-stages"); + if (!heading || !duration || !steps) return; + heading.textContent = summary.status === "running" + ? "正在执行操作" + : summary.status === "completed" + ? "已完成处理 · 查看过程摘要" + : "处理未完成 · 查看过程摘要"; + duration.textContent = `${elapsed} 秒`; + steps.replaceChildren(...summary.steps.map(step => { + const row = document.createElement("li"); + row.dataset.status = step.status; + row.textContent = `${step.status === "running" ? "◌" : step.status === "completed" ? "✓" : "×"} ${step.title}`; + return row; + })); } - private hideThinking(): void { - this.thinking?.remove(); - this.thinking = undefined; + /** Moves a turn's safe ledger card under its matching final Agent reply. */ + private attachExecutionCard(executionID: string): void { + const card = this.executionCards.get(executionID); + const reply = this.agentReplyGroups.get(executionID); + const container = reply?.querySelector(".message-agent-content"); + if (card && container && card.parentElement !== container) container.append(card); + } + + private refreshExecutionDurations(): void { + for (const [id, summary] of this.executionSummaries) { + if (summary.status !== "running") continue; + const card = this.executionCards.get(id); + if (card) this.updateExecutionSummary(card, summary); + } } private scrollLatest(): void { @@ -470,6 +560,12 @@ export class TrustedDOMRendererContext { } } +function executionElapsed(summary: ExecutionProgressSummary): number { + const end = summary.status === "running" ? Date.now() : Date.parse(summary.finished_at ?? summary.updated_at); + const start = Date.parse(summary.started_at); + return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1_000)) : 0; +} + function deliveryLabel(delivery: DeliveryState): string { if (delivery.status === "local_pending") return "发送中"; if (delivery.status === "failed") return "发送失败";