diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index b31e6576..de26933e 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -18,6 +18,7 @@ "⚠️ **Security:** TLS/SSL Disabled": "⚠️ **Security:** TLS/SSL Disabled", "⚠ TLS/SSL Disabled": "⚠ TLS/SSL Disabled", "✅ **Security:** TLS/SSL Enabled": "✅ **Security:** TLS/SSL Enabled", + "🔍 Explain": "🔍 Explain", "$(add) Create...": "$(add) Create...", "$(info) Some storage accounts were filtered because of their sku. Learn more...": "$(info) Some storage accounts were filtered because of their sku. Learn more...", "$(keyboard) Manually enter error": "$(keyboard) Manually enter error", @@ -42,6 +43,9 @@ "An element with the following id already exists: {id}": "An element with the following id already exists: {id}", "An error has occurred. Check output window for more details.": "An error has occurred. Check output window for more details.", "An item with id \"{0}\" already exists for workspace \"{1}\".": "An item with id \"{0}\" already exists for workspace \"{1}\".", + "API key from Azure portal": "API key from Azure portal", + "API key is required": "API key is required", + "API key seems too short": "API key seems too short", "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".": "API version \"{0}\" for extension id \"{1}\" is no longer supported. Minimum version is \"{2}\".", "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"": "API: Registered new migration provider: \"{providerId}\" - \"{providerLabel}\"", "Are you sure?": "Are you sure?", @@ -53,6 +57,8 @@ "Azure Activity": "Azure Activity", "Azure Cosmos DB for MongoDB (RU) Emulator": "Azure Cosmos DB for MongoDB (RU) Emulator", "Azure Cosmos DB for MongoDB (vCore)": "Azure Cosmos DB for MongoDB (vCore)", + "Azure OpenAI": "Azure OpenAI", + "Azure OpenAI endpoint is required": "Azure OpenAI endpoint is required", "Azure Service Discovery": "Azure Service Discovery", "Azure VM Service Discovery": "Azure VM Service Discovery", "Azure VM: Attempting to authenticate with \"{vmName}\"…": "Azure VM: Attempting to authenticate with \"{vmName}\"…", @@ -81,6 +87,8 @@ "Collection name is required.": "Collection name is required.", "Collection names should begin with an underscore or a letter character.": "Collection names should begin with an underscore or a letter character.", "Configure Azure VM Discovery Filters": "Configure Azure VM Discovery Filters", + "Configure LLM": "Configure LLM", + "Configure LLM Resource": "Configure LLM Resource", "Configure TLS/SSL Security": "Configure TLS/SSL Security", "Connect to a database": "Connect to a database", "Connected to \"{cluster}\" as \"{username}\"": "Connected to \"{cluster}\" as \"{username}\"", @@ -138,9 +146,11 @@ "Edit selected document": "Edit selected document", "Element with id of {rootId} not found.": "Element with id of {rootId} not found.", "Enable TLS/SSL (Default)": "Enable TLS/SSL (Default)", + "Endpoint must use HTTPS": "Endpoint must use HTTPS", "Enforce TLS/SSL checks for a secure connection.": "Enforce TLS/SSL checks for a secure connection.", "Enter a collection name.": "Enter a collection name.", "Enter a database name.": "Enter a database name.", + "Enter OpenAI API endpoint (leave empty for default)": "Enter OpenAI API endpoint (leave empty for default)", "Enter the Azure VM tag key used for discovering DocumentDB instances.": "Enter the Azure VM tag key used for discovering DocumentDB instances.", "Enter the Azure VM tag to filter by": "Enter the Azure VM tag to filter by", "Enter the connection string of your local connection": "Enter the connection string of your local connection", @@ -151,6 +161,9 @@ "Enter the port number your DocumentDB uses. The default port: {defaultPort}.": "Enter the port number your DocumentDB uses. The default port: {defaultPort}.", "Enter the username": "Enter the username", "Enter the username for {experience}": "Enter the username for {experience}", + "Enter your Azure OpenAI API key": "Enter your Azure OpenAI API key", + "Enter your Azure OpenAI endpoint URL": "Enter your Azure OpenAI endpoint URL", + "Enter your OpenAI API key": "Enter your OpenAI API key", "Error creating resource: {0}": "Error creating resource: {0}", "Error deleting selected documents": "Error deleting selected documents", "Error exporting documents: {error}": "Error exporting documents: {error}", @@ -172,6 +185,7 @@ "Execution timed out.": "Execution timed out.", "Expected a file name \"{0}\", but the selected filename is \"{1}\"": "Expected a file name \"{0}\", but the selected filename is \"{1}\"", "Expecting parentheses or quotes at \"{text}\"": "Expecting parentheses or quotes at \"{text}\"", + "Explaining the command…": "Explaining the command…", "Export": "Export", "Export Current Query Results…": "Export Current Query Results…", "Export Entire Collection…": "Export Entire Collection…", @@ -205,6 +219,7 @@ "failed.": "failed.", "Find Query": "Find Query", "Finished importing": "Finished importing", + "Getting performance insights for database \"{0}\"…": "Getting performance insights for database \"{0}\"…", "Go back.": "Go back.", "Go to first page": "Go to first page", "Go to next page": "Go to next page", @@ -212,6 +227,8 @@ "Go to start": "Go to start", "Got a moment? Share your feedback on DocumentDB for VS Code!": "Got a moment? Share your feedback on DocumentDB for VS Code!", "How do you want to connect?": "How do you want to connect?", + "https://api.openai.com/v1": "https://api.openai.com/v1", + "https://your-resource.openai.azure.com/": "https://your-resource.openai.azure.com/", "I want to choose the server from an online registry.": "I want to choose the server from an online registry.", "I want to connect to a local DocumentDB instance.": "I want to connect to a local DocumentDB instance.", "I want to connect to the Azure Cosmos DB Emulator for MongoDB (RU).": "I want to connect to the Azure Cosmos DB Emulator for MongoDB (RU).", @@ -254,6 +271,8 @@ "Learn more…": "Learn more…", "Length must be greater than 1": "Length must be greater than 1", "Level up": "Level up", + "LLM configuration saved successfully. Enhanced features are now available.": "LLM configuration saved successfully. Enhanced features are now available.", + "LLM Enhanced Features Not Available": "LLM Enhanced Features Not Available", "Load More...": "Load More...", "Loading \"{0}\"...": "Loading \"{0}\"...", "Loading cluster details for \"{cluster}\"": "Loading cluster details for \"{cluster}\"", @@ -266,6 +285,7 @@ "Loading...": "Loading...", "Local Emulators": "Local Emulators", "Location": "Location", + "Microsoft Azure OpenAI Service": "Microsoft Azure OpenAI Service", "Mongo Shell connected.": "Mongo Shell connected.", "Mongo Shell Error: {error}": "Mongo Shell Error: {error}", "MongoDB Accounts": "MongoDB Accounts", @@ -282,9 +302,12 @@ "No Azure subscription found for this tree item.": "No Azure subscription found for this tree item.", "No Azure VMs found with tag \"{tagName}\" in subscription \"{subscriptionName}\".": "No Azure VMs found with tag \"{tagName}\" in subscription \"{subscriptionName}\".", "No collection selected.": "No collection selected.", + "No command found at the current position.": "No command found at the current position.", "No commands found in this document.": "No commands found in this document.", "No Connectivity": "No Connectivity", "No credentials found for id {credentialId}": "No credentials found for id {credentialId}", + "No database connection found.": "No database connection found.", + "No database selected.": "No database selected.", "No matching resources found.": "No matching resources found.", "No node selected.": "No node selected.", "No properties found in the schema at path \"{0}\"": "No properties found in the schema at path \"{0}\"", @@ -294,16 +317,21 @@ "No session found for id {sessionId}": "No session found for id {sessionId}", "No subscriptions found": "No subscriptions found", "Not connected to any MongoDB database.": "Not connected to any MongoDB database.", + "Not Now": "Not Now", "Note: This confirmation type can be configured in the extension settings.": "Note: This confirmation type can be configured in the extension settings.", "Note: You can disable these URL handling confirmations in the extension settings.": "Note: You can disable these URL handling confirmations in the extension settings.", "Open Collection": "Open Collection", "Open installation page": "Open installation page", + "OpenAI": "OpenAI", + "OpenAI API": "OpenAI API", + "OpenAI API key should start with \"sk-\"": "OpenAI API key should start with \"sk-\"", "Opening DocumentDB connection…": "Opening DocumentDB connection…", "Operation cancelled.": "Operation cancelled.", "Password for {username_at_resource}": "Password for {username_at_resource}", "Pick \"{number}\" to confirm and continue.": "Pick \"{number}\" to confirm and continue.", "Please authenticate first by expanding the tree item of the selected cluster.": "Please authenticate first by expanding the tree item of the selected cluster.", "Please confirm by re-entering the previous value.": "Please confirm by re-entering the previous value.", + "Please connect to a MongoDB database before explaining a command.": "Please connect to a MongoDB database before explaining a command.", "Please connect to a MongoDB database before running a Scrapbook command.": "Please connect to a MongoDB database before running a Scrapbook command.", "Please edit the connection string.": "Please edit the connection string.", "Please enter a new connection name.": "Please enter a new connection name.", @@ -318,6 +346,8 @@ "Process exited: \"{command}\"": "Process exited: \"{command}\"", "Provide Feedback": "Provide Feedback", "Provider \"{0}\" does not have resource type \"{1}\".": "Provider \"{0}\" does not have resource type \"{1}\".", + "Provider and API key are required": "Provider and API key are required", + "Provider must be selected first": "Provider must be selected first", "Refresh": "Refresh", "Refresh current view": "Refresh current view", "Registering Providers...": "Registering Providers...", @@ -344,6 +374,7 @@ "Select a location for new resources.": "Select a location for new resources.", "Select a workspace folder": "Select a workspace folder", "Select Existing": "Select Existing", + "Select LLM provider": "Select LLM provider", "Select resource": "Select resource", "Select subscription": "Select subscription", "Select Subscriptions": "Select Subscriptions", @@ -355,6 +386,7 @@ "Sign In": "Sign In", "Sign in to Azure...": "Sign in to Azure...", "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.": "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.", + "sk-...": "sk-...", "Skip for now": "Skip for now", "Small breadcrumb example with buttons": "Small breadcrumb example with buttons", "Some items could not be displayed": "Some items could not be displayed", @@ -413,6 +445,7 @@ "This operation is not supported.": "This operation is not supported.", "This table view presents data at the root level by default.": "This table view presents data at the root level by default.", "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.": "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting.", + "To use enhanced features powered by AI, you need to configure an LLM resource (Azure OpenAI or OpenAI). Would you like to configure it now?": "To use enhanced features powered by AI, you need to configure an LLM resource (Azure OpenAI or OpenAI). Would you like to configure it now?", "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.": "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.", "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}": "Too many arguments. Expecting 0 or 1 argument(s) to {constructorCall}", "Tree View": "Tree View", @@ -467,6 +500,7 @@ "You clicked a link that wants to open a DocumentDB connection in VS Code.": "You clicked a link that wants to open a DocumentDB connection in VS Code.", "You do not have permission to create a resource group in subscription \"{0}\".": "You do not have permission to create a resource group in subscription \"{0}\".", "You might be asked for credentials to establish the connection.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the extension settings.": "You might be asked for credentials to establish the connection.\nDo you want to continue?\n\nNote: You can disable these URL handling confirmations in the extension settings.", + "You must open a *.vscode-documentdb-scrapbook file to explain commands.": "You must open a *.vscode-documentdb-scrapbook file to explain commands.", "You must open a *.vscode-documentdb-scrapbook file to run commands.": "You must open a *.vscode-documentdb-scrapbook file to run commands.", "You need to provide the password for \"{username}\" in order to continue. Your password will not be stored.": "You need to provide the password for \"{username}\" in order to continue. Your password will not be stored.", "Your database stores documents with embedded fields, allowing for hierarchical data organization.": "Your database stores documents with embedded fields, allowing for hierarchical data organization.", diff --git a/package.json b/package.json index 40c87778..df9aed5e 100644 --- a/package.json +++ b/package.json @@ -360,6 +360,25 @@ "command": "vscode-documentdb.command.createDatabase", "title": "Create Database…" }, + { + "//": "Performance Insight", + "category": "DocumentDB", + "command": "vscode-documentdb.command.performanceInsight", + "title": "Performance Insight" + }, + { + "//": "Configure LLM Resource", + "category": "DocumentDB", + "command": "vscode-documentdb.command.configureLlm", + "title": "Configure LLM Resource…", + "icon": "$(settings-gear)" + }, + { + "//": "Scrapbook: Explain Command", + "category": "DocumentDB", + "command": "vscode-documentdb.command.scrapbook.explainCommand", + "title": "Explain Command" + }, { "//": "Scrapbook: Connect Database", "category": "DocumentDB", @@ -605,11 +624,17 @@ "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "2@1" }, + { + "//": "[Database] Performance Insight", + "command": "vscode-documentdb.command.performanceInsight", + "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "2@2" + }, { "//": "[Database] Mongo DB|Cluster Scrapbook Submenu", "submenu": "documentDB.submenus.mongo.database.scrapbook", "when": "view =~ /connectionsView|discoveryView/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", - "group": "2@2" + "group": "2@3" }, { "//": "[Collection] Mongo DB|Cluster Open collection", @@ -862,6 +887,28 @@ "type": "number", "description": "The batch size to be used when querying working with the shell.", "default": 50 + }, + "documentDB.llm.provider": { + "order": 30, + "type": "string", + "description": "The LLM provider to use for enhanced features (Azure OpenAI or OpenAI).", + "enum": [ + "", + "azure-openai", + "openai" + ], + "enumItemLabels": [ + "Not Configured", + "Azure OpenAI", + "OpenAI" + ], + "default": "" + }, + "documentDB.llm.endpoint": { + "order": 31, + "type": "string", + "description": "The endpoint URL for the LLM service (required for Azure OpenAI, optional for OpenAI).", + "default": "" } } } diff --git a/src/commands/configureLlm/ConfigureLlmWizardContext.ts b/src/commands/configureLlm/ConfigureLlmWizardContext.ts new file mode 100644 index 00000000..6a8c4980 --- /dev/null +++ b/src/commands/configureLlm/ConfigureLlmWizardContext.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type LlmProvider } from '../../services/LlmConfigurationService'; + +export interface ConfigureLlmWizardContext extends IActionContext { + provider?: LlmProvider; + endpoint?: string; + apiKey?: string; +} \ No newline at end of file diff --git a/src/commands/configureLlm/ExecuteStep.ts b/src/commands/configureLlm/ExecuteStep.ts new file mode 100644 index 00000000..ee5bd5da --- /dev/null +++ b/src/commands/configureLlm/ExecuteStep.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { LlmConfigurationService } from '../../services/LlmConfigurationService'; +import { type ConfigureLlmWizardContext } from './ConfigureLlmWizardContext'; + +export class ExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: ConfigureLlmWizardContext): Promise { + if (!context.provider || !context.apiKey) { + throw new Error(l10n.t('Provider and API key are required')); + } + + const llmService = LlmConfigurationService.getInstance(); + + await llmService.setConfiguration({ + provider: context.provider, + endpoint: context.endpoint, + apiKey: context.apiKey, + }); + + void vscode.window.showInformationMessage( + l10n.t('LLM configuration saved successfully. Enhanced features are now available.') + ); + } + + public shouldExecute(context: ConfigureLlmWizardContext): boolean { + return !!context.provider && !!context.apiKey; + } +} \ No newline at end of file diff --git a/src/commands/configureLlm/PromptApiKeyStep.ts b/src/commands/configureLlm/PromptApiKeyStep.ts new file mode 100644 index 00000000..08e5e919 --- /dev/null +++ b/src/commands/configureLlm/PromptApiKeyStep.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { LlmProvider } from '../../services/LlmConfigurationService'; +import { type ConfigureLlmWizardContext } from './ConfigureLlmWizardContext'; + +export class PromptApiKeyStep extends AzureWizardPromptStep { + public async prompt(context: ConfigureLlmWizardContext): Promise { + if (!context.provider) { + throw new Error(l10n.t('Provider must be selected first')); + } + + let prompt: string; + let placeholder: string; + + if (context.provider === LlmProvider.AzureOpenAI) { + prompt = l10n.t('Enter your Azure OpenAI API key'); + placeholder = l10n.t('API key from Azure portal'); + } else { + prompt = l10n.t('Enter your OpenAI API key'); + placeholder = l10n.t('sk-...'); + } + + const apiKey = await context.ui.showInputBox({ + prompt, + placeHolder: placeholder, + password: true, + ignoreFocusOut: true, + validateInput: (input) => this.validateApiKey(input, context.provider!), + }); + + context.apiKey = apiKey.trim(); + } + + public shouldPrompt(context: ConfigureLlmWizardContext): boolean { + return !!context.provider; + } + + private validateApiKey(apiKey: string, provider: LlmProvider): string | undefined { + if (!apiKey) { + return l10n.t('API key is required'); + } + + if (provider === LlmProvider.OpenAI && !apiKey.startsWith('sk-')) { + return l10n.t('OpenAI API key should start with "sk-"'); + } + + if (apiKey.length < 10) { + return l10n.t('API key seems too short'); + } + + return undefined; + } +} \ No newline at end of file diff --git a/src/commands/configureLlm/PromptEndpointStep.ts b/src/commands/configureLlm/PromptEndpointStep.ts new file mode 100644 index 00000000..59aa19a6 --- /dev/null +++ b/src/commands/configureLlm/PromptEndpointStep.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { LlmProvider } from '../../services/LlmConfigurationService'; +import { type ConfigureLlmWizardContext } from './ConfigureLlmWizardContext'; + +export class PromptEndpointStep extends AzureWizardPromptStep { + public async prompt(context: ConfigureLlmWizardContext): Promise { + if (!context.provider) { + throw new Error(l10n.t('Provider must be selected first')); + } + + let placeholder: string; + let prompt: string; + + if (context.provider === LlmProvider.AzureOpenAI) { + placeholder = l10n.t('https://your-resource.openai.azure.com/'); + prompt = l10n.t('Enter your Azure OpenAI endpoint URL'); + } else { + placeholder = l10n.t('https://api.openai.com/v1'); + prompt = l10n.t('Enter OpenAI API endpoint (leave empty for default)'); + } + + const endpoint = await context.ui.showInputBox({ + prompt, + placeHolder: placeholder, + ignoreFocusOut: true, + validateInput: (input) => this.validateEndpoint(input, context.provider!), + }); + + context.endpoint = endpoint.trim() || (context.provider === LlmProvider.OpenAI ? 'https://api.openai.com/v1' : undefined); + } + + public shouldPrompt(context: ConfigureLlmWizardContext): boolean { + return !!context.provider; + } + + private validateEndpoint(endpoint: string, provider: LlmProvider): string | undefined { + if (!endpoint && provider === LlmProvider.AzureOpenAI) { + return l10n.t('Azure OpenAI endpoint is required'); + } + + if (endpoint && !endpoint.startsWith('https://')) { + return l10n.t('Endpoint must use HTTPS'); + } + + return undefined; + } +} \ No newline at end of file diff --git a/src/commands/configureLlm/PromptProviderStep.ts b/src/commands/configureLlm/PromptProviderStep.ts new file mode 100644 index 00000000..2cadd011 --- /dev/null +++ b/src/commands/configureLlm/PromptProviderStep.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { LlmProvider } from '../../services/LlmConfigurationService'; +import { type ConfigureLlmWizardContext } from './ConfigureLlmWizardContext'; + +export class PromptProviderStep extends AzureWizardPromptStep { + public async prompt(context: ConfigureLlmWizardContext): Promise { + const picks: IAzureQuickPickItem[] = [ + { + label: l10n.t('Azure OpenAI'), + description: l10n.t('Microsoft Azure OpenAI Service'), + data: LlmProvider.AzureOpenAI, + }, + { + label: l10n.t('OpenAI'), + description: l10n.t('OpenAI API'), + data: LlmProvider.OpenAI, + }, + ]; + + const selection = await context.ui.showQuickPick(picks, { + placeHolder: l10n.t('Select LLM provider'), + ignoreFocusOut: true, + }); + + context.provider = selection.data; + } + + public shouldPrompt(): boolean { + return true; + } +} \ No newline at end of file diff --git a/src/commands/configureLlm/configureLlm.ts b/src/commands/configureLlm/configureLlm.ts new file mode 100644 index 00000000..eee41048 --- /dev/null +++ b/src/commands/configureLlm/configureLlm.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import { type ConfigureLlmWizardContext } from './ConfigureLlmWizardContext'; +import { ExecuteStep } from './ExecuteStep'; +import { PromptApiKeyStep } from './PromptApiKeyStep'; +import { PromptEndpointStep } from './PromptEndpointStep'; +import { PromptProviderStep } from './PromptProviderStep'; + +export async function configureLlm(context: IActionContext): Promise { + const wizardContext: ConfigureLlmWizardContext = { + ...context, + }; + + const wizard = new AzureWizard(wizardContext, { + title: l10n.t('Configure LLM Resource'), + promptSteps: [ + new PromptProviderStep(), + new PromptEndpointStep(), + new PromptApiKeyStep(), + ], + executeSteps: [new ExecuteStep()], + }); + + await wizard.prompt(); + await wizard.execute(); +} \ No newline at end of file diff --git a/src/commands/performanceInsight/performanceInsight.ts b/src/commands/performanceInsight/performanceInsight.ts new file mode 100644 index 00000000..c56710df --- /dev/null +++ b/src/commands/performanceInsight/performanceInsight.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { openReadOnlyContent, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ClustersClient } from '../../documentdb/ClustersClient'; +import { type DatabaseItem } from '../../tree/documentdb/DatabaseItem'; +import { ensureLlmConfigured } from '../../utils/llmHelpers'; +import { withProgress } from '../../utils/withProgress'; + +export async function performanceInsight(context: IActionContext, databaseItem: DatabaseItem): Promise { + // Check if LLM is configured and prompt user if not + const isLlmConfigured = await ensureLlmConfigured(context, 'performanceInsight'); + if (!isLlmConfigured) { + return; // User declined to configure LLM or configuration failed + } + + const performanceOperation = async (): Promise => { + const client: ClustersClient = await ClustersClient.getClient(databaseItem.cluster.id); + const result = await client.runProfileCommand(databaseItem.databaseInfo.name); + + const label = `Performance-Insight-${databaseItem.databaseInfo.name}`; + const fullId = `${databaseItem.cluster.name}/${label}`; + + await openReadOnlyContent({ label, fullId }, JSON.stringify(result, null, 2), '.json', { + viewColumn: vscode.ViewColumn.Active, + preserveFocus: false, + }); + }; + + await withProgress( + performanceOperation(), + l10n.t('Getting performance insights for database "{0}"…', databaseItem.databaseInfo.name), + ); +} diff --git a/src/commands/scrapbook-commands/explainCommand.ts b/src/commands/scrapbook-commands/explainCommand.ts new file mode 100644 index 00000000..d5389d24 --- /dev/null +++ b/src/commands/scrapbook-commands/explainCommand.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { openReadOnlyContent, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { ClustersClient } from '../../documentdb/ClustersClient'; +import { findCommandAtPosition, getAllCommandsFromText } from '../../documentdb/scrapbook/ScrapbookHelpers'; +import { ScrapbookService } from '../../documentdb/scrapbook/ScrapbookService'; +import { ensureLlmConfigured } from '../../utils/llmHelpers'; +import { withProgress } from '../../utils/withProgress'; + +export async function explainCommand(context: IActionContext, position?: vscode.Position): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error(l10n.t('You must open a *.vscode-documentdb-scrapbook file to explain commands.')); + } + + if (!ScrapbookService.isConnected()) { + throw new Error(l10n.t('Please connect to a MongoDB database before explaining a command.')); + } + + // Check if LLM is configured and prompt user if not + const isLlmConfigured = await ensureLlmConfigured(context, 'explainCommand'); + if (!isLlmConfigured) { + return; // User declined to configure LLM or configuration failed + } + + const pos = position ?? editor.selection.start; + + const explainOperation = async (): Promise => { + const commands = getAllCommandsFromText(editor.document.getText()); + const command = findCommandAtPosition(commands, pos); + + if (!command) { + throw new Error(l10n.t('No command found at the current position.')); + } + + const clusterId = ScrapbookService.getClusterId(); + if (!clusterId) { + throw new Error(l10n.t('No database connection found.')); + } + + const client: ClustersClient = await ClustersClient.getClient(clusterId); + const databaseName = ScrapbookService.getDatabaseName(); + + if (!databaseName) { + throw new Error(l10n.t('No database selected.')); + } + + // use regex to extract collection name from command text + const collectionNameMatch = command.text.match(/db\.(\w+)\./); + const collectionName = collectionNameMatch ? collectionNameMatch[1] : 'collection'; + + const result = await client.explainQuery(databaseName, collectionName, command.text); + + const label = 'Scrapbook-explain-results'; + const fullId = `${ScrapbookService.getDisplayName()}/${label}`; + + await openReadOnlyContent({ label, fullId }, JSON.stringify(result, null, 2), '.json', { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + }; + + await withProgress(explainOperation(), l10n.t('Explaining the command…')); +} diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index f2941c0e..cba0a1d7 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -501,4 +501,87 @@ export class ClustersClient { }; } } + + private async explainAggregate(dbName: string, collectionName: string, pipeline: Document[]): Promise { + const db = this._mongoClient.db(dbName); + return await db.command({ + aggregate: collectionName, + pipeline: pipeline, + explain: true, + }); + } + + private async explainFind(dbName: string, collectionName: string, filter: Filter): Promise { + const collection = this._mongoClient.db(dbName).collection(collectionName); + return await collection.find(filter).explain(); + } + + // ---- type guards ---- + private isPlainObject(v: unknown): v is Document { + return typeof v === 'object' && v !== null && !Array.isArray(v); + } + private isPipelineArray(v: unknown): v is Document[] { + return Array.isArray(v) && v.every((item) => this.isPlainObject(item)); + } + + async explainQuery(databaseName: string, collectionName: string, query: string): Promise { + try { + const trimmed = query.replace(/\s+/g, ' '); + + if (trimmed.includes('.aggregate(')) { + const match = trimmed.match(/\.aggregate\s*\(\s*(\[[\s\S]*?\])\s*\)/); + if (!match) return { error: 'Failed to parse aggregate pipeline' }; + + let parsed: unknown; + try { + parsed = JSON.parse(match[1]); + } catch (e) { + return { error: 'Invalid JSON in aggregate pipeline: ' + (e as Error).message }; + } + + if (!this.isPipelineArray(parsed)) { + return { error: 'Aggregate pipeline must be an array of stage documents' }; + } + const pipeline: Document[] = parsed; + return await this.explainAggregate(databaseName, collectionName, pipeline); + } else if (trimmed.includes('.find(')) { + const match = trimmed.match(/\.find\s*\(\s*(\{[\s\S]*?\})\s*\)/); + if (!match) return { error: 'Failed to parse find filter' }; + + let parsed: unknown; + try { + parsed = JSON.parse(match[1]); + } catch (e) { + return { error: 'Invalid JSON in find filter: ' + (e as Error).message }; + } + + if (!this.isPlainObject(parsed)) { + return { error: 'Find filter must be a JSON object' }; + } + const filter: Filter = parsed as Filter; + return await this.explainFind(databaseName, collectionName, filter); + } else { + return { error: 'Unsupported query type. Only aggregate and find are supported.' }; + } + } catch (err) { + return { error: (err as Error).message }; + } + } + + async runProfileCommand( + databaseName: string, + // , collectionName?: string + ): Promise { + const db = this._mongoClient.db(databaseName); + + const command: Document = { profile: {} }; + // if (databaseName) { + // command.profile.db = databaseName; + // } + // if (collectionName) { + // command.profile.col = collectionName; + // } + + return await db.command(command); + } } diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index 167e5602..0fe7524f 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -20,6 +20,7 @@ import * as vscode from 'vscode'; import { addConnectionFromRegistry } from '../commands/addConnectionFromRegistry/addConnectionFromRegistry'; import { addDiscoveryRegistry } from '../commands/addDiscoveryRegistry/addDiscoveryRegistry'; import { chooseDataMigrationExtension } from '../commands/chooseDataMigrationExtension/chooseDataMigrationExtension'; +import { configureLlm } from '../commands/configureLlm/configureLlm'; import { copyAzureConnectionString } from '../commands/copyConnectionString/copyConnectionString'; import { createCollection } from '../commands/createCollection/createCollection'; import { createAzureDatabase } from '../commands/createDatabase/createDatabase'; @@ -35,6 +36,7 @@ import { newConnection } from '../commands/newConnection/newConnection'; import { newLocalConnection } from '../commands/newLocalConnection/newLocalConnection'; import { openCollectionView, openCollectionViewInternal } from '../commands/openCollectionView/openCollectionView'; import { openDocumentView } from '../commands/openDocument/openDocument'; +import { performanceInsight } from '../commands/performanceInsight/performanceInsight'; import { refreshTreeElement } from '../commands/refreshTreeElement/refreshTreeElement'; import { refreshView } from '../commands/refreshView/refreshView'; import { removeConnection } from '../commands/removeConnection/removeConnection'; @@ -141,6 +143,12 @@ export class ClustersExtension implements vscode.Disposable { 'vscode-documentdb.command.copyConnectionString', copyAzureConnectionString, ); + registerCommandWithTreeNodeUnwrapping( + 'vscode-documentdb.command.performanceInsight', + performanceInsight, + ); + + registerCommandWithModalErrors('vscode-documentdb.command.configureLlm', configureLlm); //// Connections View Commands: registerCommandWithModalErrors( diff --git a/src/documentdb/scrapbook/registerScrapbookCommands.ts b/src/documentdb/scrapbook/registerScrapbookCommands.ts index 45c3af90..d22a0921 100644 --- a/src/documentdb/scrapbook/registerScrapbookCommands.ts +++ b/src/documentdb/scrapbook/registerScrapbookCommands.ts @@ -16,6 +16,7 @@ import { connectCluster } from '../../commands/scrapbook-commands/connectCluster import { createScrapbook } from '../../commands/scrapbook-commands/createScrapbook'; import { executeAllCommand } from '../../commands/scrapbook-commands/executeAllCommand'; import { executeCommand } from '../../commands/scrapbook-commands/executeCommand'; +import { explainCommand } from '../../commands/scrapbook-commands/explainCommand'; import { ext } from '../../extensionVariables'; import { MongoConnectError } from './connectToClient'; import { MongoDBLanguageClient } from './languageClient'; @@ -39,6 +40,7 @@ export function registerScrapbookCommands(): void { registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.new', createScrapbook); registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.executeCommand', executeCommand); + registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.explainCommand', explainCommand); registerCommandWithTreeNodeUnwrapping('vscode-documentdb.command.scrapbook.executeAllCommands', executeAllCommand); // #region Database command diff --git a/src/documentdb/scrapbook/services/MongoCodeLensProvider.ts b/src/documentdb/scrapbook/services/MongoCodeLensProvider.ts index 0372c23e..8cc991b6 100644 --- a/src/documentdb/scrapbook/services/MongoCodeLensProvider.ts +++ b/src/documentdb/scrapbook/services/MongoCodeLensProvider.ts @@ -95,18 +95,29 @@ export class MongoCodeLensProvider implements vscode.CodeLensProvider { private createIndividualCommandLenses(commands: { range: vscode.Range }[]): vscode.CodeLens[] { const currentCommandInExectution = ScrapbookService.getSingleCommandInExecution(); - return commands.map((cmd) => { + return commands.flatMap((cmd) => { const running = currentCommandInExectution && cmd.range.isEqual(currentCommandInExectution.range); - const title = running ? l10n.t('⏳ Running Command…') : l10n.t('▶️ Run Command'); - - return { - command: { - title, - command: 'vscode-documentdb.command.scrapbook.executeCommand', - arguments: [cmd.range.start], + const runTitle = running ? l10n.t('⏳ Running Command…') : l10n.t('▶️ Run Command'); + + // Create both Run and Explain lenses for each command + return [ + { + command: { + title: runTitle, + command: 'vscode-documentdb.command.scrapbook.executeCommand', + arguments: [cmd.range.start], + }, + range: cmd.range, + }, + { + command: { + title: l10n.t('🔍 Explain'), + command: 'vscode-documentdb.command.scrapbook.explainCommand', + arguments: [cmd.range.start], + }, + range: cmd.range, }, - range: cmd.range, - }; + ]; }); } } diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts index 0ad21186..79643506 100644 --- a/src/extensionVariables.ts +++ b/src/extensionVariables.ts @@ -59,6 +59,10 @@ export namespace ext { export const showOperationSummaries = 'documentDB.userInterface.ShowOperationSummaries'; export const showUrlHandlingConfirmations = 'documentDB.confirmations.showUrlHandlingConfirmations'; export const localPort = 'documentDB.local.port'; + + // LLM Configuration settings + export const llmProvider = 'documentDB.llm.provider'; + export const llmEndpoint = 'documentDB.llm.endpoint'; export namespace vsCode { export const proxyStrictSSL = 'http.proxyStrictSSL'; diff --git a/src/services/LlmConfigurationService.ts b/src/services/LlmConfigurationService.ts new file mode 100644 index 00000000..f23f051b --- /dev/null +++ b/src/services/LlmConfigurationService.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ext } from '../extensionVariables'; +import { SettingUtils } from './SettingsService'; + +export enum LlmProvider { + AzureOpenAI = 'azure-openai', + OpenAI = 'openai', +} + +export interface LlmConfiguration { + provider: LlmProvider; + endpoint?: string; + apiKey?: string; +} + +/** + * Service for managing LLM (Large Language Model) configuration + */ +export class LlmConfigurationService { + private static instance: LlmConfigurationService; + private readonly settingsUtils = new SettingUtils(); + private readonly secretKeyPrefix = 'documentDB.llm'; + + private constructor() {} + + public static getInstance(): LlmConfigurationService { + if (!LlmConfigurationService.instance) { + LlmConfigurationService.instance = new LlmConfigurationService(); + } + return LlmConfigurationService.instance; + } + + /** + * Check if LLM is configured + */ + public isConfigured(): boolean { + const provider = this.getProvider(); + return provider !== undefined; + } + + /** + * Get configured LLM provider + */ + public getProvider(): LlmProvider | undefined { + return this.settingsUtils.getGlobalSetting(ext.settingsKeys.llmProvider.split('.').pop()!, ext.prefix); + } + + /** + * Get configured LLM endpoint + */ + public getEndpoint(): string | undefined { + return this.settingsUtils.getGlobalSetting(ext.settingsKeys.llmEndpoint.split('.').pop()!, ext.prefix); + } + + /** + * Get stored API key for the configured provider + */ + public async getApiKey(): Promise { + const provider = this.getProvider(); + if (!provider) { + return undefined; + } + + const secretKey = `${this.secretKeyPrefix}.${provider}.apiKey`; + return await ext.secretStorage.get(secretKey); + } + + /** + * Get full LLM configuration + */ + public async getConfiguration(): Promise { + const provider = this.getProvider(); + if (!provider) { + return undefined; + } + + const endpoint = this.getEndpoint(); + const apiKey = await this.getApiKey(); + + return { + provider, + endpoint, + apiKey, + }; + } + + /** + * Set LLM configuration + */ + public async setConfiguration(config: LlmConfiguration): Promise { + // Save provider and endpoint in settings + await this.settingsUtils.updateGlobalSetting( + ext.settingsKeys.llmProvider.split('.').pop()!, + config.provider, + ext.prefix, + ); + + if (config.endpoint) { + await this.settingsUtils.updateGlobalSetting( + ext.settingsKeys.llmEndpoint.split('.').pop()!, + config.endpoint, + ext.prefix, + ); + } + + // Save API key securely + if (config.apiKey) { + const secretKey = `${this.secretKeyPrefix}.${config.provider}.apiKey`; + await ext.secretStorage.store(secretKey, config.apiKey); + } + } + + /** + * Clear LLM configuration + */ + public async clearConfiguration(): Promise { + const provider = this.getProvider(); + + // Clear settings + await this.settingsUtils.updateGlobalSetting( + ext.settingsKeys.llmProvider.split('.').pop()!, + undefined, + ext.prefix, + ); + await this.settingsUtils.updateGlobalSetting( + ext.settingsKeys.llmEndpoint.split('.').pop()!, + undefined, + ext.prefix, + ); + + // Clear API key from secret storage + if (provider) { + const secretKey = `${this.secretKeyPrefix}.${provider}.apiKey`; + await ext.secretStorage.delete(secretKey); + } + } +} \ No newline at end of file diff --git a/src/utils/llmHelpers.ts b/src/utils/llmHelpers.ts new file mode 100644 index 00000000..1bd19693 --- /dev/null +++ b/src/utils/llmHelpers.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; +import { configureLlm } from '../commands/configureLlm/configureLlm'; +import { LlmConfigurationService } from '../services/LlmConfigurationService'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; + +/** + * Check if LLM is configured. If not, prompt the user to configure it. + * @param context Action context for telemetry + * @param commandName Name of the command being executed (for telemetry) + * @returns Promise - true if LLM is configured or user configured it, false if user declined + */ +export async function ensureLlmConfigured(context: IActionContext, _commandName: string): Promise { + const llmService = LlmConfigurationService.getInstance(); + + if (llmService.isConfigured()) { + return true; + } + + // Show information dialog asking if user wants to configure LLM + const configureButton = l10n.t('Configure LLM'); + const cancelButton = l10n.t('Not Now'); + + const selection = await vscode.window.showInformationMessage( + l10n.t('LLM Enhanced Features Not Available'), + { + modal: false, + detail: l10n.t('To use enhanced features powered by AI, you need to configure an LLM resource (Azure OpenAI or OpenAI). Would you like to configure it now?'), + }, + configureButton, + cancelButton, + ); + + if (selection === configureButton) { + try { + await configureLlm(context); + // Check if configuration was successful + return llmService.isConfigured(); + } catch { + // User cancelled configuration or error occurred + context.telemetry.properties.llmConfigurationCancelled = 'true'; + return false; + } + } + + // User selected "Not Now" or dismissed dialog + context.telemetry.properties.llmConfigurationDeclined = 'true'; + return false; +} \ No newline at end of file