refactor(interact): separate runtime actions from ui projection
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import type { ChatRuntimeSDK } from "@/runtime/app-management/app-sdk";
|
||||
import type { InteractRuntimeSDK } from "@/runtime/app-management/app-sdk";
|
||||
import { InteractionModeRegistry, type InteractionMode } from "@/core-apps/chat/interaction-mode-registry";
|
||||
|
||||
/** Restricted Interaction App projection: IM today, Audio/Video when enabled. */
|
||||
export class InteractionRuntime {
|
||||
public readonly modes = new InteractionModeRegistry();
|
||||
private current: InteractionMode = "im";
|
||||
public constructor(public readonly sdk: ChatRuntimeSDK) {}
|
||||
public constructor(public readonly sdk: InteractRuntimeSDK) {}
|
||||
public activeMode(): InteractionMode { return this.current; }
|
||||
public selectMode(mode: InteractionMode): boolean {
|
||||
if (!this.modes.get(mode).enabled) return false;
|
||||
|
||||
+20
-20
@@ -102,17 +102,17 @@ let chatSDK: ReturnType<LineUpRuntime["openApp"]>;
|
||||
// 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); },
|
||||
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.tasks.requestCancel(operationID); },
|
||||
read(operationID) { return chatSDK.tasks.read(operationID); },
|
||||
requestCancel(operationID) { return chatSDK.runtime.tasks.requestCancel(operationID); },
|
||||
read(operationID) { return chatSDK.runtime.tasks.read(operationID); },
|
||||
},
|
||||
capabilityInteractions: {
|
||||
async approve(callID) { return approveCapabilityCall(callID); },
|
||||
@@ -273,15 +273,15 @@ function loginFailureMessage(reason: unknown, endpoint?: string): string {
|
||||
|
||||
// Chat 是 Runtime 上的 Core App。它不接收 Transport 原始 payload;Runtime 会先完成
|
||||
// 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。
|
||||
chatSDK.subscribeAgentMessages(message => {
|
||||
chatSDK.ui.subscribeAgentMessages(message => {
|
||||
// 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。
|
||||
// ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。
|
||||
projectExecutionProgress(message.item, message.received_at);
|
||||
renderIncoming(message.item);
|
||||
chatSDK.acknowledgeAgentMessage(message.message_id);
|
||||
chatSDK.ui.acknowledgeAgentMessage(message.message_id);
|
||||
});
|
||||
|
||||
chatSDK.subscribe(view => renderAppSessions(view.app_sessions));
|
||||
chatSDK.ui.subscribe(view => renderAppSessions(view.app_sessions));
|
||||
|
||||
function renderAppSessions(sessions: readonly {
|
||||
app_session_id: string;
|
||||
@@ -318,7 +318,7 @@ function renderAppSessions(sessions: readonly {
|
||||
continueButton.type = "button";
|
||||
continueButton.textContent = "继续处理";
|
||||
continueButton.addEventListener("click", () => {
|
||||
void chatSDK.dispatch({ type: "chat.continue_app_session", conversation_id: chatSDK.snapshot().conversation_id ?? "", app_session_id: session.app_session_id });
|
||||
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);
|
||||
}
|
||||
@@ -667,10 +667,10 @@ async function approveCapabilityCall(callID: string): Promise<CapabilityCallReco
|
||||
function queueAppResult(record: CapabilityCallRecord): void {
|
||||
// 回传给 Agent 的只是有限结果(call_id、能力名、状态和有限错误码),不包含 URL、
|
||||
// 剪贴板正文、本地路径、异常堆栈或 Artifact 引用等敏感输入。
|
||||
const conversationID = chatSDK.snapshot().conversation_id;
|
||||
const conversationID = chatSDK.ui.snapshot().conversation_id;
|
||||
if (!conversationID) return;
|
||||
const payload = safeCapabilityResult(record);
|
||||
void chatSDK.dispatch({ type: "chat.report_app_result", conversation_id: conversationID, result: payload })
|
||||
void chatSDK.runtime.dispatch({ type: "chat.report_app_result", conversation_id: conversationID, result: payload })
|
||||
.catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason)));
|
||||
}
|
||||
|
||||
@@ -725,9 +725,9 @@ function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
|
||||
|
||||
function queueSurfaceCancel(instanceID: string): void {
|
||||
// Surface 内部事件只转换为 Runtime Action;它不能直接发送网络消息或调用系统能力。
|
||||
const conversationID = chatSDK.snapshot().conversation_id;
|
||||
const conversationID = chatSDK.ui.snapshot().conversation_id;
|
||||
if (!conversationID) return;
|
||||
void chatSDK.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID })
|
||||
void chatSDK.runtime.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID })
|
||||
.then(receipt => {
|
||||
if (receipt.disposition === "accepted") rendererContext.renderSystemMessage("已请求取消任务。");
|
||||
})
|
||||
@@ -783,7 +783,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
// DOM nodes or re-executing a user action.
|
||||
// 这些 Inbox 消息已经通过持久化 transcript 成功渲染,因此这里只 ACK,不再次渲染,
|
||||
// 避免刷新后出现重复消息或重新执行用户动作。
|
||||
for (const pending of chatSDK.listAgentMessages()) chatSDK.acknowledgeAgentMessage(pending.message_id);
|
||||
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();
|
||||
@@ -801,7 +801,7 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
|
||||
if (!text) return;
|
||||
const conversationID = chatSDK.snapshot().conversation_id;
|
||||
const conversationID = chatSDK.ui.snapshot().conversation_id;
|
||||
if (!conversationID) {
|
||||
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
|
||||
input.focus();
|
||||
@@ -809,7 +809,7 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
||||
}
|
||||
input.value = ""; send.disabled = true;
|
||||
try {
|
||||
const receipt = await chatSDK.dispatch({ type: "chat.send_text", conversation_id: conversationID, text });
|
||||
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));
|
||||
|
||||
@@ -52,8 +52,41 @@ export type CommandReceipt =
|
||||
| { disposition: "accepted"; local_id?: string }
|
||||
| { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" | "capability_not_declared" | "capability_unavailable" | "invalid_capability_request" | "capability_requires_foreground" };
|
||||
|
||||
export interface ChatRuntimeSDK {
|
||||
export type InteractRuntimeOperations = Readonly<{
|
||||
dispatch(action: ChatAction): Promise<CommandReceipt>;
|
||||
interactions: {
|
||||
submit(callID: string, result: JsonObject): boolean;
|
||||
cancel(callID: string, reason: string): boolean;
|
||||
expire(callID: string): boolean;
|
||||
read(callID: string): ToolCallRecord | undefined;
|
||||
loadDraft(callID: string): JsonObject | undefined;
|
||||
saveDraft(callID: string, values: JsonObject): void;
|
||||
clearDraft(callID: string): void;
|
||||
};
|
||||
tasks: {
|
||||
requestCancel(operationID: string): boolean;
|
||||
read(operationID: string): TaskRecord | undefined;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type InteractUIProjection = Readonly<{
|
||||
snapshot(): ChatViewModel;
|
||||
subscribe(listener: (view: ChatViewModel) => void): Unsubscribe;
|
||||
subscribeEvents(listener: (event: Exclude<RuntimeAppEvent, { type: "agent-message" }>) => void): Unsubscribe;
|
||||
subscribeAgentMessages(listener: (message: AgentChatMessage) => void): Unsubscribe;
|
||||
listAgentMessages(afterMessageID?: string): readonly AgentChatMessage[];
|
||||
acknowledgeAgentMessage(messageID: string): boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Interact's SDK is the common Runtime contract plus a trusted-DOM UI projection.
|
||||
* The UI projection is an adapter for IM rendering; it is not a second transport,
|
||||
* persistence, Tool or capability protocol.
|
||||
*/
|
||||
export interface InteractRuntimeSDK {
|
||||
readonly app_scope: "chat";
|
||||
readonly runtime: InteractRuntimeOperations;
|
||||
readonly ui: InteractUIProjection;
|
||||
snapshot(): ChatViewModel;
|
||||
subscribe(listener: (view: ChatViewModel) => void): Unsubscribe;
|
||||
/** Chat-safe Runtime events, excluding raw Agent delivery (use subscribeAgentMessages). */
|
||||
@@ -79,6 +112,9 @@ export interface ChatRuntimeSDK {
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use InteractRuntimeSDK; kept for the compatibility app_scope=chat name. */
|
||||
export type ChatRuntimeSDK = InteractRuntimeSDK;
|
||||
|
||||
/**
|
||||
* The intentionally smaller SDK granted to an installed Extension App. It
|
||||
* does not reveal another App's inbox, the focus stack, Transport, DOM root,
|
||||
|
||||
@@ -105,6 +105,10 @@ describe("LineUpRuntime", () => {
|
||||
]));
|
||||
const chat = runtime.openApp("chat");
|
||||
expect(chat.app_scope).toBe("chat");
|
||||
expect(chat.runtime.dispatch).toBe(chat.dispatch);
|
||||
expect(chat.runtime.interactions).toBe(chat.interactions);
|
||||
expect(chat.runtime.tasks).toBe(chat.tasks);
|
||||
expect(chat.ui.snapshot).toBe(chat.snapshot);
|
||||
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false });
|
||||
|
||||
await runtime.login(login);
|
||||
|
||||
@@ -253,40 +253,43 @@ export class LineUpRuntime {
|
||||
const app = this.apps.get(appScope);
|
||||
if (!app?.enabled) throw new Error(`The ${appScope} Core App is unavailable.`);
|
||||
if (appScope !== "chat") throw new Error(`The ${appScope} Core App SDK is not registered.`);
|
||||
return {
|
||||
app_scope: "chat",
|
||||
const ui = {
|
||||
snapshot: () => this.chatView(),
|
||||
subscribe: listener => {
|
||||
subscribe: (listener: (view: ChatViewModel) => void) => {
|
||||
this.chatStateListeners.add(listener);
|
||||
listener(this.chatView());
|
||||
return () => this.chatStateListeners.delete(listener);
|
||||
},
|
||||
subscribeEvents: listener => this.onEvent(event => {
|
||||
// Agent content has a separate, scope-checked SDK channel. Keeping it
|
||||
// out of the generic event stream prevents a Chat renderer from
|
||||
// accidentally consuming an unprojected delivery path.
|
||||
subscribeEvents: (listener: (event: Exclude<RuntimeAppEvent, { type: "agent-message" }>) => void) => this.onEvent(event => {
|
||||
if (event.type !== "agent-message") listener(event);
|
||||
}),
|
||||
subscribeAgentMessages: listener => {
|
||||
subscribeAgentMessages: (listener: (message: AgentChatMessage) => void) => {
|
||||
this.chatMessageListeners.add(listener);
|
||||
return () => this.chatMessageListeners.delete(listener);
|
||||
},
|
||||
listAgentMessages: afterMessageID => this.listChatMessages(afterMessageID),
|
||||
acknowledgeAgentMessage: messageID => this.acknowledgeChatMessage(messageID),
|
||||
dispatch: action => this.dispatchChatAction(action),
|
||||
interactions: {
|
||||
submit: (callID, result) => this.submitToolCall(callID, result),
|
||||
cancel: (callID, reason) => this.cancelToolCall(callID, reason),
|
||||
expire: callID => this.expireToolCall(callID),
|
||||
read: callID => this.toolCalls().find(record => record.call_id === callID),
|
||||
loadDraft: callID => this.getToolDraft(callID),
|
||||
saveDraft: (callID, values) => this.saveToolDraft(callID, values),
|
||||
clearDraft: callID => this.clearToolDraft(callID),
|
||||
},
|
||||
tasks: {
|
||||
requestCancel: operationID => this.requestTaskCancel(operationID),
|
||||
read: operationID => this.tasks().find(task => task.operation_id === operationID),
|
||||
},
|
||||
listAgentMessages: (afterMessageID?: string) => this.listChatMessages(afterMessageID),
|
||||
acknowledgeAgentMessage: (messageID: string) => this.acknowledgeChatMessage(messageID),
|
||||
};
|
||||
const interactions = {
|
||||
submit: (callID: string, result: JsonObject) => this.submitToolCall(callID, result),
|
||||
cancel: (callID: string, reason: string) => this.cancelToolCall(callID, reason),
|
||||
expire: (callID: string) => this.expireToolCall(callID),
|
||||
read: (callID: string) => this.toolCalls().find(record => record.call_id === callID),
|
||||
loadDraft: (callID: string) => this.getToolDraft(callID),
|
||||
saveDraft: (callID: string, values: JsonObject) => this.saveToolDraft(callID, values),
|
||||
clearDraft: (callID: string) => this.clearToolDraft(callID),
|
||||
};
|
||||
const tasks = {
|
||||
requestCancel: (operationID: string) => this.requestTaskCancel(operationID),
|
||||
read: (operationID: string) => this.tasks().find(task => task.operation_id === operationID),
|
||||
};
|
||||
const runtime = { dispatch: (action: ChatAction) => this.dispatchChatAction(action), interactions, tasks };
|
||||
return {
|
||||
app_scope: "chat",
|
||||
runtime,
|
||||
ui,
|
||||
...ui,
|
||||
...runtime,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user