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"; import type { TaskRecord } from "./task-state"; 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; }; export type TaskInteractionHandlers = { requestCancel: (operationID: string) => boolean; read: (operationID: string) => TaskRecord | undefined; }; /** * Trusted presentation context for the first-party chat shell. It owns only * DOM nodes and display-local state; it deliberately has no Store, Transport, * Kernel, Tauri bridge, or capability reference. */ export class TrustedDOMRendererContext { private thinking: HTMLElement | undefined; private readonly userRows = new Map(); private readonly taskCards = new Map(); private readonly expirationTimers = new Set(); public constructor( private readonly messages: HTMLElement, private readonly presence: HTMLElement, private readonly toolInteractions?: ToolInteractionHandlers, private readonly taskInteractions?: TaskInteractionHandlers, ) {} public reset(): void { this.thinking = undefined; this.userRows.clear(); this.taskCards.clear(); for (const timer of this.expirationTimers) window.clearTimeout(timer); this.expirationTimers.clear(); } public renderUserMessage(item: Extract): void { const row = this.appendBubble("human", item.text, false, deliveryLabel(item.delivery)); if (item.id) this.userRows.set(item.id, row); } public renderMarkdown(item: Extract): void { this.hideThinking(); this.appendBubble("agent", item.markdown, true); } public renderAgentStatus(item: Extract): void { if (item.status === "thinking") this.showThinking(item.detail || "正在思考…"); else { this.hideThinking(); this.setPresence(item.detail || item.status || "在线"); } } public renderProgress(item: Extract): void { if (item.task) { this.renderTaskProgress(this.taskInteractions?.read(item.task.operation_id) ?? { ...item.task, conversation_id: item.envelope?.conversation_id ?? "", updated_at: new Date().toISOString(), }); return; } const percent = Math.max(0, Math.min(100, item.percent)); this.renderSystemMessage(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`); } public renderError(item: Extract): void { this.hideThinking(); this.renderSystemMessage(`Agent 错误:${item.message}`); } public renderToolCall(item: Extract): void { const record = this.toolInteractions?.read(item.call.call_id); if (item.call.tool === "choice" && item.call.action_group) { this.renderActionGroup(item.call, item.call.action_group, record); return; } if (item.call.tool === "confirm" && item.call.confirm) { this.renderConfirmCard(item.call, item.call.confirm, record); return; } if (item.call.tool === "input" && item.call.form) { this.renderInputForm(item.call, item.call.form, record); return; } this.renderSystemMessage("收到无法安全展示的标准交互请求。"); } public renderToolResult(item: Extract): void { this.renderSystemMessage(`收到工具调用结果(${item.result.call_id} · ${item.result.status})。`); } public renderToolCancel(item: Extract): void { this.renderSystemMessage(`工具调用已取消(${item.cancel.call_id})。`); } public renderSurface(item: Extract): void { this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); } public renderAppCall(_item: Extract): void { this.renderSystemMessage("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。"); } public renderFallback(item: Extract): void { this.renderSystemMessage(item.reason === "unsupported_type" ? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}` : "收到无法安全展示的消息。"); } public renderSystemMessage(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; this.messages.append(item); this.scrollLatest(); } /** Updates the visible local echo without requiring DOM access in App Shell. */ public updateUserDelivery(localID: string, delivery: DeliveryState): void { const meta = this.userRows.get(localID)?.querySelector(".meta"); if (!meta) return; const time = meta.textContent?.split(" · ")[0] ?? ""; meta.textContent = `${time} · ${deliveryLabel(delivery)}`; } public setPresence(value: string): void { this.presence.textContent = value; } private appendBubble(role: "human" | "agent", content: string, markdown = false, delivery = ""): HTMLElement { const row = document.createElement("article"); row.className = `message-row ${role}`; if (role === "agent") { const avatar = document.createElement("span"); avatar.className = "message-avatar"; avatar.textContent = "✦"; row.append(avatar); } const bubble = document.createElement("div"); bubble.className = "bubble"; if (markdown) { bubble.classList.add("markdown"); bubble.innerHTML = DOMPurify.sanitize(marked.parse(content, { gfm: true, breaks: true }) as string, { FORBID_TAGS: ["style", "form", "input", "button"], }); bubble.querySelectorAll("a").forEach(link => { link.target = "_blank"; link.rel = "noopener noreferrer"; }); } else { bubble.textContent = content; } const meta = document.createElement("small"); meta.className = "meta"; meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`; bubble.append(meta); row.append(bubble); this.messages.append(row); this.scrollLatest(); return row; } private renderTaskProgress(task: TaskRecord): void { const existing = this.taskCards.get(task.operation_id); if (existing) { this.updateTaskCard(existing, task); return; } const card = document.createElement("article"); card.className = "task-card"; card.dataset.operationId = task.operation_id; const title = document.createElement("strong"); const progress = document.createElement("progress"); progress.max = 100; const status = document.createElement("small"); status.className = "task-card-status"; const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "secondary-action"; cancel.textContent = "请求取消"; cancel.addEventListener("click", () => { if (!this.taskInteractions?.requestCancel(task.operation_id)) return; const current = this.taskInteractions.read(task.operation_id); if (current) this.updateTaskCard(this.taskCards.get(task.operation_id)!, current); }); const entry = { card, title, progress, status, cancel }; card.append(title, progress, status, cancel); this.taskCards.set(task.operation_id, entry); this.updateTaskCard(entry, task); this.messages.append(card); this.scrollLatest(); } private updateTaskCard(entry: { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }, task: TaskRecord): void { entry.card.dataset.status = task.status; entry.title.textContent = task.title; entry.progress.value = Math.max(0, Math.min(100, task.percent)); const requested = task.cancel_requested_at !== undefined && task.status !== "cancelled"; entry.status.textContent = requested ? `取消请求已记录 · ${task.percent}%` : `${task.status} · ${task.percent}%`; entry.cancel.hidden = !task.cancellable || task.status === "completed" || task.status === "failed" || task.status === "cancelled"; entry.cancel.disabled = requested; } private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void { const card = document.createElement("article"); card.className = "action-group-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 = `action-group-actions ${group.mode}`; const status = document.createElement("small"); status.className = "action-group-status"; const locked = this.applyCallStatus(card, status, call, record); const buttons: HTMLButtonElement[] = []; const selected = new Set(); const submit = (actionIDs: readonly string[]) => { const accepted = this.toolInteractions?.submit(call.call_id, { action_ids: [...actionIDs] }) ?? false; if (!accepted) { status.textContent = "该交互已结束或已提交"; return; } card.dataset.status = "submitted"; status.textContent = "已提交,等待 Agent 确认"; for (const button of buttons) button.disabled = true; }; for (const action of group.actions) { const button = document.createElement("button"); button.type = "button"; button.className = "action-group-action"; button.textContent = action.label; button.title = action.description ?? action.label; button.disabled = locked; button.addEventListener("click", () => { if (group.mode !== "multi-choice") { submit([action.id]); return; } if (selected.has(action.id)) selected.delete(action.id); else selected.add(action.id); button.setAttribute("aria-pressed", String(selected.has(action.id))); submitButton.disabled = selected.size === 0; }); buttons.push(button); actions.append(button); } let submitButton: HTMLButtonElement; if (group.mode === "multi-choice") { submitButton = document.createElement("button"); submitButton.type = "button"; submitButton.className = "action-group-submit"; submitButton.textContent = "提交选择"; submitButton.disabled = true; submitButton.addEventListener("click", () => submit([...selected])); buttons.push(submitButton); } else { // It is only referenced by the multi-choice click handler above. submitButton = document.createElement("button"); } card.append(heading, detail, actions); if (group.mode === "multi-choice") card.append(submitButton); card.append(status); this.messages.append(card); this.scheduleExpiry(card, status, call, buttons); this.scrollLatest(); } private renderConfirmCard(call: ToolCallRequest, definition: ConfirmDefinition, record?: ToolCallRecord): void { const card = document.createElement("article"); card.className = "tool-card confirm-card"; card.dataset.callId = call.call_id; const heading = document.createElement("strong"); heading.textContent = call.title || "请确认"; const detail = document.createElement("p"); detail.textContent = call.prompt; const actions = document.createElement("div"); actions.className = "tool-card-actions"; const approve = document.createElement("button"); approve.type = "button"; approve.textContent = definition.approve_label; const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "secondary-action"; cancel.textContent = definition.cancel_label; const status = document.createElement("small"); status.className = "tool-card-status"; const buttons = [approve, cancel]; const locked = this.applyCallStatus(card, status, call, record); for (const button of buttons) button.disabled = locked; approve.addEventListener("click", () => { if (!this.toolInteractions?.submit(call.call_id, { confirmed: true })) return this.markInteractionUnavailable(status); this.markSubmitted(card, status, buttons); }); cancel.addEventListener("click", () => { if (!this.toolInteractions?.cancel(call.call_id, "用户取消确认")) return this.markInteractionUnavailable(status); this.markCancelled(card, status, buttons); }); actions.append(approve, cancel); card.append(heading, detail, actions, status); this.messages.append(card); this.scheduleExpiry(card, status, call, buttons); this.scrollLatest(); } private renderInputForm(call: ToolCallRequest, form: InputForm, record?: ToolCallRecord): void { const card = document.createElement("article"); card.className = "tool-card input-form-card"; card.dataset.callId = call.call_id; const heading = document.createElement("strong"); heading.textContent = call.title || "填写信息"; const detail = document.createElement("p"); detail.textContent = call.prompt; const nativeForm = document.createElement("form"); nativeForm.noValidate = true; const errors = document.createElement("p"); errors.className = "tool-card-error"; errors.setAttribute("role", "alert"); const draft = this.toolInteractions?.loadDraft(call.call_id) ?? {}; const fields = new Map(); for (const definition of form.fields) { const label = document.createElement("label"); label.textContent = definition.label; const control = definition.type === "textarea" ? document.createElement("textarea") : document.createElement("input"); if (control instanceof HTMLInputElement) control.type = definition.type === "number" ? "number" : "text"; control.name = definition.id; control.placeholder = definition.placeholder ?? ""; const prior = draft[definition.id]; control.value = typeof prior === "string" ? prior : ""; control.addEventListener("input", () => this.toolInteractions?.saveDraft(call.call_id, this.readFormValues(fields))); fields.set(definition.id, control); label.append(control); nativeForm.append(label); } const actions = document.createElement("div"); actions.className = "tool-card-actions"; const submit = document.createElement("button"); submit.type = "submit"; submit.textContent = form.submit_label; const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "secondary-action"; cancel.textContent = form.cancel_label; const status = document.createElement("small"); status.className = "tool-card-status"; const controls = [...fields.values(), submit, cancel]; const locked = this.applyCallStatus(card, status, call, record); for (const control of controls) control.disabled = locked; nativeForm.addEventListener("submit", event => { event.preventDefault(); const values = this.readFormValues(fields); const problem = validateFormValues(form, values); if (problem) { errors.textContent = problem; return; } if (!this.toolInteractions?.submit(call.call_id, { values })) return this.markInteractionUnavailable(status); errors.textContent = ""; this.markSubmitted(card, status, controls); }); cancel.addEventListener("click", () => { if (!this.toolInteractions?.cancel(call.call_id, "用户取消输入")) return this.markInteractionUnavailable(status); this.markCancelled(card, status, controls); }); actions.append(submit, cancel); nativeForm.append(errors, actions); card.append(heading, detail, nativeForm, status); this.messages.append(card); this.scheduleExpiry(card, status, call, controls); this.scrollLatest(); } private applyCallStatus(card: HTMLElement, status: HTMLElement, call: ToolCallRequest, record?: ToolCallRecord): boolean { const effective = record?.status ?? (call.expires_at && Date.parse(call.expires_at) <= Date.now() ? "expired" : "pending"); if (effective === "pending") { status.textContent = "等待你的操作"; return false; } card.dataset.status = effective; status.textContent = effective === "submitted" ? "已提交,等待 Agent 确认" : effective === "cancelled" ? "已取消" : effective === "expired" ? "该交互已过期" : effective === "completed" ? "已完成" : "处理失败"; return true; } private scheduleExpiry(card: HTMLElement, status: HTMLElement, call: ToolCallRequest, controls: readonly HTMLButtonElement[] | readonly (HTMLInputElement | HTMLTextAreaElement | HTMLButtonElement)[]): void { if (!call.expires_at || Date.parse(call.expires_at) <= Date.now()) return; const timer = window.setTimeout(() => { this.expirationTimers.delete(timer); if (!this.toolInteractions?.expire(call.call_id)) return; card.dataset.status = "expired"; status.textContent = "该交互已过期"; for (const control of controls) control.disabled = true; }, Math.max(0, Date.parse(call.expires_at) - Date.now()) + 1); this.expirationTimers.add(timer); } private readFormValues(fields: ReadonlyMap): JsonObject { return Object.fromEntries([...fields].map(([id, control]) => [id, control.value])); } private markSubmitted(card: HTMLElement, status: HTMLElement, controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[]): void { card.dataset.status = "submitted"; status.textContent = "已提交,等待 Agent 确认"; for (const control of controls) control.disabled = true; } private markCancelled(card: HTMLElement, status: HTMLElement, controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[]): void { card.dataset.status = "cancelled"; status.textContent = "已取消"; for (const control of controls) control.disabled = true; } private markInteractionUnavailable(status: HTMLElement): void { status.textContent = "该交互已结束或已提交"; } private showThinking(detail: string): void { this.thinking?.remove(); this.thinking = this.appendBubble("agent", detail); this.thinking.classList.add("thinking"); this.setPresence("正在思考"); } private hideThinking(): void { this.thinking?.remove(); this.thinking = undefined; } private scrollLatest(): void { this.messages.scrollTop = this.messages.scrollHeight; } } function deliveryLabel(delivery: DeliveryState): string { if (delivery.status === "local_pending") return "发送中"; 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; }