mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(ai): add plugin recommendations and agent context
This commit is contained in:
@@ -184,6 +184,7 @@ import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChan
|
||||
import { resolveWindowContext } from "@/lib/app/windowContext";
|
||||
import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow";
|
||||
import { OPEN_PLUGIN_AI_CONVERSATION, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue"));
|
||||
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
|
||||
@@ -491,6 +492,20 @@ const blockingAiRunCount = computed(() => (isDesktop ? blockingDesktopAiRunsForQ
|
||||
let aiRunsQuitConfirmed = false;
|
||||
|
||||
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
|
||||
const pluginAiRecommendationsByTab = ref<Record<string, PluginAiRecommendationHostUpdate>>({});
|
||||
const activePluginAiRecommendations = computed(() => {
|
||||
const tab = activeTab.value;
|
||||
if (!tab || tab.mode !== "plugin-workbench" || !tab.pluginWorkbench) return undefined;
|
||||
return pluginAiRecommendationsByTab.value[tab.id];
|
||||
});
|
||||
|
||||
function updatePluginAiRecommendations(tabId: string, update: PluginAiRecommendationHostUpdate): void {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab?.pluginWorkbench || tab.pluginWorkbench.pluginId !== update.pluginId || tab.pluginWorkbench.contributionId !== update.contributionId) return;
|
||||
const expectedWorkbenchId = typeof tab.pluginWorkbench.context?.workbenchId === "string" ? tab.pluginWorkbench.context.workbenchId : undefined;
|
||||
if (expectedWorkbenchId && update.workbenchId && expectedWorkbenchId !== update.workbenchId) return;
|
||||
pluginAiRecommendationsByTab.value = { ...pluginAiRecommendationsByTab.value, [tabId]: update };
|
||||
}
|
||||
// Plugin workbench tabs stay mounted once opened (hidden via v-show): an
|
||||
// iframe moved out of the DOM reloads from scratch, so KeepAlive/ContentArea
|
||||
// remounts flash the whole webview and drop its live session state.
|
||||
@@ -4461,6 +4476,7 @@ onUnmounted(() => {
|
||||
:contribution-id="workbenchTab.pluginWorkbench!.contributionId"
|
||||
:context="workbenchTab.pluginWorkbench!.context"
|
||||
@close-tab="queryStore.closeTab(workbenchTab.id)"
|
||||
@recommendations="updatePluginAiRecommendations(workbenchTab.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4482,6 +4498,7 @@ onUnmounted(() => {
|
||||
:tab="activeTab"
|
||||
:connection="activeConnection"
|
||||
:maximized="isAiPanelMaximized"
|
||||
:plugin-recommendations="activePluginAiRecommendations"
|
||||
@append-sql="onAiAppendSql"
|
||||
@execute-sql="onAiExecuteSql"
|
||||
@temp-run-sql="onAiTempRunSql"
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
type AiAction,
|
||||
type AiActionSelection,
|
||||
type AiAssistantMode,
|
||||
type AiContext,
|
||||
type AiContextTarget,
|
||||
type AiCsvFileContext,
|
||||
type AiTextAttachmentEncoding,
|
||||
@@ -187,7 +188,8 @@ import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from
|
||||
import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue";
|
||||
import AiToolApprovalCard from "@/components/editor/AiToolApprovalCard.vue";
|
||||
import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
|
||||
import { buildPluginAiRequest, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import { buildPluginAiRequest, createPluginAiConversation, pluginComposerConnectionLabel, pluginContextConnectionId, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
const { t } = useI18n();
|
||||
const AiChartRenderer = defineAsyncComponent({
|
||||
@@ -291,6 +293,7 @@ const props = defineProps<{
|
||||
tab?: QueryTab;
|
||||
connection?: ConnectionConfig;
|
||||
maximized?: boolean;
|
||||
pluginRecommendations?: PluginAiRecommendationHostUpdate;
|
||||
}>();
|
||||
|
||||
// Every AI-initiated action carries the *target* it must run against: the
|
||||
@@ -356,6 +359,27 @@ const boundConnectionId = computed(() => conversationBinding.value.connectionId)
|
||||
const boundConnection = computed(() => (boundConnectionId.value ? connectionStore.getConfig(boundConnectionId.value) : undefined));
|
||||
const boundDatabase = computed(() => conversationBinding.value.database);
|
||||
const boundSchema = computed(() => conversationBinding.value.schema);
|
||||
const pluginContextConnection = computed(() => {
|
||||
const contextConnectionId = pluginContext.value ? pluginContextConnectionId(pluginContext.value) : undefined;
|
||||
return boundConnection.value ?? (contextConnectionId ? connectionStore.getConfig(contextConnectionId) : undefined);
|
||||
});
|
||||
const pluginComposerConnectionName = computed(() => {
|
||||
const context = pluginContext.value;
|
||||
if (!context) return "";
|
||||
return pluginComposerConnectionLabel(context, pluginContextConnection.value?.name, activeConversation.value?.connectionName);
|
||||
});
|
||||
|
||||
function canUsePluginAgentMode(context = pluginContext.value, connection = boundConnection.value): boolean {
|
||||
return !context || !!connection;
|
||||
}
|
||||
|
||||
watch(
|
||||
[pluginContext, boundConnection],
|
||||
([context, connection]) => {
|
||||
if (context && !connection && assistantMode.value === "agent") assistantMode.value = "ask";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// `immediate` runs this during setup whenever the AI config finished loading
|
||||
// before the panel mounted (the usual case: the app loads it at startup), and
|
||||
@@ -1663,6 +1687,7 @@ function openCodeSnapshot(seg: { content: string; lang: string }) {
|
||||
}
|
||||
|
||||
const showActionButtons = computed(() => {
|
||||
if (pluginContext.value) return false;
|
||||
if (!boundConnection.value) return true;
|
||||
return !isVectorDbType(boundConnection.value.db_type);
|
||||
});
|
||||
@@ -1677,6 +1702,7 @@ const modeActionTriggerLabel = computed(() => {
|
||||
});
|
||||
|
||||
function switchModeActionTab(mode: "ask" | "agent") {
|
||||
if (mode === "agent" && !canUsePluginAgentMode()) return;
|
||||
activeAction.value = resolveDefaultActionSelection(mode);
|
||||
if (assistantMode.value !== mode) {
|
||||
// Set the mode after the action so the tab label and picker stay aligned.
|
||||
@@ -3245,9 +3271,9 @@ async function send() {
|
||||
// database. When it still matches, dropping it would silently discard what
|
||||
// the user attached alongside the affirmative.
|
||||
const confirmationRetargets = !!confirmationTarget && !sameConversationBinding(runBinding, conversationBinding.value);
|
||||
const connection = runPluginContext ? undefined : runBinding.connectionId ? connectionStore.getConfig(runBinding.connectionId) : undefined;
|
||||
const connection = runBinding.connectionId ? connectionStore.getConfig(runBinding.connectionId) : undefined;
|
||||
const tab = runPluginContext ? undefined : aiContextTargetFor(runBinding, props.tab);
|
||||
const runSourceName = runPluginContext?.pluginName ?? connection?.name ?? "";
|
||||
const runSourceName = connection?.name ?? runPluginContext?.pluginName ?? "";
|
||||
if (!runPluginContext && (!connection || !tab)) {
|
||||
clearPendingWriteGrant();
|
||||
return;
|
||||
@@ -3467,7 +3493,7 @@ async function send() {
|
||||
if (autoSendVisible) scrollToBottom({ force: true });
|
||||
|
||||
const requestedSelection: AiActionSelection = auto ? auto.action : activeAction.value;
|
||||
const requestedMode: AiAssistantMode = runPluginContext ? "ask" : auto ? auto.mode : assistantMode.value;
|
||||
const requestedMode: AiAssistantMode = auto ? auto.mode : assistantMode.value;
|
||||
// A confirmed-write turn (the ✅ reply, or the segment that resumes an
|
||||
// `awaiting_write_confirmation` run) is a continuation of the pending proposal,
|
||||
// not a new user request: its reply text is component copy, so the Auto router
|
||||
@@ -3482,8 +3508,8 @@ async function send() {
|
||||
// router entirely, so their behavior is unchanged (#9118).
|
||||
let requestedAction: AiAction;
|
||||
if (runPluginContext) {
|
||||
// A plugin conversation carries its own data snapshot: the Auto router must
|
||||
// not classify the prompt, and the task contract stays host-owned.
|
||||
// Plugin requests use the general action; the Agent decides which live
|
||||
// plugin tools are needed from their advertised definitions.
|
||||
requestedAction = "general";
|
||||
} else if (!isAutoActionSelection(requestedSelection)) {
|
||||
requestedAction = requestedSelection;
|
||||
@@ -3741,7 +3767,36 @@ async function send() {
|
||||
}
|
||||
if (runIsVisible()) scrollToBottom();
|
||||
};
|
||||
if (runPluginContext) {
|
||||
if (runPluginContext && requestedMode === "agent" && connection) {
|
||||
if (!generationCanContinue()) return;
|
||||
if (runIsVisible()) generationStatus.value = { ...generationStatus.value, phase: "waiting_model" };
|
||||
const context: AiContext = {
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name,
|
||||
databaseType: "plugin",
|
||||
database: "",
|
||||
currentSql: "",
|
||||
tables: [],
|
||||
sqlFiles: [],
|
||||
truncated: false,
|
||||
};
|
||||
const instruction = [text, `Plugin: ${runPluginContext.pluginName}`, `Recommendation context: ${runPluginContext.title}`, "Use the connected plugin tools to retrieve the current state before answering."].filter(Boolean).join("\n\n");
|
||||
await runAgentStream(
|
||||
{
|
||||
config: activeConfig,
|
||||
action: requestedAction,
|
||||
mode: requestedMode,
|
||||
instruction,
|
||||
taskContractUserRequest: text,
|
||||
context,
|
||||
inlineImages: imageAttachments.map(({ mediaType, data }) => ({ mediaType, data })),
|
||||
},
|
||||
history,
|
||||
onEvent,
|
||||
sessionId,
|
||||
customPromptContext,
|
||||
);
|
||||
} else if (runPluginContext) {
|
||||
if (!generationCanContinue()) return;
|
||||
if (runIsVisible()) generationStatus.value = { ...generationStatus.value, phase: "waiting_model" };
|
||||
const request = buildPluginAiRequest(
|
||||
@@ -4725,7 +4780,7 @@ function selectConversation(conv: AiConversation) {
|
||||
const activeRun = backgroundAiRunsEnabled ? desktopAiRun<ChatMessage>(conv.id) : undefined;
|
||||
messages.value = activeRun?.messages ?? chatMessagesFromConversation(conv);
|
||||
if (pluginContext.value) {
|
||||
assistantMode.value = "ask";
|
||||
assistantMode.value = pluginContext.value.mode === "agent" && !boundConnection.value ? "ask" : (pluginContext.value.mode ?? "ask");
|
||||
activeAction.value = "general";
|
||||
}
|
||||
unreadConversations.delete(conv.id);
|
||||
@@ -5266,12 +5321,21 @@ function openPluginConversation(request: AiPluginConversationRequest) {
|
||||
defaultModeInitialized = true;
|
||||
startNewChat();
|
||||
draftPluginContext.value = request.context;
|
||||
assistantMode.value = "ask";
|
||||
const pluginConnectionId = pluginContextConnectionId(request.context);
|
||||
const pluginConnection = pluginConnectionId ? connectionStore.getConfig(pluginConnectionId) : undefined;
|
||||
draftBinding.value = pluginConnection ? { connectionId: pluginConnection.id, connectionName: pluginConnection.name, database: "", schema: undefined } : null;
|
||||
assistantMode.value = request.mode === "agent" && !pluginConnection ? "ask" : (request.mode ?? request.context.mode ?? "ask");
|
||||
activeAction.value = "general";
|
||||
setPrompt(request.prompt, true);
|
||||
if (request.send) void send();
|
||||
}
|
||||
|
||||
function sendPluginRecommendation(item: { id: string; label: string; prompt: string }) {
|
||||
const update = props.pluginRecommendations;
|
||||
if (!update || !update.items.some((candidate) => candidate.id === item.id)) return;
|
||||
openPluginConversation(createPluginAiConversation({ id: update.pluginId, name: update.pluginName }, { title: item.label, prompt: item.prompt, context: update.context, send: true, mode: "agent" }));
|
||||
}
|
||||
|
||||
function setPrompt(text: string, fromPlugin = false) {
|
||||
if (!fromPlugin && pluginContext.value) startNewChat();
|
||||
prompt.value = text;
|
||||
@@ -5496,10 +5560,34 @@ async function openExternalUrl(url: string) {
|
||||
<div v-if="messages.length === 0" class="flex-1 min-h-0 flex flex-col items-center justify-center text-center text-muted-foreground">
|
||||
<Bot class="h-10 w-10 mb-3 opacity-30" />
|
||||
<p class="text-sm">{{ t(pluginContext ? "ai.pluginWelcome" : "ai.welcome") }}</p>
|
||||
<div v-if="pluginRecommendations?.items.length" class="mt-5 flex max-w-[95%] flex-wrap justify-center gap-2">
|
||||
<button
|
||||
v-for="recommendation in pluginRecommendations.items"
|
||||
:key="recommendation.id"
|
||||
type="button"
|
||||
class="rounded-full border border-border/80 bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
:title="recommendation.prompt"
|
||||
@click="sendPluginRecommendation(recommendation)"
|
||||
>
|
||||
{{ recommendation.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="relative min-h-0 flex-1">
|
||||
<ScrollArea ref="scrollRef" class="ai-message-scroll h-full overflow-hidden">
|
||||
<div v-else class="relative flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea ref="scrollRef" class="ai-message-scroll min-h-0 flex-1 overflow-hidden">
|
||||
<div class="flex flex-col gap-3 p-3">
|
||||
<div v-if="pluginRecommendations?.items.length" class="flex shrink-0 flex-wrap justify-center gap-2 border-b border-border/60 px-3 py-2" data-ai-plugin-recommendations>
|
||||
<button
|
||||
v-for="recommendation in pluginRecommendations.items"
|
||||
:key="`history:${recommendation.id}`"
|
||||
type="button"
|
||||
class="rounded-full border border-border/80 bg-background px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
:title="recommendation.prompt"
|
||||
@click="sendPluginRecommendation(recommendation)"
|
||||
>
|
||||
{{ recommendation.label }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-for="(msg, i) in visibleMessages" :key="i">
|
||||
<div v-if="awayUpdatesBaselineIndex >= 0 && i === awayUpdatesBaselineIndex" class="mb-1 flex items-center gap-2 py-0.5" role="separator" :aria-label="t('ai.awayUpdatesDivider')">
|
||||
<span class="h-px flex-1 bg-primary/25" />
|
||||
@@ -5906,11 +5994,12 @@ async function openExternalUrl(url: string) {
|
||||
<div class="resize-handle" @mousedown="startResize"></div>
|
||||
<div class="px-2 pb-2 pt-1">
|
||||
<div data-ai-composer-context-row :class="['ai-prompt-context-row mb-1 flex min-w-0 items-center gap-x-1 text-xs text-foreground/80', showAiSchemaSelector && 'ai-prompt-context-row--schema', compactContextControls && 'ai-prompt-context-row--compact']">
|
||||
<details v-if="pluginContext" class="min-w-0 flex-1" data-ai-plugin-context>
|
||||
<summary class="cursor-pointer truncate">{{ pluginContext.pluginName }} · {{ pluginContext.title }}</summary>
|
||||
<pre class="max-h-56 overflow-auto whitespace-pre-wrap break-all p-2 text-[11px]">{{ pluginContextText(pluginContext) }}</pre>
|
||||
</details>
|
||||
<template v-else-if="connectionStore.connections.length">
|
||||
<div v-if="pluginContext && !connectionStore.connections.length" class="flex min-w-0 max-w-[14rem] items-center gap-1" data-ai-plugin-context :title="pluginContext.title">
|
||||
<ConnectionIcon v-if="pluginContextConnection" :connection="pluginContextConnection" class="h-3 w-3 shrink-0" />
|
||||
<Server v-else class="h-3 w-3 shrink-0" />
|
||||
<span class="truncate">{{ pluginComposerConnectionName }}</span>
|
||||
</div>
|
||||
<template v-if="connectionStore.connections.length">
|
||||
<ConnectionIcon v-if="boundConnection" :connection="boundConnection" class="h-3 w-3 shrink-0" />
|
||||
<Server v-else class="h-3 w-3 shrink-0" />
|
||||
<ConnectionTreeSelect
|
||||
@@ -6272,12 +6361,7 @@ async function openExternalUrl(url: string) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<!-- Combined mode + action selector -->
|
||||
<span v-if="pluginContext" class="ai-mode-static-trigger flex shrink-0 items-center gap-1 text-xs text-muted-foreground" :title="t('ai.modes.ask')">
|
||||
<MessageSquarePlus class="h-3 w-3" aria-hidden="true" />
|
||||
<span class="ai-mode-action-label" aria-hidden="true">{{ t("ai.modes.ask") }}</span>
|
||||
<span class="sr-only">{{ t("ai.modes.ask") }}</span>
|
||||
</span>
|
||||
<Popover v-else v-model:open="modeActionOpen">
|
||||
<Popover v-model:open="modeActionOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="ai-mode-action-trigger flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="modeActionTriggerLabel" :title="modeActionTriggerLabel">
|
||||
<component :is="modeIcon" class="h-3 w-3" />
|
||||
|
||||
+28
@@ -31,6 +31,34 @@ function bodyOf(fnSignature: string): string {
|
||||
}
|
||||
|
||||
describe("AI conversation owns its connection binding (#9902)", () => {
|
||||
it("renders plugin recommendation chips and sends the selected snapshot immediately", () => {
|
||||
expect(source).toContain("pluginRecommendations?: PluginAiRecommendationHostUpdate");
|
||||
expect(source).toContain('v-for="recommendation in pluginRecommendations.items"');
|
||||
expect(source).toContain('@click="sendPluginRecommendation(recommendation)"');
|
||||
expect(source).toContain("`history:${recommendation.id}`");
|
||||
const recommendationStart = source.indexOf("function sendPluginRecommendation(");
|
||||
expect(source.slice(recommendationStart, recommendationStart + 900)).toContain("send: true");
|
||||
expect(source.slice(recommendationStart, recommendationStart + 900)).toContain("context: update.context");
|
||||
});
|
||||
|
||||
it("keeps the connection selector available after a plugin recommendation is selected", () => {
|
||||
const contextStart = source.lastIndexOf("data-ai-composer-context-row");
|
||||
const contextEnd = source.indexOf("data-ai-composer-actions", contextStart);
|
||||
const contextMarkup = source.slice(contextStart, contextEnd);
|
||||
|
||||
expect(contextMarkup).toContain('v-if="pluginContext && !connectionStore.connections.length"');
|
||||
expect(contextMarkup).toContain("data-ai-plugin-context");
|
||||
expect(contextMarkup).toContain('v-if="connectionStore.connections.length"');
|
||||
expect(contextMarkup).toContain("<ConnectionTreeSelect");
|
||||
expect(contextMarkup).toContain('@update:model-value="(v) => changeConnection(v)"');
|
||||
});
|
||||
|
||||
it("does not expose Agent mode for a plugin conversation without a connection", () => {
|
||||
expect(source).toContain("function canUsePluginAgentMode");
|
||||
expect(source).toContain('if (mode === "agent" && !canUsePluginAgentMode()) return;');
|
||||
expect(source).toContain('request.mode === "agent" && !pluginConnection ? "ask"');
|
||||
});
|
||||
|
||||
it("rebinding the conversation never rewrites the editor tab or the global active connection", () => {
|
||||
const body = bodyOf("async function changeConnection(connectionId: string)");
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import AiAssistant from "@/components/editor/AiAssistant.vue";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -38,10 +39,10 @@ afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig, configureSettings?: (settings: ReturnType<typeof useSettingsStore>) => void) {
|
||||
async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig, configureSettings?: (settings: ReturnType<typeof useSettingsStore>) => void, pluginRecommendations?: PluginAiRecommendationHostUpdate) {
|
||||
const pinia = createPinia();
|
||||
const errors: unknown[] = [];
|
||||
const app = createApp({ render: () => h(TooltipProvider, () => h(AiAssistant, { connection })) });
|
||||
const app = createApp({ render: () => h(TooltipProvider, () => h(AiAssistant, { connection, pluginRecommendations })) });
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.config.errorHandler = (error) => errors.push(error);
|
||||
@@ -63,6 +64,40 @@ async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig
|
||||
}
|
||||
|
||||
describe("AiAssistant mount", () => {
|
||||
it("keeps the real Agent mode and an interactive picker after clicking a plugin recommendation", async () => {
|
||||
const { container, errors } = await mountPanel(
|
||||
true,
|
||||
{ id: "plugin-connection", name: "Sample", db_type: "plugin", plugin_id: "sample.plugin", host: "localhost", port: 22, username: "", password: "" },
|
||||
(settings) => {
|
||||
settings.defaultAiMode = "agent";
|
||||
},
|
||||
{
|
||||
pluginId: "sample.plugin",
|
||||
pluginName: "Sample",
|
||||
contributionId: "workbench",
|
||||
workbenchId: "cluster",
|
||||
context: { connectionId: "plugin-connection" },
|
||||
items: [{ id: "overview", label: "Inspect cluster", prompt: "Inspect cluster" }],
|
||||
},
|
||||
);
|
||||
expect(container.querySelector(".ai-mode-action-trigger")?.textContent).toContain(i18n.global.t("ai.modes.agent"));
|
||||
container.querySelector<HTMLButtonElement>('button[title="Inspect cluster"]')!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const trigger = container.querySelector<HTMLButtonElement>(".ai-mode-action-trigger");
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.textContent).toContain(i18n.global.t("ai.modes.agent"));
|
||||
expect(container.querySelector(".ai-mode-static-trigger")).toBeNull();
|
||||
trigger!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
const askButton = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')).find((button) => button.textContent?.trim() === i18n.global.t("ai.modes.ask"));
|
||||
expect(askButton).toBeDefined();
|
||||
askButton!.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(trigger?.textContent).toContain(i18n.global.t("ai.modes.ask"));
|
||||
expect(errors.map(String)).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens when the AI config was loaded before the panel mounted", async () => {
|
||||
expect((await mountPanel(true)).errors.map(String)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type PluginSaveFileRequest,
|
||||
type PluginSaveFileResult,
|
||||
type PluginWorkbenchContext,
|
||||
type PluginAiRecommendationHostUpdate,
|
||||
} from "@/lib/plugins/pluginHostBridge";
|
||||
import { getCachedPluginUiHtml, getOrLoadPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache";
|
||||
import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
|
||||
@@ -42,6 +43,7 @@ const emit = defineEmits<{
|
||||
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }];
|
||||
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
|
||||
closeTab: [];
|
||||
recommendations: [update: PluginAiRecommendationHostUpdate];
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
@@ -381,6 +383,7 @@ function createBridge() {
|
||||
sendBinary: api.sendPluginBinary,
|
||||
readAsset: api.readPluginUiAsset,
|
||||
openAiConversation,
|
||||
setAiRecommendations: (update) => emit("recommendations", update),
|
||||
openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options),
|
||||
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
||||
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
|
||||
|
||||
@@ -5,7 +5,7 @@ import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import PluginRefreshState from "@/components/plugins/PluginRefreshState.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import type { PluginAiRecommendationHostUpdate, PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -21,6 +21,7 @@ const props = defineProps<{
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const emit = defineEmits<{
|
||||
closeTab: [];
|
||||
recommendations: [update: PluginAiRecommendationHostUpdate];
|
||||
}>();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
@@ -129,6 +130,6 @@ defineExpose({ refresh });
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error || t("pluginPlatform.workbenchUnavailableFallback") }}</span>
|
||||
</div>
|
||||
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="hostContext" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" @close-tab="emit('closeTab')" />
|
||||
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="hostContext" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" @close-tab="emit('closeTab')" @recommendations="emit('recommendations', $event)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2953,7 +2953,7 @@ export default withEnglishFallback({
|
||||
clear: "Söhbəti təmizlə",
|
||||
maximize: "Maksimum böyüt",
|
||||
restore: "Bərpa et",
|
||||
welcome: "Nəyi sorğulamaq istədiyinizi deyin, SQL-i yazım",
|
||||
welcome: "Nəyi anlamaq istədiyinizi deyin, birlikdə təhlil edək",
|
||||
requestFailed: "AI sorğusu uğursuz oldu",
|
||||
requestCancelled: "Sorğu ləğv edildi",
|
||||
backgroundRunCompleted: "AI cavabı arxa planda tamamlandı",
|
||||
@@ -3025,7 +3025,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "Yenidən yükləmədən sonra məzmun əlçatan deyil; təkrar göndərilməyəcək",
|
||||
attachmentUnsupportedDocument: "Bu fayl növü hələ dəstəklənmir. Əvəzində şəkillər və ya mətn əsaslı fayllar əlavə edin.",
|
||||
tableMentionEmpty: "Uyğun cədvəl və ya SQL faylı yoxdur",
|
||||
tableMentionPlaceholderHint: "Kontekst üçün cədvəlləri və ya SQL fayllarını qeyd etmək məqsədilə {'@'} yazın",
|
||||
tableMentionPlaceholderHint: "Mövcud obyektləri və ya faylları kontekstə əlavə etmək üçün {'@'} yazın",
|
||||
tableMentionTypes: {
|
||||
table: "Cədvəl",
|
||||
view: "Görünüş",
|
||||
@@ -3285,7 +3285,7 @@ export default withEnglishFallback({
|
||||
switchTo: "{action} seçiminə keç",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Mənə istənilən sualı verin...",
|
||||
general: "Sualınızı yazın, birlikdə təhlil edək...",
|
||||
generate: "Nəyi sorğulamaq istədiyinizi təsvir edin, məsələn, son 7 gündə hər istifadəçinin sifarişləri",
|
||||
explain: "İstəyə bağlı: nəyi anlamaq istədiyinizi əlavə edin",
|
||||
optimize: "İstəyə bağlı: məqsəd əlavə edin, məsələn, cədvəlin tam skan edilməsini azaltmaq",
|
||||
@@ -3312,8 +3312,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}, standart {default}",
|
||||
maxAgentTurnsSaved: "Agentin gediş limiti yadda saxlanıldı",
|
||||
pluginHttpModelOnly: "Plugin analizində bir HTTP modeli və ya Ollama seçin.",
|
||||
pluginWelcome: "Bu məlumat snapshot-u barədə soruşun və söhbəti burada davam etdirin",
|
||||
pluginFollowUp: "Bu snapshot barədə əlavə sual verin…",
|
||||
pluginWelcome: "Cari kontekst barədə soruşun və söhbəti burada davam etdirin",
|
||||
pluginFollowUp: "Cari kontekst barədə əlavə sual verin…",
|
||||
defaultAiMode: "Standart AI rejimi",
|
||||
defaultAiModeDescription: "Yeni AI söhbətinə başlayarkən istifadə olunan rejim. Cari söhbətdə rejimin dəyişdirilməsi bu parametri dəyişmir.",
|
||||
defaultAutoRouting: "Standart olaraq avtomatik niyyət marşrutlaşdırması",
|
||||
|
||||
@@ -3313,7 +3313,7 @@ export default {
|
||||
clear: "Clear Chat",
|
||||
maximize: "Maximize",
|
||||
restore: "Restore",
|
||||
welcome: "Tell me what you'd like to query, and I'll write the SQL",
|
||||
welcome: "Tell me what you'd like to understand, and I'll help you analyze it",
|
||||
requestFailed: "AI request failed",
|
||||
requestCancelled: "Request cancelled",
|
||||
backgroundRunCompleted: "AI response completed in the background",
|
||||
@@ -3385,7 +3385,7 @@ export default {
|
||||
attachmentUnavailableAfterReload: "Content unavailable after reload; it will not be sent again",
|
||||
attachmentUnsupportedDocument: "This file type is not supported yet. Attach images or text-based files instead.",
|
||||
tableMentionEmpty: "No matching tables or SQL files",
|
||||
tableMentionPlaceholderHint: "Type {'@'} to mention tables or SQL files for context",
|
||||
tableMentionPlaceholderHint: "Type {'@'} to add available objects or files as context",
|
||||
tableMentionTypes: {
|
||||
table: "Table",
|
||||
view: "View",
|
||||
@@ -3645,7 +3645,7 @@ export default {
|
||||
switchTo: "Switch to {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Ask me anything...",
|
||||
general: "Ask a question and I'll help you analyze it...",
|
||||
generate: "Describe what you want to query, e.g. orders per user in the last 7 days",
|
||||
explain: "Optional: add what you want to understand",
|
||||
optimize: "Optional: add a goal, e.g. reduce full table scans",
|
||||
@@ -3672,8 +3672,8 @@ export default {
|
||||
maxAgentTurnsRange: "{min}–{max}, default {default}",
|
||||
maxAgentTurnsSaved: "Agent turn limit saved",
|
||||
pluginHttpModelOnly: "For plugin analysis, select an HTTP model or Ollama in the model menu.",
|
||||
pluginWelcome: "Ask about this data snapshot and continue the conversation here",
|
||||
pluginFollowUp: "Ask a follow-up about this snapshot…",
|
||||
pluginWelcome: "Ask about the current context and continue the conversation here",
|
||||
pluginFollowUp: "Ask a follow-up about the current context…",
|
||||
defaultAiMode: "Default AI Mode",
|
||||
defaultAiModeDescription: "Mode used when starting a new AI conversation. Switching mode in the current conversation does not change this setting.",
|
||||
defaultAutoRouting: "Auto Intent Routing by Default",
|
||||
|
||||
@@ -3308,7 +3308,7 @@ export default withEnglishFallback({
|
||||
clear: "Limpiar chat",
|
||||
maximize: "Maximizar",
|
||||
restore: "Restaurar",
|
||||
welcome: "Dime qué quieres consultar y escribiré el SQL",
|
||||
welcome: "Dime qué quieres entender y te ayudaré a analizarlo",
|
||||
requestFailed: "La solicitud de AI falló",
|
||||
requestCancelled: "Solicitud de AI cancelada",
|
||||
backgroundRunCompleted: "La respuesta de AI se completó en segundo plano",
|
||||
@@ -3380,7 +3380,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "El contenido no está disponible tras recargar y no se volverá a enviar",
|
||||
attachmentUnsupportedDocument: "Este tipo de archivo aún no es compatible. Adjunta imágenes o archivos de texto.",
|
||||
tableMentionEmpty: "Sin tablas ni archivos SQL coincidentes",
|
||||
tableMentionPlaceholderHint: "Escribe {'@'} para mencionar tablas o archivos SQL como contexto",
|
||||
tableMentionPlaceholderHint: "Escribe {'@'} para añadir objetos o archivos disponibles como contexto",
|
||||
tableMentionTypes: {
|
||||
table: "Tabla",
|
||||
view: "Vista",
|
||||
@@ -3424,8 +3424,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}, predeterminado {default}",
|
||||
maxAgentTurnsSaved: "Límite de turnos del agente guardado",
|
||||
pluginHttpModelOnly: "Para el análisis de plugins, selecciona un modelo HTTP u Ollama en el menú de modelos.",
|
||||
pluginWelcome: "Pregunta sobre esta instantánea de datos y continúa la conversación aquí",
|
||||
pluginFollowUp: "Haz una pregunta de seguimiento sobre esta instantánea…",
|
||||
pluginWelcome: "Pregunta sobre el contexto actual y continúa la conversación aquí",
|
||||
pluginFollowUp: "Haz una pregunta de seguimiento sobre el contexto actual…",
|
||||
defaultAiMode: "Modo de IA predeterminado",
|
||||
defaultAiModeDescription: "Modo usado al iniciar una nueva conversación de IA. Cambiar de modo en la conversación actual no modifica esta configuración.",
|
||||
defaultAutoRouting: "Enrutamiento de intención automático por defecto",
|
||||
@@ -3629,7 +3629,7 @@ export default withEnglishFallback({
|
||||
switchTo: "Cambiar a {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Pregunta lo que quieras...",
|
||||
general: "Escribe una pregunta y te ayudaré a analizarla...",
|
||||
generate: "Describe lo que quieres consultar, p. ej. pedidos por usuario en los últimos 7 días",
|
||||
explain: "Opcional: indica qué quieres entender",
|
||||
optimize: "Opcional: indica un objetivo, p. ej. reducir los escaneos completos de tabla",
|
||||
|
||||
@@ -3127,7 +3127,7 @@ export default withEnglishFallback({
|
||||
clear: "Cancella Chat",
|
||||
maximize: "Massimizza",
|
||||
restore: "Ripristina",
|
||||
welcome: "Dimmi cosa vorresti interrogare e io scriverò il codice SQL",
|
||||
welcome: "Dimmi cosa vuoi capire e ti aiuterò ad analizzarlo",
|
||||
requestFailed: "Richiesta AI non riuscita",
|
||||
requestCancelled: "Richiesta AI annullata",
|
||||
backgroundRunCompleted: "La risposta di AI è stata completata in background",
|
||||
@@ -3199,7 +3199,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "Il contenuto non è disponibile dopo il ricaricamento e non verrà inviato di nuovo",
|
||||
attachmentUnsupportedDocument: "Questo tipo di file non è ancora supportato. Allega immagini o file di testo.",
|
||||
tableMentionEmpty: "Nessuna tabella o file SQL corrispondente",
|
||||
tableMentionPlaceholderHint: "Digita {'@'} per menzionare tabelle o file SQL per il contesto",
|
||||
tableMentionPlaceholderHint: "Digita {'@'} per aggiungere oggetti o file disponibili al contesto",
|
||||
tableMentionTypes: {
|
||||
table: "Tabella",
|
||||
view: "Vista",
|
||||
@@ -3243,8 +3243,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}, predefinito {default}",
|
||||
maxAgentTurnsSaved: "Limite di turni dell'agente salvato",
|
||||
pluginHttpModelOnly: "Per l'analisi dei plugin, seleziona un modello HTTP o Ollama nel menu dei modelli.",
|
||||
pluginWelcome: "Chiedi informazioni su questo snapshot di dati e continua qui la conversazione",
|
||||
pluginFollowUp: "Fai una domanda di approfondimento su questo snapshot…",
|
||||
pluginWelcome: "Chiedi informazioni sul contesto attuale e continua qui la conversazione",
|
||||
pluginFollowUp: "Fai una domanda di approfondimento sul contesto attuale…",
|
||||
defaultAiMode: "Modalità IA predefinita",
|
||||
defaultAiModeDescription: "Modalità usata all'avvio di una nuova conversazione IA. Cambiare modalità nella conversazione corrente non modifica questa impostazione.",
|
||||
defaultAutoRouting: "Routing automatico delle intenzioni predefinito",
|
||||
@@ -3552,7 +3552,7 @@ export default withEnglishFallback({
|
||||
switchTo: "Passa a {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Chiedi qualsiasi cosa...",
|
||||
general: "Inserisci una domanda e ti aiuterò ad analizzarla...",
|
||||
generate: "Descrivi cosa desideri interrogare, es. ordini per utente negli ultimi 7 giorni",
|
||||
explain: "Opzionale: aggiungi cosa desideri comprendere",
|
||||
optimize: "Opzionale: aggiungi un obiettivo, es. ridurre le scansioni complete della tabella",
|
||||
|
||||
@@ -3267,7 +3267,7 @@ export default withEnglishFallback({
|
||||
clear: "チャットをクリア",
|
||||
maximize: "最大化",
|
||||
restore: "元に戻す",
|
||||
welcome: "クエリしたい内容を教えてください。SQLを作成します",
|
||||
welcome: "知りたいことを教えてください。一緒に分析します",
|
||||
requestFailed: "AIリクエストに失敗しました",
|
||||
requestCancelled: "AIリクエストがキャンセルされました",
|
||||
backgroundRunCompleted: "AIの応答はバックグラウンドで完了しました",
|
||||
@@ -3339,7 +3339,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "再読み込み後は内容を利用できないため、再送信されません",
|
||||
attachmentUnsupportedDocument: "このファイル形式はまだ対応していません。画像またはテキスト形式のファイルを添付してください。",
|
||||
tableMentionEmpty: "一致するテーブルまたは SQL ファイルがありません",
|
||||
tableMentionPlaceholderHint: "{'@'}を入力してコンテキスト用のテーブルまたは SQL ファイルを指定",
|
||||
tableMentionPlaceholderHint: "{'@'}を入力して利用可能なオブジェクトやファイルをコンテキストに追加",
|
||||
tableMentionTypes: {
|
||||
table: "テーブル",
|
||||
view: "ビュー",
|
||||
@@ -3383,8 +3383,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}、デフォルト {default}",
|
||||
maxAgentTurnsSaved: "エージェントターン上限を保存しました",
|
||||
pluginHttpModelOnly: "プラグイン分析では、モデルメニューから HTTP モデルまたは Ollama を選択してください。",
|
||||
pluginWelcome: "このデータスナップショットについて質問し、ここで会話を続けられます",
|
||||
pluginFollowUp: "このスナップショットについて追加で質問…",
|
||||
pluginWelcome: "現在のコンテキストについて質問し、ここで会話を続けられます",
|
||||
pluginFollowUp: "現在のコンテキストについて追加で質問…",
|
||||
defaultAiMode: "既定の AI モード",
|
||||
defaultAiModeDescription: "新しい AI 会話を開始するときに使用されるモードです。現在の会話でモードを切り替えてもこの設定は変更されません。",
|
||||
defaultAutoRouting: "自動インテントルーティングを既定で有効化",
|
||||
@@ -3576,7 +3576,7 @@ export default withEnglishFallback({
|
||||
switchTo: "{action}に切り替え",
|
||||
},
|
||||
placeholders: {
|
||||
general: "何でも聞いてください...",
|
||||
general: "質問を入力してください。一緒に分析します...",
|
||||
generate: "クエリしたい内容を説明してください(例: 過去7日間のユーザーごとの注文数)",
|
||||
explain: "任意: 理解したい内容を追加",
|
||||
optimize: "任意: 目標を追加(例: フルテーブルスキャンを減らす)",
|
||||
|
||||
@@ -3031,7 +3031,7 @@ export default withEnglishFallback({
|
||||
clear: "채팅 지우기",
|
||||
maximize: "최대화",
|
||||
restore: "복원",
|
||||
welcome: "쿼리할 내용을 말씀해 주세요. SQL을 작성해 드릴게요",
|
||||
welcome: "궁금한 내용을 말씀해 주세요. 함께 분석해 드릴게요",
|
||||
requestFailed: "AI 요청 실패",
|
||||
requestCancelled: "AI 요청이 취소되었습니다",
|
||||
backgroundRunCompleted: "AI 응답이 백그라운드에서 완료되었습니다",
|
||||
@@ -3103,7 +3103,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "다시 로드한 후에는 콘텐츠를 사용할 수 없으며 다시 전송되지 않습니다",
|
||||
attachmentUnsupportedDocument: "이 파일 형식은 아직 지원되지 않습니다. 이미지 또는 텍스트 파일을 첨부하세요.",
|
||||
tableMentionEmpty: "일치하는 테이블이나 SQL 파일이 없습니다",
|
||||
tableMentionPlaceholderHint: "{'@'}를 입력하여 컨텍스트용 테이블이나 SQL 파일을 멘션하세요",
|
||||
tableMentionPlaceholderHint: "{'@'}를 입력하여 사용 가능한 객체나 파일을 컨텍스트에 추가하세요",
|
||||
tableMentionTypes: {
|
||||
table: "테이블",
|
||||
view: "뷰",
|
||||
@@ -3357,7 +3357,7 @@ export default withEnglishFallback({
|
||||
switchTo: "{action}(으)로 전환",
|
||||
},
|
||||
placeholders: {
|
||||
general: "무엇이든 물어보세요...",
|
||||
general: "질문을 입력해 주세요. 함께 분석해 드릴게요...",
|
||||
generate: "쿼리할 내용을 설명하세요. 예: 최근 7일간 사용자별 주문",
|
||||
explain: "선택: 이해하고 싶은 내용 추가",
|
||||
optimize: "선택: 목표 추가. 예: 전체 테이블 스캔 줄이기",
|
||||
@@ -3384,8 +3384,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}, 기본 {default}",
|
||||
maxAgentTurnsSaved: "에이전트 턴 제한을 저장했습니다",
|
||||
pluginHttpModelOnly: "플러그인 분석에는 모델 메뉴에서 HTTP 모델 또는 Ollama를 선택하세요.",
|
||||
pluginWelcome: "이 데이터 스냅샷에 대해 질문하고 여기에서 대화를 이어가세요",
|
||||
pluginFollowUp: "이 스냅샷에 대해 추가로 질문…",
|
||||
pluginWelcome: "현재 컨텍스트에 대해 질문하고 여기에서 대화를 이어가세요",
|
||||
pluginFollowUp: "현재 컨텍스트에 대해 추가로 질문…",
|
||||
defaultAiMode: "기본 AI 모드",
|
||||
defaultAiModeDescription: "새 AI 대화를 시작할 때 사용되는 모드입니다. 현재 대화에서 모드를 전환해도 이 설정은 변경되지 않습니다.",
|
||||
defaultAutoRouting: "자동 의도 라우팅 기본 사용",
|
||||
|
||||
@@ -3234,7 +3234,7 @@ export default withEnglishFallback({
|
||||
clear: "Limpar Conversa",
|
||||
maximize: "Maximizar",
|
||||
restore: "Restaurar",
|
||||
welcome: "Diga o que você gostaria de consultar e eu escreverei o SQL",
|
||||
welcome: "Diga o que você gostaria de entender e eu ajudarei a analisar",
|
||||
requestFailed: "Falha na solicitação de AI",
|
||||
requestCancelled: "Solicitação de AI cancelada",
|
||||
backgroundRunCompleted: "A resposta de AI foi concluída em segundo plano",
|
||||
@@ -3306,7 +3306,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "O conteúdo fica indisponível após recarregar e não será enviado novamente",
|
||||
attachmentUnsupportedDocument: "Este tipo de arquivo ainda não é compatível. Anexe imagens ou arquivos de texto.",
|
||||
tableMentionEmpty: "Nenhuma tabela ou arquivo SQL correspondente",
|
||||
tableMentionPlaceholderHint: "Digite {'@'} para mencionar tabelas ou arquivos SQL como contexto",
|
||||
tableMentionPlaceholderHint: "Digite {'@'} para adicionar objetos ou arquivos disponíveis como contexto",
|
||||
tableMentionTypes: {
|
||||
table: "Tabela",
|
||||
view: "Visão",
|
||||
@@ -3350,8 +3350,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}, padrão {default}",
|
||||
maxAgentTurnsSaved: "Limite de turnos do agente salvo",
|
||||
pluginHttpModelOnly: "Para a análise de plugins, selecione um modelo HTTP ou Ollama no menu de modelos.",
|
||||
pluginWelcome: "Pergunte sobre este snapshot de dados e continue a conversa aqui",
|
||||
pluginFollowUp: "Faça uma pergunta de acompanhamento sobre este snapshot…",
|
||||
pluginWelcome: "Pergunte sobre o contexto atual e continue a conversa aqui",
|
||||
pluginFollowUp: "Faça uma pergunta de acompanhamento sobre o contexto atual…",
|
||||
defaultAiMode: "Modo de IA padrão",
|
||||
defaultAiModeDescription: "Modo usado ao iniciar uma nova conversa de IA. Alterar o modo na conversa atual não altera esta configuração.",
|
||||
defaultAutoRouting: "Roteamento automático de intenção por padrão",
|
||||
@@ -3553,7 +3553,7 @@ export default withEnglishFallback({
|
||||
switchTo: "Mudar para {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Pergunte qualquer coisa...",
|
||||
general: "Digite uma pergunta e eu ajudarei a analisá-la...",
|
||||
generate: "Descreva o que você quer consultar, por exemplo, pedidos por usuário nos últimos 7 dias",
|
||||
explain: "Opcional: adicione o que você quer entender",
|
||||
optimize: "Opcional: adicione um objetivo, por exemplo, reduzir varreduras completas de tabela",
|
||||
|
||||
@@ -3176,7 +3176,7 @@ export default withEnglishFallback({
|
||||
clear: "Очистить чат",
|
||||
maximize: "Развернуть",
|
||||
restore: "Восстановить",
|
||||
welcome: "Расскажите, что вы хотите запросить, и я напишу SQL",
|
||||
welcome: "Расскажите, что вы хотите понять, и я помогу это проанализировать",
|
||||
requestFailed: "Сбой запроса к ИИ",
|
||||
requestCancelled: "Запрос отменён",
|
||||
backgroundRunCompleted: "Ответ ИИ получен в фоновом режиме",
|
||||
@@ -3248,7 +3248,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "Содержимое недоступно после перезагрузки; повторно оно не будет отправлено",
|
||||
attachmentUnsupportedDocument: "Этот тип файлов пока не поддерживается. Прикрепите изображения или текстовые файлы.",
|
||||
tableMentionEmpty: "Подходящих таблиц или SQL-файлов нет",
|
||||
tableMentionPlaceholderHint: "Введите {'@'}, чтобы упомянуть таблицы или SQL-файлы для контекста",
|
||||
tableMentionPlaceholderHint: "Введите {'@'}, чтобы добавить доступные объекты или файлы в контекст",
|
||||
tableMentionTypes: {
|
||||
table: "Таблица",
|
||||
view: "Представление",
|
||||
@@ -3508,7 +3508,7 @@ export default withEnglishFallback({
|
||||
switchTo: "Переключить на {action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Спросите что угодно...",
|
||||
general: "Введите вопрос, и я помогу его проанализировать...",
|
||||
generate: "Опишите, что нужно запросить, например заказы на пользователя за последние 7 дней",
|
||||
explain: "Необязательно: добавьте, что хотите понять",
|
||||
optimize: "Необязательно: добавьте цель, например сократить полные сканирования таблиц",
|
||||
@@ -3535,8 +3535,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}, по умолчанию {default}",
|
||||
maxAgentTurnsSaved: "Лимит шагов агента сохранён",
|
||||
pluginHttpModelOnly: "Для анализа плагина выберите HTTP-модель или Ollama в меню моделей.",
|
||||
pluginWelcome: "Спросите об этом снимке данных и продолжите диалог здесь",
|
||||
pluginFollowUp: "Задайте уточняющий вопрос об этом снимке…",
|
||||
pluginWelcome: "Задайте вопрос о текущем контексте и продолжите диалог здесь",
|
||||
pluginFollowUp: "Задайте уточняющий вопрос о текущем контексте…",
|
||||
defaultAiMode: "Режим ИИ по умолчанию",
|
||||
defaultAiModeDescription: "Режим, используемый при начале нового диалога с ИИ. Переключение режима в текущем диалоге не изменяет эту настройку.",
|
||||
defaultAutoRouting: "Автоопределение намерения по умолчанию",
|
||||
|
||||
@@ -2929,7 +2929,7 @@ export default withEnglishFallback({
|
||||
clear: "Sohbeti Temizle",
|
||||
maximize: "Ekranı kapla",
|
||||
restore: "Geri yükle",
|
||||
welcome: "Ne sorgulamak istediğinizi söyleyin, SQL'i ben yazayım",
|
||||
welcome: "Neyi anlamak istediğinizi söyleyin, birlikte analiz edelim",
|
||||
requestFailed: "Yapay zekâ isteği başarısız",
|
||||
requestCancelled: "İstek iptal edildi",
|
||||
backgroundRunCompleted: "Yapay zekâ yanıtı arka planda tamamlandı",
|
||||
@@ -3001,7 +3001,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "Yeniden yüklemeden sonra içerik kullanılamıyor; yeniden gönderilmeyecek",
|
||||
attachmentUnsupportedDocument: "Bu dosya türü henüz desteklenmiyor. Bunun yerine görsel veya metin tabanlı dosyalar ekleyin.",
|
||||
tableMentionEmpty: "Eşleşen tablo veya SQL dosyası yok",
|
||||
tableMentionPlaceholderHint: "Bağlam için tablolardan veya SQL dosyalarından söz etmek üzere {'@'} yazın",
|
||||
tableMentionPlaceholderHint: "Kullanılabilir nesneleri veya dosyaları bağlama eklemek için {'@'} yazın",
|
||||
tableMentionTypes: {
|
||||
table: "Tablo",
|
||||
view: "Görünüm",
|
||||
@@ -3258,7 +3258,7 @@ export default withEnglishFallback({
|
||||
switchTo: "{action} seçeneğine geç",
|
||||
},
|
||||
placeholders: {
|
||||
general: "Bana istediğinizi sorun...",
|
||||
general: "Sorunuzu yazın, birlikte analiz edelim...",
|
||||
generate: "Ne sorgulamak istediğinizi anlatın, örn. son 7 gündeki kullanıcı başına siparişler",
|
||||
explain: "İsteğe bağlı: neyi anlamak istediğinizi ekleyin",
|
||||
optimize: "İsteğe bağlı: bir hedef ekleyin, örn. tam tablo taramalarını azaltmak",
|
||||
@@ -3285,8 +3285,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max}, varsayılan {default}",
|
||||
maxAgentTurnsSaved: "Agent tur sınırı kaydedildi",
|
||||
pluginHttpModelOnly: "Eklenti analizi için model menüsünden bir HTTP modeli veya Ollama seçin.",
|
||||
pluginWelcome: "Bu veri anlık görüntüsü hakkında sorun ve sohbeti burada sürdürün",
|
||||
pluginFollowUp: "Bu anlık görüntü hakkında ek soru sorun…",
|
||||
pluginWelcome: "Mevcut bağlam hakkında soru sorun ve sohbete burada devam edin",
|
||||
pluginFollowUp: "Mevcut bağlam hakkında ek soru sorun…",
|
||||
defaultAiMode: "Varsayılan Yapay Zekâ Modu",
|
||||
defaultAiModeDescription: "Yeni bir yapay zekâ konuşması başlatılırken kullanılan mod. Geçerli konuşmada mod değiştirmek bu ayarı değiştirmez.",
|
||||
defaultAutoRouting: "Varsayılan olarak otomatik niyet yönlendirme",
|
||||
|
||||
@@ -3225,7 +3225,7 @@ export default withEnglishFallback({
|
||||
clear: "清空对话",
|
||||
maximize: "最大化",
|
||||
restore: "还原",
|
||||
welcome: "聊聊你想查什么,我来写 SQL",
|
||||
welcome: "告诉我你想了解什么,我来帮你分析",
|
||||
requestFailed: "AI 请求失败",
|
||||
requestCancelled: "请求已取消",
|
||||
backgroundRunCompleted: "AI 已在后台完成回复",
|
||||
@@ -3297,7 +3297,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "重载后内容不可用,不会再次发送",
|
||||
attachmentUnsupportedDocument: "暂不支持此文件类型。请附加图片或文本类文件。",
|
||||
tableMentionEmpty: "没有匹配的表或 SQL 文件",
|
||||
tableMentionPlaceholderHint: "输入 {'@'} 可选择表或 SQL 文件并带入上下文",
|
||||
tableMentionPlaceholderHint: "输入 {'@'} 可添加可用对象或文件作为上下文",
|
||||
tableMentionTypes: {
|
||||
table: "表",
|
||||
view: "视图",
|
||||
@@ -3557,7 +3557,7 @@ export default withEnglishFallback({
|
||||
switchTo: "切换到{action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "问我任何问题...",
|
||||
general: "输入你的问题,我来帮你分析...",
|
||||
generate: "描述你想查询什么,例如:统计最近 7 天每个用户的订单数",
|
||||
explain: "可留空,或补充你关心的点",
|
||||
optimize: "可留空,或说明优化目标,例如:减少全表扫描",
|
||||
@@ -3584,8 +3584,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max},默认 {default}",
|
||||
maxAgentTurnsSaved: "Agent 回合上限已保存",
|
||||
pluginHttpModelOnly: "插件分析请在模型菜单中选择 HTTP 模型或 Ollama。",
|
||||
pluginWelcome: "围绕这份数据快照分析,在这里继续追问",
|
||||
pluginFollowUp: "继续追问这份数据快照…",
|
||||
pluginWelcome: "围绕当前上下文提问,在这里继续对话",
|
||||
pluginFollowUp: "继续询问当前上下文…",
|
||||
defaultAiMode: "默认 AI 模式",
|
||||
defaultAiModeDescription: "新建 AI 对话时使用的模式。切换当前对话的模式不会修改此设置。",
|
||||
defaultAutoRouting: "默认启用自动意图路由",
|
||||
|
||||
@@ -3125,7 +3125,7 @@ export default withEnglishFallback({
|
||||
clear: "清空對話",
|
||||
maximize: "最大化",
|
||||
restore: "還原",
|
||||
welcome: "告訴我你想查詢什麼,我會寫出 SQL",
|
||||
welcome: "告訴我你想了解什麼,我來幫你分析",
|
||||
requestFailed: "AI 請求失敗",
|
||||
requestCancelled: "AI 請求已取消",
|
||||
backgroundRunCompleted: "AI 已在背景完成回覆",
|
||||
@@ -3197,7 +3197,7 @@ export default withEnglishFallback({
|
||||
attachmentUnavailableAfterReload: "重新載入後內容無法使用,不會再次傳送",
|
||||
attachmentUnsupportedDocument: "暫不支援此檔案類型。請附加圖片或文字類檔案。",
|
||||
tableMentionEmpty: "沒有相符的資料表或 SQL 檔案",
|
||||
tableMentionPlaceholderHint: "輸入 {'@'} 可提及資料表或 SQL 檔案作為脈絡",
|
||||
tableMentionPlaceholderHint: "輸入 {'@'} 可新增可用物件或檔案作為脈絡",
|
||||
tableMentionTypes: {
|
||||
table: "資料表",
|
||||
view: "檢視",
|
||||
@@ -3241,8 +3241,8 @@ export default withEnglishFallback({
|
||||
maxAgentTurnsRange: "{min}–{max},預設 {default}",
|
||||
maxAgentTurnsSaved: "Agent 回合上限已儲存",
|
||||
pluginHttpModelOnly: "插件分析請在模型選單中選擇 HTTP 模型或 Ollama。",
|
||||
pluginWelcome: "針對這份資料快照分析,在這裡繼續追問",
|
||||
pluginFollowUp: "繼續追問這份資料快照…",
|
||||
pluginWelcome: "圍繞目前脈絡提問,在這裡繼續對話",
|
||||
pluginFollowUp: "繼續詢問目前脈絡…",
|
||||
defaultAiMode: "預設 AI 模式",
|
||||
defaultAiModeDescription: "建立新 AI 對話時使用的模式。切換目前對話的模式不會變更此設定。",
|
||||
defaultAutoRouting: "預設啟用自動意圖路由",
|
||||
@@ -3527,7 +3527,7 @@ export default withEnglishFallback({
|
||||
switchTo: "切換為{action}",
|
||||
},
|
||||
placeholders: {
|
||||
general: "問我任何問題...",
|
||||
general: "輸入你的問題,我來幫你分析...",
|
||||
generate: "描述你想查詢什麼,例如:統計最近 7 天每個使用者的訂單數",
|
||||
explain: "可留空,或補充你關心的點",
|
||||
optimize: "可留空,或說明最佳化目標,例如:減少全資料表掃描",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { AGENT_ACTIONS, ASK_ACTIONS, buildAgentRequest, isValidActionForMode, type AiAction, type AiContext } from "@/lib/ai/ai";
|
||||
import { AGENT_ACTIONS, ASK_ACTIONS, buildAgentRequest, buildSystemPrompt, isValidActionForMode, type AiAction, type AiContext } from "@/lib/ai/ai";
|
||||
import { INTENT_CLASSIFY_MAX_TEXT_CHARS, actionForIntent, classifyIntentByLlm, intentCandidatesForMode, parseClassifierAction, requestsExplicitExecution, routeIntent, routeIntentByRules, type AiIntent, type AiIntentRouteInput } from "@/lib/ai/aiIntentRouter";
|
||||
import type { AiConfig } from "@/stores/settingsStore";
|
||||
|
||||
@@ -428,6 +428,13 @@ describe("routeIntent (two-stage entry point)", () => {
|
||||
});
|
||||
|
||||
describe("'auto' never reaches the transport layer", () => {
|
||||
it("builds plugin Agent prompts that require live tools", () => {
|
||||
const request = buildAgentRequest({ config, action: "general", mode: "agent", instruction: "检查当前集群", context: context({ databaseType: "plugin", connectionName: "orb", database: "" }) });
|
||||
expect(request.messages[request.messages.length - 1]?.content).toBe("检查当前集群");
|
||||
expect(buildSystemPrompt("general", context({ databaseType: "plugin", connectionName: "orb", database: "" }), "agent")).toContain("live data");
|
||||
expect(buildSystemPrompt("general", context({ databaseType: "plugin", connectionName: "orb", database: "" }), "agent")).not.toContain("返回 SQL");
|
||||
});
|
||||
|
||||
it("keeps the concrete action tables free of 'auto'", () => {
|
||||
expect(ASK_ACTIONS).not.toContain("auto");
|
||||
expect(AGENT_ACTIONS).not.toContain("auto");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildPluginAiRequest, createPluginAiConversation, pluginContextFromMessages, streamPluginAiConversation } from "../aiPluginConversation";
|
||||
import { buildPluginAiRequest, createPluginAiConversation, pluginComposerConnectionLabel, pluginContextConnectionId, pluginContextFromMessages, streamPluginAiConversation } from "../aiPluginConversation";
|
||||
import type { AiConfig } from "@/types/ai";
|
||||
import type { AgentEvent } from "@/lib/backend/tauri";
|
||||
|
||||
@@ -7,6 +7,19 @@ const identity = { id: "market", name: "Market Watch" };
|
||||
const input = () => ({ title: "AAPL", prompt: "Analyse risks", context: { quotes: [{ symbol: "AAPL", price: 100, currency: "USD" }], snapshotId: "snap-1" } });
|
||||
|
||||
describe("plugin conversations in DBX AI", () => {
|
||||
it("keeps the composer label on the connection instead of the recommendation title", () => {
|
||||
const context = createPluginAiConversation(identity, input()).context;
|
||||
expect(pluginComposerConnectionLabel(context, "orb", "Saved connection")).toBe("orb");
|
||||
expect(pluginComposerConnectionLabel(context, undefined, "Saved connection")).toBe("Saved connection");
|
||||
expect(pluginComposerConnectionLabel(context)).toBe("Market Watch");
|
||||
});
|
||||
|
||||
it("reads an optional connection id from the plugin snapshot without requiring it", () => {
|
||||
const context = createPluginAiConversation(identity, { ...input(), context: { connectionId: "conn-orb" } }).context;
|
||||
expect(pluginContextConnectionId(context)).toBe("conn-orb");
|
||||
expect(pluginContextConnectionId(createPluginAiConversation(identity, input()).context)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binds the host identity and freezes a data snapshot without auto-sending by default", () => {
|
||||
const request = input();
|
||||
const opened = createPluginAiConversation(identity, { ...request, pluginId: "other-plugin", pluginName: "spoof" });
|
||||
@@ -16,6 +29,16 @@ describe("plugin conversations in DBX AI", () => {
|
||||
expect(opened).not.toHaveProperty("apiKey");
|
||||
});
|
||||
|
||||
it("keeps legacy opens in snapshot Ask mode and accepts explicit Agent mode", () => {
|
||||
const legacy = createPluginAiConversation(identity, input());
|
||||
expect(legacy.mode).toBeUndefined();
|
||||
expect(legacy.context.mode).toBeUndefined();
|
||||
const live = createPluginAiConversation(identity, { ...input(), mode: "agent" });
|
||||
expect(live.mode).toBe("agent");
|
||||
expect(live.context.mode).toBe("agent");
|
||||
expect(() => createPluginAiConversation(identity, { ...input(), mode: "invalid" })).toThrow();
|
||||
});
|
||||
|
||||
it.each([{ title: "" }, { prompt: "" }, { context: [] }, { send: "true" }, { context: { callback: () => {} } }, { context: { big: "x".repeat(2 * 1024 * 1024) } }])("rejects malformed or oversized requests", (override) => {
|
||||
expect(() => createPluginAiConversation(identity, { ...input(), ...override })).toThrow();
|
||||
});
|
||||
|
||||
@@ -286,6 +286,9 @@ export async function runAgentStream(input: AiRequestInput, history: api.AiMessa
|
||||
}
|
||||
|
||||
export function buildUserPrompt(action: AiAction, context: AiContext, instruction: string, isZh: boolean): string {
|
||||
if (context.databaseType === "plugin") {
|
||||
return instruction.trim() || (isZh ? "(无额外说明)" : "(No extra instruction provided.)");
|
||||
}
|
||||
const userRequest = instruction.trim() || (isZh ? "(无额外说明)" : "(No extra instruction provided.)");
|
||||
const attachedTextData = formatAttachedTextData(context, isZh);
|
||||
if (isVectorDbType(context.databaseType) || context.databaseType === "redis") {
|
||||
@@ -325,6 +328,20 @@ function attachmentSafetyInstruction(isZh: boolean): string {
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(action: AiAction, context: AiContext, mode: AiAssistantMode = "ask", custom?: CustomPromptContext): string {
|
||||
if (context.databaseType === "plugin") {
|
||||
const isZh = isChineseLocale(currentLocale());
|
||||
return [
|
||||
isZh ? "你是 DBX 中连接插件的实时 Agent。" : "You are DBX's live Agent for a connected plugin.",
|
||||
isZh
|
||||
? "必须优先调用当前插件提供的工具获取实时数据,再基于工具结果回答。不要把历史上下文快照当作当前状态,也不要在没有工具结果时声称已经查询过资源。"
|
||||
: "Always call the connected plugin's tools first to obtain live data. Do not treat historical context snapshots as current state or claim that a resource was queried without tool results.",
|
||||
isZh ? "工具调用遵循工具定义和现有确认策略;如果没有可用工具,明确告知用户当前连接未提供实时查询能力。" : "Follow the tool definitions and the existing approval policy. If no tool is available, tell the user that this connection does not provide live query capability.",
|
||||
...buildCustomInstructionLines(custom, isZh),
|
||||
`Connection: ${context.connectionName}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
if (isVectorDbType(context.databaseType)) {
|
||||
return buildVectorSystemPrompt(context, mode, custom);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InjectionKey } from "vue";
|
||||
import { snapshotPluginWorkbenchContext } from "@/lib/plugins/pluginData";
|
||||
import type { AiConfig } from "@/types/ai";
|
||||
import type { AiAssistantMode, AiConfig } from "@/types/ai";
|
||||
import type { AiCompletionRequest, AiMessage, AiStreamChunk, AgentEvent } from "@/lib/backend/tauri";
|
||||
|
||||
/** Data owned by a conversation, independent of the currently selected DBX tab. */
|
||||
@@ -10,12 +10,16 @@ export interface AiPluginContext {
|
||||
title: string;
|
||||
capturedAt: string;
|
||||
data: Record<string, unknown>;
|
||||
/** Persisted so reopening a live recommendation conversation keeps Agent mode. */
|
||||
mode?: AiAssistantMode;
|
||||
}
|
||||
|
||||
export interface AiPluginConversationRequest {
|
||||
context: AiPluginContext;
|
||||
prompt: string;
|
||||
send: boolean;
|
||||
/** Legacy plugin calls stay on snapshot Ask mode unless they opt into live Agent mode. */
|
||||
mode?: AiAssistantMode;
|
||||
}
|
||||
|
||||
export const OPEN_PLUGIN_AI_CONVERSATION: InjectionKey<(request: AiPluginConversationRequest) => Promise<void>> = Symbol("open-plugin-ai-conversation");
|
||||
@@ -27,6 +31,7 @@ export function createPluginAiConversation(plugin: { id: string; name: string },
|
||||
if (typeof input.prompt !== "string" || !input.prompt.trim() || input.prompt.length > 32000) throw new Error("AI conversation prompt must contain 1–32000 characters");
|
||||
if (!input.context || typeof input.context !== "object" || Array.isArray(input.context)) throw new Error("AI conversation context must be an object");
|
||||
if (input.send !== undefined && typeof input.send !== "boolean") throw new Error("AI conversation send must be a boolean");
|
||||
if (input.mode !== undefined && input.mode !== "ask" && input.mode !== "agent") throw new Error("AI conversation mode must be ask or agent");
|
||||
return {
|
||||
context: {
|
||||
pluginId: plugin.id,
|
||||
@@ -34,9 +39,11 @@ export function createPluginAiConversation(plugin: { id: string; name: string },
|
||||
title: input.title.trim(),
|
||||
capturedAt: new Date().toISOString(),
|
||||
data: snapshotPluginWorkbenchContext(input.context as Record<string, unknown>),
|
||||
...(input.mode === undefined ? {} : { mode: input.mode }),
|
||||
},
|
||||
prompt: input.prompt.trim(),
|
||||
send: input.send === true,
|
||||
...(input.mode === undefined ? {} : { mode: input.mode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +55,25 @@ export function pluginContextText(context: AiPluginContext): string {
|
||||
return `${context.pluginName} · ${context.title}\n${context.capturedAt}\n\n${JSON.stringify(context.data, null, 2)}`;
|
||||
}
|
||||
|
||||
/** A plugin may include the DBX connection id in its snapshot context. */
|
||||
export function pluginContextConnectionId(context: AiPluginContext): string | undefined {
|
||||
const value = context.data.connectionId;
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label shown in the AI composer for a plugin-backed conversation.
|
||||
*
|
||||
* The plugin title describes the request and can change for every
|
||||
* recommendation. The composer is bound to a connection instead, so prefer
|
||||
* the resolved connection name and use the persisted name for conversations
|
||||
* whose connection was removed. Older plugin conversations may have neither,
|
||||
* in which case the plugin name is the safest available fallback.
|
||||
*/
|
||||
export function pluginComposerConnectionLabel(context: AiPluginContext, boundConnectionName?: string, persistedConnectionName?: string): string {
|
||||
return boundConnectionName?.trim() || persistedConnectionName?.trim() || context.pluginName;
|
||||
}
|
||||
|
||||
export function buildPluginAiRequest(config: AiConfig, context: AiPluginContext, messages: AiMessage[], instructions?: string[]): AiCompletionRequest {
|
||||
return {
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { interpolatePluginAiRecommendation, isValidPluginAiRecommendationTemplate, resolvePluginAiRecommendationUpdate, resolvePluginAiRecommendations, selectPluginAiRecommendations } from "./pluginAiRecommendations";
|
||||
import type { PluginAiRecommendation } from "@/types/pluginAiRecommendations";
|
||||
|
||||
const item = (overrides: Partial<PluginAiRecommendation> = {}): PluginAiRecommendation => ({
|
||||
id: "health",
|
||||
label: "Check {{resource.name}}",
|
||||
prompt: "Analyze {{resource.kind}}/{{resource.name}}",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("plugin AI recommendations", () => {
|
||||
it("rejects malformed placeholders", () => {
|
||||
expect(isValidPluginAiRecommendationTemplate("Inspect {{resource.name}}")).toBe(true);
|
||||
expect(isValidPluginAiRecommendationTemplate("Inspect {{resource..name}}")).toBe(false);
|
||||
expect(isValidPluginAiRecommendationTemplate("Inspect {{resource.name")).toBe(false);
|
||||
expect(isValidPluginAiRecommendationTemplate("Inspect resource.name}}")).toBe(false);
|
||||
expect(isValidPluginAiRecommendationTemplate('Render JSON {"name": "{{resource.name}}"}')).toBe(true);
|
||||
expect(isValidPluginAiRecommendationTemplate("Inspect {{resource.name}} and {{resource.kind}}")).toBe(true);
|
||||
});
|
||||
|
||||
it("interpolates nested context paths in label and prompt", () => {
|
||||
expect(interpolatePluginAiRecommendation(item(), { resource: { kind: "deployment", name: "whoami" } })).toEqual({
|
||||
id: "health",
|
||||
label: "Check whoami",
|
||||
prompt: "Analyze deployment/whoami",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters a recommendation when a placeholder is unresolved", () => {
|
||||
expect(resolvePluginAiRecommendations([item()], { resource: { kind: "deployment" } })).toEqual([]);
|
||||
expect(resolvePluginAiRecommendations([item({ label: "Static", prompt: "No placeholders" })], {})).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not traverse inherited or forbidden context properties", () => {
|
||||
const context = Object.create({ resource: { name: "inherited" } }) as Record<string, unknown>;
|
||||
expect(resolvePluginAiRecommendations([item()], context)).toEqual([]);
|
||||
expect(resolvePluginAiRecommendations([item({ label: "{{constructor}}", prompt: "safe" })], {})).toEqual([]);
|
||||
});
|
||||
|
||||
it("orders by order while keeping declaration order for ties and caps at five", () => {
|
||||
const items = Array.from({ length: 7 }, (_, index) => item({ id: `item-${index}`, label: `Item ${index}`, prompt: `Prompt ${index}`, order: index === 6 ? -1 : 1 }));
|
||||
expect(resolvePluginAiRecommendations(items, {}).map(({ id }) => id)).toEqual(["item-6", "item-0", "item-1", "item-2", "item-3"]);
|
||||
});
|
||||
|
||||
it("does not mutate input recommendations", () => {
|
||||
const defaults = [item({ label: "{{resource.name}}", prompt: "Ask {{resource.name}}" })];
|
||||
const output = resolvePluginAiRecommendations(defaults, { resource: { name: "db" } });
|
||||
expect(output[0]).not.toBe(defaults[0]);
|
||||
expect(defaults[0].label).toBe("{{resource.name}}");
|
||||
});
|
||||
|
||||
it("uses runtime recommendations as an override, including an explicit empty list", () => {
|
||||
const defaults = [item({ id: "default", label: "Default", prompt: "Default" })];
|
||||
expect(selectPluginAiRecommendations(defaults, undefined)).toBe(defaults);
|
||||
expect(selectPluginAiRecommendations(defaults, [])).toEqual([]);
|
||||
expect(resolvePluginAiRecommendationUpdate(defaults, { context: {}, items: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses runtime context and falls back to the supplied context when no update exists", () => {
|
||||
const defaults = [item()];
|
||||
expect(resolvePluginAiRecommendationUpdate(defaults, undefined, { resource: { kind: "table", name: "users" } })[0]?.prompt).toBe("Analyze table/users");
|
||||
expect(resolvePluginAiRecommendationUpdate(defaults, { context: { resource: { kind: "pod", name: "api" } }, items: [item()] })[0]?.prompt).toBe("Analyze pod/api");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MAX_PLUGIN_AI_RECOMMENDATIONS, type PluginAiRecommendation, type PluginAiRecommendationContext, type PluginAiRecommendationUpdate } from "@/types/pluginAiRecommendations";
|
||||
|
||||
export { MAX_PLUGIN_AI_RECOMMENDATIONS, type PluginAiRecommendation, type PluginAiRecommendationContext, type PluginAiRecommendationUpdate };
|
||||
|
||||
const PLACEHOLDER = /\{\{\s*([A-Za-z_$][A-Za-z0-9_$]*(?:\.(?:[A-Za-z_$][A-Za-z0-9_$]*|[0-9]+))*)\s*\}\}/g;
|
||||
const PLACEHOLDER_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.(?:[A-Za-z_$][A-Za-z0-9_$]*|[0-9]+))*$/;
|
||||
const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
/** Validate that every brace pair uses the supported context placeholder syntax. */
|
||||
export function isValidPluginAiRecommendationTemplate(value: string): boolean {
|
||||
let offset = 0;
|
||||
while (offset < value.length) {
|
||||
const open = value.indexOf("{{", offset);
|
||||
const close = value.indexOf("}}", offset);
|
||||
const next = open < 0 ? close : close < 0 ? open : Math.min(open, close);
|
||||
if (next < 0) return true;
|
||||
if (value.startsWith("}}", next)) return false;
|
||||
const end = value.indexOf("}}", next + 2);
|
||||
if (end < 0) return false;
|
||||
const path = value.slice(next + 2, end).trim();
|
||||
if (!PLACEHOLDER_PATH.test(path) || FORBIDDEN_PATH_SEGMENTS.has(path) || path.split(".").some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment))) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dotted context path without traversing inherited properties.
|
||||
* This keeps plugin supplied templates from reading prototype members.
|
||||
*/
|
||||
function readContextPath(context: unknown, path: string): unknown {
|
||||
let current: unknown = context;
|
||||
for (const segment of path.split(".")) {
|
||||
if (FORBIDDEN_PATH_SEGMENTS.has(segment) || (typeof current !== "object" && typeof current !== "function") || current === null) return undefined;
|
||||
if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Convert a context leaf to safe human-readable template text. */
|
||||
function contextValueText(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === "string") return value.trim() ? value : undefined;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined;
|
||||
if (typeof value === "boolean" || typeof value === "bigint") return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate one recommendation. If any placeholder cannot be resolved, the
|
||||
* item is omitted (`null`) so an unfinished prompt is never shown to users.
|
||||
*/
|
||||
export function interpolatePluginAiRecommendation(item: PluginAiRecommendation, context: PluginAiRecommendationContext): PluginAiRecommendation | null {
|
||||
if (!isValidRecommendation(item)) return null;
|
||||
|
||||
let unresolved = false;
|
||||
const interpolate = (text: string): string =>
|
||||
text.replace(PLACEHOLDER, (_match, path: string) => {
|
||||
const value = contextValueText(readContextPath(context, path));
|
||||
if (value === undefined) {
|
||||
unresolved = true;
|
||||
return "";
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
const label = interpolate(item.label);
|
||||
const prompt = interpolate(item.prompt);
|
||||
if (unresolved || !label.trim() || !prompt.trim()) return null;
|
||||
|
||||
return {
|
||||
id: item.id.trim(),
|
||||
label,
|
||||
prompt,
|
||||
...(item.order === undefined ? {} : { order: item.order }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, order, and cap a list of recommendations for a context.
|
||||
* The input is never mutated and the result contains fresh objects.
|
||||
*/
|
||||
export function resolvePluginAiRecommendations(items: readonly PluginAiRecommendation[] | undefined, context: PluginAiRecommendationContext = {}): PluginAiRecommendation[] {
|
||||
const resolved = (items ?? [])
|
||||
.map((item) => interpolatePluginAiRecommendation(item, context))
|
||||
.filter((item): item is PluginAiRecommendation => item !== null)
|
||||
.map((item, index) => ({ item, index }));
|
||||
|
||||
resolved.sort((left, right) => (left.item.order ?? 0) - (right.item.order ?? 0) || left.index - right.index);
|
||||
return resolved.slice(0, MAX_PLUGIN_AI_RECOMMENDATIONS).map(({ item }) => item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the effective list. An omitted runtime list keeps manifest defaults;
|
||||
* an explicitly supplied list (including `[]`) replaces them.
|
||||
*/
|
||||
export function selectPluginAiRecommendations(defaults: readonly PluginAiRecommendation[] | undefined, runtime: readonly PluginAiRecommendation[] | undefined): readonly PluginAiRecommendation[] {
|
||||
return runtime === undefined ? (defaults ?? []) : runtime;
|
||||
}
|
||||
|
||||
/** Resolve a declaration plus an optional runtime update in one pure step. */
|
||||
export function resolvePluginAiRecommendationUpdate(defaults: readonly PluginAiRecommendation[] | undefined, runtime: PluginAiRecommendationUpdate | undefined, fallbackContext: PluginAiRecommendationContext = {}): PluginAiRecommendation[] {
|
||||
const items = selectPluginAiRecommendations(defaults, runtime?.items);
|
||||
return resolvePluginAiRecommendations(items, runtime?.context ?? fallbackContext);
|
||||
}
|
||||
|
||||
function isValidRecommendation(item: PluginAiRecommendation): boolean {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
if (typeof item.id !== "string" || !item.id.trim()) return false;
|
||||
if (typeof item.label !== "string" || !item.label.trim()) return false;
|
||||
if (typeof item.prompt !== "string" || !item.prompt.trim()) return false;
|
||||
if (!isValidPluginAiRecommendationTemplate(item.label) || !isValidPluginAiRecommendationTemplate(item.prompt)) return false;
|
||||
return item.order === undefined || (typeof item.order === "number" && Number.isFinite(item.order));
|
||||
}
|
||||
@@ -141,6 +141,87 @@ describe("PluginHostBridge", () => {
|
||||
expect(messages[0].capabilities.ai).toBe(false);
|
||||
});
|
||||
|
||||
it("does not publish recommendation defaults without the host.ai permission", () => {
|
||||
const updates: unknown[] = [];
|
||||
const target = { postMessage: vi.fn() } as unknown as Window;
|
||||
const contribution = {
|
||||
...workbench,
|
||||
ai: { recommendations: [{ id: "health", label: "Health", prompt: "Check health" }] },
|
||||
} as PluginWorkbenchContribution;
|
||||
new PluginHostBridge(plugin([], [contribution]), contribution, { workbenchId: "wb-1" }, () => target, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
setAiRecommendations: (update) => updates.push(update),
|
||||
});
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("publishes manifest and runtime AI recommendations per workbench instance", async () => {
|
||||
const messages: any[] = [];
|
||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||
const updates: any[] = [];
|
||||
const contribution = {
|
||||
...workbench,
|
||||
ai: { recommendations: [{ id: "health", label: "Inspect {{resource.name}}", prompt: "Check {{resource.name}}", order: 1 }] },
|
||||
} as PluginWorkbenchContribution;
|
||||
const bridge = new PluginHostBridge(plugin(["host.ai"], [contribution]), contribution, { workbenchId: "wb-1", connectionId: "conn-1", resource: { name: "orders" } }, () => target, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
openAiConversation: vi.fn().mockResolvedValue(undefined),
|
||||
setAiRecommendations: (update) => updates.push(update),
|
||||
});
|
||||
|
||||
expect(updates[0]).toMatchObject({ workbenchId: "wb-1", items: [{ label: "Inspect orders", prompt: "Check orders" }] });
|
||||
bridge.handleWindowMessage({
|
||||
source: target,
|
||||
data: {
|
||||
source: "dbx-plugin",
|
||||
version: 1,
|
||||
type: "request",
|
||||
id: "set",
|
||||
method: "host.ai.setRecommendations",
|
||||
params: { context: { connectionId: "other-connection", resource: { name: "customers" } }, items: [{ id: "custom", label: "Review {{resource.name}}", prompt: "Review {{resource.name}}" }] },
|
||||
},
|
||||
} as MessageEvent);
|
||||
await vi.waitFor(() => expect(messages.some((message) => message.id === "set" && message.result === null)).toBe(true));
|
||||
expect(updates[updates.length - 1]).toMatchObject({
|
||||
pluginId: "sample",
|
||||
pluginName: "Sample",
|
||||
workbenchId: "wb-1",
|
||||
context: { connectionId: "conn-1", resource: { name: "customers" } },
|
||||
items: [{ label: "Review customers", prompt: "Review customers" }],
|
||||
});
|
||||
|
||||
bridge.handleWindowMessage({
|
||||
source: target,
|
||||
data: { source: "dbx-plugin", version: 1, type: "request", id: "clear", method: "host.ai.clearRecommendations", params: {} },
|
||||
} as MessageEvent);
|
||||
await vi.waitFor(() => expect(messages.some((message) => message.id === "clear" && message.result === null)).toBe(true));
|
||||
expect(updates[updates.length - 1]).toMatchObject({ workbenchId: "wb-1", items: [] });
|
||||
});
|
||||
|
||||
it("rejects malformed runtime recommendation placeholders", async () => {
|
||||
const messages: any[] = [];
|
||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||
const bridge = new PluginHostBridge(plugin(["host.ai"]), workbench, {}, () => target, {
|
||||
invoke: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
sendBinary: vi.fn(),
|
||||
readAsset: vi.fn(),
|
||||
openAiConversation: vi.fn().mockResolvedValue(undefined),
|
||||
setAiRecommendations: vi.fn(),
|
||||
});
|
||||
bridge.handleWindowMessage({
|
||||
source: target,
|
||||
data: { source: "dbx-plugin", version: 1, type: "request", id: "bad", method: "host.ai.setRecommendations", params: { context: {}, items: [{ id: "bad", label: "Inspect {{resource..name}}", prompt: "Check" }] } },
|
||||
} as MessageEvent);
|
||||
await vi.waitFor(() => expect(messages[0]?.error).toContain("invalid placeholder"));
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayl
|
||||
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";
|
||||
import { isValidPluginAiRecommendationTemplate, resolvePluginAiRecommendationUpdate, type PluginAiRecommendationContext, type PluginAiRecommendationUpdate } from "@/lib/plugins/pluginAiRecommendations";
|
||||
import type { PluginAiRecommendation } from "@/types/pluginAiRecommendations";
|
||||
import { MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, PLUGIN_SCHEMA_METADATA_CAPABILITY, PLUGIN_SCHEMA_METADATA_PERMISSION, type PluginTableContext, type PluginTableMetadata } from "@/types/pluginSchemaMetadata";
|
||||
import { MAX_PLUGIN_DATA_MAX_ROWS, MAX_PLUGIN_DATA_NAME_CHARS, MAX_PLUGIN_DATA_SQL_CHARS, MAX_PLUGIN_DATA_TIMEOUT_MS, PLUGIN_DATA_ACCESS_NOT_GRANTED, PLUGIN_DATA_CAPABILITY, PLUGIN_DATA_READ_PERMISSION, type PluginDataQueryRequest, type PluginDataQueryResult } from "@/types/pluginData";
|
||||
|
||||
@@ -152,6 +154,7 @@ export interface PluginHostBridgeApi {
|
||||
sendBinary(pluginId: string, channel: string, dataBase64: string): Promise<void>;
|
||||
readAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload>;
|
||||
openAiConversation?(request: AiPluginConversationRequest): Promise<void>;
|
||||
setAiRecommendations?(update: PluginAiRecommendationHostUpdate): 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). */
|
||||
@@ -236,6 +239,15 @@ export interface PluginHostBridgeApi {
|
||||
storageDelete?(pluginId: string, key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginAiRecommendationHostUpdate {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
contributionId: string;
|
||||
workbenchId: string;
|
||||
context: PluginAiRecommendationContext;
|
||||
items: readonly PluginAiRecommendation[];
|
||||
}
|
||||
|
||||
interface PluginRequestMessage {
|
||||
source: typeof PLUGIN_MESSAGE_SOURCE;
|
||||
version: typeof BRIDGE_VERSION;
|
||||
@@ -265,6 +277,7 @@ export class PluginHostBridge {
|
||||
/** One consent prompt per connection at a time; concurrent queries share it. */
|
||||
private pendingDataAccess = new Map<string, Promise<void>>();
|
||||
private inFlightDataQueries = 0;
|
||||
private runtimeAiRecommendations: PluginAiRecommendationUpdate | null | undefined;
|
||||
|
||||
/** Bounded audit trail of this session's clipboard read attempts (oldest first). */
|
||||
get clipboardAudit(): readonly PluginClipboardAuditEntry[] {
|
||||
@@ -286,6 +299,7 @@ export class PluginHostBridge {
|
||||
this.context = snapshotPluginWorkbenchContext(context);
|
||||
this.locale = locale;
|
||||
this.theme = theme ? clonePluginData(theme) : undefined;
|
||||
this.publishAiRecommendations();
|
||||
}
|
||||
|
||||
handleWindowMessage(event: MessageEvent): boolean {
|
||||
@@ -360,6 +374,7 @@ export class PluginHostBridge {
|
||||
this.downloads.clear();
|
||||
for (const token of this.mediaTokens) void this.api.closeMedia?.(this.plugin.manifest.id, token).catch(() => undefined);
|
||||
this.mediaTokens.clear();
|
||||
this.publishAiRecommendations({ context: {}, items: [] });
|
||||
}
|
||||
|
||||
private disposed = false;
|
||||
@@ -419,6 +434,7 @@ export class PluginHostBridge {
|
||||
[PLUGIN_DATA_CAPABILITY]: !!this.api.queryData && !!this.api.hasDataGrant && !!this.api.confirmDataAccess && !!this.api.grantDataAccess,
|
||||
storage: !!this.api.storageGet && !!this.api.storageSet && !!this.api.storageDelete,
|
||||
ai: !!this.api.openAiConversation,
|
||||
aiRecommendations: !!this.api.openAiConversation && !!this.api.setAiRecommendations,
|
||||
// Additive with the same "absence means unsupported" contract: an older
|
||||
// host omits these, and a web host has neither.
|
||||
clipboardWrite: !!this.api.copyText,
|
||||
@@ -437,9 +453,42 @@ export class PluginHostBridge {
|
||||
*/
|
||||
updateContext(context: PluginWorkbenchContext): void {
|
||||
this.context = snapshotPluginWorkbenchContext(context);
|
||||
this.runtimeAiRecommendations = undefined;
|
||||
this.publishAiRecommendations();
|
||||
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "context", context: snapshotPluginWorkbenchContext(this.context) });
|
||||
}
|
||||
|
||||
private publishAiRecommendations(override?: PluginAiRecommendationUpdate): void {
|
||||
if (!this.hasPermission("host.ai") || !this.api.openAiConversation || !this.api.setAiRecommendations) return;
|
||||
const workbenchId = typeof this.context.workbenchId === "string" ? this.context.workbenchId : "";
|
||||
const contribution = this.contribution.type === "workbench" ? this.contribution : undefined;
|
||||
const defaults = contribution?.ai?.recommendations;
|
||||
const runtime = override ?? this.runtimeAiRecommendations;
|
||||
// Runtime updates usually contain only the resource-specific fields used by
|
||||
// placeholders. Keep host-owned routing fields authoritative so clicking a
|
||||
// recommendation remains bound to this workbench's connection even when the
|
||||
// plugin does not repeat connectionId in every update.
|
||||
const runtimeContext = runtime
|
||||
? snapshotPluginWorkbenchContext({
|
||||
...runtime.context,
|
||||
...(this.context.connectionId === undefined ? {} : { connectionId: this.context.connectionId }),
|
||||
...(this.context.database === undefined ? {} : { database: this.context.database }),
|
||||
...(this.context.schema === undefined ? {} : { schema: this.context.schema }),
|
||||
...(this.context.workbenchId === undefined ? {} : { workbenchId: this.context.workbenchId }),
|
||||
})
|
||||
: this.context;
|
||||
const effectiveRuntime = runtime === null ? { context: {}, items: [] } : runtime ? { ...runtime, context: runtimeContext } : undefined;
|
||||
const items = resolvePluginAiRecommendationUpdate(defaults, effectiveRuntime, this.context);
|
||||
this.api.setAiRecommendations({
|
||||
pluginId: this.plugin.manifest.id,
|
||||
pluginName: this.plugin.manifest.name,
|
||||
contributionId: this.contribution.id,
|
||||
workbenchId,
|
||||
context: runtime ? runtimeContext : this.context,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
/** Notify the plugin UI about a locale change without a reload. */
|
||||
updateLocale(locale: string): void {
|
||||
this.locale = locale;
|
||||
@@ -515,6 +564,21 @@ export class PluginHostBridge {
|
||||
await this.api.openAiConversation(request);
|
||||
return null;
|
||||
}
|
||||
if (method === "host.ai.setRecommendations") {
|
||||
this.requirePermission("host.ai");
|
||||
if (!this.api.openAiConversation || !this.api.setAiRecommendations) throw new Error("DBX AI recommendations are unavailable");
|
||||
const update = requirePluginAiRecommendationUpdate(params);
|
||||
this.runtimeAiRecommendations = update;
|
||||
this.publishAiRecommendations();
|
||||
return null;
|
||||
}
|
||||
if (method === "host.ai.clearRecommendations") {
|
||||
this.requirePermission("host.ai");
|
||||
if (!this.api.openAiConversation || !this.api.setAiRecommendations) throw new Error("DBX AI recommendations are unavailable");
|
||||
this.runtimeAiRecommendations = { context: {}, items: [] };
|
||||
this.publishAiRecommendations();
|
||||
return null;
|
||||
}
|
||||
if (method === "backend.invoke") {
|
||||
const input = requireRecord(params, "backend.invoke params");
|
||||
const backendMethod = requireProtocolName(input.method, "backend method");
|
||||
@@ -1087,7 +1151,11 @@ 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) }),
|
||||
ai: Object.freeze({
|
||||
openConversation: (options) => request('host.ai.openConversation', options),
|
||||
setRecommendations: (update) => request('host.ai.setRecommendations', update),
|
||||
clearRecommendations: () => request('host.ai.clearRecommendations'),
|
||||
}),
|
||||
invoke: (method, params, options = {}) => request('backend.invoke', { method, params, timeoutMs: options.timeoutMs }),
|
||||
stream,
|
||||
notify: (method, params) => request('backend.notify', { method, params }),
|
||||
@@ -1385,6 +1453,29 @@ function requireHandleId(value: unknown): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePluginAiRecommendationUpdate(value: unknown): PluginAiRecommendationUpdate {
|
||||
const input = requireRecord(value, "AI recommendation update");
|
||||
if (!isRecord(input.context)) throw new Error("AI recommendation context must be an object");
|
||||
if (!Array.isArray(input.items)) throw new Error("AI recommendation items must be an array");
|
||||
if (input.items.length > 5) throw new Error("At most 5 AI recommendations may be registered");
|
||||
const context = snapshotPluginWorkbenchContext(input.context) as PluginAiRecommendationContext;
|
||||
const items = input.items.map((candidate, index) => {
|
||||
if (!isRecord(candidate)) throw new Error(`AI recommendation ${index} must be an object`);
|
||||
if (typeof candidate.id !== "string" || !candidate.id.trim()) throw new Error(`AI recommendation ${index} requires id`);
|
||||
if (typeof candidate.label !== "string" || !candidate.label.trim() || candidate.label.length > 200) throw new Error(`AI recommendation ${index} label is invalid`);
|
||||
if (typeof candidate.prompt !== "string" || !candidate.prompt.trim() || candidate.prompt.length > 32000) throw new Error(`AI recommendation ${index} prompt is invalid`);
|
||||
if (!isValidPluginAiRecommendationTemplate(candidate.label) || !isValidPluginAiRecommendationTemplate(candidate.prompt)) throw new Error(`AI recommendation ${index} contains an invalid placeholder`);
|
||||
if (candidate.order !== undefined && (typeof candidate.order !== "number" || !Number.isFinite(candidate.order))) throw new Error(`AI recommendation ${index} order is invalid`);
|
||||
return {
|
||||
id: candidate.id.trim(),
|
||||
label: candidate.label.trim(),
|
||||
prompt: candidate.prompt.trim(),
|
||||
...(candidate.order === undefined ? {} : { order: candidate.order }),
|
||||
};
|
||||
});
|
||||
return { context, items };
|
||||
}
|
||||
|
||||
function requireOffset(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("offset is invalid");
|
||||
return Math.floor(value);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNa
|
||||
import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution";
|
||||
import type { DatabaseType } from "@/types/generated/databaseTypes";
|
||||
import type { PluginAiRecommendation } from "@/types/pluginAiRecommendations";
|
||||
|
||||
export type { DatabaseType } from "@/types/generated/databaseTypes";
|
||||
|
||||
@@ -418,6 +419,11 @@ export interface PluginWorkbenchContribution {
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
ai?: PluginWorkbenchAiContribution;
|
||||
}
|
||||
|
||||
export interface PluginWorkbenchAiContribution {
|
||||
recommendations?: PluginAiRecommendation[];
|
||||
}
|
||||
|
||||
export interface PluginFilesystemProviderContribution {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Context-aware AI prompts contributed by a plugin workbench.
|
||||
*
|
||||
* The host keeps the recommendation protocol deliberately small: a plugin
|
||||
* supplies display text, the prompt sent to the AI assistant, and an optional
|
||||
* order. Values inside `{{path.to.value}}` placeholders are read from the
|
||||
* workbench context when recommendations are rendered.
|
||||
*/
|
||||
|
||||
/** Maximum number of shortcut prompts shown by the AI assistant. */
|
||||
export const MAX_PLUGIN_AI_RECOMMENDATIONS = 5;
|
||||
|
||||
/** JSON-like context supplied by a plugin for the active workbench resource. */
|
||||
export type PluginAiRecommendationContext = Record<string, unknown>;
|
||||
|
||||
/** A declaration accepted by a plugin manifest or a runtime update. */
|
||||
export interface PluginAiRecommendation {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** Runtime update sent by a workbench when its resource context changes. */
|
||||
export interface PluginAiRecommendationUpdate {
|
||||
context: PluginAiRecommendationContext;
|
||||
items: readonly PluginAiRecommendation[];
|
||||
}
|
||||
@@ -55,16 +55,16 @@ async fn run() -> Result<(), String> {
|
||||
|
||||
// Nothing reaches the model before the user opts the plugin in, and
|
||||
// nothing for a connection that is not open.
|
||||
if !plugin_tools::discover_plugin_tools(&state, None).await.is_empty() {
|
||||
if !plugin_tools::discover_plugin_tools(&state, None, None).await.is_empty() {
|
||||
return Err("tools were exposed before the plugin was enabled for the AI".to_string());
|
||||
}
|
||||
state.storage.set_ai_plugin_tool_plugin_enabled(PLUGIN_ID, true).await?;
|
||||
if !plugin_tools::discover_plugin_tools(&state, None).await.is_empty() {
|
||||
if !plugin_tools::discover_plugin_tools(&state, None, None).await.is_empty() {
|
||||
return Err("tools were exposed without an open plugin connection".to_string());
|
||||
}
|
||||
|
||||
state.get_or_create_pool(CONNECTION_ID, None).await?;
|
||||
let tools = plugin_tools::discover_plugin_tools(&state, None).await;
|
||||
let tools = plugin_tools::discover_plugin_tools(&state, None, None).await;
|
||||
let definitions = tools.definitions();
|
||||
let names = definitions.iter().map(|definition| definition.name.as_ref()).collect::<Vec<_>>();
|
||||
if names != ["hello__hello_greet", "hello__hello_set_greeting"] {
|
||||
|
||||
@@ -297,7 +297,10 @@ async fn run_agent_loop_inner(
|
||||
// Plugin tools join agent runs only; ask mode keeps its database-only
|
||||
// read tools.
|
||||
let plugin_tool_set = if is_agent_mode {
|
||||
plugin_tools::discover_plugin_tools(&agent_ctx.state, agent_ctx.host_runtime.as_ref()).await
|
||||
let bound_plugin_connection =
|
||||
(agent_ctx.db_type == DatabaseType::Plugin).then_some(agent_ctx.connection_id.as_str());
|
||||
plugin_tools::discover_plugin_tools(&agent_ctx.state, agent_ctx.host_runtime.as_ref(), bound_plugin_connection)
|
||||
.await
|
||||
} else {
|
||||
PluginToolSet::default()
|
||||
};
|
||||
|
||||
@@ -254,6 +254,7 @@ impl PluginToolBinding {
|
||||
pub async fn discover_plugin_tools(
|
||||
state: &Arc<AppState>,
|
||||
host_runtime: Option<&tokio::runtime::Handle>,
|
||||
connection_id: Option<&str>,
|
||||
) -> PluginToolSet {
|
||||
let enabled = match state.storage.load_ai_plugin_tool_plugin_ids().await {
|
||||
Ok(ids) if !ids.is_empty() => ids.into_iter().collect::<HashSet<_>>(),
|
||||
@@ -271,7 +272,8 @@ pub async fn discover_plugin_tools(
|
||||
return PluginToolSet::default();
|
||||
}
|
||||
};
|
||||
let connections = open_plugin_connections(state, &plugin_names).await;
|
||||
let mut connections = open_plugin_connections(state, &plugin_names).await;
|
||||
restrict_connections(&mut connections, connection_id);
|
||||
if connections.is_empty() {
|
||||
return PluginToolSet::default();
|
||||
}
|
||||
@@ -357,6 +359,12 @@ async fn open_plugin_connections(state: &AppState, plugins: &HashMap<String, Str
|
||||
connections
|
||||
}
|
||||
|
||||
fn restrict_connections(connections: &mut Vec<OpenPluginConnection>, connection_id: Option<&str>) {
|
||||
if let Some(connection_id) = connection_id {
|
||||
connections.retain(|connection| connection.connection_id == connection_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The lifecycle payload of `connection_id` if it is still open, with a fresh
|
||||
/// operation id for this call.
|
||||
async fn current_lifecycle(state: &AppState, plugin_id: &str, connection_id: &str) -> Option<Value> {
|
||||
@@ -950,6 +958,16 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_plugin_agent_sees_only_its_connection() {
|
||||
let mut connections =
|
||||
vec![connection("k1", "cluster-a", "io.dbx.kafka"), connection("k2", "cluster-b", "io.dbx.kafka")];
|
||||
restrict_connections(&mut connections, Some("k2"));
|
||||
assert_eq!(connections.iter().map(|item| item.connection_id.as_str()).collect::<Vec<_>>(), ["k2"]);
|
||||
restrict_connections(&mut connections, None);
|
||||
assert_eq!(connections.len(), 1);
|
||||
}
|
||||
|
||||
fn kafka_listing() -> Value {
|
||||
json!({
|
||||
"tools": [
|
||||
|
||||
@@ -664,6 +664,25 @@ pub struct PluginWorkbenchContribution {
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ai: Option<PluginWorkbenchAiContribution>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginWorkbenchAiContribution {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub recommendations: Vec<PluginAiRecommendation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginAiRecommendation {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub prompt: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<i32>,
|
||||
}
|
||||
|
||||
/// Native context-menu entry contributed to DBX surfaces. Legacy entries
|
||||
@@ -1320,6 +1339,9 @@ fn validate_contributions(
|
||||
if !has_ui {
|
||||
errors.push(format!("Workbench contribution '{id}' requires a UI entrypoint"));
|
||||
}
|
||||
if let Some(ai) = &workbench.ai {
|
||||
validate_ai_recommendations(&ai.recommendations, id, errors);
|
||||
}
|
||||
}
|
||||
PluginContribution::ResultView(result_view) => {
|
||||
validate_required_text(&result_view.label, &format!("Result view '{id}' label"), errors);
|
||||
@@ -1491,6 +1513,108 @@ fn validate_contributions(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ai_recommendations(
|
||||
recommendations: &[PluginAiRecommendation],
|
||||
workbench_id: &str,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if recommendations.len() > 5 {
|
||||
errors.push(format!(
|
||||
"Workbench '{workbench_id}' declares {} AI recommendations; at most 5 are allowed",
|
||||
recommendations.len()
|
||||
));
|
||||
}
|
||||
let mut seen_ids = HashSet::new();
|
||||
for recommendation in recommendations {
|
||||
if !valid_identifier(&recommendation.id) {
|
||||
errors.push(format!("Workbench '{workbench_id}' AI recommendation has an invalid id"));
|
||||
} else if !seen_ids.insert(recommendation.id.as_str()) {
|
||||
errors.push(format!(
|
||||
"Workbench '{workbench_id}' declares duplicate AI recommendation id '{}'",
|
||||
recommendation.id
|
||||
));
|
||||
}
|
||||
validate_required_text(
|
||||
&recommendation.label,
|
||||
&format!("Workbench '{workbench_id}' AI recommendation label"),
|
||||
errors,
|
||||
);
|
||||
validate_required_text(
|
||||
&recommendation.prompt,
|
||||
&format!("Workbench '{workbench_id}' AI recommendation prompt"),
|
||||
errors,
|
||||
);
|
||||
validate_ai_recommendation_template(&recommendation.label, workbench_id, &recommendation.id, "label", errors);
|
||||
validate_ai_recommendation_template(&recommendation.prompt, workbench_id, &recommendation.id, "prompt", errors);
|
||||
if recommendation.label.chars().count() > 200 {
|
||||
errors.push(format!(
|
||||
"Workbench '{workbench_id}' AI recommendation '{}' label exceeds 200 characters",
|
||||
recommendation.id
|
||||
));
|
||||
}
|
||||
if recommendation.prompt.chars().count() > 32_000 {
|
||||
errors.push(format!(
|
||||
"Workbench '{workbench_id}' AI recommendation '{}' prompt exceeds 32000 characters",
|
||||
recommendation.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the only interpolation syntax supported by the workbench AI
|
||||
/// recommendation contract. Ordinary single braces remain valid prompt text;
|
||||
/// double braces must contain a safe dotted context path.
|
||||
fn validate_ai_recommendation_template(
|
||||
value: &str,
|
||||
workbench_id: &str,
|
||||
recommendation_id: &str,
|
||||
field: &str,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let remainder = &value[offset..];
|
||||
let Some(open_relative) = remainder.find("{{") else {
|
||||
if remainder.contains("}}") {
|
||||
errors.push(format!("Workbench '{workbench_id}' AI recommendation '{recommendation_id}' {field} contains an unmatched placeholder close").to_string());
|
||||
}
|
||||
return;
|
||||
};
|
||||
if remainder[..open_relative].contains("}}") {
|
||||
errors.push(format!("Workbench '{workbench_id}' AI recommendation '{recommendation_id}' {field} contains an unmatched placeholder close").to_string());
|
||||
return;
|
||||
}
|
||||
let content_start = offset + open_relative + 2;
|
||||
let Some(close_relative) = value[content_start..].find("}}") else {
|
||||
errors.push(format!("Workbench '{workbench_id}' AI recommendation '{recommendation_id}' {field} contains an unterminated placeholder").to_string());
|
||||
return;
|
||||
};
|
||||
let path = value[content_start..content_start + close_relative].trim();
|
||||
if !valid_template_path(path) {
|
||||
errors.push(format!("Workbench '{workbench_id}' AI recommendation '{recommendation_id}' {field} contains an invalid placeholder").to_string());
|
||||
}
|
||||
offset = content_start + close_relative + 2;
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_template_path(value: &str) -> bool {
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
value.split('.').enumerate().all(|(index, segment)| {
|
||||
if index > 0 && segment.chars().all(|character| character.is_ascii_digit()) {
|
||||
return !segment.is_empty();
|
||||
}
|
||||
let mut chars = segment.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first.is_ascii_alphabetic() || first == '_' || first == '$')
|
||||
&& chars.all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '$')
|
||||
&& !matches!(segment, "__proto__" | "prototype" | "constructor")
|
||||
})
|
||||
}
|
||||
|
||||
/// Sidecar method names look like `<domain>/<action>[/<sub>]` (lower-case
|
||||
/// words, digits, `-`, `_`, `.` separated by `/`), e.g. `local/shells/list`.
|
||||
fn valid_sidecar_method(value: &str) -> bool {
|
||||
@@ -1940,6 +2064,57 @@ mod tests {
|
||||
assert!(rpc.is_err(), "RPC actions are outside the v1 contract");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbench_ai_recommendations_parse_as_an_additive_manifest_field() {
|
||||
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
|
||||
"manifest_version": 1,
|
||||
"id": "io.dbx.example",
|
||||
"name": "Example",
|
||||
"version": "1.0.0",
|
||||
"publisher": "example",
|
||||
"engines": { "host_api": "^1.0" },
|
||||
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
|
||||
"contributions": [{
|
||||
"type": "workbench",
|
||||
"id": "io.dbx.example.workbench",
|
||||
"label": "Example",
|
||||
"ai": { "recommendations": [{
|
||||
"id": "health",
|
||||
"label": "Inspect {{resource.name}}",
|
||||
"prompt": "Check {{resource.kind}}/{{resource.name}}",
|
||||
"order": 10
|
||||
}] }
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let PluginContribution::Workbench(workbench) = &manifest.contributions[0] else {
|
||||
panic!("expected workbench contribution");
|
||||
};
|
||||
let ai = workbench.ai.as_ref().expect("AI contribution");
|
||||
assert_eq!(ai.recommendations[0].id, "health");
|
||||
assert_eq!(ai.recommendations[0].order, Some(10));
|
||||
|
||||
let plugin_dir = std::env::temp_dir();
|
||||
for invalid in ["Inspect {{resource..name}}", "Inspect {{resource.name", "Inspect resource.name}}"] {
|
||||
let contribution = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "workbench",
|
||||
"id": "io.dbx.example.workbench",
|
||||
"label": "Example",
|
||||
"ai": { "recommendations": [{ "id": "health", "label": invalid, "prompt": "Check" }] }
|
||||
}))
|
||||
.unwrap();
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(std::slice::from_ref(&contribution), false, true, &plugin_dir, &mut errors);
|
||||
assert!(
|
||||
errors.iter().any(|error| error.contains("invalid placeholder")
|
||||
|| error.contains("unterminated")
|
||||
|| error.contains("unmatched")),
|
||||
"{invalid:?}: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_launch_extension_fields_parse_and_validate() {
|
||||
let plugin_dir = std::env::temp_dir();
|
||||
|
||||
@@ -139,7 +139,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
|
||||
- `host.plans:read` 允许针对 DBX 自己的连接读取估算执行计划(见 [估算执行计划](#估算执行计划))。它是只读权限:不授予普通 SQL 执行、写操作、DDL 或真实执行计划,且这是唯一存在的计划类权限。
|
||||
- `host.schema:read` 允许通过 `host.getTableMetadata` 读取已打开连接的窄化 table schema metadata;它不会授予任意 SQL、写入或隐式重连(见 [Table Schema Metadata](#table-schema-metadata))。
|
||||
- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的插件数据目录里。
|
||||
- `host.ai` 允许插件在 DBX 内置 AI 面板中新建带数据快照的对话,沿用宿主的模型选择、历史、追问、停止和导出功能。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力。
|
||||
- `host.ai` 允许插件接入 DBX 全局 AI 面板:可以打开 Ask 快照对话、选择性打开使用实时插件工具的 Agent 对话,以及注册当前工作台的推荐问题。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力,并通过 `capabilities.ai` / `capabilities.aiRecommendations` 判断运行时支持。
|
||||
- `host.clipboard:read` 允许工作台 UI 读取系统剪贴板(见 [系统剪贴板](#系统剪贴板))。写剪贴板不需要权限;读取会把用户数据(密码、令牌)交给插件代码,因此需要门控。
|
||||
- `host.data:read` 允许 `window.dbxPlugin.queryData` 在用户授权给插件的连接上执行一条只读 SQL(见 [只读数据查询](#只读数据查询))。每个连接都需要用户同意;它不会授予写入、DDL 或重连。
|
||||
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
|
||||
@@ -351,8 +351,10 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
|
||||
| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 |
|
||||
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | 在用户授权给插件的连接上执行一条只读 SQL;需要 `host.data:read`。 |
|
||||
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, clipboardWrite, clipboardRead }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
|
||||
| `ai.openConversation({ title, prompt, context, send? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,为 `true` 时立即分析;需要 `host.ai`,并以 `capabilities.ai` 广播——旧宿主不带该字段,请按能力位关闭而不是用真实请求试探。 |
|
||||
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
|
||||
| `ai.openConversation({ title, prompt, context, send?, mode? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,`mode` 默认 `ask`。`mode: "agent"` 会在 context 包含已打开插件连接时使用实时插件工具;需要 `host.ai`,并以 `capabilities.ai` 广播。 |
|
||||
| `ai.setRecommendations({ context, items })` | 覆盖当前工作台的默认推荐问题并立即刷新全局 AI 面板;每次最多 5 条,支持 `{{path.to.value}}` 占位符;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
|
||||
| `ai.clearRecommendations()` | 清空当前工作台的推荐问题;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
|
||||
| `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 |
|
||||
| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 |
|
||||
| `fileTransfer` | 本地文件流式传输与系统拖放(仅桌面端);见下文。 |
|
||||
@@ -595,20 +597,83 @@ if (window.dbxPlugin.capabilities.storage) {
|
||||
|
||||
### 在 DBX AI 中分析插件数据
|
||||
|
||||
在 manifest 中声明 `"permissions": ["host.ai"]`,然后从插件的分析按钮调用:
|
||||
在 manifest 中声明 `"permissions": ["host.ai"]`。插件可以直接打开全局 AI 面板,也可以为每个 Workbench 注册快捷推荐问题。两种能力都沿用 DBX 的模型选择、历史、追问、停止和导出功能。
|
||||
|
||||
#### 声明 Workbench 的默认推荐问题
|
||||
|
||||
在 `workbench` contribution 下增加可选的 `ai.recommendations`。`label` 显示在 DBX 全局 AI 面板,`prompt` 是点击后发送给 AI 的文本;`order` 越小越靠前。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "workbench",
|
||||
"id": "com.example.files.main",
|
||||
"label": "Files",
|
||||
"ai": {
|
||||
"recommendations": [
|
||||
{
|
||||
"id": "health",
|
||||
"label": "检查 {{resource.name}} 健康状态",
|
||||
"prompt": "分析 {{resource.kind}}/{{resource.name}} 的健康状态和风险",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
占位符从当前 Workbench context 读取,支持对象属性和数组下标,例如 `{{resource.name}}`、`{{items.0.status}}`。无法解析的占位符会使该条推荐被隐藏;`__proto__`、`prototype` 和 `constructor` 等路径会被拒绝。推荐问题最多显示 5 条。
|
||||
|
||||
#### 根据资源变化更新推荐问题
|
||||
|
||||
插件切换页面或资源时,可以提交运行时推荐问题。运行时 `items` 会覆盖 manifest 默认值;传入空数组表示清空当前推荐。运行时 context 通常只需要提交资源字段,Host 会保留当前 Workbench 的 `connectionId`、数据库和实例标识,确保点击推荐问题后仍绑定到正确的插件连接。
|
||||
|
||||
```js
|
||||
function updateRecommendations(resource) {
|
||||
if (!dbxPlugin.capabilities.aiRecommendations) return;
|
||||
|
||||
return dbxPlugin.ai.setRecommendations({
|
||||
context: { resource },
|
||||
items: [
|
||||
{
|
||||
id: "health",
|
||||
label: `检查 ${resource.name} 健康状态`,
|
||||
prompt: "分析 {{resource.kind}}/{{resource.name}} 的健康状态和风险",
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "events",
|
||||
label: "查看最近事件",
|
||||
prompt: "列出这个资源最近的重要事件并给出处理建议",
|
||||
order: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await dbxPlugin.ai.clearRecommendations();
|
||||
```
|
||||
|
||||
推荐问题显示在 DBX 全局 AI 对话窗口中,而不是插件页面内新增一个 AI 入口。用户点击后,DBX 会使用当前推荐文本创建新的 Agent 对话并立即发送;Agent 会通过当前已打开的插件连接调用插件工具获取实时数据。推荐列表本身只属于当前 Workbench 上下文,不会写入对话历史;用户点击后发送的实际问题会正常保存。
|
||||
|
||||
#### 直接打开 Ask 或 Agent 对话
|
||||
|
||||
`ai.openConversation` 仍适合插件按钮或其他明确动作。默认 `mode` 为 `ask`,宿主会复制插件提供的 JSON 快照;快照会随对话保存,后续资源刷新不会改变已经创建的对话。需要实时工具时显式使用 `mode: "agent"`,并在 context 中提供当前已打开插件连接的 `connectionId`:
|
||||
|
||||
```js
|
||||
await dbxPlugin.ai.openConversation({
|
||||
title: "Watchlist analysis",
|
||||
prompt: "Compare the supplied quotes and explain risks and missing data.",
|
||||
context: { snapshotId: "watchlist-1", quotes, source, dataTimestamp },
|
||||
prompt: "Compare the current quotes and explain risks and missing data.",
|
||||
context: {
|
||||
connectionId,
|
||||
resource: { kind: "watchlist", name: "primary" },
|
||||
dataTimestamp,
|
||||
},
|
||||
mode: "agent",
|
||||
send: true,
|
||||
});
|
||||
```
|
||||
|
||||
`title` 最多 200 字符,`prompt` 最多 32000 字符,`context` 必须是纯 JSON 对象,上限 2 MiB。宿主绑定真实插件身份,并复制快照;后续行情刷新或切换数据库不会替换该会话的数据。快照作为会话数据保存,在历史恢复及导出时保留。插件应提供来源、数据时间、延迟及缺失字段,AI 不会自动拉取行情。
|
||||
|
||||
该接口只打开内置对话,不向插件返回模型回复或 AI 配置。首版使用纯文本 Ask 通道,支持 DBX 的 HTTP 模型供应商和 Ollama;不提供数据库工具或 CLI Agent。选择未配置的模型时会保留待发送问题,用户可在 DBX 面板中选择模型后继续。
|
||||
`title` 最多 200 字符,`prompt` 最多 32000 字符,`context` 必须是纯 JSON 对象,上限 2 MiB。Agent 只能使用宿主为该插件连接公开的工具;插件不会收到模型回复或模型配置,也不能借此获得数据库执行能力。旧插件不声明推荐问题时仍可正常使用原有 AI API;旧宿主缺少新增 capability 字段时,插件应关闭对应入口并继续提供其他功能。
|
||||
|
||||
### 主题、布局和静态资源
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
|
||||
- `host.plans:read` allows reading estimated execution plans for DBX's own connections (see [Estimated Execution Plans](#estimated-execution-plans)). It is read-only: it never grants SQL execution, writes, DDL, or actual plans, and it is the only plan scope that exists.
|
||||
- `host.schema:read` allows `host.getTableMetadata` to read narrow table schema metadata from an already-open connection; it grants no arbitrary SQL, writes, or implicit reconnect (see [Table Schema Metadata](#table-schema-metadata)).
|
||||
- `host.storage` gives the workbench UI a persistent key-value store scoped to this plugin (see [Persistent UI State](#persistent-ui-state)). Values never leave the plugin's own data directory.
|
||||
- `host.ai` lets the plugin open a conversation with a data snapshot in the built-in DBX AI panel, using its model selector, history, follow-up messages, cancellation and export. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional.
|
||||
- `host.ai` lets a plugin use the global DBX AI panel for Ask snapshot conversations, optional Agent conversations backed by live plugin tools, and workbench-specific quick-question recommendations. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional and check `capabilities.ai` / `capabilities.aiRecommendations` at runtime.
|
||||
- `host.clipboard:read` allows reading the system clipboard from the workbench UI (see [System Clipboard](#system-clipboard)). Clipboard writes need no permission; reads hand user data (passwords, tokens) to plugin code, so they are gated.
|
||||
- `host.data:read` allows `window.dbxPlugin.queryData` to run one read-only SQL statement on a connection the user granted to the plugin (see [Read-only Data Queries](#read-only-data-queries)). Every connection needs the user's consent; it never grants writes, DDL, or reconnects.
|
||||
- `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
|
||||
@@ -351,8 +351,10 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
||||
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. |
|
||||
| `getTableMetadata({ connectionId, database?, schema?, table })` | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`. |
|
||||
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | Runs one read-only SQL statement on a connection the user granted to the plugin; requires `host.data:read`. |
|
||||
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, clipboardWrite, clipboardRead }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
|
||||
| `ai.openConversation({ title, prompt, context, send? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false`, or starts analysis when `true`; requires `host.ai` and is advertised as `capabilities.ai` — gate on it instead of probing, since older hosts omit it. |
|
||||
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
|
||||
| `ai.openConversation({ title, prompt, context, send?, mode? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false` and `mode` defaults to `ask`. `mode: "agent"` uses live plugin tools when context includes an open plugin connection; requires `host.ai` and is advertised as `capabilities.ai`. |
|
||||
| `ai.setRecommendations({ context, items })` | Replaces the current workbench's default recommendations and refreshes the global AI panel; up to five items are shown and `{{path.to.value}}` placeholders are supported; requires `host.ai` and `capabilities.aiRecommendations`. |
|
||||
| `ai.clearRecommendations()` | Clears recommendations for the current workbench; requires `host.ai` and `capabilities.aiRecommendations`. |
|
||||
| `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. |
|
||||
| `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. |
|
||||
| `fileTransfer` | Streams local files and OS drops (desktop only); see below. |
|
||||
@@ -595,20 +597,83 @@ Keys are strings up to 256 characters; values are capped at 256 KiB serialized a
|
||||
|
||||
### Analyse plugin data in DBX AI
|
||||
|
||||
Declare `"permissions": ["host.ai"]` in the manifest, then call this from the plugin's analysis action:
|
||||
Declare `"permissions": ["host.ai"]` in the manifest. A plugin can open the global AI panel directly, or register quick recommendations for each Workbench. Both paths use DBX's model selector, history, follow-up messages, cancellation and export.
|
||||
|
||||
#### Declare default Workbench recommendations
|
||||
|
||||
Add optional `ai.recommendations` under a `workbench` contribution. `label` is shown in the global DBX AI panel, `prompt` is sent when the user clicks it, and lower `order` values appear first.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "workbench",
|
||||
"id": "com.example.files.main",
|
||||
"label": "Files",
|
||||
"ai": {
|
||||
"recommendations": [
|
||||
{
|
||||
"id": "health",
|
||||
"label": "Check {{resource.name}} health",
|
||||
"prompt": "Analyze the health and risks of {{resource.kind}}/{{resource.name}}",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Placeholders are resolved from the current Workbench context. Object properties and array indexes are supported, such as `{{resource.name}}` and `{{items.0.status}}`. A recommendation with an unresolved placeholder is hidden; paths containing `__proto__`, `prototype`, or `constructor` are rejected. At most five recommendations are displayed.
|
||||
|
||||
#### Update recommendations when the resource changes
|
||||
|
||||
When the plugin switches pages or resources, it can publish runtime recommendations. Runtime `items` replace the Manifest defaults; an empty array clears the current recommendations. Runtime context usually needs to contain only resource fields. The Host preserves the Workbench's `connectionId`, database, and instance identity so a click remains bound to the correct plugin connection.
|
||||
|
||||
```js
|
||||
function updateRecommendations(resource) {
|
||||
if (!dbxPlugin.capabilities.aiRecommendations) return;
|
||||
|
||||
return dbxPlugin.ai.setRecommendations({
|
||||
context: { resource },
|
||||
items: [
|
||||
{
|
||||
id: "health",
|
||||
label: `Check ${resource.name} health`,
|
||||
prompt: "Analyze the health and risks of {{resource.kind}}/{{resource.name}}",
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "events",
|
||||
label: "Review recent events",
|
||||
prompt: "List the important recent events for this resource and suggest next steps",
|
||||
order: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await dbxPlugin.ai.clearRecommendations();
|
||||
```
|
||||
|
||||
Recommendations appear in the global DBX AI conversation window, without adding a separate AI entry point inside the plugin page. Clicking one creates a new Agent conversation with the selected text and sends it immediately; the Agent uses tools exposed by the current open plugin connection to retrieve live data. The recommendation list belongs only to the current Workbench context and is not written to conversation history; the selected prompt is saved normally after it is sent.
|
||||
|
||||
#### Open an Ask or Agent conversation directly
|
||||
|
||||
`ai.openConversation` remains useful for a plugin button or another explicit action. `mode` defaults to `ask`, where the Host copies the plugin's JSON snapshot into the conversation; the snapshot is retained with the conversation and later resource refreshes do not replace it. To use live tools, pass `mode: "agent"` and include the `connectionId` of the current open plugin connection in the context:
|
||||
|
||||
```js
|
||||
await dbxPlugin.ai.openConversation({
|
||||
title: "Watchlist analysis",
|
||||
prompt: "Compare the supplied quotes and explain risks and missing data.",
|
||||
context: { snapshotId: "watchlist-1", quotes, source, dataTimestamp },
|
||||
prompt: "Compare the current quotes and explain risks and missing data.",
|
||||
context: {
|
||||
connectionId,
|
||||
resource: { kind: "watchlist", name: "primary" },
|
||||
dataTimestamp,
|
||||
},
|
||||
mode: "agent",
|
||||
send: true,
|
||||
});
|
||||
```
|
||||
|
||||
`title` allows 200 characters and `prompt` allows 32000. `context` must be a plain JSON object and is limited to 2 MiB. The host binds the installed plugin identity and copies the snapshot. Quote refreshes or database tab changes do not replace conversation data. The snapshot is stored as conversation data for history restoration and export. Include source, data timestamp, delays and missing fields; AI does not fetch market data itself.
|
||||
|
||||
This method opens the built-in conversation and returns no model response or AI configuration to the plugin. The initial implementation uses text-only Ask through DBX's HTTP providers and Ollama, without database tools or CLI Agents. If no model is configured, the prompt remains an editable draft so the user can select a model and continue.
|
||||
`title` allows 200 characters and `prompt` allows 32000. `context` must be a plain JSON object and is limited to 2 MiB. An Agent can use only tools exposed by the Host for that plugin connection; the plugin receives no model response or model configuration and gains no database execution capability. Older plugins without recommendations continue to use the existing AI API, and older hosts omit the new capability fields, so disable only the unavailable entry points and keep the rest of the plugin usable.
|
||||
|
||||
### Themes, Layout, and Static Assets
|
||||
|
||||
|
||||
+4
-3
@@ -374,8 +374,9 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san
|
||||
- `getTableMetadata({ connectionId, database?, schema?, table })` — reads narrow schema metadata for one table on an already-open connection; requires `host.schema:read`, see [Table Schema Metadata](#table-schema-metadata)
|
||||
- `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` — runs one read-only SQL statement on a connection the user granted to the plugin; requires `host.data:read`, see [Read-only data queries](#read-only-data-queries)
|
||||
- `storage` — `storage.get(key)` / `storage.set(key, value)` / `storage.delete(key)` persist small JSON state per plugin in `plugin-data/<id>`; requires `host.storage`; values cap at 256 KiB and the whole store at 1 MiB, bulk data belongs in the sidecar's `DBX_PLUGIN_DATA_DIR`
|
||||
- `ai.openConversation({ title, prompt, context, send? })` — opens a plugin conversation in the built-in DBX AI panel from a snapshot the plugin supplies; requires `host.ai`; `title` caps at 200 characters, `prompt` at 32000, and `context` at 2 MiB, and `send` defaults to `false` so pass `true` to start the analysis immediately. The host copies the snapshot into the conversation as history data and never hands model output or model configuration back to the plugin
|
||||
- `capabilities` — `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request
|
||||
- `ai.openConversation({ title, prompt, context, send?, mode? })` — opens a plugin conversation in the built-in DBX AI panel; requires `host.ai`; `mode` defaults to `ask` for snapshot compatibility, while `mode: "agent"` uses live tools on the context's open plugin connection when available. `title` caps at 200 characters, `prompt` at 32000, and `context` at 2 MiB, and `send` defaults to `false` so pass `true` to start the analysis immediately. The host copies the snapshot into the conversation as history data and never hands model output or model configuration back to the plugin
|
||||
- `ai.setRecommendations({ context, items })` / `ai.clearRecommendations()` — updates the context-aware quick-question chips shown by the global DBX AI panel; requires `host.ai`. Each item is `{ id, label, prompt, order? }`, supports `{{path.to.value}}` placeholders resolved against the supplied context, and is capped at five visible items. Recommendations belong to the current workbench instance and are not persisted into conversation history. Use `ai.openConversation()` when a user clicks a recommendation; DBX sends the selected prompt through the same snapshot safety boundary.
|
||||
- `capabilities` — `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request
|
||||
- `onEvent(listener)` — events are forwarded only with `host.events`
|
||||
- `onBinary(listener)` — binary frames are forwarded only with `host.binary`; listeners receive `{ channel, data: Uint8Array }`
|
||||
|
||||
@@ -823,7 +824,7 @@ Sidecars are shared per plugin process, not spawned per tab. Plugins own their i
|
||||
- **UI isolation:** sandboxed iframe, restrictive CSP, bounded bridge payloads, safe asset paths, plugin identity binding.
|
||||
- **Secret persistence:** plugin secrets are removed from connection JSON and stored through DBX's secret-store path. Ordinary cloud-sync snapshots always contain redacted placeholders. Secrets enter sync data only inside the encrypted payload when the user has configured a sync passphrase; without one, plugin secrets remain local and are not synchronized.
|
||||
- **Native backend trust:** a native sidecar runs with the current OS user's privileges. A signature identifies the repository that approved and published the package; it is not an OS sandbox or proof that the author is harmless. Install only plugins whose backend code you trust.
|
||||
- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.schema:read` grants only narrow metadata for a table on an already-open host connection; it never grants arbitrary SQL, writes, or reconnects. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/<id>` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies; the plugin gets no model output, no model configuration, and no SQL execution out of it. `host.data:read` grants single read-only statements only on connections the user consented to, per plugin and connection, revocable in Plugin Center; it never grants writes, DDL, locking reads, database switches, or reconnects.
|
||||
- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.schema:read` grants only narrow metadata for a table on an already-open host connection; it never grants arbitrary SQL, writes, or reconnects. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/<id>` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies and publish context-aware recommendation chips; the plugin gets no model output, no model configuration, and no SQL execution out of it. `host.data:read` grants single read-only statements only on connections the user consented to, per plugin and connection, revocable in Plugin Center; it never grants writes, DDL, locking reads, database switches, or reconnects.
|
||||
- **AI tool exposure:** plugin MCP tools reach the built-in AI agent only after the user enables the plugin for it, only on open connections, and — unless declared read-only — only after a per-call approval.
|
||||
|
||||
Custom repository public keys can be added or removed in Plugin Center. Obtain them through a channel independent from the downloaded package.
|
||||
|
||||
@@ -9,6 +9,7 @@ This example exercises the complete manifest v1 path instead of mocking a contri
|
||||
- per-connection backend registry;
|
||||
- asynchronous connection/progress events;
|
||||
- sandboxed workbench UI using `window.dbxPlugin`;
|
||||
- declarative AI recommendation chips for the active workbench resource;
|
||||
- workbench-to-sidecar RPC plus a read-only filesystem contribution rendered by DBX's host-owned file manager;
|
||||
- `mcp/tools` / `mcp/call` tools for the built-in DBX AI assistant: `hello_greet` is declared read-only, `hello_set_greeting` changes state and therefore asks the user first;
|
||||
- unsigned `.dbxp` candidate packaging plus separate repository signing;
|
||||
@@ -60,6 +61,7 @@ In DBX, open **Plugin Center**, enable **Allow unsigned development package**, a
|
||||
6. Invoke the sidecar from the workbench.
|
||||
7. Click **Open host files** to browse and preview the virtual `hello:/` filesystem without plugin-owned file-browser UI.
|
||||
8. In Plugin Center → Installed, turn on **Built-in AI tools** for the plugin, keep the Hello connection open, and ask the AI assistant in Agent mode to greet someone or change the greeting. The greeting change pauses for your approval.
|
||||
9. Open the AI assistant from the workbench and click the **Inspect current workbench** recommendation to send a prompt immediately.
|
||||
|
||||
The example token is persisted through `connection_secrets`; the raw connection `config_json` contains only an empty placeholder. The iframe receives the saved connection ID, never the token.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"dbx": ">=0.5.68",
|
||||
"host_api": "^1.0"
|
||||
},
|
||||
"permissions": ["host.events", "host.filesystem", "host.data:read"],
|
||||
"permissions": ["host.events", "host.filesystem", "host.data:read", "host.ai"],
|
||||
"entrypoints": {
|
||||
"backend": {
|
||||
"executable": "bin/dbx-example-hello"
|
||||
@@ -88,7 +88,17 @@
|
||||
"type": "workbench",
|
||||
"id": "dbx.example.hello.main",
|
||||
"label": "Hello Workbench",
|
||||
"description": "A sandboxed plugin UI hosted in a normal DBX workbench tab."
|
||||
"description": "A sandboxed plugin UI hosted in a normal DBX workbench tab.",
|
||||
"ai": {
|
||||
"recommendations": [
|
||||
{
|
||||
"id": "inspect-resource",
|
||||
"label": "Inspect current workbench",
|
||||
"prompt": "Analyze the current Hello workbench context and summarize its health risks.",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "filesystem-provider",
|
||||
|
||||
@@ -312,7 +312,30 @@
|
||||
"id": { "$ref": "#/$defs/identifier" },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string" },
|
||||
"icon": { "$ref": "#/$defs/assetPath" }
|
||||
"icon": { "$ref": "#/$defs/assetPath" },
|
||||
"ai": { "$ref": "#/$defs/workbenchAi" }
|
||||
}
|
||||
},
|
||||
"workbenchAi": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"recommendations": {
|
||||
"type": "array",
|
||||
"maxItems": 5,
|
||||
"items": { "$ref": "#/$defs/aiRecommendation" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRecommendation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label", "prompt"],
|
||||
"properties": {
|
||||
"id": { "$ref": "#/$defs/identifier" },
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"prompt": { "type": "string", "minLength": 1, "maxLength": 32000 },
|
||||
"order": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }
|
||||
}
|
||||
},
|
||||
"contextMenuContribution": {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"publisher": "{{PUBLISHER_JSON}}",
|
||||
"description": "{{DESCRIPTION_JSON}}",
|
||||
"icon": "assets/plugin.svg",
|
||||
"permissions": ["host.ai"],
|
||||
"engines": {
|
||||
"dbx": ">=0.5.68",
|
||||
"host_api": "1"
|
||||
@@ -62,7 +63,17 @@
|
||||
"id": "{{PLUGIN_ID}}.workbench",
|
||||
"label": "{{PLUGIN_NAME_JSON}} Workbench",
|
||||
"description": "Sandboxed UI contributed by {{PLUGIN_NAME_JSON}}.",
|
||||
"icon": "assets/plugin.svg"
|
||||
"icon": "assets/plugin.svg",
|
||||
"ai": {
|
||||
"recommendations": [
|
||||
{
|
||||
"id": "inspect-context",
|
||||
"label": "Inspect current workbench",
|
||||
"prompt": "Analyze the current workbench context and list the most important risks.",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"localizations": {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"publisher": "{{PUBLISHER_JSON}}",
|
||||
"description": "{{DESCRIPTION_JSON}}",
|
||||
"icon": "assets/plugin.svg",
|
||||
"permissions": ["host.ai"],
|
||||
"engines": {
|
||||
"dbx": ">=0.5.68",
|
||||
"host_api": "1"
|
||||
@@ -23,7 +24,17 @@
|
||||
"id": "{{PLUGIN_ID}}.workbench",
|
||||
"label": "{{PLUGIN_NAME_JSON}}",
|
||||
"description": "Sandboxed frontend workbench contributed by {{PLUGIN_NAME_JSON}}.",
|
||||
"icon": "assets/plugin.svg"
|
||||
"icon": "assets/plugin.svg",
|
||||
"ai": {
|
||||
"recommendations": [
|
||||
{
|
||||
"id": "inspect-context",
|
||||
"label": "Inspect current workbench",
|
||||
"prompt": "Analyze the current workbench context and list the most important risks.",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"localizations": {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"publisher": "{{PUBLISHER_JSON}}",
|
||||
"description": "{{DESCRIPTION_JSON}}",
|
||||
"icon": "assets/plugin.svg",
|
||||
"permissions": ["host.ai"],
|
||||
"engines": { "dbx": ">=0.5.68", "host_api": "1" },
|
||||
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
|
||||
"contributions": [
|
||||
@@ -15,7 +16,17 @@
|
||||
"id": "{{PLUGIN_ID}}.workbench",
|
||||
"label": "{{PLUGIN_NAME_JSON}}",
|
||||
"description": "Svelte workbench contributed by {{PLUGIN_NAME_JSON}}.",
|
||||
"icon": "assets/plugin.svg"
|
||||
"icon": "assets/plugin.svg",
|
||||
"ai": {
|
||||
"recommendations": [
|
||||
{
|
||||
"id": "inspect-context",
|
||||
"label": "Inspect current workbench",
|
||||
"prompt": "Analyze the current workbench context and list the most important risks.",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"localizations": {
|
||||
|
||||
@@ -114,6 +114,11 @@ function installBridge(channel) {
|
||||
request,
|
||||
invoke: (method, params, options = {}) => request("backend.invoke", { method, params, timeoutMs: options.timeoutMs }),
|
||||
stream,
|
||||
ai: Object.freeze({
|
||||
openConversation: (options) => request("host.ai.openConversation", options),
|
||||
setRecommendations: (update) => request("host.ai.setRecommendations", update),
|
||||
clearRecommendations: () => request("host.ai.clearRecommendations"),
|
||||
}),
|
||||
notify: (method, params) => request("backend.notify", { method, params }),
|
||||
sendBinary: (channel, data) => request("backend.sendBinary", { channel, dataBase64: typeof data === "string" ? data : encode(data) }),
|
||||
readAsset: (path) => request("ui.readAsset", { path }),
|
||||
|
||||
@@ -27,6 +27,37 @@ function requirePermission(manifest, permission) {
|
||||
function ensureSucceeded(result) {
|
||||
if (result?.success === false) throw new Error(result.message || "Plugin connection operation failed");
|
||||
}
|
||||
const AI_RECOMMENDATION_PLACEHOLDER_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.(?:[A-Za-z_$][A-Za-z0-9_$]*|[0-9]+))*$/;
|
||||
const AI_RECOMMENDATION_FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
function isValidAiRecommendationTemplate(value) {
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const open = value.indexOf("{{", offset);
|
||||
const close = value.indexOf("}}", offset);
|
||||
const next = open < 0 ? close : close < 0 ? open : Math.min(open, close);
|
||||
if (next < 0) return true;
|
||||
if (value.startsWith("}}", next)) return false;
|
||||
const end = value.indexOf("}}", next + 2);
|
||||
if (end < 0) return false;
|
||||
const path = value.slice(next + 2, end).trim();
|
||||
if (!AI_RECOMMENDATION_PLACEHOLDER_PATH.test(path) || path.split(".").some((segment) => AI_RECOMMENDATION_FORBIDDEN_SEGMENTS.has(segment))) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
}
|
||||
function requireAiRecommendationUpdate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("AI recommendation update must be an object");
|
||||
if (!value.context || typeof value.context !== "object" || Array.isArray(value.context)) throw new Error("AI recommendation context must be an object");
|
||||
if (!Array.isArray(value.items) || value.items.length > 5) throw new Error("AI recommendation items must contain at most 5 entries");
|
||||
for (const [index, item] of value.items.entries()) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`AI recommendation ${index} must be an object`);
|
||||
if (typeof item.id !== "string" || !item.id.trim()) throw new Error(`AI recommendation ${index} requires id`);
|
||||
if (typeof item.label !== "string" || !item.label.trim() || item.label.length > 200) throw new Error(`AI recommendation ${index} label is invalid`);
|
||||
if (typeof item.prompt !== "string" || !item.prompt.trim() || item.prompt.length > 32000) throw new Error(`AI recommendation ${index} prompt is invalid`);
|
||||
if (!isValidAiRecommendationTemplate(item.label) || !isValidAiRecommendationTemplate(item.prompt)) throw new Error(`AI recommendation ${index} contains an invalid placeholder`);
|
||||
if (item.order !== undefined && (typeof item.order !== "number" || !Number.isFinite(item.order))) throw new Error(`AI recommendation ${index} order is invalid`);
|
||||
}
|
||||
return structuredClone({ context: value.context, items: value.items });
|
||||
}
|
||||
function pageId(value = "legacy") {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9-]{1,64}$/.test(value)) throw new Error("Invalid page ID");
|
||||
return value;
|
||||
@@ -203,7 +234,7 @@ export async function createMockHost(options) {
|
||||
existing.name = name;
|
||||
return expose(existing);
|
||||
}
|
||||
const frame = { id: randomUUID(), channel: randomUUID(), session: session.id, page: session.page, connectionId, contributionId: contribution.id, context, name };
|
||||
const frame = { id: randomUUID(), channel: randomUUID(), session: session.id, page: session.page, connectionId, contributionId: contribution.id, context, name, aiRecommendations: [] };
|
||||
frames.set(frame.id, frame);
|
||||
return expose(frame);
|
||||
}
|
||||
@@ -241,6 +272,22 @@ export async function createMockHost(options) {
|
||||
switch (input.method) {
|
||||
case "host.getContext":
|
||||
return structuredClone(frame.context);
|
||||
case "host.ai.openConversation":
|
||||
requirePermission(manifest, "host.ai");
|
||||
if (!p || typeof p.title !== "string" || !p.title.trim() || p.title.length > 200) throw new Error("AI conversation title is invalid");
|
||||
if (typeof p.prompt !== "string" || !p.prompt.trim() || p.prompt.length > 32000) throw new Error("AI conversation prompt is invalid");
|
||||
if (!p.context || typeof p.context !== "object" || Array.isArray(p.context) || jsonSize(p.context) > BRIDGE_LIMIT) throw new Error("AI conversation context is invalid");
|
||||
if (p.send !== undefined && typeof p.send !== "boolean") throw new Error("AI conversation send is invalid");
|
||||
if (p.mode !== undefined && p.mode !== "ask" && p.mode !== "agent") throw new Error("AI conversation mode is invalid");
|
||||
return null;
|
||||
case "host.ai.setRecommendations":
|
||||
requirePermission(manifest, "host.ai");
|
||||
frame.aiRecommendations = requireAiRecommendationUpdate(p);
|
||||
return null;
|
||||
case "host.ai.clearRecommendations":
|
||||
requirePermission(manifest, "host.ai");
|
||||
frame.aiRecommendations = [];
|
||||
return null;
|
||||
case "backend.invoke": {
|
||||
if (p.timeoutMs !== undefined && (typeof p.timeoutMs !== "number" || !Number.isFinite(p.timeoutMs))) throw new Error("Invalid request timeout");
|
||||
const timeout = p.timeoutMs === undefined ? 30000 : Math.min(120000, Math.max(1, Math.round(p.timeoutMs)));
|
||||
|
||||
@@ -322,6 +322,24 @@ test("a frontend-only workbench opens without a connection or sidecar", async (t
|
||||
assert.match(rpc.error.message, /no backend/);
|
||||
assert.equal((await request("frames/close", { id: f.id })).status, 200);
|
||||
});
|
||||
test("host AI methods enforce permission and keep recommendation state per frame", async (t) => {
|
||||
const denied = await fixture(t, true);
|
||||
const deniedFrame = (await denied.request("workbenches/open", { contributionId: "example.main" })).value.frame;
|
||||
const deniedDocument = (await denied.request("frame-document", { frameId: deniedFrame.id })).value;
|
||||
const deniedCall = (method, params) => denied.request("bridge", { frameId: deniedFrame.id, channel: deniedDocument.channel, method, params });
|
||||
assert.equal((await deniedCall("host.ai.setRecommendations", { context: {}, items: [] })).status, 400);
|
||||
|
||||
const allowed = await fixture(t, true, { pluginManifest: { ...manifest, permissions: ["host.ai"] } });
|
||||
const frame = (await allowed.request("workbenches/open", { contributionId: "example.main" })).value.frame;
|
||||
const document = (await allowed.request("frame-document", { frameId: frame.id })).value;
|
||||
const call = (method, params) => allowed.request("bridge", { frameId: frame.id, channel: document.channel, method, params });
|
||||
assert.equal((await call("host.ai.openConversation", { title: "Inspect", prompt: "Check", context: {}, send: true })).value, null);
|
||||
assert.equal((await call("host.ai.setRecommendations", { context: { resource: { name: "orders" } }, items: [{ id: "health", label: "Inspect {{resource.name}}", prompt: "Check {{resource.name}}" }] })).value, null);
|
||||
assert.equal((await call("host.ai.setRecommendations", { context: {}, items: [{ id: "bad", label: "Inspect {{resource..name}}", prompt: "Check" }] })).status, 400);
|
||||
assert.deepEqual((await call("host.getContext", {})).value, {});
|
||||
assert.equal((await call("host.ai.clearRecommendations", {})).value, null);
|
||||
assert.equal((await call("host.ai.setRecommendations", { context: {}, items: Array.from({ length: 6 }, (_, index) => ({ id: `item-${index}`, label: "Item", prompt: "Prompt" })) })).status, 400);
|
||||
});
|
||||
test("srcdoc workbench documents inline local Vite scripts and styles", async (t) => {
|
||||
const { root, request } = await fixture(t, true);
|
||||
await mkdir(join(root, "ui/assets"));
|
||||
|
||||
Reference in New Issue
Block a user