feat: add trusted confirm and input forms

This commit is contained in:
2026-08-03 16:00:19 +08:00
parent bddf15a2d2
commit 9312b09e76
11 changed files with 492 additions and 31 deletions
+6
View File
@@ -62,6 +62,12 @@ M0-01M0-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` 通过。 提交仅写入 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 的改造对象) ### 当前结构债务(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 功能。 `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 功能。
+27 -2
View File
@@ -94,8 +94,28 @@ function loginFailureMessage(reason: unknown): string {
return message || "登录失败,请稍后重试。"; return message || "登录失败,请稍后重试。";
} }
const rendererContext = new TrustedDOMRendererContext(messages, presence, (callID, submission) => { const rendererContext = new TrustedDOMRendererContext(messages, presence, {
return interactionKernel.submitToolCall(callID, new Date().toISOString(), submission).disposition === "accepted"; 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<TrustedDOMRendererContext>(); const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
@@ -163,6 +183,9 @@ async function syncLoop(): Promise<void> {
source: "sync", source: "sync",
transport_id: String(message.message_seq), 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) { for (const item of result.items) {
if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item); if (conversationStore.recordIncoming(item, message.message_seq, new Date().toISOString())) renderIncoming(item);
} }
@@ -209,6 +232,8 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
transport = candidateTransport; 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 }; 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()); const snapshot = conversationStore.open(currentConversationKey());
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
session.lastSeq = snapshot.cursor; session.lastSeq = snapshot.cursor;
messages.replaceChildren(); rendererContext.reset(); messages.replaceChildren(); rendererContext.reset();
loginView.hidden = true; chatView.hidden = false; loginView.hidden = true; chatView.hidden = false;
+88
View File
@@ -53,6 +53,30 @@ export type ActionGroup = {
actions: readonly ActionGroupAction[]; 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 = { export type ToolCallRequest = {
call_id: string; call_id: string;
tool: ToolCallKind; tool: ToolCallKind;
@@ -62,6 +86,10 @@ export type ToolCallRequest = {
data: JsonObject; data: JsonObject;
/** Present only for the trusted `choice` renderer introduced in M1-02. */ /** Present only for the trusted `choice` renderer introduced in M1-02. */
action_group?: ActionGroup; 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"; export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
@@ -283,6 +311,7 @@ function validAppCallPayload(payload: JsonObject): boolean {
const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]); const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]); const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]); const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
function optionalTimestamp(value: unknown): string | undefined { function optionalTimestamp(value: unknown): string | undefined {
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : 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 }; 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<string>();
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 { function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
const callID = identifier(payload.call_id); const callID = identifier(payload.call_id);
const tool = text(payload.tool) as ToolCallKind; 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); const data = payload.data === undefined ? {} : object(payload.data);
if (!data) return undefined; if (!data) return undefined;
const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : 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 === "choice" && !actionGroup) return undefined;
if (tool === "confirm" && !confirm) return undefined;
if (tool === "input" && !form) return undefined;
return { return {
call_id: callID, call_id: callID,
tool, tool,
@@ -325,6 +411,8 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
...(expiresAt ? { expires_at: expiresAt } : {}), ...(expiresAt ? { expires_at: expiresAt } : {}),
data, data,
...(actionGroup ? { action_group: actionGroup } : {}), ...(actionGroup ? { action_group: actionGroup } : {}),
...(confirm ? { confirm } : {}),
...(form ? { form } : {}),
}; };
} }
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ToolCallRecord } from "./tool-call-state";
import { ConversationStore } from "./conversation-store"; import { ConversationStore } from "./conversation-store";
class MemoryStorage implements Storage { class MemoryStorage implements Storage {
@@ -70,4 +71,30 @@ describe("ConversationStore", () => {
expect(store.snapshot()).toMatchObject({ cursor: 42 }); expect(store.snapshot()).toMatchObject({ cursor: 42 });
expect(store.snapshot().items).toHaveLength(1); 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" } });
});
}); });
+65 -15
View File
@@ -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 = { export type StoredConversationItem = {
local_id: string; local_id: string;
@@ -18,13 +19,17 @@ export type ConversationSnapshot = {
cursor: number; cursor: number;
items: readonly StoredConversationItem[]; items: readonly StoredConversationItem[];
outbox: readonly OutboxEntry[]; outbox: readonly OutboxEntry[];
tool_calls: readonly ToolCallRecord[];
tool_drafts: Readonly<Record<string, JsonObject>>;
}; };
type PersistedConversation = { type PersistedConversation = {
version: 3; version: 4;
cursor: number; cursor: number;
items: StoredConversationItem[]; items: StoredConversationItem[];
outbox: OutboxEntry[]; outbox: OutboxEntry[];
tool_calls: ToolCallRecord[];
tool_drafts: Record<string, JsonObject>;
}; };
const STORE_PREFIX = "lineup.conversation.v1"; const STORE_PREFIX = "lineup.conversation.v1";
@@ -32,13 +37,14 @@ const STORE_PREFIX = "lineup.conversation.v1";
/** /**
* Conversation-local state and its browser persistence adapter. * Conversation-local state and its browser persistence adapter.
* *
* It stores only validated ConversationItems and outgoing plaintext awaiting * It stores only validated ConversationItems, Tool Call cache/drafts, and
* transport acknowledgement. It has no DOM, renderer, HTTP, or IM dependency. * 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. * A native Store can implement the same snapshot semantics in M4 hosts.
*/ */
export class ConversationStore { export class ConversationStore {
private activeConversationID: string | undefined; 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) {} public constructor(private readonly storage: Storage) {}
@@ -50,7 +56,7 @@ export class ConversationStore {
public close(): void { public close(): void {
this.activeConversationID = undefined; 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 { public snapshot(): ConversationSnapshot {
@@ -59,9 +65,33 @@ export class ConversationStore {
cursor: this.state.cursor, cursor: this.state.cursor,
items: this.state.items.map(item => ({ ...item })), items: this.state.items.map(item => ({ ...item })),
outbox: this.state.outbox.map(entry => ({ ...entry })), 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 { public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
const stored: StoredConversationItem = { const stored: StoredConversationItem = {
@@ -157,23 +187,30 @@ export class ConversationStore {
private load(conversationID: string): PersistedConversation { private load(conversationID: string): PersistedConversation {
try { try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); 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: [] }; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown }; 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) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { 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 { version: 3, cursor: 0, items: [], outbox: [] }; return emptyConversation();
} }
if (candidate.version !== 3) { if (candidate.version !== 4) {
// v1 advanced its cursor from a send acknowledgement. Some browsers // v1 advanced its cursor from a send acknowledgement. Some browsers
// had already been upgraded to v2 before that correction, retaining a // had already been upgraded to v2 before that correction, retaining a
// high cursor with holes for their own echoes. Preserve every valid // high cursor with holes for their own echoes. Preserve every valid
// local item but replay the server history once to repair those holes; // local item but replay the server history once to repair those holes;
// the next persist upgrades this record to v3. // the next persist upgrades this record to v4.
return { version: 3, cursor: 0, items: candidate.items as StoredConversationItem[], outbox: candidate.outbox as OutboxEntry[] }; 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 { } catch {
return { version: 3, cursor: 0, items: [], outbox: [] }; return emptyConversation();
} }
} }
@@ -190,3 +227,16 @@ export class ConversationStore {
return this.activeConversationID; return this.activeConversationID;
} }
} }
function emptyConversation(): PersistedConversation {
return { version: 4, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {} };
}
function isDraftMap(value: unknown): value is Record<string, JsonObject> {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft));
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
@@ -158,4 +158,24 @@ describe("InteractionKernel", () => {
})]); })]);
expect(duplicateActionID.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]); 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" })]);
});
}); });
+12
View File
@@ -78,6 +78,18 @@ export class InteractionKernel {
return this.toolCalls.complete({ call_id: callID, status, result, ...(error ? { error } : {}) }, updatedAt); 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. */ /** Clear only the in-memory projection when the active conversation changes. */
public reset(): void { public reset(): void {
this.seenIDs.clear(); this.seenIDs.clear();
+15
View File
@@ -67,4 +67,19 @@ describe("ToolCallStateMachine", () => {
]); ]);
expect(state.get("no_expiry")?.status).toBe("pending"); 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" } } });
});
}); });
+40
View File
@@ -49,6 +49,12 @@ export class ToolCallStateMachine {
} }
public submit(callID: string, updatedAt: string, submission: JsonObject = {}): ToolCallTransition { 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 => { return this.transition(callID, updatedAt, ["pending"], record => {
record.status = "submitted"; record.status = "submitted";
record.submission = { ...submission }; 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[] { public expire(now: string): readonly ToolCallRecord[] {
const changed: ToolCallRecord[] = []; const changed: ToolCallRecord[] = [];
const nowTime = Date.parse(now); const nowTime = Date.parse(now);
@@ -96,6 +113,20 @@ export class ToolCallStateMachine {
this.calls.clear(); 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( private transition(
callID: string, callID: string,
updatedAt: 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<ToolCallRecord>;
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 { function copy(record: ToolCallRecord): ToolCallRecord {
return { return {
...record, ...record,
+191 -13
View File
@@ -2,12 +2,24 @@ import DOMPurify from "dompurify";
import { marked } from "marked"; import { marked } from "marked";
import type { import type {
ActionGroup, ActionGroup,
ConfirmDefinition,
ConversationItem, ConversationItem,
DeliveryState, DeliveryState,
InputForm,
JsonObject, JsonObject,
ToolCallRequest,
} from "../protocol"; } 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 * 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 { export class TrustedDOMRendererContext {
private thinking: HTMLElement | undefined; private thinking: HTMLElement | undefined;
private readonly userRows = new Map<string, HTMLElement>(); private readonly userRows = new Map<string, HTMLElement>();
private readonly expirationTimers = new Set<number>();
public constructor( public constructor(
private readonly messages: HTMLElement, private readonly messages: HTMLElement,
private readonly presence: HTMLElement, private readonly presence: HTMLElement,
private readonly submitToolAction?: ToolActionSubmitter, private readonly toolInteractions?: ToolInteractionHandlers,
) {} ) {}
public reset(): void { public reset(): void {
this.thinking = undefined; this.thinking = undefined;
this.userRows.clear(); this.userRows.clear();
for (const timer of this.expirationTimers) window.clearTimeout(timer);
this.expirationTimers.clear();
} }
public renderUserMessage(item: Extract<ConversationItem, { kind: "user-message" }>): void { public renderUserMessage(item: Extract<ConversationItem, { kind: "user-message" }>): void {
@@ -58,11 +73,20 @@ export class TrustedDOMRendererContext {
} }
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void { 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) { 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; 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<ConversationItem, { kind: "tool-result" }>): void { public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
@@ -140,24 +164,23 @@ export class TrustedDOMRendererContext {
return row; 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"); const card = document.createElement("article");
card.className = "action-group-card"; card.className = "action-group-card";
card.dataset.callId = callID; card.dataset.callId = call.call_id;
const heading = document.createElement("strong"); const heading = document.createElement("strong");
heading.textContent = title || "请选择"; heading.textContent = call.title || "请选择";
const detail = document.createElement("p"); const detail = document.createElement("p");
detail.textContent = prompt; detail.textContent = call.prompt;
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = `action-group-actions ${group.mode}`; actions.className = `action-group-actions ${group.mode}`;
const status = document.createElement("small"); const status = document.createElement("small");
status.className = "action-group-status"; status.className = "action-group-status";
const expired = expiresAt !== undefined && Date.parse(expiresAt) <= Date.now(); const locked = this.applyCallStatus(card, status, call, record);
status.textContent = expired ? "该交互已过期" : "等待你的选择";
const buttons: HTMLButtonElement[] = []; const buttons: HTMLButtonElement[] = [];
const selected = new Set<string>(); const selected = new Set<string>();
const submit = (actionIDs: readonly string[]) => { 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) { if (!accepted) {
status.textContent = "该交互已结束或已提交"; status.textContent = "该交互已结束或已提交";
return; return;
@@ -173,7 +196,7 @@ export class TrustedDOMRendererContext {
button.className = "action-group-action"; button.className = "action-group-action";
button.textContent = action.label; button.textContent = action.label;
button.title = action.description ?? action.label; button.title = action.description ?? action.label;
button.disabled = expired; button.disabled = locked;
button.addEventListener("click", () => { button.addEventListener("click", () => {
if (group.mode !== "multi-choice") { if (group.mode !== "multi-choice") {
submit([action.id]); submit([action.id]);
@@ -205,11 +228,152 @@ export class TrustedDOMRendererContext {
card.append(heading, detail, actions); card.append(heading, detail, actions);
if (group.mode === "multi-choice") card.append(submitButton); if (group.mode === "multi-choice") card.append(submitButton);
card.append(status); card.append(status);
if (expired) card.dataset.status = "expired";
this.messages.append(card); this.messages.append(card);
this.scheduleExpiry(card, status, call, buttons);
this.scrollLatest(); 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<string, HTMLInputElement | HTMLTextAreaElement>();
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<string, HTMLInputElement | HTMLTextAreaElement>): 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 { private showThinking(detail: string): void {
this.thinking?.remove(); this.thinking?.remove();
this.thinking = this.appendBubble("agent", detail); this.thinking = this.appendBubble("agent", detail);
@@ -232,3 +396,17 @@ function deliveryLabel(delivery: DeliveryState): string {
if (delivery.status === "failed") return "发送失败"; if (delivery.status === "failed") return "发送失败";
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;
}
+1 -1
View File
@@ -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}}