feat(runtime): isolate bundled miniapps behind bridge
This commit is contained in:
+7
-19
@@ -27,8 +27,6 @@ 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";
|
||||
import { TaskDashboardMiniApp } from "@/core-apps/task-dashboard/task-dashboard-miniapp";
|
||||
import { WhiteboardMiniApp } from "@/core-apps/whiteboard/whiteboard-miniapp";
|
||||
|
||||
// Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。
|
||||
declare global {
|
||||
@@ -131,12 +129,15 @@ const { messages, appSessions, loginView, chatView, presence, rendererContext, r
|
||||
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 Map<string, { stop: () => void }>();
|
||||
const bundledMiniApps = new Set<string>();
|
||||
function reconcileBundledMiniApps(): void {
|
||||
const active = new Set<string>();
|
||||
for (const instance of runtime.lifecycle.instances.list(runtime.currentConversationID())) {
|
||||
@@ -144,24 +145,11 @@ function reconcileBundledMiniApps(): void {
|
||||
if (!["starting", "foreground", "background", "suspended"].includes(instance.state)) continue;
|
||||
active.add(instance.instance_id);
|
||||
if (bundledMiniApps.has(instance.instance_id)) continue;
|
||||
try {
|
||||
const sdk = runtime.openBundledMiniApp(instance.app_scope, instance.instance_id);
|
||||
if (instance.app_scope === "task-dashboard") {
|
||||
const miniapp = new TaskDashboardMiniApp(sdk);
|
||||
miniapp.start();
|
||||
bundledMiniApps.set(instance.instance_id, { stop: () => miniapp.stop() });
|
||||
} else {
|
||||
const miniapp = new WhiteboardMiniApp(sdk);
|
||||
miniapp.start();
|
||||
bundledMiniApps.set(instance.instance_id, { stop: () => { void miniapp.close(); } });
|
||||
}
|
||||
} catch (reason) {
|
||||
console.warn("[LineUp Host] bundled MiniApp mount rejected", instance.instance_id, reason);
|
||||
}
|
||||
const receipt = runtime.openBundledSurface(instance.app_scope, instance.instance_id);
|
||||
if (receipt.disposition === "accepted") bundledMiniApps.add(instance.instance_id);
|
||||
}
|
||||
for (const [instanceID, mounted] of bundledMiniApps) {
|
||||
for (const instanceID of bundledMiniApps) {
|
||||
if (active.has(instanceID)) continue;
|
||||
mounted.stop();
|
||||
bundledMiniApps.delete(instanceID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||
import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
||||
import type { ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
|
||||
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
@@ -42,6 +41,4 @@ export interface LineUpMiniAppSDK {
|
||||
readonly capabilities: {
|
||||
request(name: string, reason: string, input?: JsonObject): Promise<CommandReceipt>;
|
||||
};
|
||||
/** Read-only host state projection for recovery and diagnostics. */
|
||||
readonly workspace: () => AppWorkspaceSnapshot;
|
||||
}
|
||||
|
||||
@@ -237,6 +237,27 @@ describe("LineUpRuntime", () => {
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.result"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("isolates bundled MiniApp Inbox subscriptions by instance and does not expose global workspace", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => new FakeTransport() });
|
||||
await runtime.login(login);
|
||||
const first = runtime.lifecycle.start({ app_scope: "task-dashboard", instance_id: "task-dashboard:first", app_session_id: "task-dashboard:first-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
const second = runtime.lifecycle.start({ app_scope: "task-dashboard", instance_id: "task-dashboard:second", app_session_id: "task-dashboard:second-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(first.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(second.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
const sdk = runtime.openBundledMiniApp("task-dashboard", first.instance_id);
|
||||
const received: string[] = [];
|
||||
sdk.inbox.subscribe(message => received.push(message.message_id));
|
||||
const store = (runtime as unknown as { store: { enqueueAppInbox: (entry: unknown) => boolean } }).store;
|
||||
store.enqueueAppInbox({ message_id: "first-message", app_scope: "task-dashboard", instance_id: first.instance_id, conversation_id: conversationID, item: { kind: "markdown", id: "first-message", markdown: "first" }, received_at: "2026-08-04T00:00:00.000Z" });
|
||||
store.enqueueAppInbox({ message_id: "second-message", app_scope: "task-dashboard", instance_id: second.instance_id, conversation_id: conversationID, item: { kind: "markdown", id: "second-message", markdown: "second" }, received_at: "2026-08-04T00:00:00.000Z" });
|
||||
const emit = (runtime as unknown as { emit: (event: unknown) => void }).emit.bind(runtime);
|
||||
emit({ type: "agent-message", message: { message_id: "first-message", scope: { app_scope: "task-dashboard", conversation_id: conversationID }, item: { kind: "markdown", id: "first-message", markdown: "first" }, received_at: "2026-08-04T00:00:00.000Z" } });
|
||||
emit({ type: "agent-message", message: { message_id: "second-message", scope: { app_scope: "task-dashboard", conversation_id: conversationID }, item: { kind: "markdown", id: "second-message", markdown: "second" }, received_at: "2026-08-04T00:00:00.000Z" } });
|
||||
expect(received).toEqual(["first-message"]);
|
||||
expect("workspace" in sdk).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps bundled Surface requests local and scopes them to the current instance", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const launch = JSON.stringify({
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ConversationItem,
|
||||
type Envelope,
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
type ToolCallRequest,
|
||||
type MiniAppToolInvoke,
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
@@ -327,6 +328,9 @@ export class LineUpRuntime {
|
||||
subscribe: listener => {
|
||||
const unsubscribe = this.onEvent(event => {
|
||||
if (event.type !== "agent-message" || event.message.scope.app_scope !== appScope) return;
|
||||
const inboxEntry = this.store.listAppInbox(appScope, instanceID)
|
||||
.find(entry => entry.message_id === event.message.message_id);
|
||||
if (!inboxEntry) return;
|
||||
listener(event.message);
|
||||
});
|
||||
for (const message of scopedMessages()) listener(message);
|
||||
@@ -379,10 +383,91 @@ export class LineUpRuntime {
|
||||
capabilities: {
|
||||
request: (name, reason, input = {}) => this.queueProtocolAction("app-result", "lineup.v1.app.call", { instance_id: instanceID, app_scope: appScope, capability: name, reason, arguments: input, expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString() }),
|
||||
},
|
||||
workspace: () => this.workspaceSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Host-only entry point used by the isolated bundled Surface bootstrap. */
|
||||
public openBundledSurface(appScope: AppScope, instanceID: string): CommandReceipt {
|
||||
const state: JsonObject = appScope === "whiteboard"
|
||||
? { title: "Whiteboard", status: "可编辑", elements: [] }
|
||||
: { title: "任务面板", status: "等待 Agent 任务", percent: 0, steps: [] };
|
||||
return this.requestBundledSurface(appScope, instanceID, "open", { state });
|
||||
}
|
||||
|
||||
/**
|
||||
* The only parent-side endpoint exposed to an isolated bundled MiniApp.
|
||||
* It returns plain data and CommandReceipt values; Runtime objects never
|
||||
* cross the iframe boundary.
|
||||
*/
|
||||
public async handleMiniAppBridgeRequest(instanceID: string, method: string, payload: JsonObject): Promise<JsonObject> {
|
||||
const instance = this.lifecycle.instances.get(instanceID);
|
||||
const conversationID = this.currentConversationID();
|
||||
if (!this.session.active || !instance || !conversationID || instance.conversation_id !== conversationID
|
||||
|| !["starting", "foreground", "background", "suspended"].includes(instance.state)) {
|
||||
throw new Error("invalid_miniapp_instance");
|
||||
}
|
||||
const text = (key: string): string | undefined => typeof payload[key] === "string" && String(payload[key]).trim() ? String(payload[key]) : undefined;
|
||||
const callID = text("call_id");
|
||||
if (method === "tools.list") {
|
||||
return { calls: this.miniappTools.list().filter(call => call.instance_id === instanceID && call.conversation_id === conversationID) };
|
||||
}
|
||||
if (method === "inbox.list") {
|
||||
return { messages: JSON.parse(JSON.stringify(this.listAppMessages(instance.app_scope, undefined, instanceID))) as JsonValue[] };
|
||||
}
|
||||
if (method === "inbox.acknowledge") {
|
||||
const messageID = text("message_id");
|
||||
if (!messageID) throw new Error("invalid_message_id");
|
||||
return { acknowledged: this.store.acknowledgeAppInbox(instance.app_scope, messageID, instanceID) };
|
||||
}
|
||||
if (method === "tools.reportProgress") {
|
||||
if (!callID || !isMiniAppToolProgress(payload.progress)) throw new Error("invalid_progress");
|
||||
return this.bridgeReceipt(await this.reportMiniAppToolProgress(instanceID, callID, payload.progress));
|
||||
}
|
||||
if (method === "tools.complete") {
|
||||
if (!callID || !isJsonObject(payload.result)) throw new Error("invalid_tool_result");
|
||||
return this.bridgeReceipt(await this.completeMiniAppTool(instanceID, callID, payload.result));
|
||||
}
|
||||
if (method === "tools.fail") {
|
||||
if (!callID || typeof payload.message !== "string") throw new Error("invalid_tool_failure");
|
||||
return this.bridgeReceipt(await this.failMiniAppTool(instanceID, callID, { code: text("code"), message: payload.message }));
|
||||
}
|
||||
if (method === "tools.cancel") {
|
||||
if (!callID) throw new Error("invalid_tool_call");
|
||||
return this.bridgeReceipt(await this.cancelMiniAppTool(instanceID, callID, text("reason") ?? "app_cancelled"));
|
||||
}
|
||||
if (method === "lifecycle.foreground") {
|
||||
this.lifecycle.foreground(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||
return { disposition: "accepted" };
|
||||
}
|
||||
if (method === "lifecycle.background") {
|
||||
this.lifecycle.background(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||
return { disposition: "accepted" };
|
||||
}
|
||||
if (method === "lifecycle.close") {
|
||||
this.closeAppInstance(instanceID, text("reason"));
|
||||
return { disposition: "accepted" };
|
||||
}
|
||||
if (method === "surface.patch" || method === "surface.close") {
|
||||
const event = method === "surface.patch" ? "patch" : "close";
|
||||
const data = method === "surface.patch" && isJsonObject(payload.data) ? payload.data : {};
|
||||
return this.bridgeReceipt(this.requestBundledSurface(instance.app_scope, instanceID, event, data));
|
||||
}
|
||||
if (method === "capabilities.request") {
|
||||
const name = text("name");
|
||||
const reason = text("reason");
|
||||
if (!name || !reason || !isJsonObject(payload.input)) throw new Error("invalid_capability_request");
|
||||
return this.bridgeReceipt(await this.queueProtocolAction("app-result", "lineup.v1.app.call", {
|
||||
instance_id: instanceID, app_scope: instance.app_scope, capability: name, reason, arguments: payload.input,
|
||||
expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString(),
|
||||
}));
|
||||
}
|
||||
throw new Error("unsupported_bridge_method");
|
||||
}
|
||||
|
||||
private bridgeReceipt(receipt: CommandReceipt): JsonObject {
|
||||
return receipt.disposition === "accepted" ? { disposition: "accepted", ...(receipt.local_id ? { local_id: receipt.local_id } : {}) } : { disposition: "rejected", code: receipt.code };
|
||||
}
|
||||
|
||||
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
|
||||
public openChatApp(): ChatRuntimeSDK {
|
||||
return this.openApp("chat");
|
||||
@@ -1156,6 +1241,17 @@ function validProgress(progress: MiniAppToolProgress): boolean {
|
||||
&& (progress.detail === undefined || (typeof progress.detail === "string" && progress.detail.length <= 500));
|
||||
}
|
||||
|
||||
function isMiniAppToolProgress(value: unknown): value is MiniAppToolProgress {
|
||||
return isJsonObject(value)
|
||||
&& (value.percent === undefined || (typeof value.percent === "number" && Number.isFinite(value.percent)))
|
||||
&& (value.status === undefined || typeof value.status === "string")
|
||||
&& (value.detail === undefined || typeof value.detail === "string");
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */
|
||||
function runtimeLog(event: string, fields: Record<string, string | number>): void {
|
||||
console.info("[LineUp Runtime]", event, fields);
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { VerifiedSurfaceBundleCache } from "@/runtime/surfaces/surface-bund
|
||||
export type SurfaceHostCallbacks = Readonly<{
|
||||
ready: (instanceID: string) => void;
|
||||
event: (instanceID: string, event: "cancel") => void;
|
||||
/** Runtime-owned MiniApp Bridge request; the iframe never receives Runtime internals. */
|
||||
request?: (instanceID: string, method: string, payload: JsonObject) => Promise<JsonObject | undefined>;
|
||||
}>;
|
||||
|
||||
/** Resolves only host-verified bytes to an iframe document; it has no bridge authority. */
|
||||
@@ -28,6 +30,8 @@ type HostedSurface = {
|
||||
const BRIDGE_VERSION = 1;
|
||||
const READY_TYPE = "lineup.surface.v1.ready";
|
||||
const STATE_TYPE = "lineup.surface.v1.state";
|
||||
const BRIDGE_REQUEST_TYPE = "lineup.miniapp.v1.request";
|
||||
const BRIDGE_RESPONSE_TYPE = "lineup.miniapp.v1.response";
|
||||
|
||||
/**
|
||||
* M2-03 Web Reference Host. The frame is deliberately an opaque sandbox
|
||||
@@ -98,6 +102,13 @@ export class IsolatedSurfaceHost {
|
||||
this.callbacks.ready(hosted.instance.instance_id);
|
||||
return;
|
||||
}
|
||||
if (message.type === BRIDGE_REQUEST_TYPE) {
|
||||
if (!hosted.ready || !this.callbacks.request) return;
|
||||
void this.callbacks.request(hosted.instance.instance_id, message.method, message.payload)
|
||||
.then(result => this.sendResponse(hosted, message.request_id, result))
|
||||
.catch(error => this.sendError(hosted, message.request_id, error instanceof Error ? error.message : "bridge_request_failed"));
|
||||
return;
|
||||
}
|
||||
if (!hosted.ready || hosted.cancelSent) return;
|
||||
hosted.cancelSent = true;
|
||||
this.callbacks.event(hosted.instance.instance_id, "cancel");
|
||||
@@ -108,6 +119,14 @@ export class IsolatedSurfaceHost {
|
||||
// and all incoming traffic is checked above. No privileged data is sent.
|
||||
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: STATE_TYPE, instance_id: hosted.instance.instance_id, state }, "*");
|
||||
}
|
||||
|
||||
private sendResponse(hosted: HostedSurface, requestID: string, result: JsonObject | undefined): void {
|
||||
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: true, result: result ?? {} }, "*");
|
||||
}
|
||||
|
||||
private sendError(hosted: HostedSurface, requestID: string, error: string): void {
|
||||
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: false, error: error.slice(0, 200) }, "*");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,24 +150,49 @@ export class CachedVerifiedSurfaceDocumentResolver implements VerifiedSurfaceDoc
|
||||
|
||||
type SurfaceReadyMessage = Readonly<{ v: 1; type: typeof READY_TYPE; instance_id: string }>;
|
||||
type SurfaceEventMessage = Readonly<{ v: 1; type: "lineup.surface.v1.event"; instance_id: string; event: "cancel" }>;
|
||||
type BridgeRequestMessage = Readonly<{ v: 1; type: typeof BRIDGE_REQUEST_TYPE; instance_id: string; request_id: string; method: string; payload: JsonObject }>;
|
||||
|
||||
export function parseReadyMessage(value: unknown): SurfaceReadyMessage | undefined {
|
||||
const parsed = parseBridgeMessage(value);
|
||||
return parsed?.type === READY_TYPE ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | undefined {
|
||||
if (!isPlainObject(value) || value.v !== BRIDGE_VERSION || (value.type !== READY_TYPE && value.type !== "lineup.surface.v1.event") || typeof value.instance_id !== "string"
|
||||
function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | BridgeRequestMessage | undefined {
|
||||
if (!isPlainObject(value) || value.v !== BRIDGE_VERSION || (value.type !== READY_TYPE && value.type !== "lineup.surface.v1.event" && value.type !== BRIDGE_REQUEST_TYPE) || typeof value.instance_id !== "string"
|
||||
|| !/^[A-Za-z0-9._:-]{1,128}$/.test(value.instance_id)) return undefined;
|
||||
if (value.type === READY_TYPE && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id")) return { v: 1, type: READY_TYPE, instance_id: value.instance_id };
|
||||
if (value.type === "lineup.surface.v1.event" && value.event === "cancel" && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "event")) return { v: 1, type: "lineup.surface.v1.event", instance_id: value.instance_id, event: "cancel" };
|
||||
if (value.type === BRIDGE_REQUEST_TYPE && typeof value.request_id === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value.request_id)
|
||||
&& typeof value.method === "string" && /^[a-z][a-z0-9._-]{0,63}$/.test(value.method) && isPlainObject(value.payload)
|
||||
&& Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "request_id" || key === "method" || key === "payload")) {
|
||||
return { v: 1, type: BRIDGE_REQUEST_TYPE, instance_id: value.instance_id, request_id: value.request_id, method: value.method, payload: cloneObject(value.payload) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A static boot document for the future host-local Task Dashboard bundle. */
|
||||
export function surfaceDocument(instanceID: string, _appID = "lineup.task-dashboard"): string {
|
||||
/**
|
||||
* Development bundles for the two shipped bundled MiniApps. The business
|
||||
* state and DOM live in this opaque iframe; the parent only exposes the
|
||||
* Runtime-owned request bridge above.
|
||||
*/
|
||||
export function surfaceDocument(instanceID: string, appID = "lineup.task-dashboard"): string {
|
||||
const safeID = JSON.stringify(instanceID);
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}</style></head><body><main><h2 id="title">任务面板</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID},q=id=>document.getElementById(id),list=(id,items)=>q(id).replaceChildren(...(Array.isArray(items)?items.slice(0,12).map(x=>{const n=document.createElement(id==="logs"?"p":"li");n.textContent=typeof x==="string"?x:"";return n}):[]));q("cancel").onclick=()=>window.parent.postMessage({v:1,type:"lineup.surface.v1.event",instance_id:instanceId,event:"cancel"},"*");window.addEventListener("message",event=>{if(event.source!==window.parent)return;const d=event.data;if(!d||d.v!==1||d.type!=="lineup.surface.v1.state"||d.instance_id!==instanceId||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;const s=d.state;q("title").textContent=typeof s.title==="string"?s.title:"任务面板";q("progress").value=typeof s.percent==="number"?Math.max(0,Math.min(100,s.percent)):0;q("status").textContent=typeof s.status==="string"?s.status:"进行中";list("steps",s.steps);list("logs",s.logs);});window.parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");</script></body></html>`;
|
||||
const isWhiteboard = appID === "lineup.whiteboard";
|
||||
const title = isWhiteboard ? "Whiteboard" : "任务面板";
|
||||
const appScript = isWhiteboard ? whiteboardSurfaceScript() : taskDashboardSurfaceScript();
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}textarea{min-height:4rem;background:#0e1713;color:#eff7f2;border:1px solid #456253;border-radius:7px;padding:7px}</style></head><body><main><h2 id="title">${title}</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><textarea id="editor" aria-label="小程序业务输入"></textarea><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID};${bridgeScript()}${appScript}</script></body></html>`;
|
||||
}
|
||||
|
||||
function bridgeScript(): string {
|
||||
return `const pending=new Map;let sequence=0;function bridge(method,payload={}){const request_id="req_"+(++sequence);return new Promise((resolve,reject)=>{pending.set(request_id,{resolve,reject});parent.postMessage({v:1,type:"lineup.miniapp.v1.request",instance_id:instanceId,request_id,method,payload},"*");setTimeout(()=>{const item=pending.get(request_id);if(item){pending.delete(request_id);item.reject(new Error("bridge_timeout"));}},10000)})}window.addEventListener("message",event=>{if(event.source!==parent)return;const d=event.data;if(!d||d.v!==1||d.instance_id!==instanceId)return;if(d.type==="lineup.miniapp.v1.response"&&typeof d.request_id==="string"){const item=pending.get(d.request_id);if(!item)return;pending.delete(d.request_id);d.ok?item.resolve(d.result||{}):item.reject(new Error(typeof d.error==="string"?d.error:"bridge_rejected"));return}if(d.type!=="lineup.surface.v1.state"||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;renderState(d.state)});function renderState(s){const title=document.getElementById("title"),progress=document.getElementById("progress"),status=document.getElementById("status"),steps=document.getElementById("steps");if(typeof s.title==="string")title.textContent=s.title;if(typeof s.percent==="number")progress.value=Math.max(0,Math.min(100,s.percent));if(typeof s.status==="string")status.textContent=s.status;steps.replaceChildren(...(Array.isArray(s.steps)?s.steps.slice(0,12).map(x=>{const n=document.createElement("li");n.textContent=typeof x==="string"?x:"";return n}):[]));}parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");`;
|
||||
}
|
||||
|
||||
function taskDashboardSurfaceScript(): string {
|
||||
return `const handled=new Set;const tasks=new Map;const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭任务面板"});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleTask(call)}}catch(_){}}async function handleTask(call){try{await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"running",percent:10}});if(call.tool_id==="task.create"){const title=typeof call.input?.title==="string"?call.input.title:"未命名任务";const task_id=typeof call.input?.task_id==="string"?call.input.task_id:"task-"+call.call_id;tasks.set(task_id,{title,status:"open"});await bridge("surface.patch",{data:{state:{title:"任务面板",status:"已创建:"+title,percent:100,steps:[...tasks.values()].map(x=>x.title)}}});await bridge("tools.complete",{call_id:call.call_id,result:{task_id}})}else if(call.tool_id==="task.list"){await bridge("tools.complete",{call_id:call.call_id,result:{tasks:[...tasks.entries()].map(([task_id,task])=>Object.assign({task_id},task))}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的任务工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"任务面板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`;
|
||||
}
|
||||
|
||||
function whiteboardSurfaceScript(): string {
|
||||
return `const handled=new Set;let board={title:"Whiteboard",status:"可编辑",elements:[]};const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭画板"});editor.addEventListener("input",()=>{board={...board,text:editor.value};bridge("surface.patch",{data:{state:board}}).catch(()=>{})});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleBoard(call)}}catch(_){}}async function handleBoard(call){try{if(call.tool_id==="whiteboard.open"){await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"opening",percent:40}});await bridge("tools.complete",{call_id:call.call_id,result:{status:"completed"}})}else if(call.tool_id==="whiteboard.export"){const artifact_id=typeof call.input?.artifact_id==="string"?call.input.artifact_id:"whiteboard-"+call.call_id;await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"exporting",percent:80}});await bridge("surface.patch",{data:{state:{...board,artifact:{artifact_id}}}});await bridge("tools.complete",{call_id:call.call_id,result:{artifact_id}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的画板工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"画板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`;
|
||||
}
|
||||
|
||||
export function productionSurfaceDocument(instanceID: string, verifiedBundle: string): string {
|
||||
@@ -164,3 +208,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function cloneObject(value: Record<string, unknown>): JsonObject {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonObject;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user