feat(m2): ship isolated task dashboard surface

This commit is contained in:
2026-08-03 23:11:29 +08:00
parent 9fd321c41c
commit ccff57b2a1
9 changed files with 829 additions and 9 deletions
+80 -7
View File
@@ -13,6 +13,9 @@ 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";
import { SurfaceRegistry } from "./runtime/surface-registry";
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager";
import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host";
type Session = {
api: string;
@@ -32,6 +35,10 @@ let flushingOutbox = false;
const interactionKernel = new InteractionKernel();
const conversationStore = new ConversationStore(localStorage);
const executionProgress = new ExecutionProgressState();
const surfaceRegistry = new SurfaceRegistry();
const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry);
const SURFACE_REGISTRY_STORAGE_KEY = "lineup.surface-registry.v1";
try { surfaceRegistry.restore(JSON.parse(localStorage.getItem(SURFACE_REGISTRY_STORAGE_KEY) ?? "null")); } catch { surfaceRegistry.restore(null); }
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing");
@@ -49,7 +56,7 @@ app.innerHTML = `
</form>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><button id="logout" class="quiet">退出</button></header>
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><div><button id="surface-demo" class="quiet" type="button">启用任务面板</button><button id="logout" class="quiet">退出</button></div></header>
<section id="messages" class="messages" aria-live="polite"></section>
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
</section>
@@ -64,6 +71,10 @@ const messages = $<HTMLElement>("#messages");
const loginView = $<HTMLElement>("#login-view");
const chatView = $<HTMLElement>("#chat-view");
const presence = $<HTMLElement>("#presence");
const surfaceHost = new IsolatedSurfaceHost(messages, {
ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); },
event(instanceID) { queueSurfaceCancel(instanceID); },
});
$<HTMLInputElement>("#api").value = session.api;
function newLocalID(prefix: string): string {
@@ -91,11 +102,12 @@ function normalizeAPIAddress(value: string): string {
return parsed.toString().replace(/\/$/, "");
}
function loginFailureMessage(reason: unknown): string {
if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,并核对上方显示的服务地址";
if (reason instanceof DOMException && reason.name === "AbortError") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,且服务地址和端口可访问。";
function loginFailureMessage(reason: unknown, endpoint?: string): string {
const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址";
if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}`;
if (reason instanceof DOMException && reason.name === "AbortError") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}`;
const message = reason instanceof Error ? reason.message : "";
if (message === "Load failed" || message === "Failed to fetch") return "无法连接 AppServer。请确认 Tailscale 已连接,并检查地址是否为 http://100.121.118.116:8090。";
if (message === "Load failed" || message === "Failed to fetch") return `无法连接 AppServer。请确认当前网络可以访问${destination}`;
return message || "登录失败,请稍后重试。";
}
@@ -150,9 +162,57 @@ rendererRegistry.register("app-call", (item, context) => context.renderAppCall(i
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
function renderIncoming(item: ConversationItem): void {
if (item.kind === "surface") {
const now = new Date().toISOString();
if (item.envelope.type === "lineup.v1.ui.open") {
const result = surfaceInstances.open(item.envelope.payload, item.envelope.conversation_id, now);
if (result.instance && (result.disposition === "accepted" || result.disposition === "duplicate")) surfaceHost.mount(result.instance);
if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot());
return;
}
if (item.envelope.type === "lineup.v1.ui.patch") {
const instanceID = item.envelope.payload.instance_id;
const result = typeof instanceID === "string" ? surfaceInstances.patch(instanceID, item.envelope.payload.state, now) : { disposition: "invalid_request" as const };
if (result.instance && result.disposition === "accepted") surfaceHost.update(result.instance);
if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot());
return;
}
if (item.envelope.type === "lineup.v1.ui.close") {
const instanceID = item.envelope.payload.instance_id;
if (typeof instanceID === "string") surfaceInstances.close(instanceID);
if (typeof instanceID === "string") surfaceHost.unmount(instanceID);
conversationStore.replaceSurfaces(surfaceInstances.snapshot());
return;
}
}
if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。");
}
/**
* A Surface can legitimately arrive before its locally packaged app has been
* enabled. Keep the envelope in the conversation log, but do not silently
* lose that lifecycle transition forever: once the user enables this exact
* local app, replay only the durable Surface transcript for this conversation.
*
* This remains a local trust boundary. Replaying does not enable an app,
* download a bundle, or loosen validation; every transition still flows
* through SurfaceInstanceManager and SurfaceRegistry.
*/
function replayStoredSurfaceLifecycle(): void {
const snapshot = conversationStore.open(currentConversationKey());
for (const stored of snapshot.items) {
if (stored.item.kind === "surface") renderIncoming(stored.item);
}
}
$<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
const now = new Date().toISOString();
surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now);
localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot()));
replayStoredSurfaceLifecycle();
conversationStore.replaceSurfaces(surfaceInstances.snapshot());
});
/**
* Status only begins/ends a turn. Public operation labels can only come from
* the separately validated adapter trace, never from Agent status/detail.
@@ -209,6 +269,17 @@ function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, pa
void flushOutbox();
}
function queueSurfaceCancel(instanceID: string): void {
if (!session.uid) return;
const createdAt = new Date().toISOString();
const envelope = makeProtocolEvent("lineup.v1.ui.event", { instance_id: instanceID, event: "cancel", data: {} }, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID });
try {
conversationStore.enqueueToolAction(newLocalID("surface"), "surface-event", JSON.stringify(envelope), createdAt);
rendererContext.renderSystemMessage("已请求取消任务。");
void flushOutbox();
} catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
}
async function flushOutbox(): Promise<void> {
if (flushingOutbox || !session.active || !transport || !session.uid) return;
flushingOutbox = true;
@@ -281,7 +352,7 @@ async function syncLoop(): Promise<void> {
const payload = decodeBase64(message.payload);
if (message.from_uid === session.uid) {
const ownEnvelope = parseEnvelopeJSON(payload);
if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel")) {
if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event")) {
// Tool action echoes advance the inclusive cursor but are never
// rendered as raw JSON user messages or replayed as a new action.
continue;
@@ -350,6 +421,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
transport = candidateTransport;
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());
surfaceInstances.restore(snapshot.surfaces);
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks);
executionProgress.restore(snapshot.execution_summaries);
@@ -364,9 +436,10 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
for (const stored of snapshot.items) {
if (stored.item.kind !== "agent-status") renderIncoming(stored.item);
}
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop();
} catch (reason) { error.textContent = loginFailureMessage(reason); }
} catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
});