833 lines
46 KiB
TypeScript
833 lines
46 KiB
TypeScript
/**
|
||
* 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.
|
||
// 打包后的 Tauri(Android + macOS)都通过 Tailscale 访问远程 AppServer,
|
||
// 不能用 loopback(手机/沙盒里 127.0.0.1 指向自己),所以走 100.121.118.116。
|
||
// 仅浏览器本地 dev 模式(`http://127.0.0.1:1420` / `http://localhost:1420`)才回退到 loopback。
|
||
const DEFAULT_API = "__TAURI_INTERNALS__" in window
|
||
? "http://100.121.118.116:8090"
|
||
: 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.runtime.interactions.submit(callID, submission); },
|
||
cancel(callID, reason) { return chatSDK.runtime.interactions.cancel(callID, reason); },
|
||
expire(callID) { return chatSDK.runtime.interactions.expire(callID); },
|
||
read(callID) { return chatSDK.runtime.interactions.read(callID); },
|
||
loadDraft(callID) { return chatSDK.runtime.interactions.loadDraft(callID); },
|
||
saveDraft(callID, values) { chatSDK.runtime.interactions.saveDraft(callID, values); },
|
||
clearDraft(callID) { chatSDK.runtime.interactions.clearDraft(callID); },
|
||
},
|
||
taskInteractions: {
|
||
requestCancel(operationID) { return chatSDK.runtime.tasks.requestCancel(operationID); },
|
||
read(operationID) { return chatSDK.runtime.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 原始 payload;Runtime 会先完成
|
||
// 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。
|
||
chatSDK.ui.subscribeAgentMessages(message => {
|
||
// 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。
|
||
// ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。
|
||
projectExecutionProgress(message.item, message.received_at);
|
||
renderIncoming(message.item);
|
||
chatSDK.ui.acknowledgeAgentMessage(message.message_id);
|
||
});
|
||
|
||
chatSDK.ui.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.runtime.dispatch({ type: "chat.continue_app_session", conversation_id: chatSDK.ui.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;
|
||
if (!executionID) {
|
||
const summaries = executionProgress.finishAllActive(terminal, now);
|
||
if (summaries.length === 0) return;
|
||
runtime.replaceExecutionSummaries(executionProgress.snapshot());
|
||
for (const summary of summaries) rendererContext.renderExecutionSummary(summary);
|
||
return;
|
||
}
|
||
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.ui.snapshot().conversation_id;
|
||
if (!conversationID) return;
|
||
const payload = safeCapabilityResult(record);
|
||
void chatSDK.runtime.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.ui.snapshot().conversation_id;
|
||
if (!conversationID) return;
|
||
void chatSDK.runtime.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.ui.listAgentMessages()) chatSDK.ui.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.ui.snapshot().conversation_id;
|
||
if (!conversationID) {
|
||
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
|
||
input.focus();
|
||
return;
|
||
}
|
||
input.value = ""; send.disabled = true;
|
||
try {
|
||
const receipt = await chatSDK.runtime.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; });
|