diff --git a/tauri/README.md b/tauri/README.md index 3f0687f..e5bf9ca 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -62,6 +62,12 @@ M0-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1 提交仅写入 Interaction Kernel 的 `{ action_ids: [...] }` submission 快照;此阶段没有网络请求、outbox、`tool.result` 回传、刷新恢复或 Hermes Adapter 改动。它们分别留给后续 M1 任务。`npm test` 当前为 3 个文件 / 15 个测试通过,`npm run build` 通过。 +### M1-03 Confirm 与 Input/Form(已完成) + +`confirm` call 的 `data.confirm` 只接受有限长度的确认/取消文案;`input` call 的 `data.form` 只接受 1~8 个具有唯一 id 的 `text`、`textarea` 或 `number` 字段,并可限定必填、placeholder、长度与正则。协议层拒绝未知字段类型、空/重复字段、非法正则和超限 schema。所有 UI 均为 Host 自己创建的原生 DOM,不解释 Agent HTML。 + +确认、取消、表单本地校验、提交与超时都更新同一 `call_id` 的状态,并在终态禁用控件。Conversation Store v4 额外持久化 Tool Call projection 及未提交表单草稿:刷新或重新登录时,待处理卡片与草稿会恢复;离线期间到期的 call 会恢复为不可操作的 expired。提交仍不发送网络请求或 `tool.result`,也不处理 Agent echo 或 Hermes Adapter。`npm test` 当前为 3 个文件 / 18 个测试通过,`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 4125c50..6d62fb9 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -94,8 +94,28 @@ function loginFailureMessage(reason: unknown): string { return message || "登录失败,请稍后重试。"; } -const rendererContext = new TrustedDOMRendererContext(messages, presence, (callID, submission) => { - return interactionKernel.submitToolCall(callID, new Date().toISOString(), submission).disposition === "accepted"; +const rendererContext = new TrustedDOMRendererContext(messages, presence, { + submit(callID, submission) { + const transition = interactionKernel.submitToolCall(callID, new Date().toISOString(), submission); + conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); + if (transition.disposition === "accepted") conversationStore.clearToolDraft(callID); + return transition.disposition === "accepted"; + }, + cancel(callID, reason) { + const transition = interactionKernel.cancelToolCall(callID, reason, new Date().toISOString()); + conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); + if (transition.disposition === "accepted") conversationStore.clearToolDraft(callID); + return transition.disposition === "accepted"; + }, + expire(callID) { + const transition = interactionKernel.expireToolCall(callID, new Date().toISOString()); + conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); + return transition.disposition === "accepted"; + }, + read(callID) { return interactionKernel.snapshot().tool_calls.find(record => record.call_id === callID); }, + loadDraft(callID) { return conversationStore.getToolDraft(callID); }, + saveDraft(callID, values) { conversationStore.saveToolDraft(callID, values); }, + clearDraft(callID) { conversationStore.clearToolDraft(callID); }, }); const rendererRegistry = new RendererRegistry(); rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); @@ -163,6 +183,9 @@ async function syncLoop(): Promise { source: "sync", transport_id: String(message.message_seq), }); + if (result.items.some(item => item.kind === "tool-call" || item.kind === "tool-result" || item.kind === "tool-cancel")) { + conversationStore.replaceToolCalls(result.snapshot.tool_calls); + } for (const item of result.items) { if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item); } @@ -209,6 +232,8 @@ $("#login-form").addEventListener("submit", async event => { transport = candidateTransport; 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()); + conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); 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 e310f9b..e304148 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/protocol.ts @@ -53,6 +53,30 @@ export type ActionGroup = { actions: readonly ActionGroupAction[]; }; +export type ConfirmDefinition = { + approve_label: string; + cancel_label: string; +}; + +export type InputFieldType = "text" | "textarea" | "number"; + +export type InputField = { + id: string; + label: string; + type: InputFieldType; + required: boolean; + placeholder?: string; + min_length?: number; + max_length?: number; + pattern?: string; +}; + +export type InputForm = { + fields: readonly InputField[]; + submit_label: string; + cancel_label: string; +}; + export type ToolCallRequest = { call_id: string; tool: ToolCallKind; @@ -62,6 +86,10 @@ export type ToolCallRequest = { data: JsonObject; /** Present only for the trusted `choice` renderer introduced in M1-02. */ action_group?: ActionGroup; + /** Present only for the trusted `confirm` renderer introduced in M1-03. */ + confirm?: ConfirmDefinition; + /** Present only for the trusted `input` renderer introduced in M1-03. */ + form?: InputForm; }; export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired"; @@ -283,6 +311,7 @@ function validAppCallPayload(payload: JsonObject): boolean { 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"]); function optionalTimestamp(value: unknown): string | undefined { return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined; @@ -307,6 +336,59 @@ function parseActionGroup(value: unknown): ActionGroup | undefined { return { mode, actions }; } +function boundedLabel(value: unknown, fallback: string): string | undefined { + const label = value === undefined ? fallback : text(value).trim(); + return label && label.length <= 80 ? label : undefined; +} + +function parseConfirm(value: unknown): ConfirmDefinition | undefined { + const candidate = object(value); + if (!candidate) return undefined; + const approve = boundedLabel(candidate.approve_label, "确认"); + const cancel = boundedLabel(candidate.cancel_label, "取消"); + return approve && cancel ? { approve_label: approve, cancel_label: cancel } : undefined; +} + +function wholeNumber(value: unknown, maximum: number): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : undefined; +} + +function parseInputForm(value: unknown): InputForm | undefined { + const candidate = object(value); + if (!candidate || !Array.isArray(candidate.fields) || candidate.fields.length < 1 || candidate.fields.length > 8) return undefined; + const seen = new Set(); + const fields: InputField[] = []; + for (const value of candidate.fields) { + const field = object(value); + const id = field && identifier(field.id); + const label = field && text(field.label).trim(); + const type = field ? text(field.type) : ""; + const placeholder = field ? text(field.placeholder).trim() : ""; + const minLength = field?.min_length === undefined ? undefined : wholeNumber(field.min_length, 10_000); + const maxLength = field?.max_length === undefined ? undefined : wholeNumber(field.max_length, 10_000); + const pattern = field ? text(field.pattern) : ""; + if (!id || !label || label.length > 120 || !INPUT_FIELD_TYPES.has(type as InputFieldType) || seen.has(id) || placeholder.length > 200 || pattern.length > 256) return undefined; + if ((field?.min_length !== undefined && minLength === undefined) || (field?.max_length !== undefined && maxLength === undefined) || (minLength !== undefined && maxLength !== undefined && minLength > maxLength)) return undefined; + if (pattern) { + try { new RegExp(pattern, "u"); } catch { return undefined; } + } + seen.add(id); + fields.push({ + id, + label, + type: type as InputFieldType, + required: field?.required === true, + ...(placeholder ? { placeholder } : {}), + ...(minLength !== undefined ? { min_length: minLength } : {}), + ...(maxLength !== undefined ? { max_length: maxLength } : {}), + ...(pattern ? { pattern } : {}), + }); + } + const submit = boundedLabel(candidate.submit_label, "提交"); + const cancel = boundedLabel(candidate.cancel_label, "取消"); + return submit && cancel ? { fields, submit_label: submit, cancel_label: cancel } : undefined; +} + function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { const callID = identifier(payload.call_id); const tool = text(payload.tool) as ToolCallKind; @@ -316,7 +398,11 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { const data = payload.data === undefined ? {} : object(payload.data); if (!data) return undefined; const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined; + const confirm = tool === "confirm" ? parseConfirm(data.confirm) : undefined; + const form = tool === "input" ? parseInputForm(data.form) : undefined; if (tool === "choice" && !actionGroup) return undefined; + if (tool === "confirm" && !confirm) return undefined; + if (tool === "input" && !form) return undefined; return { call_id: callID, tool, @@ -325,6 +411,8 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { ...(expiresAt ? { expires_at: expiresAt } : {}), data, ...(actionGroup ? { action_group: actionGroup } : {}), + ...(confirm ? { confirm } : {}), + ...(form ? { form } : {}), }; } diff --git a/tauri/src/runtime/conversation-store.test.ts b/tauri/src/runtime/conversation-store.test.ts index 14acb38..e2c1b89 100644 --- a/tauri/src/runtime/conversation-store.test.ts +++ b/tauri/src/runtime/conversation-store.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { ToolCallRecord } from "./tool-call-state"; import { ConversationStore } from "./conversation-store"; class MemoryStorage implements Storage { @@ -70,4 +71,30 @@ describe("ConversationStore", () => { expect(store.snapshot()).toMatchObject({ cursor: 42 }); expect(store.snapshot().items).toHaveLength(1); }); + + it("persists Tool Call state and an unsubmitted form draft per conversation", () => { + const storage = new MemoryStorage(); + const state: ToolCallRecord = { + call_id: "call_form_001", + conversation_id: conversationID, + request: { + call_id: "call_form_001", + tool: "input", + title: "Deploy", + prompt: "Provide a version.", + data: { form: {} }, + }, + status: "pending", + created_at: createdAt, + updated_at: createdAt, + }; + const first = new ConversationStore(storage); + first.open(conversationID); + first.replaceToolCalls([state]); + first.saveToolDraft("call_form_001", { version: "1.2.3" }); + + const reopened = new ConversationStore(storage).open(conversationID); + 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" } }); + }); }); diff --git a/tauri/src/runtime/conversation-store.ts b/tauri/src/runtime/conversation-store.ts index cdce253..8465452 100644 --- a/tauri/src/runtime/conversation-store.ts +++ b/tauri/src/runtime/conversation-store.ts @@ -1,4 +1,5 @@ -import type { ConversationItem, DeliveryState } from "../protocol"; +import type { ConversationItem, DeliveryState, JsonObject } from "../protocol"; +import type { ToolCallRecord } from "./tool-call-state"; export type StoredConversationItem = { local_id: string; @@ -18,13 +19,17 @@ export type ConversationSnapshot = { cursor: number; items: readonly StoredConversationItem[]; outbox: readonly OutboxEntry[]; + tool_calls: readonly ToolCallRecord[]; + tool_drafts: Readonly>; }; type PersistedConversation = { - version: 3; + version: 4; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; + tool_calls: ToolCallRecord[]; + tool_drafts: Record; }; const STORE_PREFIX = "lineup.conversation.v1"; @@ -32,13 +37,14 @@ const STORE_PREFIX = "lineup.conversation.v1"; /** * Conversation-local state and its browser persistence adapter. * - * It stores only validated ConversationItems and outgoing plaintext awaiting - * transport acknowledgement. It has no DOM, renderer, HTTP, or IM dependency. + * It stores only validated ConversationItems, Tool Call cache/drafts, and + * outgoing plaintext awaiting transport acknowledgement. It has no DOM, + * renderer, HTTP, or IM dependency. * A native Store can implement the same snapshot semantics in M4 hosts. */ export class ConversationStore { private activeConversationID: string | undefined; - private state: PersistedConversation = { version: 3, cursor: 0, items: [], outbox: [] }; + private state: PersistedConversation = { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} }; public constructor(private readonly storage: Storage) {} @@ -50,7 +56,7 @@ export class ConversationStore { public close(): void { this.activeConversationID = undefined; - this.state = { version: 3, cursor: 0, items: [], outbox: [] }; + this.state = { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} }; } public snapshot(): ConversationSnapshot { @@ -59,9 +65,33 @@ export class ConversationStore { cursor: this.state.cursor, items: this.state.items.map(item => ({ ...item })), outbox: this.state.outbox.map(entry => ({ ...entry })), + tool_calls: clone(this.state.tool_calls), + tool_drafts: clone(this.state.tool_drafts), }; } + /** Replaces only the current conversation's validated Kernel Tool Call projection. */ + public replaceToolCalls(records: readonly ToolCallRecord[]): void { + this.state.tool_calls = records.map(record => clone(record)); + this.persist(); + } + + public getToolDraft(callID: string): JsonObject | undefined { + const draft = this.state.tool_drafts[callID]; + return draft ? clone(draft) : undefined; + } + + public saveToolDraft(callID: string, values: JsonObject): void { + this.state.tool_drafts[callID] = clone(values); + this.persist(); + } + + public clearToolDraft(callID: string): void { + if (!(callID in this.state.tool_drafts)) return; + delete this.state.tool_drafts[callID]; + this.persist(); + } + public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem { const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const stored: StoredConversationItem = { @@ -157,23 +187,30 @@ export class ConversationStore { private load(conversationID: string): PersistedConversation { try { const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { version: 3, cursor: 0, items: [], outbox: [] }; - const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown }; - if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { - return { version: 3, cursor: 0, items: [], outbox: [] }; + 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") { + return emptyConversation(); } - if (candidate.version !== 3) { + if (candidate.version !== 4) { // 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 v3. - return { version: 3, cursor: 0, items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] }; + // 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: {} }; } - return { version: 3, cursor: Math.max(0, candidate.cursor), items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] }; + return { + version: 4, + 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 : {}, + }; } catch { - return { version: 3, cursor: 0, items: [], outbox: [] }; + return emptyConversation(); } } @@ -190,3 +227,16 @@ export class ConversationStore { return this.activeConversationID; } } + +function emptyConversation(): PersistedConversation { + return { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} }; +} + +function isDraftMap(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + && Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft)); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/tauri/src/runtime/interaction-kernel.test.ts b/tauri/src/runtime/interaction-kernel.test.ts index 3fc88b6..854c0c8 100644 --- a/tauri/src/runtime/interaction-kernel.test.ts +++ b/tauri/src/runtime/interaction-kernel.test.ts @@ -158,4 +158,24 @@ describe("InteractionKernel", () => { })]); expect(duplicateActionID.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]); }); + + it("accepts trusted confirm and input schemas while rejecting unsafe form fields", () => { + const kernel = new InteractionKernel(); + const confirm = kernel.ingest({ + raw: envelope({ id: "evt_confirm", type: "lineup.v1.tool.call", payload: { call_id: "call_confirm", tool: "confirm", title: "Continue?", prompt: "This can be cancelled.", data: { confirm: { approve_label: "Continue", cancel_label: "Stop" } } } }), + source: "sync", + }); + const form = kernel.ingest({ + raw: envelope({ id: "evt_input", type: "lineup.v1.tool.call", payload: { call_id: "call_input", tool: "input", title: "Release", prompt: "Version and notes", data: { form: { fields: [{ id: "version", label: "Version", type: "text", required: true, pattern: "^v\\d+\\.\\d+\\.\\d+$" }, { id: "notes", label: "Notes", type: "textarea", max_length: 200 }], submit_label: "Create", cancel_label: "Cancel" } } } }), + source: "sync", + }); + const invalid = kernel.ingest({ + raw: envelope({ id: "evt_input_invalid", type: "lineup.v1.tool.call", payload: { call_id: "call_input_invalid", tool: "input", title: "Bad", prompt: "Bad", data: { form: { fields: [{ id: "bad", label: "Bad", type: "file" }] } } } }), + source: "sync", + }); + + expect(confirm.items).toEqual([expect.objectContaining({ kind: "tool-call", call: expect.objectContaining({ confirm: { approve_label: "Continue", cancel_label: "Stop" } }) })]); + 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" })]); + }); }); diff --git a/tauri/src/runtime/interaction-kernel.ts b/tauri/src/runtime/interaction-kernel.ts index 016b82d..8fb7d04 100644 --- a/tauri/src/runtime/interaction-kernel.ts +++ b/tauri/src/runtime/interaction-kernel.ts @@ -78,6 +78,18 @@ export class InteractionKernel { return this.toolCalls.complete({ call_id: callID, status, result, ...(error ? { error } : {}) }, updatedAt); } + public cancelToolCall(callID: string, reason: string, updatedAt: string) { + return this.toolCalls.cancel({ call_id: callID, reason }, updatedAt); + } + + public expireToolCall(callID: string, updatedAt: string) { + return this.toolCalls.expireCall(callID, updatedAt); + } + + public restoreToolCalls(records: readonly ToolCallRecord[], now: string): void { + this.toolCalls.restore(records, now); + } + /** Clear only the in-memory projection when the active conversation changes. */ public reset(): void { this.seenIDs.clear(); diff --git a/tauri/src/runtime/tool-call-state.test.ts b/tauri/src/runtime/tool-call-state.test.ts index 7694770..5116406 100644 --- a/tauri/src/runtime/tool-call-state.test.ts +++ b/tauri/src/runtime/tool-call-state.test.ts @@ -67,4 +67,19 @@ describe("ToolCallStateMachine", () => { ]); expect(state.get("no_expiry")?.status).toBe("pending"); }); + + it("restores an active record but expires it if its deadline passed while away", () => { + const state = new ToolCallStateMachine(); + state.restore([{ + call_id: "call_restore_001", + conversation_id: "conversation_001", + request: request({ call_id: "call_restore_001", expires_at: "2026-08-03T08:05:00.000Z" }), + status: "submitted", + submission: { values: { version: "1.2.3" } }, + created_at: RECEIVED_AT, + updated_at: "2026-08-03T08:01:00.000Z", + }], "2026-08-03T08:06:00.000Z"); + + expect(state.get("call_restore_001")).toMatchObject({ status: "expired", submission: { values: { version: "1.2.3" } } }); + }); }); diff --git a/tauri/src/runtime/tool-call-state.ts b/tauri/src/runtime/tool-call-state.ts index 3bae31e..0f948f5 100644 --- a/tauri/src/runtime/tool-call-state.ts +++ b/tauri/src/runtime/tool-call-state.ts @@ -49,6 +49,12 @@ export class ToolCallStateMachine { } public submit(callID: string, updatedAt: string, submission: JsonObject = {}): ToolCallTransition { + const record = this.calls.get(callID); + if (record?.request.expires_at && Date.parse(record.request.expires_at) <= Date.parse(updatedAt) && (record.status === "pending" || record.status === "submitted")) { + record.status = "expired"; + record.updated_at = updatedAt; + return { disposition: "invalid_transition", record: copy(record) }; + } return this.transition(callID, updatedAt, ["pending"], record => { record.status = "submitted"; record.submission = { ...submission }; @@ -70,6 +76,17 @@ export class ToolCallStateMachine { }); } + public expireCall(callID: string, now: string): ToolCallTransition { + const record = this.calls.get(callID); + if (!record) return { disposition: "missing" }; + if ((record.status !== "pending" && record.status !== "submitted") || !record.request.expires_at || Date.parse(record.request.expires_at) > Date.parse(now)) { + return { disposition: "invalid_transition", record: copy(record) }; + } + record.status = "expired"; + record.updated_at = now; + return { disposition: "accepted", record: copy(record) }; + } + public expire(now: string): readonly ToolCallRecord[] { const changed: ToolCallRecord[] = []; const nowTime = Date.parse(now); @@ -96,6 +113,20 @@ export class ToolCallStateMachine { this.calls.clear(); } + /** Restores trusted local cache records for the current conversation only. */ + public restore(records: readonly ToolCallRecord[], now: string): void { + this.calls.clear(); + for (const record of records) { + if (!isRestoreableRecord(record) || this.calls.has(record.call_id)) continue; + const restored = copy(record); + if ((restored.status === "pending" || restored.status === "submitted") && restored.request.expires_at && Date.parse(restored.request.expires_at) <= Date.parse(now)) { + restored.status = "expired"; + restored.updated_at = now; + } + this.calls.set(restored.call_id, restored); + } + } + private transition( callID: string, updatedAt: string, @@ -111,6 +142,15 @@ export class ToolCallStateMachine { } } +function isRestoreableRecord(value: unknown): value is ToolCallRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Partial; + if (typeof record.call_id !== "string" || !record.call_id || typeof record.conversation_id !== "string" || !record.conversation_id || !record.request || typeof record.request !== "object") return false; + if (record.request.call_id !== record.call_id || !["choice", "confirm", "input"].includes(record.request.tool) || !record.request.data || typeof record.request.data !== "object" || Array.isArray(record.request.data)) return false; + if (!["pending", "submitted", "completed", "failed", "cancelled", "expired"].includes(record.status ?? "") || typeof record.created_at !== "string" || typeof record.updated_at !== "string") return false; + return !Number.isNaN(Date.parse(record.created_at)) && !Number.isNaN(Date.parse(record.updated_at)); +} + function copy(record: ToolCallRecord): ToolCallRecord { return { ...record, diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/runtime/trusted-dom-renderers.ts index 697fdf1..05537e1 100644 --- a/tauri/src/runtime/trusted-dom-renderers.ts +++ b/tauri/src/runtime/trusted-dom-renderers.ts @@ -2,12 +2,24 @@ import DOMPurify from "dompurify"; import { marked } from "marked"; import type { ActionGroup, + ConfirmDefinition, ConversationItem, DeliveryState, + InputForm, JsonObject, + ToolCallRequest, } from "../protocol"; +import type { ToolCallRecord } from "./tool-call-state"; -export type ToolActionSubmitter = (callID: string, submission: JsonObject) => boolean; +export type ToolInteractionHandlers = { + submit: (callID: string, submission: JsonObject) => boolean; + cancel: (callID: string, reason: string) => boolean; + expire: (callID: string) => boolean; + read: (callID: string) => ToolCallRecord | undefined; + loadDraft: (callID: string) => JsonObject | undefined; + saveDraft: (callID: string, values: JsonObject) => void; + clearDraft: (callID: string) => void; +}; /** * Trusted presentation context for the first-party chat shell. It owns only @@ -17,16 +29,19 @@ export type ToolActionSubmitter = (callID: string, submission: JsonObject) => bo export class TrustedDOMRendererContext { private thinking: HTMLElement | undefined; private readonly userRows = new Map(); + private readonly expirationTimers = new Set(); public constructor( private readonly messages: HTMLElement, private readonly presence: HTMLElement, - private readonly submitToolAction?: ToolActionSubmitter, + private readonly toolInteractions?: ToolInteractionHandlers, ) {} public reset(): void { this.thinking = undefined; this.userRows.clear(); + for (const timer of this.expirationTimers) window.clearTimeout(timer); + this.expirationTimers.clear(); } public renderUserMessage(item: Extract): void { @@ -58,11 +73,20 @@ export class TrustedDOMRendererContext { } public renderToolCall(item: Extract): void { + const record = this.toolInteractions?.read(item.call.call_id); if (item.call.tool === "choice" && item.call.action_group) { - this.renderActionGroup(item.call.call_id, item.call.title, item.call.prompt, item.call.action_group, item.call.expires_at); + this.renderActionGroup(item.call, item.call.action_group, record); return; } - this.renderSystemMessage(`收到 ${item.call.tool} 标准交互请求;交互卡片将在 M1-03 接入。`); + if (item.call.tool === "confirm" && item.call.confirm) { + this.renderConfirmCard(item.call, item.call.confirm, record); + return; + } + if (item.call.tool === "input" && item.call.form) { + this.renderInputForm(item.call, item.call.form, record); + return; + } + this.renderSystemMessage("收到无法安全展示的标准交互请求。"); } public renderToolResult(item: Extract): void { @@ -140,24 +164,23 @@ export class TrustedDOMRendererContext { return row; } - private renderActionGroup(callID: string, title: string, prompt: string, group: ActionGroup, expiresAt?: string): void { + private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void { const card = document.createElement("article"); card.className = "action-group-card"; - card.dataset.callId = callID; + card.dataset.callId = call.call_id; const heading = document.createElement("strong"); - heading.textContent = title || "请选择"; + heading.textContent = call.title || "请选择"; const detail = document.createElement("p"); - detail.textContent = prompt; + detail.textContent = call.prompt; const actions = document.createElement("div"); actions.className = `action-group-actions ${group.mode}`; const status = document.createElement("small"); status.className = "action-group-status"; - const expired = expiresAt !== undefined && Date.parse(expiresAt) <= Date.now(); - status.textContent = expired ? "该交互已过期" : "等待你的选择"; + const locked = this.applyCallStatus(card, status, call, record); const buttons: HTMLButtonElement[] = []; const selected = new Set(); const submit = (actionIDs: readonly string[]) => { - const accepted = this.submitToolAction?.(callID, { action_ids: [...actionIDs] }) ?? false; + const accepted = this.toolInteractions?.submit(call.call_id, { action_ids: [...actionIDs] }) ?? false; if (!accepted) { status.textContent = "该交互已结束或已提交"; return; @@ -173,7 +196,7 @@ export class TrustedDOMRendererContext { button.className = "action-group-action"; button.textContent = action.label; button.title = action.description ?? action.label; - button.disabled = expired; + button.disabled = locked; button.addEventListener("click", () => { if (group.mode !== "multi-choice") { submit([action.id]); @@ -205,11 +228,152 @@ export class TrustedDOMRendererContext { card.append(heading, detail, actions); if (group.mode === "multi-choice") card.append(submitButton); card.append(status); - if (expired) card.dataset.status = "expired"; this.messages.append(card); + this.scheduleExpiry(card, status, call, buttons); this.scrollLatest(); } + private renderConfirmCard(call: ToolCallRequest, definition: ConfirmDefinition, record?: ToolCallRecord): void { + const card = document.createElement("article"); + card.className = "tool-card confirm-card"; + card.dataset.callId = call.call_id; + const heading = document.createElement("strong"); + heading.textContent = call.title || "请确认"; + const detail = document.createElement("p"); + detail.textContent = call.prompt; + const actions = document.createElement("div"); + actions.className = "tool-card-actions"; + const approve = document.createElement("button"); + approve.type = "button"; + approve.textContent = definition.approve_label; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "secondary-action"; + cancel.textContent = definition.cancel_label; + const status = document.createElement("small"); + status.className = "tool-card-status"; + const buttons = [approve, cancel]; + const locked = this.applyCallStatus(card, status, call, record); + for (const button of buttons) button.disabled = locked; + approve.addEventListener("click", () => { + if (!this.toolInteractions?.submit(call.call_id, { confirmed: true })) return this.markInteractionUnavailable(status); + this.markSubmitted(card, status, buttons); + }); + cancel.addEventListener("click", () => { + if (!this.toolInteractions?.cancel(call.call_id, "用户取消确认")) return this.markInteractionUnavailable(status); + this.markCancelled(card, status, buttons); + }); + actions.append(approve, cancel); + card.append(heading, detail, actions, status); + this.messages.append(card); + this.scheduleExpiry(card, status, call, buttons); + this.scrollLatest(); + } + + private renderInputForm(call: ToolCallRequest, form: InputForm, record?: ToolCallRecord): void { + const card = document.createElement("article"); + card.className = "tool-card input-form-card"; + card.dataset.callId = call.call_id; + const heading = document.createElement("strong"); + heading.textContent = call.title || "填写信息"; + const detail = document.createElement("p"); + detail.textContent = call.prompt; + const nativeForm = document.createElement("form"); + nativeForm.noValidate = true; + const errors = document.createElement("p"); + errors.className = "tool-card-error"; + errors.setAttribute("role", "alert"); + const draft = this.toolInteractions?.loadDraft(call.call_id) ?? {}; + const fields = new Map(); + for (const definition of form.fields) { + const label = document.createElement("label"); + label.textContent = definition.label; + const control = definition.type === "textarea" ? document.createElement("textarea") : document.createElement("input"); + if (control instanceof HTMLInputElement) control.type = definition.type === "number" ? "number" : "text"; + control.name = definition.id; + control.placeholder = definition.placeholder ?? ""; + const prior = draft[definition.id]; + control.value = typeof prior === "string" ? prior : ""; + control.addEventListener("input", () => this.toolInteractions?.saveDraft(call.call_id, this.readFormValues(fields))); + fields.set(definition.id, control); + label.append(control); + nativeForm.append(label); + } + const actions = document.createElement("div"); + actions.className = "tool-card-actions"; + const submit = document.createElement("button"); + submit.type = "submit"; + submit.textContent = form.submit_label; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "secondary-action"; + cancel.textContent = form.cancel_label; + const status = document.createElement("small"); + status.className = "tool-card-status"; + const controls = [...fields.values(), submit, cancel]; + const locked = this.applyCallStatus(card, status, call, record); + for (const control of controls) control.disabled = locked; + nativeForm.addEventListener("submit", event => { + event.preventDefault(); + const values = this.readFormValues(fields); + const problem = validateFormValues(form, values); + if (problem) { errors.textContent = problem; return; } + if (!this.toolInteractions?.submit(call.call_id, { values })) return this.markInteractionUnavailable(status); + errors.textContent = ""; + this.markSubmitted(card, status, controls); + }); + cancel.addEventListener("click", () => { + if (!this.toolInteractions?.cancel(call.call_id, "用户取消输入")) return this.markInteractionUnavailable(status); + this.markCancelled(card, status, controls); + }); + actions.append(submit, cancel); + nativeForm.append(errors, actions); + card.append(heading, detail, nativeForm, status); + this.messages.append(card); + this.scheduleExpiry(card, status, call, controls); + this.scrollLatest(); + } + + private applyCallStatus(card: HTMLElement, status: HTMLElement, call: ToolCallRequest, record?: ToolCallRecord): boolean { + const effective = record?.status ?? (call.expires_at && Date.parse(call.expires_at) <= Date.now() ? "expired" : "pending"); + if (effective === "pending") { status.textContent = "等待你的操作"; return false; } + card.dataset.status = effective; + status.textContent = effective === "submitted" ? "已提交,等待 Agent 确认" : effective === "cancelled" ? "已取消" : effective === "expired" ? "该交互已过期" : effective === "completed" ? "已完成" : "处理失败"; + return true; + } + + private scheduleExpiry(card: HTMLElement, status: HTMLElement, call: ToolCallRequest, controls: readonly HTMLButtonElement[] | readonly (HTMLInputElement | HTMLTextAreaElement | HTMLButtonElement)[]): void { + if (!call.expires_at || Date.parse(call.expires_at) <= Date.now()) return; + const timer = window.setTimeout(() => { + this.expirationTimers.delete(timer); + if (!this.toolInteractions?.expire(call.call_id)) return; + card.dataset.status = "expired"; + status.textContent = "该交互已过期"; + for (const control of controls) control.disabled = true; + }, Math.max(0, Date.parse(call.expires_at) - Date.now()) + 1); + this.expirationTimers.add(timer); + } + + private readFormValues(fields: ReadonlyMap): JsonObject { + return Object.fromEntries([...fields].map(([id, control]) => [id, control.value])); + } + + private markSubmitted(card: HTMLElement, status: HTMLElement, controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[]): void { + card.dataset.status = "submitted"; + status.textContent = "已提交,等待 Agent 确认"; + for (const control of controls) control.disabled = true; + } + + private markCancelled(card: HTMLElement, status: HTMLElement, controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[]): void { + card.dataset.status = "cancelled"; + status.textContent = "已取消"; + for (const control of controls) control.disabled = true; + } + + private markInteractionUnavailable(status: HTMLElement): void { + status.textContent = "该交互已结束或已提交"; + } + private showThinking(detail: string): void { this.thinking?.remove(); this.thinking = this.appendBubble("agent", detail); @@ -232,3 +396,17 @@ function deliveryLabel(delivery: DeliveryState): string { if (delivery.status === "failed") return "发送失败"; return "已发送"; } + +function validateFormValues(form: InputForm, values: JsonObject): string | undefined { + for (const field of form.fields) { + const value = values[field.id]; + const text = typeof value === "string" ? value.trim() : ""; + if (field.required && !text) return `请填写“${field.label}”。`; + if (!text) continue; + if (field.min_length !== undefined && text.length < field.min_length) return `“${field.label}”至少需要 ${field.min_length} 个字符。`; + if (field.max_length !== undefined && text.length > field.max_length) return `“${field.label}”最多允许 ${field.max_length} 个字符。`; + if (field.type === "number" && !Number.isFinite(Number(text))) return `“${field.label}”必须是有效数字。`; + if (field.pattern && !(new RegExp(field.pattern, "u")).test(text)) return `“${field.label}”格式不正确。`; + } + return undefined; +} diff --git a/tauri/src/style.css b/tauri/src/style.css index 424d375..8b2ef5b 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{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{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{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{color:#aac5b5}.action-group-card[data-status="submitted"]{border-color:#89c7a6}.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{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{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}}