feat(plugins): analyze plugin data in the built-in AI panel

This commit is contained in:
zipg authored and GitHub committed 2026-09-22 15:04:33 +08:00
1 parent 7653ebfda6
commit 1566bb8a9f
33 files changed
+716 -216

No files matched your search

+44
View File
@@ -73,6 +73,50 @@ describe("PluginHostBridge", () => {
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id: "open", method: "host.downloadFile", params: {} } } as MessageEvent);
await vi.waitFor(() => expect(messages[1].error).toContain("desktop host"));
});
it("opens the built-in AI conversation only with the declared permission and host-owned identity", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const openAiConversation = vi.fn().mockResolvedValue(undefined);
const api = { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn(), openAiConversation };
const request = { source: "dbx-plugin", version: 1, type: "request", id: "ai", method: "host.ai.openConversation", params: { title: "自选分析", prompt: "分析", context: { snapshotId: "s1" }, send: true, pluginId: "forged" } };
const denied = new PluginHostBridge(plugin(), workbench, {}, () => target, api);
denied.handleWindowMessage({ source: target, data: request } as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(messages[0]).toMatchObject({ error: "Plugin has not declared permission 'host.ai'" });
expect(openAiConversation).not.toHaveBeenCalled();
const allowed = new PluginHostBridge(plugin(["host.ai"]), workbench, {}, () => target, api);
allowed.handleWindowMessage({ source: target, data: request } as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(2));
expect(openAiConversation).toHaveBeenCalledWith({ context: { pluginId: "sample", pluginName: "Sample", title: "自选分析", capturedAt: expect.any(String), data: { snapshotId: "s1" } }, prompt: "分析", send: true });
expect(messages[1]).toMatchObject({ id: "ai", result: null });
expect(api.invoke).not.toHaveBeenCalled();
});
it("advertises the ai capability group in the init message", () => {
const messages: any[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const withAi = new PluginHostBridge(plugin(["host.ai"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
openAiConversation: vi.fn().mockResolvedValue(undefined),
});
withAi.sendInit();
expect(messages[0].capabilities.ai).toBe(true);
messages.length = 0;
const withoutAdapter = new PluginHostBridge(plugin(["host.ai"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
withoutAdapter.sendInit();
expect(messages[0].capabilities.ai).toBe(false);
});
it("binds backend calls to the owning plugin identity", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -1,6 +1,7 @@
import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginUiContribution } from "@/types/database";
import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData";
import { MAX_PLUGIN_PLAN_SQL_CHARS, MAX_PLUGIN_PLAN_TIMEOUT_MS, PLUGIN_PLAN_PERMISSION, type PluginPlanCapabilities, type PluginPlanRequest, type PluginPlanResult } from "@/types/pluginPlan";
import { createPluginAiConversation, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
const PLUGIN_MESSAGE_SOURCE = "dbx-plugin";
const HOST_MESSAGE_SOURCE = "dbx-host";
@@ -92,6 +93,7 @@ export interface PluginHostBridgeApi {
notify(pluginId: string, method: string, params?: unknown): Promise<void>;
sendBinary(pluginId: string, channel: string, dataBase64: string): Promise<void>;
readAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload>;
openAiConversation?(request: AiPluginConversationRequest): Promise<void>;
openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }): Promise<void> | void;
openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise<void> | void;
/** Explicit user-triggered reconnect of an owned plugin connection (full flow, interactive password prompt allowed). */
@@ -248,6 +250,7 @@ export class PluginHostBridge {
downloadFile: !!this.api.downloadFile,
planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan,
storage: !!this.api.storageGet && !!this.api.storageSet && !!this.api.storageDelete,
ai: !!this.api.openAiConversation,
},
context: snapshotPluginWorkbenchContext(this.context),
});
@@ -331,6 +334,13 @@ export class PluginHostBridge {
return null;
}
if (method === "host.getContext") return snapshotPluginWorkbenchContext(this.context);
if (method === "host.ai.openConversation") {
this.requirePermission("host.ai");
if (!this.api.openAiConversation) throw new Error("DBX AI conversation panel is unavailable");
const request = createPluginAiConversation(this.plugin.manifest, params);
await this.api.openAiConversation(request);
return null;
}
if (method === "backend.invoke") {
const input = requireRecord(params, "backend.invoke params");
const backendMethod = requireProtocolName(input.method, "backend method");
@@ -786,6 +796,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
downloadFile: (options) => request('host.downloadFile', options),
cancelDownload: (downloadId) => request('host.cancelDownload', { downloadId }),
request,
ai: Object.freeze({ openConversation: (options) => request('host.ai.openConversation', options) }),
invoke: (method, params, options = {}) => request('backend.invoke', { method, params, timeoutMs: options.timeoutMs }),
stream,
notify: (method, params) => request('backend.notify', { method, params }),