feat(runtime): isolate bundled miniapps behind bridge
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user