471 lines
20 KiB
TypeScript
471 lines
20 KiB
TypeScript
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;
|
||
}
|