diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 66672df80..bcf4e533b 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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>({}); +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)" /> @@ -4482,6 +4498,7 @@ onUnmounted(() => { :tab="activeTab" :connection="activeConnection" :maximized="isAiPanelMaximized" + :plugin-recommendations="activePluginAiRecommendations" @append-sql="onAiAppendSql" @execute-sql="onAiExecuteSql" @temp-run-sql="onAiTempRunSql" diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index e5c60b928..d9a6d5967 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -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(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) {

{{ t(pluginContext ? "ai.pluginWelcome" : "ai.welcome") }}

+
+ +
-
- +
+
+
+ +