feat: add trusted confirm and input forms
This commit is contained in:
@@ -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 功能。
|
||||
|
||||
+27
-2
@@ -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<TrustedDOMRendererContext>();
|
||||
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
|
||||
@@ -163,6 +183,9 @@ async function syncLoop(): Promise<void> {
|
||||
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 @@ $<HTMLFormElement>("#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;
|
||||
|
||||
@@ -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<ToolCallKind>(["choice", "confirm", "input"]);
|
||||
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
|
||||
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 {
|
||||
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<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 {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, JsonObject>>;
|
||||
};
|
||||
|
||||
type PersistedConversation = {
|
||||
version: 3;
|
||||
version: 4;
|
||||
cursor: number;
|
||||
items: StoredConversationItem[];
|
||||
outbox: OutboxEntry[];
|
||||
tool_calls: ToolCallRecord[];
|
||||
tool_drafts: Record<string, JsonObject>;
|
||||
};
|
||||
|
||||
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<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" })]);
|
||||
});
|
||||
|
||||
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" })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" } } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 {
|
||||
return {
|
||||
...record,
|
||||
|
||||
@@ -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<string, HTMLElement>();
|
||||
private readonly expirationTimers = new Set<number>();
|
||||
|
||||
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<ConversationItem, { kind: "user-message" }>): void {
|
||||
@@ -58,11 +73,20 @@ export class TrustedDOMRendererContext {
|
||||
}
|
||||
|
||||
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) {
|
||||
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<ConversationItem, { kind: "tool-result" }>): 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<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) {
|
||||
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<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 {
|
||||
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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user