From 26a015cd86731d91f40104bf64175e7a2aed0e22 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Fri, 25 Sep 2026 01:47:03 +0800 Subject: [PATCH] feat(plugins): add AI tools and consent-gated data access --- .../connection/ConnectionTreeSelect.vue | 5 +- ...onnectionTreeSelect.searchCollapse.spec.ts | 18 +- .../src/components/editor/AiAssistant.vue | 63 +- .../components/editor/AiToolApprovalCard.vue | 57 + .../__tests__/AiAssistant.mount.spec.ts | 31 +- .../__tests__/AiToolApprovalCard.spec.ts | 55 + .../components/icons/ConnectionIcon.spec.ts | 74 ++ .../src/components/icons/ConnectionIcon.vue | 13 + .../src/components/layout/ContentArea.vue | 4 +- .../plugins/PluginAiAccessSection.vue | 163 +++ .../plugins/PluginContributionsPanel.vue | 3 + .../plugins/PluginWorkbenchHost.vue | 24 + .../i18n/__tests__/featureMessages.spec.ts | 5 + apps/desktop/src/i18n/locales/az.ts | 38 + apps/desktop/src/i18n/locales/en.ts | 38 + apps/desktop/src/i18n/locales/es.ts | 38 + apps/desktop/src/i18n/locales/it.ts | 39 + apps/desktop/src/i18n/locales/ja.ts | 38 + apps/desktop/src/i18n/locales/ko.ts | 38 + apps/desktop/src/i18n/locales/pt-BR.ts | 38 + apps/desktop/src/i18n/locales/ru.ts | 38 + apps/desktop/src/i18n/locales/tr.ts | 38 + apps/desktop/src/i18n/locales/zh-CN.ts | 38 + apps/desktop/src/i18n/locales/zh-TW.ts | 38 + .../src/lib/__tests__/ai/aiContext.spec.ts | 19 + .../__tests__/aiAgentStepPresentation.spec.ts | 34 +- .../ai/__tests__/aiGenerationStatus.spec.ts | 32 + apps/desktop/src/lib/ai/ai.ts | 9 +- .../src/lib/ai/aiAgentStepPresentation.ts | 47 + apps/desktop/src/lib/ai/aiGenerationStatus.ts | 10 +- apps/desktop/src/lib/backend/api.ts | 7 + apps/desktop/src/lib/backend/http.ts | 37 + apps/desktop/src/lib/backend/tauri.ts | 53 + .../src/lib/plugins/pluginHostBridge.spec.ts | 123 +- .../src/lib/plugins/pluginHostBridge.ts | 122 ++ apps/desktop/src/types/pluginAiTools.ts | 29 + apps/desktop/src/types/pluginData.ts | 54 + crates/dbx-ai-provider/src/agent_events.rs | 40 +- crates/dbx-ai-provider/src/ai.rs | 12 +- .../examples/plugin_ai_tools_smoke.rs | 166 +++ crates/dbx-core/src/ai/agent_loop.rs | 286 ++++- crates/dbx-core/src/ai/agent_tools.rs | 83 +- crates/dbx-core/src/ai/mod.rs | 2 + crates/dbx-core/src/ai/plugin_tools.rs | 1143 +++++++++++++++++ crates/dbx-core/src/ai/tool_approval.rs | 158 +++ crates/dbx-core/src/lib.rs | 2 + crates/dbx-core/src/persistence/storage.rs | 197 ++- crates/dbx-core/src/query/mod.rs | 1 + crates/dbx-core/src/query/plugin_data.rs | 473 +++++++ crates/dbx-plugin-runtime/src/plugins/host.rs | 9 + .../src/plugins/manifest.rs | 29 +- crates/dbx-web/src/demo.rs | 5 + crates/dbx-web/src/main.rs | 9 + crates/dbx-web/src/routes/ai.rs | 56 + crates/dbx-web/src/routes/plugins.rs | 5 + crates/dbx-web/src/routes/query.rs | 53 + docs/content/docs/ai-assistant.cn.mdx | 13 + docs/content/docs/ai-assistant.mdx | 13 + docs/content/docs/plugin-development.cn.mdx | 65 +- docs/content/docs/plugin-development.mdx | 65 +- plugins/README.md | 58 +- plugins/examples/hello-workbench/README.md | 4 +- .../hello-workbench/backend/src/main.rs | 80 ++ .../examples/hello-workbench/manifest.json | 40 +- plugins/examples/hello-workbench/smoke.mjs | 6 + .../examples/hello-workbench/ui/index.html | 80 +- plugins/manifest.schema.json | 2 +- src-tauri/src/commands/ai.rs | 33 + src-tauri/src/commands/plugins.rs | 7 +- src-tauri/src/commands/query.rs | 30 + src-tauri/src/lib.rs | 7 + 71 files changed, 4613 insertions(+), 129 deletions(-) create mode 100644 apps/desktop/src/components/editor/AiToolApprovalCard.vue create mode 100644 apps/desktop/src/components/editor/__tests__/AiToolApprovalCard.spec.ts create mode 100644 apps/desktop/src/components/icons/ConnectionIcon.spec.ts create mode 100644 apps/desktop/src/components/icons/ConnectionIcon.vue create mode 100644 apps/desktop/src/components/plugins/PluginAiAccessSection.vue create mode 100644 apps/desktop/src/types/pluginAiTools.ts create mode 100644 apps/desktop/src/types/pluginData.ts create mode 100644 crates/dbx-core/examples/plugin_ai_tools_smoke.rs create mode 100644 crates/dbx-core/src/ai/plugin_tools.rs create mode 100644 crates/dbx-core/src/ai/tool_approval.rs create mode 100644 crates/dbx-core/src/query/plugin_data.rs diff --git a/apps/desktop/src/components/connection/ConnectionTreeSelect.vue b/apps/desktop/src/components/connection/ConnectionTreeSelect.vue index f594b9d51b..b4a5cd1cc2 100644 --- a/apps/desktop/src/components/connection/ConnectionTreeSelect.vue +++ b/apps/desktop/src/components/connection/ConnectionTreeSelect.vue @@ -5,9 +5,8 @@ import { Check, ChevronDown, ChevronRight, Folder, Search } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import ConnectionIcon from "@/components/icons/ConnectionIcon.vue"; import { buildConnectionPickerRows, connectionPickerSelectableRows } from "@/lib/connection/connectionPickerTree"; -import { connectionIconType } from "@/lib/connection/connectionPresentation"; import { cn } from "@/lib/common/utils"; import type { ConnectionConfig, SidebarLayout } from "@/types/database"; @@ -184,7 +183,7 @@ function handleKeydown(event: KeyboardEvent) { @click="selectConnection(row.id)" > - + {{ row.label }} diff --git a/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts b/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts index 2bb638e2b2..c41e763c58 100644 --- a/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts +++ b/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts @@ -26,6 +26,13 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ }), })); +vi.mock("@/components/plugins/PluginIcon.vue", () => ({ + default: defineComponent({ + props: ["pluginId", "contributionId"], + setup: (props) => () => h("span", { "data-plugin-id": props.pluginId, "data-contribution-id": props.contributionId }), + }), +})); + const mountedApps: App[] = []; const connections: ConnectionConfig[] = [ @@ -45,12 +52,12 @@ const layout: SidebarLayout = { order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }], }; -async function mountPicker() { +async function mountPicker(extraConnections: ConnectionConfig[] = []) { const container = document.createElement("div"); document.body.append(container); const app = createApp(ConnectionTreeSelect, { modelValue: "", - connections, + connections: [...connections, ...extraConnections], layout, placeholder: "Select connection", searchPlaceholder: "Search connections", @@ -82,6 +89,13 @@ afterEach(() => { }); describe("ConnectionTreeSelect search collapse behavior", () => { + it("passes plugin identity and provider logo metadata to the shared icon", async () => { + await mountPicker([{ ...connections[0], id: "plugin", name: "Plugin", db_type: "plugin", plugin_id: "sample.plugin", plugin_connection_provider: "sample.connection" }]); + const icon = document.body.querySelector('[data-picker-connection="plugin"] [data-plugin-id]'); + expect(icon?.getAttribute("data-plugin-id")).toBe("sample.plugin"); + expect(icon?.getAttribute("data-contribution-id")).toBe("sample.connection"); + }); + it("ignores group clicks during search without changing the post-search state", async () => { await mountPicker(); await setSearchText("Primary"); diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index b4eafa5f99..b4fca92534 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -66,8 +66,8 @@ import { usePromptTemplateStore } from "@/stores/promptTemplateStore"; import { useUserSkillStore } from "@/stores/userSkillStore"; import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection"; import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills"; -import { connectionIconType } from "@/lib/connection/connectionPresentation"; -import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; +import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport"; +import ConnectionIcon from "@/components/icons/ConnectionIcon.vue"; import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue"; import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue"; import { useQueryStore } from "@/stores/queryStore"; @@ -154,11 +154,11 @@ import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan"; import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue"; -import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; +import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation"; import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter"; import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender"; import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown"; -import { aiStream, aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api"; +import { aiStream, aiCancelStream, resolveAiToolApproval, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api"; import type { AiMessage } from "@/lib/backend/api"; import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai"; import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database"; @@ -184,6 +184,7 @@ import { buildAiAnalysisExport } from "@/lib/export/aiAnalysisExport"; import { buildAiConversationExport, type AiConversationExportFormat } from "@/lib/export/aiConversationExport"; import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch"; 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"; @@ -1780,9 +1781,11 @@ watch( ); watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true }); +const showAiDatabaseSelector = computed(() => !!boundConnection.value && supportsAiAssistantContext(boundConnection.value.db_type)); + const showAiSchemaSelector = computed(() => { const connection = boundConnection.value; - return !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection); + return showAiDatabaseSelector.value && !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection); }); const aiSchemaDatabaseKey = computed(() => { @@ -1804,7 +1807,7 @@ async function loadAiSchemas() { } async function loadDatabases(connection = boundConnection.value): Promise { - if (!connection) return []; + if (!connection || !supportsAiAssistantContext(connection.db_type)) return []; if (connection.db_type !== "dameng") { await loadDatabaseOptions(connection.id); return databaseOptions.value[connection.id] || []; @@ -2103,6 +2106,21 @@ function parseExplainFromData(explainData: unknown, dbType: string): ParsedExpla } } +/** Sends the user's answer for a pending plugin tool approval back to the run that asked. */ +async function resolveToolApproval(step: AiAgentStepItem, approved: boolean) { + const approval = step.approval; + if (!approval || approval.status !== "pending") return; + approval.status = "submitting"; + try { + // The final status arrives with `tool_approval_resolved`; `false` means + // the run stopped waiting (timed out, cancelled, or app restarted). + if (!(await resolveAiToolApproval(approval.sessionId, approval.approvalId, approved))) approval.status = "expired"; + } catch (error) { + approval.status = "pending"; + toast(t("ai.toolApproval.answerFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + } +} + function agentEventToStep(event: AgentEvent, index: number, now: number): AiAgentStepItem | undefined { if (event.type === "context_compacted") { return { @@ -2343,7 +2361,7 @@ function mentionTargetDatabase(): string { async function loadMentionCandidates(query: string) { const connection = boundConnection.value; const mentionDatabase = mentionTargetDatabase(); - if (pluginContext.value || !connection || !boundConnectionId.value || !mentionDatabase) return; + if (pluginContext.value || !connection || !supportsAiAssistantContext(connection.db_type) || !boundConnectionId.value || !mentionDatabase) return; const key = mentionCacheKey(boundConnectionId.value, mentionDatabase, query); if (mentionCache.value[key]) { @@ -3693,6 +3711,28 @@ async function send() { if (step) upsertAgentStep(msg.agentSteps, step); } } + // A plugin tool call waits for (or got) the user's approval: keep the + // state on the tool card so the answer buttons live next to the call. + if (event.type === "tool_approval_required" || event.type === "tool_approval_resolved") { + const msg = runMessages[assistantIdx]; + if (msg?.agentSteps) { + const key = toolCallStepKey(event.tool_call_id, agentEvents.length - 1, "tool_call_start"); + if (event.type === "tool_approval_required") { + updateAgentStepApproval(msg.agentSteps, key, () => ({ + approvalId: event.approval_id, + sessionId, + pluginName: event.plugin_name, + pluginTool: event.plugin_tool, + connectionName: event.connection_name, + args: event.args ?? {}, + expiresAtMs: Date.now() + event.timeout_secs * 1000, + status: "pending", + })); + } else { + updateAgentStepApproval(msg.agentSteps, key, (approval) => (approval && approval.approvalId === event.approval_id ? { ...approval, status: event.outcome } : approval)); + } + } + } if (runIsVisible()) scrollToBottom(); }; if (runPluginContext) { @@ -5530,16 +5570,17 @@ async function openExternalUrl(url: string) { {{ t(step.labelKey) }} - : {{ step.toolName }} + : {{ formatAgentToolName(step.toolName) }} {{ formatToolDurationMs(step.durationMs) }} +
{{ step.toolArgs.sql }}