diff --git a/extensions/positron-assistant/package.json b/extensions/positron-assistant/package.json index b2a234439ac..0163a0dcbd1 100644 --- a/extensions/positron-assistant/package.json +++ b/extensions/positron-assistant/package.json @@ -37,8 +37,12 @@ { "command": "positron-assistant.addModelConfiguration", "title": "%commands.addModelConfiguration.title%", - "category": "%commands.addModelConfiguration.category%", - "enablement": "isDevelopment" + "category": "%commands.category%" + }, + { + "command": "positron-assistant.configureModels", + "title": "%commands.configureModels.title%", + "category": "%commands.category%" } ], "languageModels": [ @@ -53,82 +57,6 @@ "type": "boolean", "default": false, "description": "%configuration.enable.description%" - }, - "positron.assistant.models": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "%configuration.models.id.description%" - }, - "name": { - "type": "string", - "description": "%configuration.models.name.description%" - }, - "model": { - "type": "string", - "description": "%configuration.models.model.description%" - }, - "baseUrl": { - "type": "string", - "description": "%configuration.models.baseUrl.description%" - }, - "numCtx": { - "type": "number", - "description": "%configuration.models.numCtx.description%" - }, - "toolCalls": { - "type": "boolean", - "description": "%configuration.models.toolCalls.description%" - }, - "project": { - "type": "string", - "description": "%configuration.models.project.description%" - }, - "location": { - "type": "string", - "description": "%configuration.models.location.description%" - }, - "resourceName": { - "type": "string", - "description": "%configuration.models.resourceName.description%" - }, - "provider": { - "type": "string", - "enum": [ - "anthropic", - "azure", - "echo", - "error", - "google", - "mistral", - "ollama", - "openai", - "openai-legacy", - "openrouter", - "bedrock", - "vertex", - "vertex-legacy" - ], - "description": "%configuration.models.provider.description%" - }, - "type": { - "enum": [ - "chat", - "completion" - ], - "description": "%configuration.models.type.description%" - } - }, - "required": [ - "name", - "provider" - ], - "additionalProperties": false - }, - "description": "%configuration.models.description%" } } } diff --git a/extensions/positron-assistant/package.nls.json b/extensions/positron-assistant/package.nls.json index f71300fa48d..5dbdbbba891 100644 --- a/extensions/positron-assistant/package.nls.json +++ b/extensions/positron-assistant/package.nls.json @@ -2,18 +2,7 @@ "displayName": "Positron Assistant", "description": "Provides default assistant and language models for Positron.", "commands.addModelConfiguration.title": "Add Language Model", - "commands.addModelConfiguration.category": "Positron Assistant", - "configuration.enable.description": "Enable Positron Assistant (experimental)", - "configuration.models.description": "Array of language model configurations.", - "configuration.models.id.description": "Identifier for the model provider, used for API key storage.", - "configuration.models.name.description": "Display name for the model.", - "configuration.models.model.description": "The specific model to use.", - "configuration.models.baseUrl.description": "URL prefix for API calls.", - "configuration.models.numCtx.description": "Context window size.", - "configuration.models.toolCalls.description": "Should the LLM make tool calls?", - "configuration.models.project.description": "Google Cloud Project ID.", - "configuration.models.location.description": "Google Cloud Location.", - "configuration.models.resourceName.description": "Azure resource name.", - "configuration.models.provider.description": "The language model provider.", - "configuration.models.type.description": "Type of model; chat participant or code completion." + "commands.configureModels.title": "Configure Language Models", + "commands.category": "Positron Assistant", + "configuration.enable.description": "Enable Positron Assistant (experimental)" } diff --git a/extensions/positron-assistant/src/config.ts b/extensions/positron-assistant/src/config.ts index 506d8910d18..fdb62f9c34b 100644 --- a/extensions/positron-assistant/src/config.ts +++ b/extensions/positron-assistant/src/config.ts @@ -12,22 +12,69 @@ interface StoredModelConfig extends Omit; + get(key: string): Thenable; + delete(key: string): Thenable; +} + +/** + * Implementation of SecretStorage that uses VS Code's secret storage API. + * + * This class should be used in desktop mode to store secrets securely. + */ +export class EncryptedSecretStorage implements SecretStorage { + constructor(private context: vscode.ExtensionContext) { } + store(key: string, value: string): Thenable { + return this.context.secrets.store(key, value); + } + get(key: string): Thenable { + return this.context.secrets.get(key); + } + delete(key: string): Thenable { + return this.context.secrets.delete(key); + } +} + +/** + * Implementation of SecretStorage that uses VS Code's global storage API. + * + * This class stores secrets **insecurely** using VS Code's global storage API. + * It is used in web mode, where there is no durable secret storage. + * + * This class should be replaced with one that uses a secure storage mechanism, + * or just removed altogether when Positron gains secure storage capabilities. + */ +export class GlobalSecretStorage implements SecretStorage { + constructor(private context: vscode.ExtensionContext) { } + store(key: string, value: string): Thenable { + return this.context.globalState.update(key, value); + } + get(key: string): Thenable { + return Promise.resolve(this.context.globalState.get(key)); + } + delete(key: string): Thenable { + return this.context.globalState.update(key, undefined); + } +} + export interface ModelConfig extends StoredModelConfig { apiKey: string; } -export function getStoredModels(): StoredModelConfig[] { - const config = vscode.workspace.getConfiguration('positron.assistant'); - const storedConfigs = config.get('models') || []; - return storedConfigs; +export function getStoredModels(context: vscode.ExtensionContext): StoredModelConfig[] { + return context.globalState.get('positron.assistant.models') || []; } -export async function getModelConfigurations(context: vscode.ExtensionContext): Promise { - const storedConfigs = getStoredModels(); +export async function getModelConfigurations(context: vscode.ExtensionContext, storage: SecretStorage): Promise { + const storedConfigs = getStoredModels(context); const fullConfigs: ModelConfig[] = await Promise.all( storedConfigs.map(async (config) => { - const apiKey = await context.secrets.get(`apiKey-${config.id}`); + const apiKey = await storage.get(`apiKey-${config.id}`); return { ...config, apiKey: apiKey || '' @@ -38,7 +85,69 @@ export async function getModelConfigurations(context: vscode.ExtensionContext): return fullConfigs; } -export async function showConfigurationDialog(context: vscode.ExtensionContext) { +export async function showModelList(context: vscode.ExtensionContext, storage: SecretStorage) { + // Create a quickpick with all configured models + const modelConfigs = await getModelConfigurations(context, storage); + const quickPick = vscode.window.createQuickPick(); + + // Create sections for chat and completion models + const chatModels = modelConfigs.filter(config => + config.type === 'chat' + ); + const completionModels = modelConfigs.filter(config => + config.type === 'completion' + ); + + const items: Array = [ + { + label: vscode.l10n.t('Chat Models'), + kind: vscode.QuickPickItemKind.Separator + }, + ...chatModels.map((config) => ({ + label: config.name, + detail: config.model + })), + { + label: vscode.l10n.t('Completion Models'), + kind: vscode.QuickPickItemKind.Separator + }, + ...completionModels.map((config) => ({ + label: config.name, + detail: config.model, + description: config.baseUrl + })), + { + label: '', + kind: vscode.QuickPickItemKind.Separator + }, + { + label: vscode.l10n.t('Add a Language Model'), + description: vscode.l10n.t('Add a new language model configuration'), + } + ]; + + vscode.window.showQuickPick(items, { + placeHolder: vscode.l10n.t('Select a language model'), + canPickMany: false, + + }).then(async (selected) => { + if (!selected) { + return; + } + if (selected.description === vscode.l10n.t('Add a new language model configuration')) { + showConfigurationDialog(context, storage); + } else { + const selectedConfig = modelConfigs.find((config) => config.name === selected.label); + if (selectedConfig) { + vscode.window.showInformationMessage( + vscode.l10n.t(`Selected language model: {0}`, selectedConfig.name) + ); + } + } + }); +} + +export async function showConfigurationDialog(context: vscode.ExtensionContext, storage: SecretStorage) { // Gather model sources const sources = [...languageModels, ...completionModels].map((provider) => provider.source); @@ -67,12 +176,11 @@ export async function showConfigurationDialog(context: vscode.ExtensionContext) // Store API key in secret storage if (apiKey) { - await context.secrets.store(`apiKey-${id}`, apiKey); + await storage.store(`apiKey-${id}`, apiKey); } // Get existing configurations - const config = vscode.workspace.getConfiguration('positron.assistant'); - const existingConfigs = config.get('models') || []; + const existingConfigs: Array = context.globalState.get('positron.assistant.models') || []; // Add new configuration const newConfig: StoredModelConfig = { @@ -83,11 +191,10 @@ export async function showConfigurationDialog(context: vscode.ExtensionContext) ...otherConfig, }; - // Update settings.json - await config.update( - 'models', - [...existingConfigs, newConfig], - vscode.ConfigurationTarget.Global + // Update global state + await context.globalState.update( + 'positron.assistant.models', + [...existingConfigs, newConfig] ); vscode.window.showInformationMessage( @@ -97,16 +204,14 @@ export async function showConfigurationDialog(context: vscode.ExtensionContext) } -export async function deleteConfiguration(context: vscode.ExtensionContext, id: string) { - const config = vscode.workspace.getConfiguration('positron.assistant'); - const existingConfigs = config.get('models') || []; +export async function deleteConfiguration(context: vscode.ExtensionContext, storage: SecretStorage, id: string) { + const existingConfigs: Array = context.globalState.get('positron.assistant.models') || []; const updatedConfigs = existingConfigs.filter(config => config.id !== id); - await config.update( - 'models', - updatedConfigs, - vscode.ConfigurationTarget.Global + await context.globalState.update( + 'positron.assistant.models', + updatedConfigs ); - await context.secrets.delete(`apiKey-${id}`); + await storage.delete(`apiKey-${id}`); } diff --git a/extensions/positron-assistant/src/extension.ts b/extensions/positron-assistant/src/extension.ts index fcb0c8b19ef..19e7ddbedf6 100644 --- a/extensions/positron-assistant/src/extension.ts +++ b/extensions/positron-assistant/src/extension.ts @@ -5,11 +5,12 @@ import * as vscode from 'vscode'; import * as positron from 'positron'; -import { getModelConfigurations, showConfigurationDialog } from './config'; +import { EncryptedSecretStorage, getModelConfigurations, GlobalSecretStorage, SecretStorage, showConfigurationDialog, showModelList } from './config'; import { newLanguageModel } from './models'; -import participants from './participants'; import { newCompletionProvider, registerHistoryTracking } from './completion'; import { editsProvider } from './edits'; +import { createParticipants } from './participants'; +import { register } from 'node:module'; const hasChatModelsContextKey = 'positron-assistant.hasChatModels'; @@ -27,12 +28,12 @@ function disposeParticipants() { participantDisposables = []; } -async function registerModels(context: vscode.ExtensionContext) { +async function registerModels(context: vscode.ExtensionContext, storage: SecretStorage) { // Dispose of existing models disposeModels(); try { - const modelConfigs = await getModelConfigurations(context); + const modelConfigs = await getModelConfigurations(context, storage); // Register with Language Model API modelConfigs.filter(config => config.type === 'chat').forEach((config, idx) => { // We need at least one default and one non-default model for the dropdown to appear. @@ -72,6 +73,7 @@ async function registerModels(context: vscode.ExtensionContext) { } function registerParticipants(context: vscode.ExtensionContext) { + const participants = createParticipants(context); Object.keys(participants).forEach(async (key) => { // Register agent with Positron Assistant API // Note: This is an alternative to a `package.json` definition that allows dynamic commands @@ -86,10 +88,18 @@ function registerParticipants(context: vscode.ExtensionContext) { }); } -function registerAddModelConfigurationCommand(context: vscode.ExtensionContext) { +function registerAddModelConfigurationCommand(context: vscode.ExtensionContext, storage: SecretStorage) { context.subscriptions.push( vscode.commands.registerCommand('positron-assistant.addModelConfiguration', () => { - showConfigurationDialog(context); + showConfigurationDialog(context, storage); + }) + ); +} + +function registerConfigureModelsCommand(context: vscode.ExtensionContext, storage: SecretStorage) { + context.subscriptions.push( + vscode.commands.registerCommand('positron-assistant.configureModels', () => { + showModelList(context, storage); }) ); } @@ -101,17 +111,25 @@ function registerMappedEditsProvider(context: vscode.ExtensionContext) { } function registerAssistant(context: vscode.ExtensionContext) { + + // Initialize secret storage. In web mode, we currently need to use global + // secret storage since encrypted storage is not available. + const storage = vscode.env.uiKind === vscode.UIKind.Web ? + new GlobalSecretStorage(context) : + new EncryptedSecretStorage(context); + // Register chat participants registerParticipants(context); // Register configured language models - registerModels(context); + registerModels(context, storage); // Track opened files for completion context registerHistoryTracking(context); - // Configuration modal command - registerAddModelConfigurationCommand(context); + // Commands + registerAddModelConfigurationCommand(context, storage); + registerConfigureModelsCommand(context, storage); // Register mapped edits provider registerMappedEditsProvider(context); @@ -120,7 +138,7 @@ function registerAssistant(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('positron.assistant.models')) { - registerModels(context); + registerModels(context, storage); } }) ); diff --git a/extensions/positron-assistant/src/participants.ts b/extensions/positron-assistant/src/participants.ts index d232ebef922..da65ea31bbd 100644 --- a/extensions/positron-assistant/src/participants.ts +++ b/extensions/positron-assistant/src/participants.ts @@ -16,8 +16,12 @@ import { defaultHandler } from './commands/default'; const mdDir = `${EXTENSION_ROOT_DIR}/src/md/`; class PositronAssistantParticipant implements positron.ai.ChatParticipant { + readonly _context: vscode.ExtensionContext; + constructor(context: vscode.ExtensionContext) { + this._context = context; + } readonly id = 'positron.positron-assistant'; - readonly iconPath = new vscode.ThemeIcon('positron-posit-logo'); + readonly iconPath = new vscode.ThemeIcon('positron-assistant'); readonly agentData: positron.ai.ChatAgentData = { id: this.id, name: 'positron-assistant', @@ -73,13 +77,13 @@ class PositronAssistantParticipant implements positron.ai.ChatParticipant { }; readonly welcomeMessageProvider = { - async provideWelcomeMessage(token: vscode.CancellationToken) { + provideWelcomeMessage: async (token: vscode.CancellationToken) => { let welcomeText = await fs.promises.readFile(`${mdDir}/welcome.md`, 'utf8'); const addLanguageModelMessage = vscode.l10n.t('Add a Language Model.'); // Show an extra configuration link if there are no configured models yet - if (getStoredModels().length === 0) { + if (getStoredModels(this._context).length === 0) { const commandUri = vscode.Uri.parse('command:positron.assistant.addModelConfiguration'); welcomeText += `\n\n[${addLanguageModelMessage}](${commandUri})`; } @@ -88,7 +92,7 @@ class PositronAssistantParticipant implements positron.ai.ChatParticipant { message.isTrusted = true; return { - icon: new vscode.ThemeIcon('positron-posit-logo'), + icon: new vscode.ThemeIcon('positron-assistant'), title: 'Positron Assistant', message, }; @@ -108,7 +112,9 @@ class PositronAssistantParticipant implements positron.ai.ChatParticipant { dispose(): void { } } -const participants: Record = { - 'positron-assistant': new PositronAssistantParticipant(), -}; -export default participants; +export function createParticipants(context: vscode.ExtensionContext): Record { + return { + 'positron-assistant': new PositronAssistantParticipant(context), + }; +} + diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 5f7681bf9d6..0aee032070b 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -74,7 +74,6 @@ "id": "api-test.participant", "name": "participant", "description": "test", - "isDefault": true, "commands": [ { "name": "hello", diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf index 6fea3baed01..ce87bb672a8 100644 Binary files a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index e3823109c4b..00b5d42f871 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -680,4 +680,5 @@ export const codiconsLibrary = { positronStatusActive: register('positron-status-active', 0xf28c), positronStatusDisconnected: register('positron-status-disconnected', 0xf28d), positronStatusIdle: register('positron-status-idle', 0xf28e), + positronAssistant: register('positron-assistant', 0xf28f), } as const;