Files
app/tauri/src/runtime/trusted-dom-renderers.ts
T

471 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, HTMLElement>();
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
private readonly expirationTimers = new Set<number>();
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<ConversationItem, { kind: "user-message" }>): 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<ConversationItem, { kind: "markdown" }>): void {
this.hideThinking();
this.appendBubble("agent", item.markdown, true);
}
public renderAgentStatus(item: Extract<ConversationItem, { kind: "agent-status" }>): void {
if (item.status === "thinking") this.showThinking(item.detail || "正在思考…");
else {
this.hideThinking();
this.setPresence(item.detail || item.status || "在线");
}
}
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): 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<ConversationItem, { kind: "error" }>): void {
this.hideThinking();
this.renderSystemMessage(`Agent 错误:${item.message}`);
}
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, 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<ConversationItem, { kind: "tool-result" }>): void {
this.renderSystemMessage(`收到工具调用结果(${item.result.call_id} · ${item.result.status})。`);
}
public renderToolCancel(item: Extract<ConversationItem, { kind: "tool-cancel" }>): void {
this.renderSystemMessage(`工具调用已取消(${item.cancel.call_id})。`);
}
public renderSurface(item: Extract<ConversationItem, { kind: "surface" }>): void {
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
}
public renderAppCall(_item: Extract<ConversationItem, { kind: "app-call" }>): void {
this.renderSystemMessage("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。");
}
public renderFallback(item: Extract<ConversationItem, { kind: "fallback" }>): 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<HTMLElement>(".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<string>();
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<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);
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;
}