From a5f8ed7c09db8caae7cd0409b7815da40403e65b Mon Sep 17 00:00:00 2001 From: kyugao Date: Mon, 3 Aug 2026 15:02:41 +0800 Subject: [PATCH] feat: move trusted renderers out of app shell --- tauri/src/main.ts | 105 +++++---------- tauri/src/runtime/trusted-dom-renderers.ts | 143 +++++++++++++++++++++ 2 files changed, 175 insertions(+), 73 deletions(-) create mode 100644 tauri/src/runtime/trusted-dom-renderers.ts diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 6320966..68d8114 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -1,5 +1,3 @@ -import DOMPurify from "dompurify"; -import { marked } from "marked"; import "./style.css"; import { type Actor, @@ -11,6 +9,7 @@ import { ConversationStore } from "./runtime/conversation-store"; import { InteractionKernel } from "./runtime/interaction-kernel"; import { RendererRegistry } from "./runtime/renderer-registry"; import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter"; +import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers"; type Session = { api: string; @@ -26,7 +25,6 @@ const DEFAULT_API = "http://127.0.0.1:8090"; let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false }; let transport: TransportAdapter | undefined; let polling = false; -let thinking: HTMLElement | undefined; const interactionKernel = new InteractionKernel(); const conversationStore = new ConversationStore(localStorage); @@ -63,10 +61,6 @@ const chatView = $("#chat-view"); const presence = $("#presence"); $("#api").value = session.api; -function scrollLatest(): void { messages.scrollTop = messages.scrollHeight; } -function setPresence(value: string): void { presence.textContent = value; } -function addSystem(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; messages.append(item); scrollLatest(); } - function newLocalID(prefix: string): string { // `crypto.randomUUID` is absent from older Safari / WKWebView versions. // A local item id needs uniqueness within this browser store, not a @@ -100,59 +94,19 @@ function loginFailureMessage(reason: unknown): string { return message || "登录失败,请稍后重试。"; } -function 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); messages.append(row); scrollLatest(); return row; -} - -function showThinking(detail = "正在思考…"): void { - thinking?.remove(); - thinking = appendBubble("agent", detail); - thinking.classList.add("thinking"); - setPresence("正在思考"); -} -function hideThinking(): void { thinking?.remove(); thinking = undefined; } - -function render(item: ConversationItem): void { - switch (item.kind) { - case "user-message": { - const delivery = item.delivery.status === "local_pending" ? "发送中" : item.delivery.status === "failed" ? "发送失败" : "已发送"; - appendBubble("human", item.text, false, delivery); - break; - } - case "markdown": hideThinking(); appendBubble("agent", item.markdown, true); break; - case "agent-status": item.status === "thinking" ? showThinking(item.detail || "正在思考…") : (hideThinking(), setPresence(item.detail || item.status || "在线")); break; - case "progress": { const percent = Math.max(0, Math.min(100, item.percent)); addSystem(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`); break; } - case "error": hideThinking(); addSystem(`Agent 错误:${item.message}`); break; - case "surface": addSystem(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); break; - case "app-call": addSystem("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。"); break; - case "fallback": addSystem(item.reason === "unsupported_type" ? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}` : "收到无法安全展示的消息。"); break; - } -} - -// M0-03 establishes the dispatch boundary. M0-06 will move these individual -// DOM renderers out of App Shell; until then the registry keeps the same UI -// behavior while ensuring all incoming content traverses the Kernel first. -const rendererRegistry = new RendererRegistry(); -rendererRegistry.register("user-message", render); -rendererRegistry.register("markdown", render); -rendererRegistry.register("agent-status", render); -rendererRegistry.register("progress", render); -rendererRegistry.register("error", render); -rendererRegistry.register("surface", render); -rendererRegistry.register("app-call", render); -rendererRegistry.register("fallback", render); +const rendererContext = new TrustedDOMRendererContext(messages, presence); +const rendererRegistry = new RendererRegistry(); +rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); +rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item)); +rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item)); +rendererRegistry.register("progress", (item, context) => context.renderProgress(item)); +rendererRegistry.register("error", (item, context) => context.renderError(item)); +rendererRegistry.register("surface", (item, context) => context.renderSurface(item)); +rendererRegistry.register("app-call", (item, context) => context.renderAppCall(item)); +rendererRegistry.register("fallback", (item, context) => context.renderFallback(item)); function renderIncoming(item: ConversationItem): void { - if (!rendererRegistry.render(item, undefined)) addSystem("收到当前客户端没有可信 Renderer 的内容。"); + if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。"); } function currentConversationKey(): string { @@ -184,7 +138,7 @@ async function syncLoop(): Promise { limit: 50, }); if (synced.length === 0) { - setPresence("已同步,等待消息"); + rendererContext.setPresence("已同步,等待消息"); break; } @@ -213,12 +167,12 @@ async function syncLoop(): Promise { // Avoid a hot loop if an upstream response repeats a sequence that // cannot advance the local cursor. if (!advanced) { - setPresence("已同步,等待消息"); + rendererContext.setPresence("已同步,等待消息"); break; } } } catch { - if (session.active) setPresence("同步中断,正在重试…"); + if (session.active) rendererContext.setPresence("同步中断,正在重试…"); } await new Promise(resolve => window.setTimeout(resolve, 1500)); } @@ -251,10 +205,10 @@ $("#login-form").addEventListener("submit", async event => { session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true }; const snapshot = conversationStore.open(currentConversationKey()); session.lastSeq = snapshot.cursor; - messages.replaceChildren(); + messages.replaceChildren(); rendererContext.reset(); loginView.hidden = true; chatView.hidden = false; for (const stored of snapshot.items) renderIncoming(stored.item); - addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop(); + rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void syncLoop(); } catch (reason) { error.textContent = loginFailureMessage(reason); } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } }); @@ -264,7 +218,7 @@ $("#message-form").addEventListener("submit", async event => { const input = $("#message-input"); const send = $("#send"); const text = input.value.trim(); if (!text) return; if (!session.uid) { - addSystem("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); + rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); input.focus(); return; } @@ -274,11 +228,16 @@ $("#message-form").addEventListener("submit", async event => { // A browser storage failure must never prevent the user from seeing or // submitting their message. The Store remains the durable source when it // is available; the server sync is the recovery path when it is not. - const row = appendBubble("human", text, false, "发送中"); - const meta = row.querySelector(".meta"); + const localItem: ConversationItem = { + kind: "user-message", + id: localID, + text, + delivery: { status: "local_pending", updated_at: createdAt }, + }; + renderIncoming(localItem); let storedLocally = true; try { conversationStore.appendLocalText(localID, text, createdAt); } - catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } + catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); } try { if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。"); const sequence = await transport.sendText({ @@ -289,23 +248,23 @@ $("#message-form").addEventListener("submit", async event => { }); if (sequence && storedLocally) { try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); } - catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); } + catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); } } - if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送`; + rendererContext.updateUserDelivery(localID, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) }); } catch (reason) { const message = reason instanceof Error ? reason.message : "发送失败"; if (storedLocally) { try { conversationStore.markFailed(localID, message, new Date().toISOString()); } - catch (storageReason) { addSystem(storageFailureMessage(storageReason)); } + catch (storageReason) { rendererContext.renderSystemMessage(storageFailureMessage(storageReason)); } } - if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败`; - addSystem(message); + rendererContext.updateUserDelivery(localID, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true }); + rendererContext.renderSystemMessage(message); } finally { send.disabled = false; input.focus(); } }); -$("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); +$("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; }); export function makeProtocolEvent( type: string, diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/runtime/trusted-dom-renderers.ts new file mode 100644 index 0000000..13f072a --- /dev/null +++ b/tauri/src/runtime/trusted-dom-renderers.ts @@ -0,0 +1,143 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; +import type { + ConversationItem, + DeliveryState, +} from "../protocol"; + +/** + * 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(); + + public constructor( + private readonly messages: HTMLElement, + private readonly presence: HTMLElement, + ) {} + + public reset(): void { + this.thinking = undefined; + this.userRows.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 { + 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 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 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 "已发送"; +}