feat: move trusted renderers out of app shell
This commit is contained in:
@@ -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 "已发送";
|
||||
}
|
||||
Reference in New Issue
Block a user