feat: move trusted renderers out of app shell

This commit is contained in:
2026-08-03 15:02:41 +08:00
parent 74aef6fb7d
commit a5f8ed7c09
2 changed files with 175 additions and 73 deletions
+143
View File
@@ -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<string, HTMLElement>();
public constructor(
private readonly messages: HTMLElement,
private readonly presence: HTMLElement,
) {}
public reset(): void {
this.thinking = undefined;
this.userRows.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 {
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 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 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 "已发送";
}