feat: move trusted renderers out of app shell
This commit is contained in:
+32
-73
@@ -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 = $<HTMLElement>("#chat-view");
|
||||
const presence = $<HTMLElement>("#presence");
|
||||
$<HTMLInputElement>("#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<void>();
|
||||
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<TrustedDOMRendererContext>();
|
||||
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<void> {
|
||||
limit: 50,
|
||||
});
|
||||
if (synced.length === 0) {
|
||||
setPresence("已同步,等待消息");
|
||||
rendererContext.setPresence("已同步,等待消息");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -213,12 +167,12 @@ async function syncLoop(): Promise<void> {
|
||||
// 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 @@ $<HTMLFormElement>("#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 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
||||
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
|
||||
if (!text) return;
|
||||
if (!session.uid) {
|
||||
addSystem("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
|
||||
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
@@ -274,11 +228,16 @@ $<HTMLFormElement>("#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<HTMLElement>(".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 @@ $<HTMLFormElement>("#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(); }
|
||||
});
|
||||
|
||||
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
|
||||
$<HTMLButtonElement>("#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,
|
||||
|
||||
Reference in New Issue
Block a user