feat(runtime): establish miniapp kernel and sdk design

This commit is contained in:
2026-08-05 00:46:16 +08:00
parent d8aa087bc8
commit 9fb8cd1598
86 changed files with 4615 additions and 1364 deletions
+218 -370
View File
@@ -1,43 +1,34 @@
import "./style.css";
import "./capability-card.css";
import "./execution-progress.css";
/**
* Tauri/Desktop 与 Web Reference Host 的组合入口。
*
* 本文件负责创建 Host 侧适配、LineUpRuntime 和默认 Chat Core App,并连接可信的
* Renderer、Surface、Capability 集成。Transport、Store、同步循环、outbox 与原始
* Agent 消息解析均由 Runtime 独占,不能重新回流到此入口。
*/
// 协议层只提供经过 Runtime 校验后的表现模型类型;本入口不直接解析原始网络数据。
import {
type Actor,
type ConversationItem,
type Envelope,
type JsonObject,
parseEnvelopeJSON,
} from "./protocol";
import { ConversationStore } from "./runtime/conversation-store";
import { ExecutionProgressState } from "./runtime/execution-progress-state";
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 { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "./runtime/isolated-surface-host";
import { ProductionSurfaceManifestRegistry } from "./runtime/production-surface-manifest";
import { ProductionSurfacePolicy } from "./runtime/production-surface-policy";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "./runtime/surface-bundle-cache";
import { CapabilityRegistry } from "./runtime/capability-registry";
import { ClientInventoryPublisher } from "./runtime/client-inventory";
import { ArtifactState } from "./runtime/artifact-state";
import { ArtifactContentCache } from "./runtime/artifact-content-cache";
import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "./runtime/capability-call-state";
import { CapabilityAuditLog } from "./runtime/capability-audit";
import { CapabilityExecutor, type PickedFile } from "./runtime/capability-executor";
type Session = {
api: string;
uid: string;
agentUID: string;
channelID: string;
channelType: number;
lastSeq: number;
active: boolean;
};
} from "@/runtime/protocol/lineup-v1";
// 下面这些模块分别对应 Host 侧的扩展界面、系统能力、Artifact 和执行状态适配。
import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state";
import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry";
import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager";
import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "@/runtime/surfaces/isolated-surface-host";
import { ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest";
import { ProductionSurfacePolicy } from "@/runtime/surfaces/production-surface-policy";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
import { ClientInventoryPublisher } from "@/runtime/inventory/client-inventory";
import { ArtifactState } from "@/runtime/artifacts/artifact-state";
import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache";
import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "@/runtime/capabilities/capability-call-state";
import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor";
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host";
// Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。
declare global {
interface ImportMeta {
readonly env: Readonly<{ DEV: boolean }>;
@@ -49,13 +40,16 @@ declare global {
}
}
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 flushingOutbox = false;
const interactionKernel = new InteractionKernel();
const conversationStore = new ConversationStore(localStorage);
// 这是登录表单首次显示时使用的本地默认地址;用户提交后的地址会保存到 localStorage。
// `0.0.0.0` is a server bind address, never a browser destination. When the
// Web Host is opened locally use loopback; when it is opened through the
// trusted Tailscale Host use the matching reachable AppServer address.
const DEFAULT_API = location.hostname === "127.0.0.1" || location.hostname === "localhost"
? "http://127.0.0.1:8090"
: "http://100.121.118.116:8090";
// 这些状态对象属于当前可信 Host 的装配层:Runtime 负责会话、消息和可靠性,
// 它们负责将 Surface、Capability、Artifact 和执行进度投影到当前页面。
const executionProgress = new ExecutionProgressState();
const surfaceRegistry = new SurfaceRegistry();
const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry);
@@ -65,6 +59,8 @@ const artifactState = new ArtifactState();
const artifactContentCache = new ArtifactContentCache();
const capabilityCalls = new CapabilityCallStateMachine();
const capabilityAudit = new CapabilityAuditLog();
// CapabilityExecutor 只在 Capability 状态机已经批准调用后才会触发这些 Host handler。
// 它不会因为 Agent 发来一个 payload 就直接调用浏览器或桌面能力。
const capabilityExecutor = new CapabilityExecutor({
async openURL(url) {
// With `noopener`, Chromium intentionally returns null even when it did
@@ -77,46 +73,64 @@ const capabilityExecutor = new CapabilityExecutor({
pickFile: pickFileFromTrustedHost,
async saveArtifact(artifactID) { saveCachedArtifact(artifactID); },
}, artifactState);
// LineUpRuntime 是本文件中最重要的底座对象。它独占 Transport、Store、同步循环、
// outbox、App Inbox 和作用域校验;main.ts 只通过 Runtime/SDK 与它协作。
const runtime = new LineUpRuntime({
storage: localStorage,
prepareIncoming(item) { return item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item; },
});
let lastInventoryRevision = "";
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); }
// RuntimeAppHost 会读取 Runtime 的 Core App Registry,选择并挂载默认 Core App。
// main.ts 不在这里直接创建 Chat/IM 页面。
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing");
app.innerHTML = `
<main class="shell">
<section id="login-view" class="login-view">
<div class="brand"><span>✦</span><div><p>LINEUP</p><h1>你的 AI 协作空间</h1></div></div>
<form id="login-form" class="login-card" novalidate>
<label>AppServer 地址<input id="api" inputmode="url" placeholder="http://100.x.y.z:8090" required /></label>
<label>手机号<input id="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号" required /></label>
<label>验证码<input id="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入验证码" required /></label>
<p id="login-error" class="error" role="alert"></p>
<button id="login-submit" type="submit">进入 LineUp</button>
</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><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>
</main>`;
// Host 页面是可信的静态 Shell。缺少必要节点属于装配错误,应尽早失败,而不是运行到
// 登录或消息处理时才出现难以定位的空引用。
const $ = <T extends Element>(selector: string): T => {
const found = document.querySelector<T>(selector);
if (!found) throw new Error(`Missing element: ${selector}`);
return found;
};
const messages = $<HTMLElement>("#messages");
const loginView = $<HTMLElement>("#login-view");
const chatView = $<HTMLElement>("#chat-view");
const presence = $<HTMLElement>("#presence");
// 当前默认 Core App 的 SDK。变量在挂载应用后赋值;前面传入的回调只会在用户操作后执行,
// 因此它们可以安全地引用这个稍后完成初始化的 SDK。
let chatSDK: ReturnType<LineUpRuntime["openApp"]>;
// 将 Chat 所需的交互回调注入 App Host。回调本身仍然只能通过 SDK 访问 Runtime
// Chat Renderer 不会获得 Transport、Store 或 Tauri 特权对象。
const mountedApp = new RuntimeAppHost(runtime, app, {
toolInteractions: {
submit(callID, submission) { return chatSDK.interactions.submit(callID, submission); },
cancel(callID, reason) { return chatSDK.interactions.cancel(callID, reason); },
expire(callID) { return chatSDK.interactions.expire(callID); },
read(callID) { return chatSDK.interactions.read(callID); },
loadDraft(callID) { return chatSDK.interactions.loadDraft(callID); },
saveDraft(callID, values) { chatSDK.interactions.saveDraft(callID, values); },
clearDraft(callID) { chatSDK.interactions.clearDraft(callID); },
},
taskInteractions: {
requestCancel(operationID) { return chatSDK.tasks.requestCancel(operationID); },
read(operationID) { return chatSDK.tasks.read(operationID); },
},
capabilityInteractions: {
async approve(callID) { return approveCapabilityCall(callID); },
reject(callID) { return rejectCapabilityCall(callID); },
expire(callID) { return expireCapabilityCall(callID); },
read(callID) { return capabilityCalls.get(callID); },
},
}).mountDefaultApp();
// 从通用挂载结果中取得当前应用的 SDK 和页面投影对象。
chatSDK = mountedApp.sdk;
const { messages, loginView, chatView, presence, rendererContext, rendererRegistry } = mountedApp;
// Surface Host 只负责承载已经通过 Runtime/Surface 策略检查的隔离界面。
// Surface 的 ready/event 回调仍回到状态管理和 SDK Action,不直接执行 Agent 指令。
const surfaceHost = new IsolatedSurfaceHost(messages, {
ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); },
event(instanceID) { queueSurfaceCancel(instanceID); },
});
$<HTMLInputElement>("#api").value = session.api;
$<HTMLInputElement>("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API;
/**
* A pre-signed public fixture used only by the headed M4 acceptance route.
@@ -124,7 +138,10 @@ $<HTMLInputElement>("#api").value = session.api;
* the actual Ed25519 → size → SHA-256 → cache → exact resolver path.
*/
async function mountM4SignedFixture(): Promise<void> {
// 这是开发环境专用的签名 Surface 验收入口。正常启动和生产构建不会执行这里。
if (!import.meta.env.DEV || !new URLSearchParams(location.search).has("m4-signed-surface-e2e")) return;
// Bundle 使用公开测试数据,下面的流程会真实执行“验签 → 大小校验 → SHA-256 校验
// → 缓存 → 按精确版本解析”,用于验证生产 Surface 的准入链路。
const encodedBundle = "PG1haW4-PGgyIGlkPSJtNC1zaWduZWQiPk00IOW3suetvuWQjSBTdXJmYWNlPC9oMj48cCBpZD0ic3RhdGUiPuetieW-heeKtuaAgTwvcD48c2NyaXB0PndpbmRvdy5hZGRFdmVudExpc3RlbmVyKCJtZXNzYWdlIixlPT57Y29uc3QgZD1lLmRhdGE7aWYoZCYmZC50eXBlPT09ImxpbmV1cC5zdXJmYWNlLnYxLnN0YXRlIilkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCIjc3RhdGUiKS50ZXh0Q29udGVudD1TdHJpbmcoZC5zdGF0ZT8uc3RhdHVzfHwi6L-Q6KGM5LitIil9KTt3aW5kb3cucGFyZW50LnBvc3RNZXNzYWdlKHt2OjEsdHlwZToibGluZXVwLnN1cmZhY2UudjEucmVhZHkiLGluc3RhbmNlX2lkOndpbmRvdy5fX0xJTkVVUF9TVVJGQUNFX0lOU1RBTkNFX199LCIqIik8L3NjcmlwdD48L21haW4-";
const standardBase64 = encodedBundle.replace(/-/g, "+").replace(/_/g, "/");
const bundle = Uint8Array.from(atob(standardBase64 + "=".repeat((4 - standardBase64.length % 4) % 4)), value => value.charCodeAt(0));
@@ -155,6 +172,7 @@ async function mountM4SignedFixture(): Promise<void> {
};
}
/** 生成只在本地 Store 中使用的临时 ID,不把它当作服务器身份或消息协议 ID。 */
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
@@ -164,11 +182,13 @@ function newLocalID(prefix: string): string {
return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
}
/** 将 Store/SDK 异常转换成用户可以理解的提示,同时保留发送尝试的语义。 */
function storageFailureMessage(reason: unknown): string {
const detail = reason instanceof Error && reason.message ? `${reason.message}` : "";
return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`;
}
/** 校验并规范化 AppServer 地址,只允许 HTTP/HTTPS,避免把任意协议交给 Transport。 */
function normalizeAPIAddress(value: string): string {
const candidate = value.trim();
if (!candidate) throw new Error("请输入 AppServer 地址。");
@@ -180,6 +200,7 @@ function normalizeAPIAddress(value: string): string {
return parsed.toString().replace(/\/$/, "");
}
/** 把网络、超时和输入错误转换成登录页可显示的中文原因。 */
function loginFailureMessage(reason: unknown, endpoint?: string): string {
const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址";
if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}`;
@@ -189,63 +210,41 @@ function loginFailureMessage(reason: unknown, endpoint?: string): string {
return message || "登录失败,请稍后重试。";
}
const rendererContext = new TrustedDOMRendererContext(messages, presence, {
submit(callID, submission) {
const transition = interactionKernel.submitToolCall(callID, new Date().toISOString(), submission);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
if (transition.disposition === "accepted") {
conversationStore.clearToolDraft(callID);
queueToolAction("tool-result", callID, { call_id: callID, status: "completed", result: submission });
}
return transition.disposition === "accepted";
},
cancel(callID, reason) {
const transition = interactionKernel.cancelToolCall(callID, reason, new Date().toISOString());
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
if (transition.disposition === "accepted") {
conversationStore.clearToolDraft(callID);
queueToolAction("tool-cancel", callID, { call_id: callID, reason });
}
return transition.disposition === "accepted";
},
expire(callID) {
const transition = interactionKernel.expireToolCall(callID, new Date().toISOString());
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
return transition.disposition === "accepted";
},
read(callID) { return interactionKernel.snapshot().tool_calls.find(record => record.call_id === callID); },
loadDraft(callID) { return conversationStore.getToolDraft(callID); },
saveDraft(callID, values) { conversationStore.saveToolDraft(callID, values); },
clearDraft(callID) { conversationStore.clearToolDraft(callID); },
}, {
requestCancel(operationID) {
const transition = interactionKernel.requestTaskCancel(operationID, new Date().toISOString());
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
return transition.disposition === "accepted";
},
read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); },
}, {
async approve(callID) { return approveCapabilityCall(callID); },
reject(callID) { return rejectCapabilityCall(callID); },
expire(callID) { return expireCapabilityCall(callID); },
read(callID) { return capabilityCalls.get(callID); },
// Chat 是 Runtime 上的 Core App。它不接收 Transport 原始 payloadRuntime 会先完成
// 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。
chatSDK.subscribeAgentMessages(message => {
// 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。
// ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。
projectExecutionProgress(message.item, message.received_at);
renderIncoming(message.item);
chatSDK.acknowledgeAgentMessage(message.message_id);
});
chatSDK.subscribeEvents(event => {
// 这些是 Runtime 给 Chat 的安全事件投影,不包含原始 Agent delivery。
if (event.type === "local-item") {
renderIncoming(event.item);
return;
}
if (event.type === "delivery" && event.item?.kind === "user-message") {
rendererContext.updateUserDelivery(event.local_id, event.item.delivery);
return;
}
if (event.type === "sync-status") {
rendererContext.setPresence(event.status === "syncing" ? "正在同步消息…" : event.status === "idle" ? "已同步,等待消息" : "同步中断,正在重试…");
return;
}
if (event.type === "scope-rejected") {
rendererContext.renderSystemMessage("收到作用域不匹配的 Agent 消息,已安全忽略。");
}
});
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("execution-summary", (item, context) => context.renderExecutionTrace(item));
rendererRegistry.register("progress", (item, context) => context.renderProgress(item));
rendererRegistry.register("error", (item, context) => context.renderError(item));
rendererRegistry.register("tool-call", (item, context) => context.renderToolCall(item));
rendererRegistry.register("tool-result", (item, context) => context.renderToolResult(item));
rendererRegistry.register("tool-cancel", (item, context) => context.renderToolCancel(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 {
// 这是 Chat 的统一表现入口。只有 Runtime 已经验证并转换成 ConversationItem 的内容
// 才能到达这里;高风险类型仍需经过各自状态机或策略再次检查。
if (item.kind === "app-call") {
// App/Capability 请求不能直接变成按钮点击。先解析能力声明,再交给状态机记录
// pending 状态;Renderer 只显示确认卡片,真正执行发生在用户批准之后。
const now = new Date().toISOString();
const parsed = parseCapabilityCall(item.envelope.payload, capabilityRegistry, now);
if (parsed.disposition === "unsupported") {
@@ -264,6 +263,8 @@ function renderIncoming(item: ConversationItem): void {
return;
}
if (item.kind === "artifact-offer") {
// Artifact 先验证元数据和可选内容,再由 Host 负责显示。持久化只保存元数据,
// 不把二进制内容写入对话 Store。
const artifact = artifactState.offer(item.envelope.payload);
if (!artifact) {
rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 ");
@@ -274,28 +275,32 @@ function renderIncoming(item: ConversationItem): void {
return;
}
if (item.kind === "surface") {
// Surface 生命周期消息分别对应打开、更新和关闭。每一步都由
// SurfaceInstanceManager 做幂等、作用域和状态跃迁检查。
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());
if (result.disposition === "accepted") runtime.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());
if (result.disposition === "accepted") runtime.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());
runtime.replaceSurfaces(surfaceInstances.snapshot());
return;
}
}
// 普通文本、状态、Tool、错误和 fallback 等内容交给受信 Renderer Registry
// 未注册的类型只能安全降级为提示,不能执行未知脚本。
if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。");
}
@@ -310,7 +315,10 @@ function renderIncoming(item: ConversationItem): void {
* through SurfaceInstanceManager and SurfaceRegistry.
*/
function replayStoredSurfaceLifecycle(): void {
const snapshot = conversationStore.open(currentConversationKey());
// 某个 Surface 可能在对应本地应用启用前就已到达。Runtime 保留了对话记录,
// 用户启用应用后这里只重放持久化的 Surface 过渡,不会自动下载、启用或放宽校验。
const snapshot = runtime.snapshot();
if (!snapshot) return;
for (const stored of snapshot.items) {
if (stored.item.kind === "surface") renderIncoming(stored.item);
}
@@ -322,26 +330,23 @@ function replayStoredSurfaceLifecycle(): void {
* availability changes, never because of a retry.
*/
async function publishInventory(): Promise<void> {
if (!session.active || !session.uid || !transport) return;
const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory());
// Inventory 是“当前客户端有哪些公开能力”的声明,不是 Agent 发来的执行命令。
// revision 没变化时不重复发送;发送失败交由 Runtime outbox/sync loop 重试。
const session = runtime.getSession();
if (!session.active || !session.uid) return;
const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory(), runtime.apps.list());
if (update.inventory.revision === lastInventoryRevision) return;
const envelope = makeProtocolEvent(
"lineup.v1.client.inventory",
update.inventory as unknown as JsonObject,
currentConversationKey(),
{ kind: "human", id: session.uid },
{ kind: "agent", id: session.agentUID },
);
await transport.sendText({ from_uid: session.uid, channel_id: session.channelID, channel_type: session.channelType, payload: JSON.stringify(envelope) });
await runtime.sendProtocolEnvelope("lineup.v1.client.inventory", update.inventory as unknown as JsonObject);
lastInventoryRevision = update.inventory.revision;
}
$<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
// 这是当前 MVP 的本地演示开关,用于启用一个可信 Surface 并刷新 Agent 可见 Inventory。
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());
runtime.replaceSurfaces(surfaceInstances.snapshot());
void publishInventory();
});
@@ -350,16 +355,18 @@ $<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
* the separately validated adapter trace, never from Agent status/detail.
*/
function projectExecutionProgress(item: ConversationItem, now: string): void {
// 将 Agent 的 thinking、validated execution trace 和终态错误/idle 状态聚合成一张
// 可恢复的执行摘要卡片。原始 status/detail 不被当作 Agent 的“思维内容”展示。
if (item.kind === "agent-status" && item.status === "thinking" && item.envelope?.sender.kind === "agent") {
const summary = executionProgress.begin(item.execution_id ?? newLocalID("execution"), now);
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
return;
}
if (item.kind === "execution-summary") {
const summary = executionProgress.applyTrace(item.trace.execution_id, item.trace.status, item.trace.steps, now);
if (summary) {
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
return;
@@ -369,12 +376,13 @@ function projectExecutionProgress(item: ConversationItem, now: string): void {
const executionID = item.kind === "agent-status" ? item.execution_id : undefined;
const summary = executionProgress.finish(executionID, terminal, now);
if (!summary) return;
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
function currentConversationKey(): string {
return `${session.uid}:${session.channelType}:${session.channelID}`;
// Surface 和全局 Runtime 事件都需要一个会话键;未登录时使用保留的全局作用域。
return runtime.currentConversationID() ?? "runtime:global";
}
/**
@@ -383,6 +391,8 @@ function currentConversationKey(): string {
* cache entry the capability is absent from the next client.inventory.
*/
function reconcileArtifactSaveAvailability(): void {
// 只有“已验证 Artifact + Host 仍持有对应临时内容”同时成立时,才把 artifact.save
// 暴露给 Agent。内容失效后要立即从下一版 Inventory 中撤回该能力。
const available = artifactState.snapshot().some(artifact => artifact.integrity === "verified" && artifactContentCache.has(artifact.local_ref));
const before = capabilityRegistry.resolve("artifact.save").disposition === "available";
if (before === available) return;
@@ -391,6 +401,8 @@ function reconcileArtifactSaveAvailability(): void {
}
function saveCachedArtifact(artifactID: string): void {
// 这里只能保存 Host 自己缓存的 Blob。协议里的 local_ref 是不透明引用,绝不当作
// 文件路径或 URL 访问,下载完成后立即释放临时 Object URL。
const artifact = artifactState.get(artifactID);
const blob = artifact?.integrity === "verified" && artifact.local_ref ? artifactContentCache.get(artifact.local_ref) : undefined;
if (!artifact || !blob) throw new Error("artifact_cache_unavailable");
@@ -415,6 +427,8 @@ function saveCachedArtifact(artifactID: string): void {
* reflected into a protocol result.
*/
async function writeClipboardFromTrustedHost(text: string): Promise<void> {
// 复制操作只能在 Capability 状态机批准后执行。HTTPS/Tauri 优先使用 Clipboard API
// HTTP 开发 Host 退回到用户激活约束下的 execCommand,不持久化剪贴板内容。
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
@@ -445,6 +459,8 @@ async function writeClipboardFromTrustedHost(text: string): Promise<void> {
* local ref before it enters the host-owned volatile cache.
*/
function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: number; local_ref?: string }): boolean {
// 可选二进制内容必须是有大小上限的 base64,并且实际字节数要和 Artifact 元数据一致。
// 通过后只进入 Host 的易失缓存,不进入 Runtime 的持久化对话记录。
const encoded = payload.content_base64;
if (encoded === undefined) return true;
if (typeof encoded !== "string" || !artifact.local_ref || encoded.length > 8 * 1024 * 1024) return false;
@@ -462,6 +478,8 @@ function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: numbe
* or silently restore a save operation without a new host-owned offer.
*/
function prepareArtifactOffer(item: Extract<ConversationItem, { kind: "artifact-offer" }>): Extract<ConversationItem, { kind: "artifact-offer" }> | undefined {
// Runtime 写入 Store 前先调用这个 Host hook:缓存内容后从持久化 payload 移除 base64
// 让刷新后仍能恢复 Artifact 元数据,但不能无提示地恢复本地文件保存动作。
const payload = item.envelope.payload;
const localRef = typeof payload.local_ref === "string" ? payload.local_ref : undefined;
const size = typeof payload.size_bytes === "number" ? payload.size_bytes : -1;
@@ -472,12 +490,14 @@ function prepareArtifactOffer(item: Extract<ConversationItem, { kind: "artifact-
}
function persistCapabilityCall(record: CapabilityCallRecord, occurredAt: string): void {
conversationStore.replaceCapabilityCalls(capabilityCalls.snapshot());
// Capability 状态变化同时更新 Runtime 快照,并写入不含敏感参数的最小审计记录。
runtime.replaceCapabilityCalls(capabilityCalls.snapshot());
const resolution = capabilityRegistry.resolve(record.request.capability);
if (resolution.disposition === "available") capabilityAudit.record(record, resolution.definition.risk, occurredAt);
}
function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined {
// 用户拒绝能力请求:只推进本地状态并向 Agent 回传受限结果,不执行 Host handler。
const now = new Date().toISOString();
const transition = capabilityCalls.reject(callID, now);
if (transition.disposition !== "accepted" || !transition.record) return transition.record;
@@ -487,6 +507,7 @@ function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined
}
function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined {
// 请求过期与拒绝类似,属于终态;过期请求不能再次被批准或执行。
const now = new Date().toISOString();
const transition = capabilityCalls.expire(callID, now);
if (transition.disposition !== "accepted" || !transition.record) return transition.record;
@@ -496,6 +517,8 @@ function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined
}
async function approveCapabilityCall(callID: string): Promise<CapabilityCallRecord | undefined> {
// 用户批准后仍要重新检查当前 Capability Policy,防止“显示确认卡片后策略已变化”
// 的旧请求绕过最新权限。只有状态机进入 executing 才会调用 Host handler。
let now = new Date().toISOString();
const approval = capabilityCalls.approve(callID, now);
if (approval.disposition !== "accepted" || !approval.record) return approval.record;
@@ -536,17 +559,17 @@ async function approveCapabilityCall(callID: string): Promise<CapabilityCallReco
* idempotency boundary: only a newly accepted terminal transition queues it.
*/
function queueAppResult(record: CapabilityCallRecord): void {
if (!session.uid) return;
// 回传给 Agent 的只是有限结果(call_id、能力名、状态和有限错误码),不包含 URL、
// 剪贴板正文、本地路径、异常堆栈或 Artifact 引用等敏感输入。
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) return;
const payload = safeCapabilityResult(record);
const createdAt = new Date().toISOString();
const envelope = makeProtocolEvent("lineup.v1.app.result", payload, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID });
try {
conversationStore.enqueueToolAction(newLocalID("app-result"), "app-result", JSON.stringify(envelope), createdAt);
void flushOutbox();
} catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
void chatSDK.dispatch({ type: "chat.report_app_result", conversation_id: conversationID, result: payload })
.catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason)));
}
function safeCapabilityResult(record: CapabilityCallRecord): JsonObject {
// 构造稳定、可幂等消费的能力结果投影;成功只报告 completed,失败只报告受控 code。
const outcome: JsonObject = {
call_id: record.call_id,
capability: record.request.capability,
@@ -558,6 +581,8 @@ function safeCapabilityResult(record: CapabilityCallRecord): JsonObject {
}
function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
// 通过浏览器原生文件选择器获得最小文件元数据。这里不读取或保存本地真实路径,
// 用户取消选择时也必须显式结束 pending Capability,避免状态机永久执行中。
return new Promise(resolve => {
const input = document.createElement("input");
input.type = "file";
@@ -592,160 +617,19 @@ function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
});
}
function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, payload: JsonObject): void {
if (!session.uid) {
rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。");
return;
}
const createdAt = new Date().toISOString();
const localID = newLocalID("tool");
const type = kind === "tool-result" ? "lineup.v1.tool.result" : "lineup.v1.tool.cancel";
const envelope = makeProtocolEvent(
type, payload, currentConversationKey(),
{ kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID },
);
try {
// Durable enqueue precedes the HTTP request: a reload or transient
// network failure cannot turn one accepted UI action into an untracked
// second submission.
conversationStore.enqueueToolAction(localID, kind, JSON.stringify(envelope), createdAt);
} catch (reason) {
rendererContext.renderSystemMessage(storageFailureMessage(reason));
return;
}
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;
try {
for (const entry of conversationStore.snapshot().outbox) {
try {
const sequence = await transport.sendText({
from_uid: session.uid,
channel_id: session.channelID,
channel_type: session.channelType,
payload: entry.payload,
});
if (entry.kind === "text") {
if (sequence) conversationStore.markSubmitted(entry.local_id, sequence, new Date().toISOString());
rendererContext.updateUserDelivery(entry.local_id, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) });
} else {
conversationStore.acknowledgeOutbox(entry.local_id);
}
} catch (reason) {
if (entry.kind === "text") {
const message = reason instanceof Error ? reason.message : "发送失败";
conversationStore.markFailed(entry.local_id, message, new Date().toISOString());
rendererContext.updateUserDelivery(entry.local_id, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true });
}
// Keep this and later entries durable; the next sync/login cycle
// retries in original order.
return;
}
}
} finally {
flushingOutbox = false;
}
}
function decodeBase64(value: string): string {
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; }
}
async function syncLoop(): Promise<void> {
if (polling) return;
polling = true;
while (session.active) {
try {
try { await publishInventory(); } catch { /* retried by the next loop */ }
// WuKongIM's legacy sync endpoint can return a single retained message
// per request even when `more` is 0. Drain until it is empty before
// switching to the paced live-polling loop; otherwise a reopened chat
// with history needs minutes to reach the latest Agent response.
while (session.active) {
const startMessageSeq = session.lastSeq === 0 ? 0 : session.lastSeq + 1;
const activeTransport = transport;
if (!activeTransport) throw new Error("当前会话传输尚未初始化。");
const synced = await activeTransport.sync({
channel_id: session.channelID,
channel_type: session.channelType,
login_uid: session.uid,
start_message_seq: startMessageSeq,
limit: 50,
});
if (synced.length === 0) {
rendererContext.setPresence("已同步,等待消息");
break;
}
let advanced = false;
for (const message of synced) {
const previousSeq = session.lastSeq;
session.lastSeq = Math.max(session.lastSeq, message.message_seq);
advanced ||= session.lastSeq > previousSeq;
conversationStore.advanceCursor(message.message_seq);
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" || ownEnvelope.envelope.type === "lineup.v1.ui.event" || ownEnvelope.envelope.type === "lineup.v1.client.inventory" || ownEnvelope.envelope.type === "lineup.v1.app.result")) {
// Tool action echoes advance the inclusive cursor but are never
// rendered as raw JSON user messages or replayed as a new action.
continue;
}
const restored = conversationStore.recordSyncedUserText(message.message_seq, payload, new Date().toISOString());
if (restored) renderIncoming(restored.item);
} else {
const result = interactionKernel.ingest({
raw: payload,
source: "sync",
transport_id: String(message.message_seq),
});
if (result.items.some(item => item.kind === "tool-call" || item.kind === "tool-result" || item.kind === "tool-cancel")) {
conversationStore.replaceToolCalls(result.snapshot.tool_calls);
}
if (result.items.some(item => item.kind === "progress" && item.task)) {
conversationStore.replaceTasks(result.snapshot.tasks);
}
for (const item of result.items) {
const receivedAt = new Date().toISOString();
const safeItem = item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item;
if (!safeItem) continue;
if (conversationStore.recordIncoming(safeItem, message.message_seq, receivedAt)) {
projectExecutionProgress(safeItem, receivedAt);
renderIncoming(safeItem);
}
}
}
}
// Avoid a hot loop if an upstream response repeats a sequence that
// cannot advance the local cursor.
if (!advanced) {
rendererContext.setPresence("已同步,等待消息");
break;
}
}
} catch {
if (session.active) rendererContext.setPresence("同步中断,正在重试…");
}
await new Promise(resolve => window.setTimeout(resolve, 1500));
}
polling = false;
// Surface 内部事件只转换为 Runtime Action;它不能直接发送网络消息或调用系统能力。
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) return;
void chatSDK.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID })
.then(receipt => {
if (receipt.disposition === "accepted") rendererContext.renderSystemMessage("已请求取消任务。");
})
.catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason)));
}
// 登录是唯一会创建 Runtime 远端会话的页面操作。登录成功后由 Runtime 返回恢复快照,
// main.ts 负责把快照投影到 Chat、挂载现有 Surface,并启动同步循环。
$<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
event.preventDefault();
const apiInput = $<HTMLInputElement>("#api");
@@ -759,110 +643,74 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; }
if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#code").focus(); return; }
session.api = endpoint;
localStorage.setItem("lineup.api", session.api);
error.textContent = `正在连接 ${session.api}`;
localStorage.setItem("lineup.api", endpoint);
error.textContent = `正在连接 ${endpoint}`;
submit.disabled = true;
submit.textContent = "正在进入…";
try {
const candidateTransport = new HttpTransportAdapter(session.api);
const body = await candidateTransport.login({ phone, code });
interactionKernel.reset();
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());
// Runtime 内部负责 Transport 登录、打开 Conversation Store 和恢复 Tool/Task 状态。
const snapshot = await runtime.login({
api: endpoint,
phone,
code,
fallback_agent_uid: "agent_hermes_main",
fallback_channel_id: "agent_default_channel",
fallback_channel_type: 2,
});
// 先恢复可持久化的交互状态,再清空并重建页面投影。
surfaceInstances.restore(snapshot.surfaces);
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks);
capabilityCalls.restore(snapshot.capability_calls);
executionProgress.restore(snapshot.execution_summaries);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
session.lastSeq = snapshot.cursor;
messages.replaceChildren(); rendererContext.reset();
loginView.hidden = true; chatView.hidden = false;
// Status envelopes describe ephemeral activity. The durable M1-08 summary
// below is the only restored execution view, so historical `thinking`
// messages cannot create a fresh or expanded pseudo-live card on reload.
// thinking 等状态消息只描述当时的临时活动,不在刷新后重新创建一张“正在执行”卡;
// 可恢复的执行摘要由下面的 restoreExecutionSummaries 统一恢复。
for (const stored of snapshot.items) {
if (stored.item.kind !== "agent-status") renderIncoming(stored.item);
}
// Runtime retained Agent deliveries while this Core App was not mounted.
// The restored transcript above is now their successful Chat projection,
// so acknowledge the durable Inbox records without replaying duplicate
// DOM nodes or re-executing a user action.
// 这些 Inbox 消息已经通过持久化 transcript 成功渲染,因此这里只 ACK,不再次渲染,
// 避免刷新后出现重复消息或重新执行用户动作。
for (const pending of chatSDK.listAgentMessages()) chatSDK.acknowledgeAgentMessage(pending.message_id);
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
await mountM4SignedFixture();
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop();
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…");
void publishInventory();
// 页面恢复完成后才开始增量同步,避免历史恢复与实时投递交错造成重复显示。
runtime.startSync();
} catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
});
// 发送消息必须通过 Chat SDK 提交 Runtime Action。Runtime 会负责本地回显、outbox、
// 作用域校验和 Transport 发送,页面只负责采集输入和显示结果。
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
event.preventDefault();
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
if (!text) return;
if (!session.uid) {
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) {
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
input.focus();
return;
}
input.value = ""; send.disabled = true;
const localID = newLocalID("local");
const createdAt = new Date().toISOString();
// 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 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; rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
try {
if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。");
const sequence = await transport.sendText({
from_uid: session.uid,
channel_id: session.channelID,
channel_type: session.channelType,
payload: text,
});
if (sequence && storedLocally) {
try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); }
catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
}
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) { rendererContext.renderSystemMessage(storageFailureMessage(storageReason)); }
}
rendererContext.updateUserDelivery(localID, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true });
rendererContext.renderSystemMessage(message);
const receipt = await chatSDK.dispatch({ type: "chat.send_text", conversation_id: conversationID, text });
if (receipt.disposition === "rejected") rendererContext.renderSystemMessage("当前会话无法发送消息,请退出后重新登录。");
} catch (reason) {
rendererContext.renderSystemMessage(storageFailureMessage(reason));
}
finally { send.disabled = false; input.focus(); }
});
$<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,
payload: JsonObject,
conversationID: string,
sender: Actor,
target?: Actor,
): Envelope {
return {
v: 1,
id: newLocalID("msg"),
type,
conversation_id: conversationID,
sender,
...(target ? { target } : {}),
timestamp: new Date().toISOString(),
payload,
};
}
// 退出时停止同步、清理 Runtime 会话和当前页面投影,但保留 localStorage 中的配置/历史,
// 以便下一次登录时由 Runtime 重新恢复。
$<HTMLButtonElement>("#logout").addEventListener("click", () => { runtime.logout(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; });