feat(m1-08): stream safe execution summaries
This commit is contained in:
@@ -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); }
|
||||
+45
-2
@@ -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<HTMLDivElement>("#app");
|
||||
if (!app) throw new Error("LineUp host root is missing");
|
||||
@@ -136,6 +139,7 @@ const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
|
||||
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<void> {
|
||||
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 @@ $<HTMLFormElement>("#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"; }
|
||||
|
||||
+35
-4
@@ -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<ActorKind>(["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<ToolCallFinalStatus>(["completed", "failed",
|
||||
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"]);
|
||||
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<string>();
|
||||
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) {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, JsonObject>>;
|
||||
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<string, JsonObject>;
|
||||
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<OutboxEntry>;
|
||||
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<string, JsonObject> {
|
||||
&& 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<ExecutionProgressSummary>;
|
||||
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<string>();
|
||||
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<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<ExecutionProgressStatus>(["running", "completed", "failed"]);
|
||||
const VALID_STEP_STATUSES = new Set<ExecutionStepStatus>(["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<string>();
|
||||
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 })) };
|
||||
}
|
||||
@@ -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<string, HTMLElement>();
|
||||
private readonly toolCards = new Map<string, { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }>();
|
||||
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
|
||||
private readonly executionCards = new Map<string, HTMLDetailsElement>();
|
||||
private readonly agentReplyGroups = new Map<string, HTMLElement>();
|
||||
private readonly executionSummaries = new Map<string, ExecutionProgressSummary>();
|
||||
private readonly expirationTimers = new Set<number>();
|
||||
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<ConversationItem, { kind: "markdown" }>): 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<ConversationItem, { kind: "agent-status" }>): 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<ConversationItem, { kind: "execution-summary" }>): void {
|
||||
// main.ts projects the validated trace into the existing summary card.
|
||||
}
|
||||
|
||||
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
|
||||
@@ -87,10 +102,52 @@ export class TrustedDOMRendererContext {
|
||||
}
|
||||
|
||||
public renderError(item: Extract<ConversationItem, { kind: "error" }>): 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<ConversationItem, { kind: "tool-call" }>): 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);
|
||||
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<HTMLElement>(".execution-progress-heading strong");
|
||||
const duration = card.querySelector<HTMLElement>(".execution-progress-duration");
|
||||
const steps = card.querySelector<HTMLUListElement>(".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<HTMLElement>(".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 "发送失败";
|
||||
|
||||
Reference in New Issue
Block a user