feat: add trusted confirm and input forms
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user