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
+191 -13
View File
@@ -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;
}