Files
app/tauri/src/main.ts
T

823 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tauri/Desktop 与 Web Reference Host 的组合入口。
*
* 本文件负责创建 Host 侧适配、LineUpRuntime 和默认 Chat Core App,并连接可信的
* Renderer、Surface、Capability 集成。Transport、Store、同步循环、outbox 与原始
* Agent 消息解析均由 Runtime 独占,不能重新回流到此入口。
*/
// 协议层只提供经过 Runtime 校验后的表现模型类型;本入口不直接解析原始网络数据。
import {
type ConversationItem,
type JsonObject,
} 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 }>;
}
interface Window {
/** Development-only M4 signed-bundle browser acceptance controller. */
__lineupM4Fixture?: Readonly<{ patch: () => void; close: () => void }>;
}
}
// 这是登录表单首次显示时使用的本地默认地址;用户提交后的地址会保存到 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);
const capabilityRegistry = new CapabilityRegistry();
const inventoryPublisher = new ClientInventoryPublisher();
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
// create the tab. Treat a synchronous exception as failure, but not this
// security-preserving return value; otherwise a successful safe open is
// incorrectly reported as `handler_failed` to the Agent.
window.open(url, "_blank", "noopener,noreferrer");
},
writeClipboard: writeClipboardFromTrustedHost,
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");
// 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;
};
// 当前默认 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, appSessions, 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); },
async request(instanceID, method, payload) {
return runtime.handleMiniAppBridgeRequest(instanceID, method, payload);
},
});
// These are the two shipped reference MiniApps. They are mounted through the
// same restricted SDK as any future bundled MiniApp; this adapter only wires
// lifecycle to the SDK object and never hands them Runtime internals.
const bundledMiniApps = new Set<string>();
function reconcileBundledMiniApps(): void {
const active = new Set<string>();
for (const instance of runtime.lifecycle.instances.list(runtime.currentConversationID())) {
if (instance.app_scope !== "task-dashboard" && instance.app_scope !== "whiteboard") continue;
if (!["starting", "foreground", "background", "suspended"].includes(instance.state)) continue;
active.add(instance.instance_id);
if (bundledMiniApps.has(instance.instance_id)) continue;
const receipt = runtime.openBundledSurface(instance.app_scope, instance.instance_id);
if (receipt.disposition === "accepted") bundledMiniApps.add(instance.instance_id);
}
for (const instanceID of bundledMiniApps) {
if (active.has(instanceID)) continue;
bundledMiniApps.delete(instanceID);
}
}
runtime.onEvent(event => {
if (event.type === "app-lifecycle") reconcileBundledMiniApps();
});
/**
* Bundled MiniApps request local Surfaces through Runtime. The Host adapter
* is the only place that turns the already-scoped request into a DOM iframe;
* no request is sent back through the Agent protocol.
*/
runtime.onEvent(event => {
if (event.type !== "surface-request") return;
const now = new Date().toISOString();
const appID = event.app_scope === "task-dashboard" ? "lineup.task-dashboard" : event.app_scope === "whiteboard" ? "lineup.whiteboard" : undefined;
if (!appID) return;
const version = "0.1.0";
if (event.event === "open") {
const result = surfaceInstances.open({ instance_id: event.instance_id, app: { app_id: appID, version }, state: event.data.state ?? event.data }, currentConversationKey(), now);
if (result.instance && (result.disposition === "accepted" || result.disposition === "duplicate")) surfaceHost.mount(result.instance);
if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot());
return;
}
if (event.event === "patch") {
const result = surfaceInstances.patch(event.instance_id, event.data.state ?? event.data, now);
if (result.instance && result.disposition === "accepted") surfaceHost.update(result.instance);
if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot());
return;
}
surfaceInstances.close(event.instance_id);
surfaceHost.unmount(event.instance_id);
runtime.replaceSurfaces(surfaceInstances.snapshot());
});
// These two Surfaces are shipped with the Host for SDK v1. Enabling them here
// does not create a market or download path; it only admits the exact local
// manifests used by the bundled reference MiniApps.
for (const [appID, version] of [["lineup.task-dashboard", "0.1.0"], ["lineup.whiteboard", "0.1.0"]] as const) {
surfaceRegistry.enable(appID, version, new Date().toISOString());
}
$<HTMLInputElement>("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API;
/**
* A pre-signed public fixture used only by the headed M4 acceptance route.
* It contains no private key, is excluded from production builds, and forces
* 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));
const manifest = {
v: 1, app_id: "lineup.m4-signed-fixture", version: "1.0.0",
artifact: { artifact_id: "m4-signed-fixture", sha256: "98cbcf29d38cf08ae6385539b3ac7359710309a1b752a14b60c8cfc8a39f1a37", size_bytes: 396 },
min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "m4-e2e-key",
signature: "dCORfPL6LTxR8CB5mVss5fEeGozTdfN3L7Td27BYgxJ518yNWAmI4FEhfrjEoc_Wovwn8u8HI5BLBjWSUxZ6Dg",
} as const;
const cache = new VerifiedSurfaceBundleCache(new WebCryptoEd25519SurfaceManifestSignatureVerifier({ "m4-e2e-key": "AKx_a1NVmsJKbqY9Jw55hLp8r01TcCadjZNp1u2POLI" }));
const policy = new ProductionSurfacePolicy(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: ["m4-e2e-key"] }), cache);
const downloader = new TrustedSurfaceBundleDownloader("https://m4-e2e.lineup.invalid/release", async url => ({ ok: true, url: url.toString(), async bytes() { return bundle; } }));
if ((await policy.downloadAndEnable(manifest, downloader)).disposition !== "installed") throw new Error("M4 signed fixture verification failed.");
const instances = new SurfaceInstanceManager(policy);
const host = new IsolatedSurfaceHost(messages, {
ready(instanceID) { instances.ready(instanceID, new Date().toISOString()); },
event() { /* fixture grants no outbound capabilities or transport events */ },
}, new CachedVerifiedSurfaceDocumentResolver(cache));
const opened = instances.open({ instance_id: "m4:signed-fixture", app: { app_id: manifest.app_id, version: manifest.version }, state: { status: "签名验证后已挂载" } }, currentConversationKey(), new Date().toISOString());
if (opened.disposition !== "accepted" || !opened.instance) throw new Error(`M4 signed fixture open rejected: ${opened.disposition}`);
host.mount(opened.instance);
window.__lineupM4Fixture = {
patch() {
const result = instances.patch("m4:signed-fixture", { status: "已通过可信 patch 更新" }, new Date().toISOString());
if (result.disposition === "accepted" && result.instance) host.update(result.instance);
},
close() { host.unmount("m4:signed-fixture"); instances.close("m4:signed-fixture"); },
};
}
/** 生成只在本地 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
// transport identity, so the timestamp-and-random fallback is sufficient
// and must not prevent the form submit handler from reaching HTTP.
const uuid = globalThis.crypto?.randomUUID?.();
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 地址。");
const withProtocol = /^https?:\/\//i.test(candidate) ? candidate : `http://${candidate}`;
let parsed: URL;
try { parsed = new URL(withProtocol); } catch { throw new Error("AppServer 地址格式不正确,例如 http://100.121.118.116:8090。"); }
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("AppServer 地址仅支持 http:// 或 https://。");
if (!parsed.hostname) throw new Error("AppServer 地址缺少主机名或 IP 地址。");
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}`;
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。请确认当前网络可以访问${destination}`;
return message || "登录失败,请稍后重试。";
}
// 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.subscribe(view => renderAppSessions(view.app_sessions));
function renderAppSessions(sessions: readonly {
app_session_id: string;
app_scope: string;
instance_id: string;
state: string;
interaction_count: number;
pending_interaction_count: number;
history: readonly { id: string; role: "agent" | "user"; text: string }[];
continued_from_app_session_id?: string;
}[]): void {
appSessions.replaceChildren();
for (const session of sessions.filter(candidate => candidate.app_scope !== "chat")) {
const group = document.createElement("details");
group.className = "app-session-group";
const summary = document.createElement("summary");
const name = session.app_scope === "task-dashboard" ? "Task Dashboard" : session.app_scope === "whiteboard" ? "Whiteboard" : session.app_scope;
const pending = session.pending_interaction_count > 0 ? ` · 等待回答 ${session.pending_interaction_count}` : "";
summary.textContent = `${name} · ${session.interaction_count} 条交互${pending}`;
const detail = document.createElement("small");
detail.textContent = session.state === "stopped" || session.state === "failed" ? "已结束,只读历史" : session.state === "background" ? "后台运行" : "当前应用会话";
group.append(summary, detail);
const history = document.createElement("div");
history.className = "app-session-history";
for (const entry of session.history) {
const line = document.createElement("p");
line.className = `app-session-history-${entry.role}`;
line.textContent = `${entry.role === "agent" ? "Agent" : "你"}${entry.text}`;
history.append(line);
}
group.append(history);
if (session.state === "stopped" || session.state === "failed") {
const continueButton = document.createElement("button");
continueButton.type = "button";
continueButton.textContent = "继续处理";
continueButton.addEventListener("click", () => {
void chatSDK.dispatch({ type: "chat.continue_app_session", conversation_id: chatSDK.snapshot().conversation_id ?? "", app_session_id: session.app_session_id });
});
group.append(continueButton);
}
appSessions.append(group);
}
}
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 消息,已安全忽略。");
}
});
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") {
rendererContext.renderSystemMessage("此 App 能力当前未在本机启用,未执行任何操作。");
return;
}
if (parsed.disposition !== "accepted") {
rendererContext.renderSystemMessage("收到无法安全确认的 App 能力请求。");
return;
}
const transition = capabilityCalls.receive(parsed.request, item.envelope.conversation_id, now);
if (transition.disposition === "accepted" && transition.record) persistCapabilityCall(transition.record, now);
// Both an accepted call and an exact protocol duplicate render from the
// state machine. A duplicate cannot create a second actionable card.
rendererContext.renderAppCall(item);
return;
}
if (item.kind === "artifact-offer") {
// Artifact 先验证元数据和可选内容,再由 Host 负责显示。持久化只保存元数据,
// 不把二进制内容写入对话 Store。
const artifact = artifactState.offer(item.envelope.payload);
if (!artifact) {
rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 ");
return;
}
rendererContext.renderArtifact(artifact);
reconcileArtifactSaveAvailability();
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") 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") 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);
runtime.replaceSurfaces(surfaceInstances.snapshot());
return;
}
}
// 普通文本、状态、Tool、错误和 fallback 等内容交给受信 Renderer Registry
// 未注册的类型只能安全降级为提示,不能执行未知脚本。
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 {
// 某个 Surface 可能在对应本地应用启用前就已到达。Runtime 保留了对话记录,
// 用户启用应用后这里只重放持久化的 Surface 过渡,不会自动下载、启用或放宽校验。
const snapshot = runtime.snapshot();
if (!snapshot) return;
for (const stored of snapshot.items) {
if (stored.item.kind === "surface") renderIncoming(stored.item);
}
}
/**
* Inventory is a declarative host-to-Agent update, never an action request.
* Failed delivery is retried by syncLoop; a revision changes only when public
* availability changes, never because of a retry.
*/
async function publishInventory(): Promise<void> {
// 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;
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();
runtime.replaceSurfaces(surfaceInstances.snapshot());
void publishInventory();
});
/**
* Status only begins/ends a turn. Public operation labels can only come from
* 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);
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) {
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
return;
}
const terminal = item.kind === "error" ? "failed" : (item.kind === "agent-status" && item.status === "idle" ? "completed" : undefined);
if (!terminal) return;
const executionID = item.kind === "agent-status" ? item.execution_id : undefined;
const summary = executionProgress.finish(executionID, terminal, now);
if (!summary) return;
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
function currentConversationKey(): string {
// Surface 和全局 Runtime 事件都需要一个会话键;未登录时使用保留的全局作用域。
return runtime.currentConversationID() ?? "runtime:global";
}
/**
* Only host-owned bytes can make artifact.save requestable. The protocol's
* local_ref is opaque and never dereferenced as a path/URL; if a host has no
* 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;
capabilityRegistry.setVisible("artifact.save", available);
void publishInventory();
}
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");
const objectURL = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectURL;
anchor.download = artifact.name;
anchor.hidden = true;
document.body.append(anchor);
try { anchor.click(); } finally {
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectURL), 0);
}
}
/**
* HTTPS/Tauri hosts use the standard asynchronous Clipboard API. The HTTP
* development reference host cannot rely on that API being exposed, so it
* falls back to the browser's user-activation-gated copy command. Both paths
* are reached only after the native capability confirmation; the temporary
* textarea is removed synchronously and no clipboard value is persisted or
* 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);
return;
} catch {
// Development HTTP contexts often expose the property but reject the
// permission-gated call. Fall through to the user-activation fallback.
}
}
const input = document.createElement("textarea");
input.value = text;
input.readOnly = true;
input.setAttribute("aria-hidden", "true");
input.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none";
document.body.append(input);
try {
input.focus();
input.select();
if (!document.execCommand("copy")) throw new Error("clipboard_copy_unavailable");
} finally {
input.remove();
}
}
/**
* An optional small binary body is a bounded delivery payload, not a URL or
* filesystem reference. It must bind exactly to the metadata size and opaque
* 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;
try {
const binary = atob(encoded);
if (binary.length !== artifact.size_bytes || binary.length > 6 * 1024 * 1024) return false;
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
return artifactContentCache.put(artifact.local_ref, new Blob([bytes]));
} catch { return false; }
}
/**
* Cache optional bytes before persistence, then remove them from the durable
* transcript. A reload can show metadata but cannot retain volatile content
* 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;
if (!cacheArtifactContent(payload, { size_bytes: size, ...(localRef ? { local_ref: localRef } : {}) })) return undefined;
if (!("content_base64" in payload)) return item;
const { content_base64: _content, ...metadata } = payload;
return { ...item, envelope: { ...item.envelope, payload: metadata } };
}
function persistCapabilityCall(record: CapabilityCallRecord, occurredAt: string): void {
// 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;
persistCapabilityCall(transition.record, now);
queueAppResult(transition.record);
return transition.record;
}
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;
persistCapabilityCall(transition.record, now);
queueAppResult(transition.record);
return transition.record;
}
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;
persistCapabilityCall(approval.record, now);
// Re-check local policy at the moment authority is consumed. A stale
// inventory or a policy change between display and click cannot run a host
// handler merely because a confirmation card was previously visible.
const resolution = capabilityRegistry.resolve(approval.record.request.capability);
if (resolution.disposition !== "available") {
const unsupported = capabilityCalls.unsupported(callID, new Date().toISOString());
if (unsupported.record) {
persistCapabilityCall(unsupported.record, new Date().toISOString());
queueAppResult(unsupported.record);
}
return unsupported.record;
}
now = new Date().toISOString();
const beginning = capabilityCalls.begin(callID, now);
if (beginning.disposition !== "accepted" || !beginning.record) return beginning.record;
persistCapabilityCall(beginning.record, now);
const execution = await capabilityExecutor.execute(beginning.record);
now = new Date().toISOString();
const terminal = execution.status === "cancelled"
? capabilityCalls.cancel(callID, now)
: capabilityCalls.finish(callID, execution.status, execution.result, now);
if (terminal.record) {
persistCapabilityCall(terminal.record, now);
if (terminal.disposition === "accepted") queueAppResult(terminal.record);
}
return terminal.record;
}
/**
* Sends a bounded outcome projection, never a capability argument, native
* exception, local file path or Artifact reference. This is also the durable
* idempotency boundary: only a newly accepted terminal transition queues it.
*/
function queueAppResult(record: CapabilityCallRecord): void {
// 回传给 Agent 的只是有限结果(call_id、能力名、状态和有限错误码),不包含 URL、
// 剪贴板正文、本地路径、异常堆栈或 Artifact 引用等敏感输入。
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) return;
const payload = safeCapabilityResult(record);
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,
status: record.status,
};
if (record.status === "failed") outcome.code = typeof record.result?.code === "string" ? record.result.code : "failed";
if (record.status === "completed") outcome.completed = true;
return outcome;
}
function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
// 通过浏览器原生文件选择器获得最小文件元数据。这里不读取或保存本地真实路径,
// 用户取消选择时也必须显式结束 pending Capability,避免状态机永久执行中。
return new Promise(resolve => {
const input = document.createElement("input");
input.type = "file";
input.hidden = true;
let settled = false;
let onFocus: () => void;
const finish = (value: PickedFile | undefined) => {
if (settled) return;
settled = true;
window.removeEventListener("focus", onFocus);
input.remove();
resolve(value);
};
input.addEventListener("change", () => {
const file = input.files?.item(0);
finish(file ? { name: file.name, mime_type: file.type || "application/octet-stream", size_bytes: file.size } : undefined);
}, { once: true });
// Cancelling a native picker does not consistently dispatch `change`.
// Once focus returns to the trusted host, resolve a no-selection outcome;
// no input.value/path is ever read or retained.
onFocus = () => window.setTimeout(() => {
const file = input.files?.item(0);
if (!file) finish(undefined);
}, 0);
document.body.append(input);
// Register before triggering the native dialog. Some browser/desktop
// combinations return focus immediately on cancellation; registering in
// a later task can miss that only signal and leave the capability call
// permanently executing.
window.addEventListener("focus", onFocus, { once: true });
input.click();
});
}
function queueSurfaceCancel(instanceID: string): void {
// 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");
const phone = $<HTMLInputElement>("#phone").value.trim();
const code = $<HTMLInputElement>("#code").value.trim();
const error = $<HTMLElement>("#login-error");
const submit = $<HTMLButtonElement>("#login-submit");
let endpoint: string;
try { endpoint = normalizeAPIAddress(apiInput.value); }
catch (reason) { error.textContent = loginFailureMessage(reason); apiInput.focus(); return; }
if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; }
if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#code").focus(); return; }
localStorage.setItem("lineup.api", endpoint);
error.textContent = `正在连接 ${endpoint}`;
submit.disabled = true;
submit.textContent = "正在进入…";
try {
// 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);
capabilityCalls.restore(snapshot.capability_calls);
executionProgress.restore(snapshot.execution_summaries);
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();
// 页面恢复完成后才开始增量同步,避免历史恢复与实时投递交错造成重复显示。
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;
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) {
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
input.focus();
return;
}
input.value = ""; send.disabled = true;
try {
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(); }
});
// 退出时停止同步、清理 Runtime 会话和当前页面投影,但保留 localStorage 中的配置/历史,
// 以便下一次登录时由 Runtime 重新恢复。
$<HTMLButtonElement>("#logout").addEventListener("click", () => { runtime.logout(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; });