feat(runtime): establish miniapp kernel and sdk design
This commit is contained in:
+218
-370
@@ -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 原始 payload;Runtime 会先完成
|
||||
// 协议解析、作用域筛选、持久化和 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; });
|
||||
|
||||
Reference in New Issue
Block a user