feat(m2): ship isolated task dashboard surface
This commit is contained in:
+80
-7
@@ -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"; }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user