diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index 4ff6a6bd62..f33c0a051f 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -40,6 +40,26 @@ devtools_module("ai_chat") { "ui/ToolDescriptionFormatter.ts", "ui/HelpDialog.ts", "ui/SettingsDialog.ts", + "ui/settings/types.ts", + "ui/settings/constants.ts", + "ui/settings/i18n-strings.ts", + "ui/settings/utils/validation.ts", + "ui/settings/utils/storage.ts", + "ui/settings/utils/styles.ts", + "ui/settings/components/ModelSelectorFactory.ts", + "ui/settings/components/SettingsHeader.ts", + "ui/settings/components/SettingsFooter.ts", + "ui/settings/components/AdvancedToggle.ts", + "ui/settings/providers/BaseProviderSettings.ts", + "ui/settings/providers/OpenAISettings.ts", + "ui/settings/providers/LiteLLMSettings.ts", + "ui/settings/providers/GroqSettings.ts", + "ui/settings/providers/OpenRouterSettings.ts", + "ui/settings/advanced/MCPSettings.ts", + "ui/settings/advanced/BrowsingHistorySettings.ts", + "ui/settings/advanced/VectorDBSettings.ts", + "ui/settings/advanced/TracingSettings.ts", + "ui/settings/advanced/EvaluationSettings.ts", "ui/PromptEditDialog.ts", "ui/EvaluationDialog.ts", "ui/WebAppCodeViewer.ts", @@ -206,6 +226,26 @@ _ai_chat_sources = [ "ui/HelpDialog.ts", "ui/PromptEditDialog.ts", "ui/SettingsDialog.ts", + "ui/settings/types.ts", + "ui/settings/constants.ts", + "ui/settings/i18n-strings.ts", + "ui/settings/utils/validation.ts", + "ui/settings/utils/storage.ts", + "ui/settings/utils/styles.ts", + "ui/settings/components/ModelSelectorFactory.ts", + "ui/settings/components/SettingsHeader.ts", + "ui/settings/components/SettingsFooter.ts", + "ui/settings/components/AdvancedToggle.ts", + "ui/settings/providers/BaseProviderSettings.ts", + "ui/settings/providers/OpenAISettings.ts", + "ui/settings/providers/LiteLLMSettings.ts", + "ui/settings/providers/GroqSettings.ts", + "ui/settings/providers/OpenRouterSettings.ts", + "ui/settings/advanced/MCPSettings.ts", + "ui/settings/advanced/BrowsingHistorySettings.ts", + "ui/settings/advanced/VectorDBSettings.ts", + "ui/settings/advanced/TracingSettings.ts", + "ui/settings/advanced/EvaluationSettings.ts", "ui/EvaluationDialog.ts", "ui/WebAppCodeViewer.ts", "ui/mcp/MCPConnectionsDialog.ts", diff --git a/front_end/panels/ai_chat/ui/SettingsDialog.ts b/front_end/panels/ai_chat/ui/SettingsDialog.ts index 24f1e373ce..391aa6e092 100644 --- a/front_end/panels/ai_chat/ui/SettingsDialog.ts +++ b/front_end/panels/ai_chat/ui/SettingsDialog.ts @@ -2,531 +2,50 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import * as i18n from '../../../core/i18n/i18n.js'; import * as UI from '../../../ui/legacy/legacy.js'; -import { getEvaluationConfig, setEvaluationConfig, isEvaluationEnabled, connectToEvaluationService, disconnectFromEvaluationService, getEvaluationClientId, isEvaluationConnected } from '../common/EvaluationConfig.js'; import { createLogger } from '../core/Logger.js'; import { LLMClient } from '../LLM/LLMClient.js'; -import { getTracingConfig, setTracingConfig, isTracingEnabled } from '../tracing/TracingConfig.js'; -import { getMCPConfig, setMCPConfig, isMCPEnabled, hasStoredAuthErrors, getStoredAuthErrors, clearStoredAuthError } from '../mcp/MCPConfig.js'; -import { MCPRegistry } from '../mcp/MCPRegistry.js'; -import { MCPConnectionsDialog } from './mcp/MCPConnectionsDialog.js'; -import { DEFAULT_PROVIDER_MODELS } from './AIChatPanel.js'; +// Import settings utilities +import { i18nString } from './settings/i18n-strings.js'; +import { PROVIDER_SELECTION_KEY, MINI_MODEL_STORAGE_KEY, NANO_MODEL_STORAGE_KEY, ADVANCED_SETTINGS_ENABLED_KEY } from './settings/constants.js'; +import { applySettingsStyles } from './settings/utils/styles.js'; +import { isVectorDBEnabled } from './settings/utils/storage.js'; +import type { ModelOption, ProviderType, FetchLiteLLMModelsFunction, UpdateModelOptionsFunction, GetModelOptionsFunction, AddCustomModelOptionFunction, RemoveCustomModelOptionFunction } from './settings/types.js'; + +// Re-export for backward compatibility +export { isVectorDBEnabled }; + +// Import provider settings classes +import { OpenAISettings } from './settings/providers/OpenAISettings.js'; +import { LiteLLMSettings } from './settings/providers/LiteLLMSettings.js'; +import { GroqSettings } from './settings/providers/GroqSettings.js'; +import { OpenRouterSettings } from './settings/providers/OpenRouterSettings.js'; + +// Import advanced feature settings classes +import { MCPSettings } from './settings/advanced/MCPSettings.js'; +import { BrowsingHistorySettings } from './settings/advanced/BrowsingHistorySettings.js'; +import { VectorDBSettings } from './settings/advanced/VectorDBSettings.js'; +import { TracingSettings } from './settings/advanced/TracingSettings.js'; +import { EvaluationSettings } from './settings/advanced/EvaluationSettings.js'; + import './model_selector/ModelSelector.js'; const logger = createLogger('SettingsDialog'); -// Model type definition -interface ModelOption { - value: string; - label: string; - type: 'openai' | 'litellm' | 'groq' | 'openrouter'; -} - -// Local storage keys -const MINI_MODEL_STORAGE_KEY = 'ai_chat_mini_model'; -const NANO_MODEL_STORAGE_KEY = 'ai_chat_nano_model'; -const LITELLM_ENDPOINT_KEY = 'ai_chat_litellm_endpoint'; -const LITELLM_API_KEY_STORAGE_KEY = 'ai_chat_litellm_api_key'; -const GROQ_API_KEY_STORAGE_KEY = 'ai_chat_groq_api_key'; -const OPENROUTER_API_KEY_STORAGE_KEY = 'ai_chat_openrouter_api_key'; -const PROVIDER_SELECTION_KEY = 'ai_chat_provider'; - -// Cache constants -const OPENROUTER_MODELS_CACHE_DURATION_MS = 60 * 60 * 1000; // 60 minutes -// Vector DB configuration keys - Milvus format -const VECTOR_DB_ENABLED_KEY = 'ai_chat_vector_db_enabled'; -const MILVUS_ENDPOINT_KEY = 'ai_chat_milvus_endpoint'; -const MILVUS_USERNAME_KEY = 'ai_chat_milvus_username'; -const MILVUS_PASSWORD_KEY = 'ai_chat_milvus_password'; -const MILVUS_COLLECTION_KEY = 'ai_chat_milvus_collection'; -const MILVUS_OPENAI_KEY = 'ai_chat_milvus_openai_key'; - -// UI Strings -const UIStrings = { - /** - *@description Settings dialog title - */ - settings: 'Settings', - /** - *@description Provider selection label - */ - providerLabel: 'Provider', - /** - *@description Provider selection hint - */ - providerHint: 'Select which AI provider to use', - /** - *@description OpenAI provider option - */ - openaiProvider: 'OpenAI', - /** - *@description LiteLLM provider option - */ - litellmProvider: 'LiteLLM', - /** - *@description Groq provider option - */ - groqProvider: 'Groq', - /** - *@description OpenRouter provider option - */ - openrouterProvider: 'OpenRouter', - /** - *@description LiteLLM API Key label - */ - liteLLMApiKey: 'LiteLLM API Key', - /** - *@description LiteLLM API Key hint - */ - liteLLMApiKeyHint: 'Your LiteLLM API key for authentication (optional)', - /** - *@description LiteLLM endpoint label - */ - litellmEndpointLabel: 'LiteLLM Endpoint', - /** - *@description LiteLLM endpoint hint - */ - litellmEndpointHint: 'Enter the URL for your LiteLLM server (e.g., http://localhost:4000 or https://your-litellm-server.com)', - /** - *@description Groq API Key label - */ - groqApiKeyLabel: 'Groq API Key', - /** - *@description Groq API Key hint - */ - groqApiKeyHint: 'Your Groq API key for authentication', - /** - *@description Fetch Groq models button text - */ - fetchGroqModelsButton: 'Fetch Groq Models', - /** - *@description OpenRouter API Key label - */ - openrouterApiKeyLabel: 'OpenRouter API Key', - /** - *@description OpenRouter API Key hint - */ - openrouterApiKeyHint: 'Your OpenRouter API key for authentication', - /** - *@description Fetch OpenRouter models button text - */ - fetchOpenRouterModelsButton: 'Fetch OpenRouter Models', - /** - *@description OpenAI API Key label - */ - apiKeyLabel: 'OpenAI API Key', - /** - *@description OpenAI API Key hint - */ - apiKeyHint: 'An OpenAI API key is required for OpenAI models (GPT-4.1, O4 Mini, etc.)', - /** - *@description Test button text - */ - testButton: 'Test', - /** - *@description Add button text - */ - addButton: 'Add', - /** - *@description Remove button text - */ - removeButton: 'Remove', - /** - *@description Fetch models button text - */ - fetchModelsButton: 'Fetch LiteLLM Models', - /** - *@description Fetching models status - */ - fetchingModels: 'Fetching models...', - /** - *@description Wildcard models only message - */ - wildcardModelsOnly: 'LiteLLM proxy returned wildcard model only. Please add custom models below.', - /** - *@description Wildcard and custom models message - */ - wildcardAndCustomModels: 'Fetched wildcard model (custom models available)', - /** - *@description Wildcard and other models message with count - */ - wildcardAndOtherModels: 'Fetched {PH1} models plus wildcard', - /** - *@description Fetched models message with count - */ - fetchedModels: 'Fetched {PH1} models', - /** - *@description LiteLLM endpoint required error - */ - endpointRequired: 'LiteLLM endpoint is required to test model', - /** - *@description Custom models label - */ - customModelsLabel: 'Custom Models', - /** - *@description Custom models hint - */ - customModelsHint: 'Add custom models one at a time.', - /** - *@description Mini model label - */ - miniModelLabel: 'Mini Model', - /** - *@description Mini model description - */ - miniModelDescription: 'Used for fast operations, tools, and sub-tasks', - /** - *@description Nano model label - */ - nanoModelLabel: 'Nano Model', - /** - *@description Nano model description - */ - nanoModelDescription: 'Used for very fast operations and simple tasks', - /** - *@description Default mini model option - */ - defaultMiniOption: 'Use default (main model)', - /** - *@description Default nano model option - */ - defaultNanoOption: 'Use default (mini model or main model)', - /** - *@description Browsing history section title - */ - browsingHistoryTitle: 'Browsing History', - /** - *@description Browsing history description - */ - browsingHistoryDescription: 'Your browsing history is stored locally to enable search by domains and keywords.', - /** - *@description Clear browsing history button - */ - clearHistoryButton: 'Clear Browsing History', - /** - *@description History cleared message - */ - historyCleared: 'Browsing history cleared successfully', - /** - *@description Important notice title - */ - importantNotice: 'Important Notice', - /** - *@description Vector DB section label - */ - vectorDBLabel: 'Vector Database Configuration', - /** - *@description Vector DB enabled label - */ - vectorDBEnabled: 'Enable Vector Database', - /** - *@description Vector DB enabled hint - */ - vectorDBEnabledHint: 'Enable Vector Database for semantic search of websites', - /** - *@description Milvus endpoint label - */ - vectorDBEndpoint: 'Milvus Endpoint', - /** - *@description Milvus endpoint hint - */ - vectorDBEndpointHint: 'Enter the URL for your Milvus server (e.g., http://localhost:19530 or https://your-milvus.com)', - /** - *@description Milvus username label - */ - vectorDBApiKey: 'Milvus Username', - /** - *@description Milvus username hint - */ - vectorDBApiKeyHint: 'For self-hosted: username (default: root). For Milvus Cloud: leave as root', - /** - *@description Vector DB collection label - */ - vectorDBCollection: 'Collection Name', - /** - *@description Vector DB collection hint - */ - vectorDBCollectionHint: 'Name of the collection to store websites (default: bookmarks)', - /** - *@description Milvus password/token label - */ - milvusPassword: 'Password/API Token', - /** - *@description Milvus password/token hint - */ - milvusPasswordHint: 'For self-hosted: password (default: Milvus). For Milvus Cloud: API token directly', - /** - *@description OpenAI API key for embeddings label - */ - milvusOpenAIKey: 'OpenAI API Key (for embeddings)', - /** - *@description OpenAI API key for embeddings hint - */ - milvusOpenAIKeyHint: 'Required for generating embeddings using OpenAI text-embedding-3-small model', - /** - *@description Test vector DB connection button - */ - testVectorDBConnection: 'Test Connection', - /** - *@description Vector DB connection testing status - */ - testingVectorDBConnection: 'Testing connection...', - /** - *@description Vector DB connection success message - */ - vectorDBConnectionSuccess: 'Vector DB connection successful!', - /** - *@description Vector DB connection failed message - */ - vectorDBConnectionFailed: 'Vector DB connection failed', - /** - *@description Tracing section title - */ - tracingSection: 'Tracing Configuration', - /** - *@description Tracing enabled label - */ - tracingEnabled: 'Enable Tracing', - /** - *@description Tracing enabled hint - */ - tracingEnabledHint: 'Enable observability tracing for AI Chat interactions', - /** - *@description Langfuse endpoint label - */ - langfuseEndpoint: 'Langfuse Endpoint', - /** - *@description Langfuse endpoint hint - */ - langfuseEndpointHint: 'URL of your Langfuse server (e.g., http://localhost:3000)', - /** - *@description Langfuse public key label - */ - langfusePublicKey: 'Langfuse Public Key', - /** - *@description Langfuse public key hint - */ - langfusePublicKeyHint: 'Your Langfuse project public key (starts with pk-lf-)', - /** - *@description Langfuse secret key label - */ - langfuseSecretKey: 'Langfuse Secret Key', - /** - *@description Langfuse secret key hint - */ - langfuseSecretKeyHint: 'Your Langfuse project secret key (starts with sk-lf-)', - /** - *@description Test tracing button - */ - testTracing: 'Test Connection', - /** - *@description Evaluation section title - */ - evaluationSection: 'Evaluation Configuration', - /** - *@description Evaluation enabled label - */ - evaluationEnabled: 'Enable Evaluation', - /** - *@description Evaluation enabled hint - */ - evaluationEnabledHint: 'Enable evaluation service connection for AI Chat interactions', - /** - *@description Evaluation endpoint label - */ - evaluationEndpoint: 'Evaluation Endpoint', - /** - *@description Evaluation endpoint hint - */ - evaluationEndpointHint: 'WebSocket endpoint for the evaluation service (e.g., ws://localhost:8080)', - /** - *@description Evaluation secret key label - */ - evaluationSecretKey: 'Evaluation Secret Key', - /** - *@description Evaluation secret key hint - */ - evaluationSecretKeyHint: 'Secret key for authentication with the evaluation service (optional)', - /** - *@description Evaluation connection status - */ - evaluationConnectionStatus: 'Connection Status', - /** - *@description MCP section title - */ - mcpSection: 'MCP Integration', - /** - *@description MCP enabled label - */ - mcpEnabled: 'Enable MCP Integration', - /** - *@description MCP enabled hint - */ - mcpEnabledHint: 'Enable MCP client to discover and call tools via Model Context Protocol', - /** - *@description MCP connections header label - */ - mcpConnectionsHeader: 'Connections', - /** - *@description MCP connections hint text - */ - mcpConnectionsHint: 'Configure one or more MCP servers. OAuth flows use PKCE automatically.', - /** - *@description MCP manage connections button text - */ - mcpManageConnections: 'Manage connections', - /** - *@description MCP refresh connections button text - */ - mcpRefreshConnections: 'Reconnect all', - /** - *@description MCP individual reconnect button text - */ - mcpReconnectButton: 'Reconnect', - /** - *@description MCP individual reconnect button text while in progress - */ - mcpReconnectInProgress: 'Reconnecting…', - /** - *@description MCP individual reconnect button failure state text - */ - mcpReconnectRetry: 'Retry reconnect', - /** - *@description MCP discovered tools label - */ - mcpDiscoveredTools: 'Discovered Tools', - /** - *@description MCP discovered tools hint - */ - mcpDiscoveredToolsHint: 'Select which MCP tools to make available to agents', - /** - *@description MCP no tools message - */ - mcpNoTools: 'No tools discovered. Connect to an MCP server first.', - - /** - *@description MCP tool mode label - */ - mcpToolMode: 'Tool Selection Mode', - /** - *@description MCP tool mode hint - */ - mcpToolModeHint: 'Choose how MCP tools are selected and surfaced to agents', - /** - *@description MCP tool mode all option - */ - mcpToolModeAll: 'All Tools - Surface all available MCP tools (may impact performance)', - /** - *@description MCP tool mode router option - */ - mcpToolModeRouter: 'Smart Router - Use LLM to select most relevant tools each turn (recommended)', - /** - *@description MCP tool mode meta option - */ - mcpToolModeMeta: 'Meta Tools - Use mcp.search/mcp.invoke for dynamic discovery (best for large catalogs)', - /** - *@description MCP max tools per turn label - */ - mcpMaxToolsPerTurn: 'Max Tools Per Turn', - /** - *@description MCP max tools per turn hint - */ - mcpMaxToolsPerTurnHint: 'Maximum number of tools to surface to agents in a single turn (default: 20)', - /** - *@description MCP max MCP tools per turn label - */ - mcpMaxMcpPerTurn: 'Max MCP Tools Per Turn', - /** - *@description MCP max MCP tools per turn hint - */ - mcpMaxMcpPerTurnHint: 'Maximum number of MCP tools to include in tool selection (default: 8)', - /** - *@description MCP auth type label - */ - mcpAuthType: 'Authentication Method', - /** - *@description MCP auth type hint - */ - mcpAuthTypeHint: 'Choose how to authenticate with your MCP server', - /** - *@description MCP bearer option - */ - mcpAuthBearer: 'Bearer token', - /** - *@description MCP OAuth option - */ - mcpAuthOAuth: 'OAuth (redirect to provider)', - /** - *@description MCP OAuth client ID label - */ - mcpOAuthClientId: 'OAuth Client ID', - /** - *@description MCP OAuth client ID hint - */ - mcpOAuthClientIdHint: 'Pre-registered public client ID for this MCP server (no secret).', - /** - *@description MCP OAuth redirect URL label - */ - mcpOAuthRedirect: 'OAuth Redirect URL', - /** - *@description MCP OAuth redirect URL hint - */ - mcpOAuthRedirectHint: 'Must match the redirect URI registered with the provider (default: https://localhost:3000/callback).', - /** - *@description MCP OAuth scope label - */ - mcpOAuthScope: 'OAuth Scope (optional)', - /** - *@description MCP OAuth scope hint - */ - mcpOAuthScopeHint: 'Provider-specific scopes, space-separated. Leave empty if unsure.', -}; - -const str_ = i18n.i18n.registerUIStrings('panels/ai_chat/ui/SettingsDialog.ts', UIStrings); -const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); - -// Helper function to check if Vector DB is enabled -export function isVectorDBEnabled(): boolean { - return localStorage.getItem(VECTOR_DB_ENABLED_KEY) === 'true'; -} - export class SettingsDialog { - // Variables to store direct references to model selectors - static #openaiMiniModelSelect: any | null = null; - static #openaiNanoModelSelect: any | null = null; - static #litellmMiniModelSelect: any | null = null; - static #litellmNanoModelSelect: any | null = null; - static #groqMiniModelSelect: any | null = null; - static #groqNanoModelSelect: any | null = null; - static #openrouterMiniModelSelect: any | null = null; - static #openrouterNanoModelSelect: any | null = null; - static async show( selectedModel: string, miniModel: string, nanoModel: string, onSettingsSaved: () => void, - fetchLiteLLMModels: (apiKey: string|null, endpoint?: string) => Promise<{models: ModelOption[], hadWildcard: boolean}>, - updateModelOptions: (litellmModels: ModelOption[], hadWildcard?: boolean) => void, - getModelOptions: (provider?: 'openai' | 'litellm' | 'groq' | 'openrouter') => ModelOption[], - addCustomModelOption: (modelName: string, modelType?: 'openai' | 'litellm' | 'groq' | 'openrouter') => ModelOption[], - removeCustomModelOption: (modelName: string) => ModelOption[], + fetchLiteLLMModels: FetchLiteLLMModelsFunction, + updateModelOptions: UpdateModelOptionsFunction, + getModelOptions: GetModelOptionsFunction, + addCustomModelOption: AddCustomModelOptionFunction, + removeCustomModelOption: RemoveCustomModelOptionFunction, ): Promise { - - // Get all model options using the provided getModelOptions function - const modelOptions = getModelOptions(); - - // Count models by provider - const openaiModels = modelOptions.filter(m => m.type === 'openai'); - const litellmModels = modelOptions.filter(m => m.type === 'litellm'); - - // Reset selector references - SettingsDialog.#openaiMiniModelSelect = null; - SettingsDialog.#openaiNanoModelSelect = null; - SettingsDialog.#litellmMiniModelSelect = null; - SettingsDialog.#litellmNanoModelSelect = null; + // Create a settings dialog const dialog = new UI.Dialog.Dialog(); dialog.setDimmed(true); @@ -538,117 +57,166 @@ export class SettingsDialog { contentDiv.className = 'settings-content'; contentDiv.style.overflowY = 'auto'; dialog.contentElement.appendChild(contentDiv); - + // Create header const headerDiv = document.createElement('div'); headerDiv.className = 'settings-header'; contentDiv.appendChild(headerDiv); - + const title = document.createElement('h2'); title.className = 'settings-title'; - title.textContent = i18nString(UIStrings.settings); + title.textContent = i18nString('settings'); headerDiv.appendChild(title); - + const closeButton = document.createElement('button'); closeButton.className = 'settings-close-button'; closeButton.setAttribute('aria-label', 'Close settings'); closeButton.textContent = '×'; closeButton.addEventListener('click', () => dialog.hide()); headerDiv.appendChild(closeButton); - + // Add provider selection dropdown const providerSection = document.createElement('div'); providerSection.className = 'provider-selection-section'; contentDiv.appendChild(providerSection); - + const providerLabel = document.createElement('div'); providerLabel.className = 'settings-label'; - providerLabel.textContent = i18nString(UIStrings.providerLabel); + providerLabel.textContent = i18nString('providerLabel'); providerSection.appendChild(providerLabel); - + const providerHint = document.createElement('div'); providerHint.className = 'settings-hint'; - providerHint.textContent = i18nString(UIStrings.providerHint); + providerHint.textContent = i18nString('providerHint'); providerSection.appendChild(providerHint); - + // Use the stored provider from localStorage - const currentProvider = (localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai') as 'openai' | 'litellm' | 'groq' | 'openrouter'; - + const currentProvider = (localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai') as ProviderType; + // Create provider selection dropdown const providerSelect = document.createElement('select'); providerSelect.className = 'settings-select provider-select'; providerSection.appendChild(providerSelect); - + // Add options to the dropdown const openaiOption = document.createElement('option'); openaiOption.value = 'openai'; - openaiOption.textContent = i18nString(UIStrings.openaiProvider); + openaiOption.textContent = i18nString('openaiProvider'); openaiOption.selected = currentProvider === 'openai'; providerSelect.appendChild(openaiOption); - + const litellmOption = document.createElement('option'); litellmOption.value = 'litellm'; - litellmOption.textContent = i18nString(UIStrings.litellmProvider); + litellmOption.textContent = i18nString('litellmProvider'); litellmOption.selected = currentProvider === 'litellm'; providerSelect.appendChild(litellmOption); - + const groqOption = document.createElement('option'); groqOption.value = 'groq'; - groqOption.textContent = i18nString(UIStrings.groqProvider); + groqOption.textContent = i18nString('groqProvider'); groqOption.selected = currentProvider === 'groq'; providerSelect.appendChild(groqOption); - + const openrouterOption = document.createElement('option'); openrouterOption.value = 'openrouter'; - openrouterOption.textContent = i18nString(UIStrings.openrouterProvider); + openrouterOption.textContent = i18nString('openrouterProvider'); openrouterOption.selected = currentProvider === 'openrouter'; providerSelect.appendChild(openrouterOption); // Ensure the select's value reflects the computed currentProvider providerSelect.value = currentProvider; - + // Create provider-specific content containers const openaiContent = document.createElement('div'); openaiContent.className = 'provider-content openai-content'; openaiContent.style.display = currentProvider === 'openai' ? 'block' : 'none'; contentDiv.appendChild(openaiContent); - + const litellmContent = document.createElement('div'); litellmContent.className = 'provider-content litellm-content'; litellmContent.style.display = currentProvider === 'litellm' ? 'block' : 'none'; contentDiv.appendChild(litellmContent); - + const groqContent = document.createElement('div'); groqContent.className = 'provider-content groq-content'; groqContent.style.display = currentProvider === 'groq' ? 'block' : 'none'; contentDiv.appendChild(groqContent); - + const openrouterContent = document.createElement('div'); openrouterContent.className = 'provider-content openrouter-content'; openrouterContent.style.display = currentProvider === 'openrouter' ? 'block' : 'none'; contentDiv.appendChild(openrouterContent); - + + // Instantiate provider settings classes + const openaiSettings = new OpenAISettings( + openaiContent, + getModelOptions, + addCustomModelOption, + removeCustomModelOption + ); + + const litellmSettings = new LiteLLMSettings( + litellmContent, + getModelOptions, + addCustomModelOption, + removeCustomModelOption, + updateModelOptions, + fetchLiteLLMModels + ); + + const groqSettings = new GroqSettings( + groqContent, + getModelOptions, + addCustomModelOption, + removeCustomModelOption, + updateModelOptions + ); + + const openrouterSettings = new OpenRouterSettings( + openrouterContent, + getModelOptions, + addCustomModelOption, + removeCustomModelOption, + updateModelOptions, + onSettingsSaved, + () => dialog.hide() + ); + + // Render all providers (only visible one will be shown) + openaiSettings.render(); + litellmSettings.render(); + groqSettings.render(); + openrouterSettings.render(); + + // Store provider settings for later access + const providerSettings = new Map([ + ['openai', openaiSettings], + ['litellm', litellmSettings], + ['groq', groqSettings], + ['openrouter', openrouterSettings], + ]); + // Event listener for provider change providerSelect.addEventListener('change', async () => { - const selectedProvider = providerSelect.value; - + const selectedProvider = providerSelect.value as ProviderType; + // Toggle visibility of provider content openaiContent.style.display = selectedProvider === 'openai' ? 'block' : 'none'; litellmContent.style.display = selectedProvider === 'litellm' ? 'block' : 'none'; groqContent.style.display = selectedProvider === 'groq' ? 'block' : 'none'; openrouterContent.style.display = selectedProvider === 'openrouter' ? 'block' : 'none'; - // If switching to LiteLLM, fetch the latest models if endpoint is configured if (selectedProvider === 'litellm') { - const endpoint = litellmEndpointInput.value.trim(); - const liteLLMApiKey = litellmApiKeyInput.value.trim() || localStorage.getItem(LITELLM_API_KEY_STORAGE_KEY) || ''; - + const endpoint = localStorage.getItem('ai_chat_litellm_endpoint'); + const liteLLMApiKey = localStorage.getItem('ai_chat_litellm_api_key') || ''; + if (endpoint) { try { logger.debug('Fetching LiteLLM models after provider change...'); const { models: litellmModels, hadWildcard } = await fetchLiteLLMModels(liteLLMApiKey, endpoint); updateModelOptions(litellmModels, hadWildcard); + litellmSettings.updateModelSelectors(); logger.debug('Successfully refreshed LiteLLM models after provider change'); } catch (error) { logger.error('Failed to fetch LiteLLM models after provider change:', error); @@ -656,8 +224,8 @@ export class SettingsDialog { } } else if (selectedProvider === 'groq') { // If switching to Groq, fetch models if API key is configured - const groqApiKey = groqApiKeyInput.value.trim() || localStorage.getItem('ai_chat_groq_api_key') || ''; - + const groqApiKey = localStorage.getItem('ai_chat_groq_api_key') || ''; + if (groqApiKey) { try { logger.debug('Fetching Groq models after provider change...'); @@ -668,6 +236,7 @@ export class SettingsDialog { type: 'groq' as const })); updateModelOptions(modelOptions, false); + groqSettings.updateModelSelectors(); logger.debug('Successfully refreshed Groq models after provider change'); } catch (error) { logger.error('Failed to fetch Groq models after provider change:', error); @@ -675,8 +244,8 @@ export class SettingsDialog { } } else if (selectedProvider === 'openrouter') { // If switching to OpenRouter, fetch models if API key is configured - const openrouterApiKey = openrouterApiKeyInput.value.trim() || localStorage.getItem('ai_chat_openrouter_api_key') || ''; - + const openrouterApiKey = localStorage.getItem('ai_chat_openrouter_api_key') || ''; + if (openrouterApiKey) { try { logger.debug('Fetching OpenRouter models after provider change...'); @@ -690,3337 +259,241 @@ export class SettingsDialog { // Persist cache alongside timestamp for consistency localStorage.setItem('openrouter_models_cache', JSON.stringify(modelOptions)); localStorage.setItem('openrouter_models_cache_timestamp', Date.now().toString()); + openrouterSettings.updateModelSelectors(); logger.debug('Successfully refreshed OpenRouter models after provider change'); } catch (error) { logger.error('Failed to fetch OpenRouter models after provider change:', error); } } } + }); - // Get model options filtered by the selected provider - const availableModels = getModelOptions(selectedProvider as 'openai' | 'litellm' | 'groq' | 'openrouter'); - - - // Refresh model selectors based on new provider - if (selectedProvider === 'openai') { - // Use our reusable function to update OpenAI model selectors - updateOpenAIModelSelectors(); - } else if (selectedProvider === 'litellm') { - // Make sure LiteLLM selectors are updated - updateLiteLLMModelSelectors(); - } else if (selectedProvider === 'groq') { - // Update Groq selectors - updateGroqModelSelectors(); - } else if (selectedProvider === 'openrouter') { - // Update OpenRouter selectors - await updateOpenRouterModelSelectors(); - } - }); - - // Setup OpenAI content - const openaiSettingsSection = document.createElement('div'); - openaiSettingsSection.className = 'settings-section'; - openaiContent.appendChild(openaiSettingsSection); - - const apiKeyLabel = document.createElement('div'); - apiKeyLabel.className = 'settings-label'; - apiKeyLabel.textContent = i18nString(UIStrings.apiKeyLabel); - openaiSettingsSection.appendChild(apiKeyLabel); - - const apiKeyHint = document.createElement('div'); - apiKeyHint.className = 'settings-hint'; - apiKeyHint.textContent = i18nString(UIStrings.apiKeyHint); - openaiSettingsSection.appendChild(apiKeyHint); - - const settingsSavedApiKey = localStorage.getItem('ai_chat_api_key') || ''; - const settingsApiKeyInput = document.createElement('input'); - settingsApiKeyInput.className = 'settings-input'; - settingsApiKeyInput.type = 'password'; - settingsApiKeyInput.placeholder = 'Enter your OpenAI API key'; - settingsApiKeyInput.value = settingsSavedApiKey; - openaiSettingsSection.appendChild(settingsApiKeyInput); - - const settingsApiKeyStatus = document.createElement('div'); - settingsApiKeyStatus.className = 'settings-status'; - settingsApiKeyStatus.style.display = 'none'; - openaiSettingsSection.appendChild(settingsApiKeyStatus); - - // Function to update OpenAI model selectors - function updateOpenAIModelSelectors() { - - // Get the latest model options filtered for OpenAI provider - const openaiModels = getModelOptions('openai'); - - // Get valid models using generic helper - const validMiniModel = getValidModelForProvider(miniModel, openaiModels, 'openai', 'mini'); - const validNanoModel = getValidModelForProvider(nanoModel, openaiModels, 'openai', 'nano'); - - // Clear any existing model selectors - const existingSelectors = openaiContent.querySelectorAll('.model-selection-section'); - existingSelectors.forEach(selector => selector.remove()); - - // Create a new model selection section - const openaiModelSection = document.createElement('div'); - openaiModelSection.className = 'settings-section model-selection-section'; - openaiContent.appendChild(openaiModelSection); - - const openaiModelSectionTitle = document.createElement('h3'); - openaiModelSectionTitle.className = 'settings-subtitle'; - openaiModelSectionTitle.textContent = 'Model Size Selection'; - openaiModelSection.appendChild(openaiModelSectionTitle); - - - // No focus handler needed for OpenAI selectors as we don't need to fetch models on focus - - // Create OpenAI Mini Model selection and store reference - SettingsDialog.#openaiMiniModelSelect = createModelSelector( - openaiModelSection, - i18nString(UIStrings.miniModelLabel), - i18nString(UIStrings.miniModelDescription), - 'mini-model-select', - openaiModels, - validMiniModel, - i18nString(UIStrings.defaultMiniOption), - undefined // No focus handler for OpenAI - ); - - - // Create OpenAI Nano Model selection and store reference - SettingsDialog.#openaiNanoModelSelect = createModelSelector( - openaiModelSection, - i18nString(UIStrings.nanoModelLabel), - i18nString(UIStrings.nanoModelDescription), - 'nano-model-select', - openaiModels, - validNanoModel, - i18nString(UIStrings.defaultNanoOption), - undefined // No focus handler for OpenAI - ); - - } - - // Initialize OpenAI model selectors - updateOpenAIModelSelectors(); - - // Setup LiteLLM content - const litellmSettingsSection = document.createElement('div'); - litellmSettingsSection.className = 'settings-section'; - litellmContent.appendChild(litellmSettingsSection); - - // LiteLLM endpoint - const litellmEndpointLabel = document.createElement('div'); - litellmEndpointLabel.className = 'settings-label'; - litellmEndpointLabel.textContent = i18nString(UIStrings.litellmEndpointLabel); - litellmSettingsSection.appendChild(litellmEndpointLabel); - - const litellmEndpointHint = document.createElement('div'); - litellmEndpointHint.className = 'settings-hint'; - litellmEndpointHint.textContent = i18nString(UIStrings.litellmEndpointHint); - litellmSettingsSection.appendChild(litellmEndpointHint); - - const settingsSavedLiteLLMEndpoint = localStorage.getItem(LITELLM_ENDPOINT_KEY) || ''; - const litellmEndpointInput = document.createElement('input'); - litellmEndpointInput.className = 'settings-input litellm-endpoint-input'; - litellmEndpointInput.type = 'text'; - litellmEndpointInput.placeholder = 'http://localhost:4000'; - litellmEndpointInput.value = settingsSavedLiteLLMEndpoint; - litellmSettingsSection.appendChild(litellmEndpointInput); - - // LiteLLM API Key - const litellmAPIKeyLabel = document.createElement('div'); - litellmAPIKeyLabel.className = 'settings-label'; - litellmAPIKeyLabel.textContent = i18nString(UIStrings.liteLLMApiKey); - litellmSettingsSection.appendChild(litellmAPIKeyLabel); - - const litellmAPIKeyHint = document.createElement('div'); - litellmAPIKeyHint.className = 'settings-hint'; - litellmAPIKeyHint.textContent = i18nString(UIStrings.liteLLMApiKeyHint); - litellmSettingsSection.appendChild(litellmAPIKeyHint); - - const settingsSavedLiteLLMApiKey = localStorage.getItem(LITELLM_API_KEY_STORAGE_KEY) || ''; - const litellmApiKeyInput = document.createElement('input'); - litellmApiKeyInput.className = 'settings-input litellm-api-key-input'; - litellmApiKeyInput.type = 'password'; - litellmApiKeyInput.placeholder = 'Enter your LiteLLM API key'; - litellmApiKeyInput.value = settingsSavedLiteLLMApiKey; - litellmSettingsSection.appendChild(litellmApiKeyInput); - - // Create event handler function - const updateFetchButtonState = () => { - fetchModelsButton.disabled = !litellmEndpointInput.value.trim(); - }; - - litellmEndpointInput.addEventListener('input', updateFetchButtonState); - - const fetchButtonContainer = document.createElement('div'); - fetchButtonContainer.className = 'fetch-button-container'; - litellmSettingsSection.appendChild(fetchButtonContainer); - - const fetchModelsButton = document.createElement('button'); - fetchModelsButton.className = 'settings-button'; - fetchModelsButton.setAttribute('type', 'button'); // Set explicit button type - fetchModelsButton.textContent = i18nString(UIStrings.fetchModelsButton); - fetchModelsButton.disabled = !litellmEndpointInput.value.trim(); - fetchButtonContainer.appendChild(fetchModelsButton); - - const fetchModelsStatus = document.createElement('div'); - fetchModelsStatus.className = 'settings-status'; - fetchModelsStatus.style.display = 'none'; - fetchButtonContainer.appendChild(fetchModelsStatus); - - // Add click handler for fetch models button - fetchModelsButton.addEventListener('click', async () => { - fetchModelsButton.disabled = true; - fetchModelsStatus.textContent = i18nString(UIStrings.fetchingModels); - fetchModelsStatus.style.display = 'block'; - fetchModelsStatus.style.backgroundColor = 'var(--color-accent-blue-background)'; - fetchModelsStatus.style.color = 'var(--color-accent-blue)'; - - try { - const endpoint = litellmEndpointInput.value; - const liteLLMApiKey = litellmApiKeyInput.value || localStorage.getItem(LITELLM_API_KEY_STORAGE_KEY) || ''; - - const { models: litellmModels, hadWildcard } = await fetchLiteLLMModels(liteLLMApiKey, endpoint || undefined); - updateModelOptions(litellmModels, hadWildcard); - - // Get counts from centralized getModelOptions - const allLiteLLMModels = getModelOptions('litellm'); - const actualModelCount = litellmModels.length; - const hasCustomModels = allLiteLLMModels.length > actualModelCount; - - // Refresh existing model selectors with new options if they exist - if (SettingsDialog.#litellmMiniModelSelect) { - refreshModelSelectOptions(SettingsDialog.#litellmMiniModelSelect, allLiteLLMModels, miniModel, i18nString(UIStrings.defaultMiniOption)); - } - if (SettingsDialog.#litellmNanoModelSelect) { - refreshModelSelectOptions(SettingsDialog.#litellmNanoModelSelect, allLiteLLMModels, nanoModel, i18nString(UIStrings.defaultNanoOption)); - } - - if (hadWildcard && actualModelCount === 0 && !hasCustomModels) { - fetchModelsStatus.textContent = i18nString(UIStrings.wildcardModelsOnly); - fetchModelsStatus.style.backgroundColor = 'var(--color-accent-orange-background)'; - fetchModelsStatus.style.color = 'var(--color-accent-orange)'; - } else if (hadWildcard && actualModelCount === 0) { - // Only wildcard was returned but we have custom models - fetchModelsStatus.textContent = i18nString(UIStrings.wildcardAndCustomModels); - fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; - fetchModelsStatus.style.color = 'var(--color-accent-green)'; - } else if (hadWildcard) { - // Wildcard plus other models - fetchModelsStatus.textContent = i18nString(UIStrings.wildcardAndOtherModels, {PH1: actualModelCount}); - fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; - fetchModelsStatus.style.color = 'var(--color-accent-green)'; - } else { - // No wildcard, just regular models - fetchModelsStatus.textContent = i18nString(UIStrings.fetchedModels, {PH1: actualModelCount}); - fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; - fetchModelsStatus.style.color = 'var(--color-accent-green)'; - } - - // Update LiteLLM model selections - updateLiteLLMModelSelectors(); - - } catch (error) { - logger.error('Failed to fetch models:', error); - fetchModelsStatus.textContent = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; - fetchModelsStatus.style.backgroundColor = 'var(--color-accent-red-background)'; - fetchModelsStatus.style.color = 'var(--color-accent-red)'; - } finally { - updateFetchButtonState(); - setTimeout(() => { - fetchModelsStatus.style.display = 'none'; - }, 3000); - } - }); - - // Custom model section with array support - const customModelsSection = document.createElement('div'); - customModelsSection.className = 'custom-models-section'; - litellmContent.appendChild(customModelsSection); - - const customModelsLabel = document.createElement('div'); - customModelsLabel.className = 'settings-label'; - customModelsLabel.textContent = i18nString(UIStrings.customModelsLabel); - customModelsSection.appendChild(customModelsLabel); - - const customModelsHint = document.createElement('div'); - customModelsHint.className = 'settings-hint'; - customModelsHint.textContent = i18nString(UIStrings.customModelsHint); - customModelsSection.appendChild(customModelsHint); - - // Current custom models list - const customModelsList = document.createElement('div'); - customModelsList.className = 'custom-models-list'; - customModelsSection.appendChild(customModelsList); - - // Helper function to refresh the model list in a select element - function refreshModelSelectOptions(select: any, models: ModelOption[], currentValue: string, defaultLabel: string) { - // Custom component path - if (select && select.tagName && select.tagName.toLowerCase() === 'ai-model-selector') { - const previousValue = select.value || select.selected || ''; - const opts = [{ value: '', label: defaultLabel }, ...models]; - select.options = opts; - if (previousValue && opts.some((o: any) => o.value === previousValue)) { - select.value = previousValue; - } else if (currentValue && opts.some((o: any) => o.value === currentValue)) { - select.value = currentValue; - } else { - select.value = ''; - } - return; - } - - // Native for existing code paths - try { - Object.defineProperty(selectorEl, 'value', { - get() { return selectorEl.selected || ''; }, - set(v: string) { selectorEl.selected = v || ''; }, - configurable: true, - }); - } catch {} - - if (onFocus) { - selectorEl.addEventListener('model-selector-focus', onFocus); + // Store in localStorage with timestamp + localStorage.setItem('openrouter_models_cache', JSON.stringify(modelOptions)); + localStorage.setItem('openrouter_models_cache_timestamp', Date.now().toString()); } - - modelContainer.appendChild(selectorEl); - return selectorEl as HTMLElement; } diff --git a/front_end/panels/ai_chat/ui/settings/advanced/BrowsingHistorySettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/BrowsingHistorySettings.ts new file mode 100644 index 0000000000..b2da27f5e1 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/advanced/BrowsingHistorySettings.ts @@ -0,0 +1,85 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { i18nString } from '../i18n-strings.js'; +import * as Common from '../../../../../core/common/common.js'; + +const logger = Common.Console.Console.instance(); + +/** + * Browsing History Settings + * + * Migrated from SettingsDialog.ts lines 1889-1933 + */ +export class BrowsingHistorySettings { + private container: HTMLElement; + private statusMessage: HTMLElement | null = null; + + constructor(container: HTMLElement) { + this.container = container; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + this.container.className = 'settings-section history-section'; + + // Title + const historyTitle = document.createElement('h3'); + historyTitle.className = 'settings-subtitle'; + historyTitle.textContent = i18nString('browsingHistoryTitle'); + this.container.appendChild(historyTitle); + + // Description + const historyDescription = document.createElement('p'); + historyDescription.className = 'settings-description'; + historyDescription.textContent = i18nString('browsingHistoryDescription'); + this.container.appendChild(historyDescription); + + // Status message element (initially hidden) + this.statusMessage = document.createElement('div'); + this.statusMessage.className = 'settings-status history-status'; + this.statusMessage.style.display = 'none'; + this.statusMessage.textContent = i18nString('historyCleared'); + this.container.appendChild(this.statusMessage); + + // Clear history button + const clearHistoryButton = document.createElement('button'); + clearHistoryButton.textContent = i18nString('clearHistoryButton'); + clearHistoryButton.className = 'settings-button clear-button'; + clearHistoryButton.setAttribute('type', 'button'); + this.container.appendChild(clearHistoryButton); + + clearHistoryButton.addEventListener('click', async () => { + try { + // Import the VisitHistoryManager from its dedicated file + const { VisitHistoryManager } = await import('../../../tools/VisitHistoryManager.js'); + await VisitHistoryManager.getInstance().clearHistory(); + + // Show success message + if (this.statusMessage) { + this.statusMessage.style.display = 'block'; + + // Hide message after 3 seconds + setTimeout(() => { + if (this.statusMessage) { + this.statusMessage.style.display = 'none'; + } + }, 3000); + } + } catch (error) { + logger.error('Error clearing browsing history:', error); + } + }); + } + + save(): void { + // Browsing history doesn't need to save settings + // It only provides a "Clear History" button + } + + cleanup(): void { + // No cleanup needed + } +} diff --git a/front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.ts new file mode 100644 index 0000000000..e6ff798a6e --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.ts @@ -0,0 +1,289 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { i18nString } from '../i18n-strings.js'; +import { + getEvaluationConfig, + setEvaluationConfig, + isEvaluationEnabled, + connectToEvaluationService, + disconnectFromEvaluationService, + getEvaluationClientId, + isEvaluationConnected +} from '../../../common/EvaluationConfig.js'; +import { createLogger } from '../../../core/Logger.js'; + +const logger = createLogger('EvaluationSettings'); + +/** + * Evaluation Service Settings + * + * Migrated from SettingsDialog.ts lines 2953-3185 + */ +export class EvaluationSettings { + private container: HTMLElement; + private statusUpdateInterval: number | null = null; + private evaluationEnabledCheckbox: HTMLInputElement | null = null; + private evaluationEndpointInput: HTMLInputElement | null = null; + private evaluationSecretKeyInput: HTMLInputElement | null = null; + + constructor(container: HTMLElement) { + this.container = container; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + this.container.className = 'settings-section evaluation-section'; + + // Title + const evaluationSectionTitle = document.createElement('h3'); + evaluationSectionTitle.className = 'settings-subtitle'; + evaluationSectionTitle.textContent = i18nString('evaluationSection'); + this.container.appendChild(evaluationSectionTitle); + + // Get current evaluation configuration + const currentEvaluationConfig = getEvaluationConfig(); + + // Evaluation enabled checkbox + const evaluationEnabledContainer = document.createElement('div'); + evaluationEnabledContainer.className = 'evaluation-enabled-container'; + this.container.appendChild(evaluationEnabledContainer); + + this.evaluationEnabledCheckbox = document.createElement('input'); + this.evaluationEnabledCheckbox.type = 'checkbox'; + this.evaluationEnabledCheckbox.id = 'evaluation-enabled'; + this.evaluationEnabledCheckbox.className = 'evaluation-checkbox'; + this.evaluationEnabledCheckbox.checked = isEvaluationEnabled(); + evaluationEnabledContainer.appendChild(this.evaluationEnabledCheckbox); + + const evaluationEnabledLabel = document.createElement('label'); + evaluationEnabledLabel.htmlFor = 'evaluation-enabled'; + evaluationEnabledLabel.className = 'evaluation-label'; + evaluationEnabledLabel.textContent = i18nString('evaluationEnabled'); + evaluationEnabledContainer.appendChild(evaluationEnabledLabel); + + const evaluationEnabledHint = document.createElement('div'); + evaluationEnabledHint.className = 'settings-hint'; + evaluationEnabledHint.textContent = i18nString('evaluationEnabledHint'); + this.container.appendChild(evaluationEnabledHint); + + // Connection status indicator + const connectionStatusContainer = document.createElement('div'); + connectionStatusContainer.className = 'connection-status-container'; + connectionStatusContainer.style.display = 'flex'; + connectionStatusContainer.style.alignItems = 'center'; + connectionStatusContainer.style.gap = '8px'; + connectionStatusContainer.style.marginTop = '8px'; + connectionStatusContainer.style.fontSize = '13px'; + this.container.appendChild(connectionStatusContainer); + + const connectionStatusDot = document.createElement('div'); + connectionStatusDot.className = 'connection-status-dot'; + connectionStatusDot.style.width = '8px'; + connectionStatusDot.style.height = '8px'; + connectionStatusDot.style.borderRadius = '50%'; + connectionStatusDot.style.flexShrink = '0'; + connectionStatusContainer.appendChild(connectionStatusDot); + + const connectionStatusText = document.createElement('span'); + connectionStatusText.className = 'connection-status-text'; + connectionStatusContainer.appendChild(connectionStatusText); + + // Function to update connection status + const updateConnectionStatus = () => { + const isConnected = isEvaluationConnected(); + + logger.debug('Updating connection status', { isConnected }); + + if (isConnected) { + connectionStatusDot.style.backgroundColor = 'var(--color-accent-green)'; + connectionStatusText.textContent = 'Connected to evaluation server'; + connectionStatusText.style.color = 'var(--color-accent-green)'; + } else { + connectionStatusDot.style.backgroundColor = 'var(--color-text-disabled)'; + connectionStatusText.textContent = 'Not connected'; + connectionStatusText.style.color = 'var(--color-text-disabled)'; + } + }; + + // Update status initially and when evaluation is enabled/disabled + updateConnectionStatus(); + + // Set up periodic status updates every 2 seconds + this.statusUpdateInterval = setInterval(updateConnectionStatus, 2000); + + // Evaluation configuration container (shown when enabled) + const evaluationConfigContainer = document.createElement('div'); + evaluationConfigContainer.className = 'evaluation-config-container'; + evaluationConfigContainer.style.display = this.evaluationEnabledCheckbox.checked ? 'block' : 'none'; + this.container.appendChild(evaluationConfigContainer); + + // Client ID display (read-only) + const clientIdLabel = document.createElement('div'); + clientIdLabel.className = 'settings-label'; + clientIdLabel.textContent = 'Client ID'; + evaluationConfigContainer.appendChild(clientIdLabel); + + const clientIdHint = document.createElement('div'); + clientIdHint.className = 'settings-hint'; + clientIdHint.textContent = 'Unique identifier for this DevTools instance'; + evaluationConfigContainer.appendChild(clientIdHint); + + const clientIdInput = document.createElement('input'); + clientIdInput.type = 'text'; + clientIdInput.className = 'settings-input'; + clientIdInput.value = currentEvaluationConfig.clientId || 'Auto-generated on first connection'; + clientIdInput.readOnly = true; + clientIdInput.style.backgroundColor = 'var(--color-background-elevation-1)'; + clientIdInput.style.cursor = 'default'; + evaluationConfigContainer.appendChild(clientIdInput); + + // Evaluation endpoint + const evaluationEndpointLabel = document.createElement('div'); + evaluationEndpointLabel.className = 'settings-label'; + evaluationEndpointLabel.textContent = i18nString('evaluationEndpoint'); + evaluationConfigContainer.appendChild(evaluationEndpointLabel); + + const evaluationEndpointHint = document.createElement('div'); + evaluationEndpointHint.className = 'settings-hint'; + evaluationEndpointHint.textContent = i18nString('evaluationEndpointHint'); + evaluationConfigContainer.appendChild(evaluationEndpointHint); + + this.evaluationEndpointInput = document.createElement('input'); + this.evaluationEndpointInput.type = 'text'; + this.evaluationEndpointInput.className = 'settings-input'; + this.evaluationEndpointInput.placeholder = 'ws://localhost:8080'; + this.evaluationEndpointInput.value = currentEvaluationConfig.endpoint || 'ws://localhost:8080'; + evaluationConfigContainer.appendChild(this.evaluationEndpointInput); + + // Evaluation secret key + const evaluationSecretKeyLabel = document.createElement('div'); + evaluationSecretKeyLabel.className = 'settings-label'; + evaluationSecretKeyLabel.textContent = i18nString('evaluationSecretKey'); + evaluationConfigContainer.appendChild(evaluationSecretKeyLabel); + + const evaluationSecretKeyHint = document.createElement('div'); + evaluationSecretKeyHint.className = 'settings-hint'; + evaluationSecretKeyHint.textContent = i18nString('evaluationSecretKeyHint'); + evaluationConfigContainer.appendChild(evaluationSecretKeyHint); + + this.evaluationSecretKeyInput = document.createElement('input'); + this.evaluationSecretKeyInput.type = 'password'; + this.evaluationSecretKeyInput.className = 'settings-input'; + this.evaluationSecretKeyInput.placeholder = 'Optional secret key'; + this.evaluationSecretKeyInput.value = currentEvaluationConfig.secretKey || ''; + evaluationConfigContainer.appendChild(this.evaluationSecretKeyInput); + + // Connection status message + const connectionStatusMessage = document.createElement('div'); + connectionStatusMessage.className = 'settings-status'; + connectionStatusMessage.style.display = 'none'; + evaluationConfigContainer.appendChild(connectionStatusMessage); + + // Auto-connect when evaluation is enabled/disabled + this.evaluationEnabledCheckbox.addEventListener('change', async () => { + const isEnabled = this.evaluationEnabledCheckbox!.checked; + evaluationConfigContainer.style.display = isEnabled ? 'block' : 'none'; + + // Show connection status + connectionStatusMessage.style.display = 'block'; + + if (isEnabled) { + // Auto-connect when enabled + connectionStatusMessage.textContent = 'Connecting...'; + connectionStatusMessage.style.backgroundColor = 'var(--color-background-elevation-1)'; + connectionStatusMessage.style.color = 'var(--color-text-primary)'; + + try { + const endpoint = this.evaluationEndpointInput!.value.trim() || 'ws://localhost:8080'; + const secretKey = this.evaluationSecretKeyInput!.value.trim(); + + // Update config and connect + setEvaluationConfig({ + enabled: true, + endpoint, + secretKey + }); + + await connectToEvaluationService(); + + // Update client ID display after connection + const clientId = getEvaluationClientId(); + if (clientId) { + clientIdInput.value = clientId; + } + + connectionStatusMessage.textContent = '✓ Connected successfully'; + connectionStatusMessage.style.backgroundColor = 'var(--color-accent-green-background)'; + connectionStatusMessage.style.color = 'var(--color-accent-green)'; + + // Update connection status indicator + setTimeout(updateConnectionStatus, 500); + } catch (error) { + connectionStatusMessage.textContent = `✗ ${error instanceof Error ? error.message : 'Connection failed'}`; + connectionStatusMessage.style.backgroundColor = 'var(--color-accent-red-background)'; + connectionStatusMessage.style.color = 'var(--color-accent-red)'; + + // Uncheck the checkbox if connection failed + this.evaluationEnabledCheckbox!.checked = false; + evaluationConfigContainer.style.display = 'none'; + } + } else { + // Auto-disconnect when disabled + connectionStatusMessage.textContent = 'Disconnecting...'; + connectionStatusMessage.style.backgroundColor = 'var(--color-background-elevation-1)'; + connectionStatusMessage.style.color = 'var(--color-text-primary)'; + + try { + disconnectFromEvaluationService(); + + // Update config + setEvaluationConfig({ + enabled: false, + endpoint: this.evaluationEndpointInput!.value.trim() || 'ws://localhost:8080', + secretKey: this.evaluationSecretKeyInput!.value.trim() + }); + + connectionStatusMessage.textContent = '✓ Disconnected'; + connectionStatusMessage.style.backgroundColor = 'var(--color-background-elevation-1)'; + connectionStatusMessage.style.color = 'var(--color-text-primary)'; + + // Update connection status indicator + updateConnectionStatus(); + } catch (error) { + connectionStatusMessage.textContent = `✗ Disconnect error: ${error instanceof Error ? error.message : 'Unknown error'}`; + connectionStatusMessage.style.backgroundColor = 'var(--color-accent-red-background)'; + connectionStatusMessage.style.color = 'var(--color-accent-red)'; + } + } + + // Hide status message after 3 seconds + setTimeout(() => { + connectionStatusMessage.style.display = 'none'; + }, 3000); + }); + } + + save(): void { + // Evaluation settings are auto-saved on enable/disable toggle + // Final save happens in the checkbox change handler + if (!this.evaluationEnabledCheckbox || !this.evaluationEndpointInput || !this.evaluationSecretKeyInput) { + return; + } + + setEvaluationConfig({ + enabled: this.evaluationEnabledCheckbox.checked, + endpoint: this.evaluationEndpointInput.value.trim() || 'ws://localhost:8080', + secretKey: this.evaluationSecretKeyInput.value.trim() + }); + } + + cleanup(): void { + if (this.statusUpdateInterval !== null) { + clearInterval(this.statusUpdateInterval); + this.statusUpdateInterval = null; + } + } +} diff --git a/front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.ts new file mode 100644 index 0000000000..2fa3b8a4e3 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.ts @@ -0,0 +1,614 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { i18nString } from '../i18n-strings.js'; +import { + getMCPConfig, + setMCPConfig, + getStoredAuthErrors, + clearStoredAuthError +} from '../../../mcp/MCPConfig.js'; +import { MCPRegistry } from '../../../mcp/MCPRegistry.js'; +import { MCPConnectionsDialog } from '../../mcp/MCPConnectionsDialog.js'; +import { createLogger } from '../../../core/Logger.js'; + +const logger = createLogger('MCPSettings'); + +/** + * MCP (Model Context Protocol) Integration Settings + * + * Migrated from SettingsDialog.ts lines 1309-1858 + * + * Features: + * - Connection management (manage/reconnect buttons) + * - Tool selection mode (all/router/meta) + * - Budget controls (max tools per turn) + * - Real-time status updates (every 10 seconds) + * - Per-server status indicators + */ +export class MCPSettings { + private container: HTMLElement; + private statusUpdateInterval: number | null = null; + private mcpActionsContainer: HTMLElement | null = null; + private mcpStatusDetails: HTMLElement | null = null; + private mcpStatusDot: HTMLElement | null = null; + private mcpStatusText: HTMLElement | null = null; + private onSettingsSaved: () => void; + private onDialogHide: () => void; + + constructor(container: HTMLElement, onSettingsSaved: () => void, onDialogHide: () => void) { + this.container = container; + this.onSettingsSaved = onSettingsSaved; + this.onDialogHide = onDialogHide; + } + + render(): void { + // Implementation split into parts due to size - continued below + this.renderHeader(); + this.renderStatusDisplay(); + this.renderActionButtons(); + this.renderConnectionManagement(); + this.renderConfigOptions(); + } + + private renderHeader(): void { + // Clear any existing content + this.container.innerHTML = ''; + this.container.className = 'settings-section mcp-section'; + this.container.style.display = 'block'; + + // Title + const mcpSectionTitle = document.createElement('h3'); + mcpSectionTitle.className = 'settings-subtitle'; + mcpSectionTitle.textContent = i18nString('mcpSection'); + this.container.appendChild(mcpSectionTitle); + } + + private renderStatusDisplay(): void { + // Status indicator + const mcpStatusContainer = document.createElement('div'); + mcpStatusContainer.className = 'connection-status-container'; + mcpStatusContainer.style.display = 'flex'; + mcpStatusContainer.style.alignItems = 'center'; + mcpStatusContainer.style.gap = '8px'; + mcpStatusContainer.style.marginTop = '8px'; + mcpStatusContainer.style.fontSize = '13px'; + this.container.appendChild(mcpStatusContainer); + + this.mcpStatusDot = document.createElement('div'); + this.mcpStatusDot.className = 'connection-status-dot'; + this.mcpStatusDot.style.width = '8px'; + this.mcpStatusDot.style.height = '8px'; + this.mcpStatusDot.style.borderRadius = '50%'; + this.mcpStatusDot.style.flexShrink = '0'; + mcpStatusContainer.appendChild(this.mcpStatusDot); + + this.mcpStatusText = document.createElement('span'); + this.mcpStatusText.className = 'connection-status-text'; + mcpStatusContainer.appendChild(this.mcpStatusText); + + this.mcpStatusDetails = document.createElement('div'); + this.mcpStatusDetails.className = 'settings-hint'; + this.mcpStatusDetails.style.marginTop = '12px'; + this.mcpStatusDetails.style.display = 'flex'; + this.mcpStatusDetails.style.flexDirection = 'column'; + this.mcpStatusDetails.style.gap = '8px'; + this.container.appendChild(this.mcpStatusDetails); + + // Update status initially + this.updateMCPStatus(); + + // Set up periodic MCP status updates every 10 seconds + this.statusUpdateInterval = setInterval(() => this.updateMCPStatus(), 10000); + } + + private renderActionButtons(): void { + // Action buttons row (shown when connected) + this.mcpActionsContainer = document.createElement('div'); + this.mcpActionsContainer.style.marginTop = '12px'; + this.mcpActionsContainer.style.marginBottom = '8px'; + this.mcpActionsContainer.style.display = 'flex'; + this.mcpActionsContainer.style.gap = '8px'; + this.mcpActionsContainer.style.flexWrap = 'wrap'; + this.container.appendChild(this.mcpActionsContainer); + + // Disconnect button + const mcpDisconnectButton = document.createElement('button'); + mcpDisconnectButton.textContent = 'Disconnect'; + mcpDisconnectButton.className = 'settings-button'; + mcpDisconnectButton.style.backgroundColor = '#fee2e2'; + mcpDisconnectButton.style.border = '1px solid #fecaca'; + mcpDisconnectButton.style.color = '#dc2626'; + mcpDisconnectButton.style.padding = '6px 12px'; + mcpDisconnectButton.style.borderRadius = '6px'; + mcpDisconnectButton.style.cursor = 'pointer'; + mcpDisconnectButton.style.fontSize = '12px'; + mcpDisconnectButton.style.fontWeight = '500'; + mcpDisconnectButton.addEventListener('click', async () => { + try { + MCPRegistry.dispose(); + this.updateMCPStatus(); + this.updateActionButtons(); + } catch (err) { + logger.error('Failed to disconnect MCP:', err); + } + }); + this.mcpActionsContainer.appendChild(mcpDisconnectButton); + + // Manage connections button + const mcpManageButton = document.createElement('button'); + mcpManageButton.textContent = 'Manage connections'; + mcpManageButton.className = 'settings-button'; + mcpManageButton.style.backgroundColor = 'var(--color-background-elevation-1)'; + mcpManageButton.style.border = '1px solid var(--color-details-hairline)'; + mcpManageButton.style.color = 'var(--color-text-primary)'; + mcpManageButton.style.padding = '6px 12px'; + mcpManageButton.style.borderRadius = '6px'; + mcpManageButton.style.cursor = 'pointer'; + mcpManageButton.style.fontSize = '12px'; + mcpManageButton.style.fontWeight = '500'; + mcpManageButton.addEventListener('click', () => { + this.onDialogHide(); + MCPConnectionsDialog.show(); + }); + this.mcpActionsContainer.appendChild(mcpManageButton); + + // Reconnect all button + const mcpReconnectAllButton = document.createElement('button'); + mcpReconnectAllButton.textContent = 'Reconnect all'; + mcpReconnectAllButton.className = 'settings-button'; + mcpReconnectAllButton.style.backgroundColor = '#dbeafe'; + mcpReconnectAllButton.style.border = '1px solid #bfdbfe'; + mcpReconnectAllButton.style.color = '#1d4ed8'; + mcpReconnectAllButton.style.padding = '6px 12px'; + mcpReconnectAllButton.style.borderRadius = '6px'; + mcpReconnectAllButton.style.cursor = 'pointer'; + mcpReconnectAllButton.style.fontSize = '12px'; + mcpReconnectAllButton.style.fontWeight = '500'; + mcpReconnectAllButton.addEventListener('click', async () => { + mcpReconnectAllButton.disabled = true; + mcpReconnectAllButton.textContent = 'Reconnecting...'; + try { + await MCPRegistry.init(true); + this.updateMCPStatus(); + this.updateActionButtons(); + } catch (err) { + logger.error('Failed to reconnect all MCP servers:', err); + } finally { + mcpReconnectAllButton.disabled = false; + mcpReconnectAllButton.textContent = 'Reconnect all'; + } + }); + this.mcpActionsContainer.appendChild(mcpReconnectAllButton); + + this.updateActionButtons(); + } + + private renderConnectionManagement(): void { + // Connections management + const mcpConnectionsLabel = document.createElement('div'); + mcpConnectionsLabel.className = 'settings-label'; + mcpConnectionsLabel.textContent = i18nString('mcpConnectionsHeader'); + this.container.appendChild(mcpConnectionsLabel); + + const mcpConnectionsHint = document.createElement('div'); + mcpConnectionsHint.className = 'settings-hint'; + mcpConnectionsHint.textContent = i18nString('mcpConnectionsHint'); + this.container.appendChild(mcpConnectionsHint); + + const mcpConnectionsActions = document.createElement('div'); + mcpConnectionsActions.className = 'mcp-connections-actions'; + mcpConnectionsActions.style.display = 'flex'; + mcpConnectionsActions.style.gap = '8px'; + mcpConnectionsActions.style.marginBottom = '12px'; + this.container.appendChild(mcpConnectionsActions); + + const manageConnectionsButton = document.createElement('button'); + manageConnectionsButton.className = 'settings-button'; + manageConnectionsButton.textContent = i18nString('mcpManageConnections'); + manageConnectionsButton.addEventListener('click', () => { + MCPConnectionsDialog.show({ + onSave: async () => { + try { + await MCPRegistry.init(true); + await MCPRegistry.refresh(); + } catch (err) { + logger.error('Failed to refresh MCP connections after save', err); + } finally { + this.updateMCPStatus(); + this.updateActionButtons(); + this.onSettingsSaved(); + } + }, + }); + }); + mcpConnectionsActions.appendChild(manageConnectionsButton); + + const refreshConnectionsButton = document.createElement('button'); + refreshConnectionsButton.className = 'settings-button'; + refreshConnectionsButton.textContent = i18nString('mcpRefreshConnections'); + refreshConnectionsButton.addEventListener('click', async () => { + try { + await MCPRegistry.init(true); + await MCPRegistry.refresh(); + } catch (err) { + logger.error('Failed to refresh MCP connections', err); + } finally { + this.updateMCPStatus(); + this.updateActionButtons(); + } + }); + mcpConnectionsActions.appendChild(refreshConnectionsButton); + } + + private renderConfigOptions(): void { + const currentMCPConfig = getMCPConfig(); + + // MCP config inputs (always visible since MCP is always enabled) + const mcpConfigContainer = document.createElement('div'); + mcpConfigContainer.className = 'mcp-config-container'; + mcpConfigContainer.style.display = 'block'; + this.container.appendChild(mcpConfigContainer); + + // Tool mode selection + const mcpToolModeLabel = document.createElement('div'); + mcpToolModeLabel.className = 'settings-label'; + mcpToolModeLabel.textContent = i18nString('mcpToolMode'); + mcpConfigContainer.appendChild(mcpToolModeLabel); + + const mcpToolModeHint = document.createElement('div'); + mcpToolModeHint.className = 'settings-hint'; + mcpToolModeHint.textContent = i18nString('mcpToolModeHint'); + mcpConfigContainer.appendChild(mcpToolModeHint); + + const mcpToolModeSelect = document.createElement('select'); + mcpToolModeSelect.className = 'settings-select'; + mcpConfigContainer.appendChild(mcpToolModeSelect); + + // Tool mode options + const toolModeOptions = [ + { value: 'all', text: i18nString('mcpToolModeAll') }, + { value: 'router', text: i18nString('mcpToolModeRouter') }, + { value: 'meta', text: i18nString('mcpToolModeMeta') }, + ]; + + toolModeOptions.forEach(option => { + const optionElement = document.createElement('option'); + optionElement.value = option.value; + optionElement.textContent = option.text; + if ((currentMCPConfig.toolMode || 'router') === option.value) { + optionElement.selected = true; + } + mcpToolModeSelect.appendChild(optionElement); + }); + + // Ensure the select reflects the currently stored mode + mcpToolModeSelect.value = (currentMCPConfig.toolMode || 'router'); + + // Handle tool mode changes + mcpToolModeSelect.addEventListener('change', () => { + setMCPConfig({ toolMode: mcpToolModeSelect.value as 'all' | 'router' | 'meta' }); + this.onSettingsSaved(); + }); + + // Advanced budget controls + const mcpMaxToolsLabel = document.createElement('div'); + mcpMaxToolsLabel.className = 'settings-label'; + mcpMaxToolsLabel.textContent = i18nString('mcpMaxToolsPerTurn'); + mcpConfigContainer.appendChild(mcpMaxToolsLabel); + + const mcpMaxToolsHint = document.createElement('div'); + mcpMaxToolsHint.className = 'settings-hint'; + mcpMaxToolsHint.textContent = i18nString('mcpMaxToolsPerTurnHint'); + mcpConfigContainer.appendChild(mcpMaxToolsHint); + + const mcpMaxToolsInput = document.createElement('input'); + mcpMaxToolsInput.type = 'number'; + mcpMaxToolsInput.className = 'settings-input'; + mcpMaxToolsInput.min = '1'; + mcpMaxToolsInput.max = '100'; + mcpMaxToolsInput.value = String(currentMCPConfig.maxToolsPerTurn || 20); + mcpConfigContainer.appendChild(mcpMaxToolsInput); + + const mcpMaxMcpLabel = document.createElement('div'); + mcpMaxMcpLabel.className = 'settings-label'; + mcpMaxMcpLabel.textContent = i18nString('mcpMaxMcpPerTurn'); + mcpConfigContainer.appendChild(mcpMaxMcpLabel); + + const mcpMaxMcpHint = document.createElement('div'); + mcpMaxMcpHint.className = 'settings-hint'; + mcpMaxMcpHint.textContent = i18nString('mcpMaxMcpPerTurnHint'); + mcpConfigContainer.appendChild(mcpMaxMcpHint); + + const mcpMaxMcpInput = document.createElement('input'); + mcpMaxMcpInput.type = 'number'; + mcpMaxMcpInput.className = 'settings-input'; + mcpMaxMcpInput.min = '1'; + mcpMaxMcpInput.max = '50'; + mcpMaxMcpInput.value = String(currentMCPConfig.maxMcpPerTurn || 8); + mcpConfigContainer.appendChild(mcpMaxMcpInput); + + // Handle budget control changes + const updateBudgetControls = () => { + const maxTools = Math.max(1, Math.min(100, parseInt(mcpMaxToolsInput.value, 10) || 20)); + const maxMcp = Math.max(1, Math.min(50, parseInt(mcpMaxMcpInput.value, 10) || 8)); + setMCPConfig({ + maxToolsPerTurn: maxTools, + maxMcpPerTurn: maxMcp, + }); + this.onSettingsSaved(); + }; + + mcpMaxToolsInput.addEventListener('change', updateBudgetControls); + mcpMaxMcpInput.addEventListener('change', updateBudgetControls); + } + + private formatTimestamp(date: Date | undefined): string { + if (!date) return ''; + return date.toLocaleString(); + } + + private formatMCPError(error: string, errorType?: string): {message: string, hint?: string} { + if (!errorType) return {message: error}; + switch (errorType) { + case 'connection': + return {message: `Connection failed: ${error}`, hint: 'Check if the MCP server is running and the endpoint URL is correct.'}; + case 'authentication': + return {message: `Authentication failed: ${error}`, hint: 'Verify your auth token is correct and has not expired.'}; + case 'configuration': + return {message: `Configuration error: ${error}`, hint: 'Check your endpoint URL format (should be ws:// or wss://).'}; + case 'network': + return {message: `Network error: ${error}`, hint: 'Check your internet connection and firewall settings.'}; + case 'server_error': + return {message: `Server error: ${error}`, hint: 'The MCP server encountered an internal error. Contact the server administrator.'}; + default: + return {message: error}; + } + } + + private updateMCPStatus(): void { + if (!this.mcpStatusDot || !this.mcpStatusText || !this.mcpStatusDetails) { + return; + } + + const status = MCPRegistry.getStatus(); + + const appendServerRow = (server: typeof status.servers[number], isConnected: boolean) => { + if (!this.mcpStatusDetails) return; + + const authErrors = getStoredAuthErrors(); + const serverAuthError = authErrors.find(error => error.serverId === server.id); + + const row = document.createElement('div'); + row.style.display = 'flex'; + row.style.alignItems = 'flex-start'; + row.style.gap = '8px'; + row.style.marginBottom = '6px'; + row.style.padding = '8px'; + row.style.borderRadius = '6px'; + row.style.backgroundColor = 'var(--color-background-elevation-1)'; + row.style.border = '1px solid var(--color-details-hairline)'; + + const statusDot = document.createElement('span'); + statusDot.style.width = '6px'; + statusDot.style.height = '6px'; + statusDot.style.borderRadius = '50%'; + statusDot.style.marginTop = '8px'; + statusDot.style.flexShrink = '0'; + + const serverInfo = document.createElement('div'); + serverInfo.style.flex = '1'; + serverInfo.style.minWidth = '0'; + + const serverNameLine = document.createElement('div'); + serverNameLine.style.display = 'flex'; + serverNameLine.style.alignItems = 'center'; + serverNameLine.style.gap = '8px'; + serverNameLine.style.marginBottom = '4px'; + serverNameLine.style.flexWrap = 'wrap'; + + const serverName = document.createElement('span'); + serverName.style.fontWeight = '600'; + serverName.style.color = 'var(--color-text-primary)'; + serverName.style.fontSize = '13px'; + serverName.textContent = server.name || server.id; + + const statusBadge = document.createElement('span'); + statusBadge.style.fontSize = '10px'; + statusBadge.style.padding = '2px 6px'; + statusBadge.style.borderRadius = '12px'; + statusBadge.style.fontWeight = '500'; + statusBadge.style.textTransform = 'uppercase'; + statusBadge.style.letterSpacing = '0.5px'; + + if (isConnected) { + if (server.toolCount === 0) { + statusDot.style.backgroundColor = '#f59e0b'; + statusBadge.style.backgroundColor = '#fef3c7'; + statusBadge.style.color = '#92400e'; + statusBadge.textContent = 'Discovering'; + } else { + statusDot.style.backgroundColor = '#10b981'; + statusBadge.style.backgroundColor = '#d1fae5'; + statusBadge.style.color = '#065f46'; + statusBadge.textContent = 'Connected'; + } + } else { + if (serverAuthError) { + statusDot.style.backgroundColor = '#ef4444'; + statusBadge.style.backgroundColor = '#fee2e2'; + statusBadge.style.color = '#991b1b'; + statusBadge.textContent = 'Auth Required'; + } else { + statusDot.style.backgroundColor = '#9ca3af'; + statusBadge.style.backgroundColor = '#f3f4f6'; + statusBadge.style.color = '#6b7280'; + statusBadge.textContent = 'Disconnected'; + } + } + + serverNameLine.appendChild(serverName); + serverNameLine.appendChild(statusBadge); + + const detailsLine = document.createElement('div'); + detailsLine.style.fontSize = '11px'; + detailsLine.style.color = 'var(--color-text-secondary)'; + const toolCountText = server.toolCount === 0 && isConnected ? 'Tools loading...' : `${server.toolCount} tools`; + detailsLine.textContent = `${toolCountText} • ${server.authType === 'oauth' ? 'OAuth' : 'Bearer'}`; + + serverInfo.appendChild(serverNameLine); + serverInfo.appendChild(detailsLine); + row.appendChild(statusDot); + row.appendChild(serverInfo); + + const needsReconnect = server.authType === 'oauth' && !isConnected; + if (needsReconnect) { + const reconnectButton = document.createElement('button'); + reconnectButton.className = 'settings-button'; + reconnectButton.style.padding = '2px 8px'; + reconnectButton.style.fontSize = '12px'; + reconnectButton.textContent = i18nString('mcpReconnectButton'); + reconnectButton.addEventListener('click', async () => { + reconnectButton.disabled = true; + reconnectButton.textContent = i18nString('mcpReconnectInProgress'); + try { + await MCPRegistry.reconnect(server.id); + clearStoredAuthError(server.id); + } catch (err) { + logger.error('Failed to reconnect MCP server', { serverId: server.id, error: err }); + reconnectButton.disabled = false; + reconnectButton.textContent = i18nString('mcpReconnectRetry'); + return; + } finally { + this.updateMCPStatus(); + this.updateActionButtons(); + } + }); + row.appendChild(reconnectButton); + } + + this.mcpStatusDetails!.appendChild(row); + + if (serverAuthError) { + const errorDetails = document.createElement('div'); + errorDetails.className = 'settings-hint'; + errorDetails.style.color = 'var(--color-error)'; + errorDetails.style.fontSize = '12px'; + errorDetails.style.marginTop = '2px'; + errorDetails.style.marginLeft = '16px'; + errorDetails.style.marginBottom = '4px'; + const timestamp = new Date(serverAuthError.timestamp).toLocaleString(); + errorDetails.textContent = `Last error (${timestamp}): ${serverAuthError.message}`; + this.mcpStatusDetails!.appendChild(errorDetails); + } + }; + + if (!status.enabled) { + this.mcpStatusDot.style.backgroundColor = 'var(--color-text-disabled)'; + this.mcpStatusText.innerHTML = `⚫ Disabled`; + this.mcpStatusDetails.textContent = ''; + return; + } + + const anyConnected = status.servers.some(s => s.connected); + const toolCount = status.registeredToolNames.length; + const authErrors = getStoredAuthErrors(); + const hasAuthErrors = authErrors.length > 0; + + if (anyConnected) { + if (hasAuthErrors) { + this.mcpStatusDot.style.backgroundColor = 'var(--color-warning)'; + this.mcpStatusText.innerHTML = `🟡 Connected with issues (${toolCount} tools)`; + } else { + this.mcpStatusDot.style.backgroundColor = 'var(--color-accent-green)'; + this.mcpStatusText.innerHTML = `🟢 Connected (${toolCount} tools)`; + } + + this.mcpStatusDetails.textContent = ''; + if (status.servers.length > 0) { + status.servers.forEach(server => appendServerRow(server, server.connected)); + } + if (status.lastConnected) { + const line = document.createElement('div'); + line.style.fontSize = '11px'; + line.style.color = 'var(--color-text-secondary)'; + line.style.marginTop = '8px'; + line.textContent = `Last connected: ${this.formatTimestamp(status.lastConnected)}`; + this.mcpStatusDetails.appendChild(line); + } + if (status.lastError) { + const {message, hint} = this.formatMCPError(status.lastError, status.lastErrorType); + const errLine = document.createElement('div'); + const errSpan = document.createElement('span'); + errSpan.style.color = 'var(--color-error-text)'; + errSpan.textContent = message; + errLine.appendChild(errSpan); + this.mcpStatusDetails.appendChild(errLine); + if (hint) { + const hintLine = document.createElement('div'); + hintLine.style.color = 'var(--color-text-secondary)'; + hintLine.style.fontSize = '12px'; + hintLine.textContent = hint; + this.mcpStatusDetails.appendChild(hintLine); + } + } + } else { + if (hasAuthErrors) { + this.mcpStatusDot.style.backgroundColor = 'var(--color-error)'; + this.mcpStatusText.innerHTML = `🔴 Authentication required`; + } else { + this.mcpStatusDot.style.backgroundColor = 'var(--color-text-disabled)'; + this.mcpStatusText.innerHTML = `⚪ Not connected`; + } + + this.mcpStatusDetails.textContent = ''; + if (status.servers.length > 0) { + status.servers.forEach(server => appendServerRow(server, false)); + } + if (status.lastDisconnected) { + const line = document.createElement('div'); + line.style.fontSize = '11px'; + line.style.color = 'var(--color-text-secondary)'; + line.style.marginTop = '8px'; + line.textContent = `Last disconnected: ${this.formatTimestamp(status.lastDisconnected)}`; + this.mcpStatusDetails.appendChild(line); + } + if (status.lastError) { + const {message, hint} = this.formatMCPError(status.lastError, status.lastErrorType); + const errLine = document.createElement('div'); + const errSpan = document.createElement('span'); + errSpan.style.color = 'var(--color-error-text)'; + errSpan.textContent = message; + errLine.appendChild(errSpan); + this.mcpStatusDetails.appendChild(errLine); + if (hint) { + const hintLine = document.createElement('div'); + hintLine.style.color = 'var(--color-text-secondary)'; + hintLine.style.fontSize = '12px'; + hintLine.textContent = hint; + this.mcpStatusDetails.appendChild(hintLine); + } + } + } + } + + private updateActionButtons(): void { + if (!this.mcpActionsContainer) return; + const status = MCPRegistry.getStatus(); + const anyConnected = status.enabled && status.servers.some(s => s.connected); + this.mcpActionsContainer.style.display = anyConnected ? 'flex' : 'none'; + } + + save(): void { + // MCP settings are auto-saved on change + // No need to save on dialog save + } + + cleanup(): void { + if (this.statusUpdateInterval !== null) { + clearInterval(this.statusUpdateInterval); + this.statusUpdateInterval = null; + } + } +} diff --git a/front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.ts new file mode 100644 index 0000000000..6bcb3a0e7f --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.ts @@ -0,0 +1,225 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { i18nString } from '../i18n-strings.js'; +import { getTracingConfig, setTracingConfig, isTracingEnabled } from '../../../tracing/TracingConfig.js'; + +/** + * Tracing (Langfuse) Settings + * + * Migrated from SettingsDialog.ts lines 2780-2951 + */ +export class TracingSettings { + private container: HTMLElement; + private tracingEnabledCheckbox: HTMLInputElement | null = null; + private endpointInput: HTMLInputElement | null = null; + private publicKeyInput: HTMLInputElement | null = null; + private secretKeyInput: HTMLInputElement | null = null; + + constructor(container: HTMLElement) { + this.container = container; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + this.container.className = 'settings-section tracing-section'; + + // Title + const tracingSectionTitle = document.createElement('h3'); + tracingSectionTitle.className = 'settings-subtitle'; + tracingSectionTitle.textContent = i18nString('tracingSection'); + this.container.appendChild(tracingSectionTitle); + + // Get current tracing configuration + const currentTracingConfig = getTracingConfig(); + + // Tracing enabled checkbox + const tracingEnabledContainer = document.createElement('div'); + tracingEnabledContainer.className = 'tracing-enabled-container'; + this.container.appendChild(tracingEnabledContainer); + + this.tracingEnabledCheckbox = document.createElement('input'); + this.tracingEnabledCheckbox.type = 'checkbox'; + this.tracingEnabledCheckbox.id = 'tracing-enabled'; + this.tracingEnabledCheckbox.className = 'tracing-checkbox'; + this.tracingEnabledCheckbox.checked = isTracingEnabled(); + tracingEnabledContainer.appendChild(this.tracingEnabledCheckbox); + + const tracingEnabledLabel = document.createElement('label'); + tracingEnabledLabel.htmlFor = 'tracing-enabled'; + tracingEnabledLabel.className = 'tracing-label'; + tracingEnabledLabel.textContent = i18nString('tracingEnabled'); + tracingEnabledContainer.appendChild(tracingEnabledLabel); + + const tracingEnabledHint = document.createElement('div'); + tracingEnabledHint.className = 'settings-hint'; + tracingEnabledHint.textContent = i18nString('tracingEnabledHint'); + this.container.appendChild(tracingEnabledHint); + + // Tracing configuration container (shown when enabled) + const tracingConfigContainer = document.createElement('div'); + tracingConfigContainer.className = 'tracing-config-container'; + tracingConfigContainer.style.display = this.tracingEnabledCheckbox.checked ? 'block' : 'none'; + this.container.appendChild(tracingConfigContainer); + + // Langfuse endpoint + const endpointLabel = document.createElement('div'); + endpointLabel.className = 'settings-label'; + endpointLabel.textContent = i18nString('langfuseEndpoint'); + tracingConfigContainer.appendChild(endpointLabel); + + const endpointHint = document.createElement('div'); + endpointHint.className = 'settings-hint'; + endpointHint.textContent = i18nString('langfuseEndpointHint'); + tracingConfigContainer.appendChild(endpointHint); + + this.endpointInput = document.createElement('input'); + this.endpointInput.className = 'settings-input'; + this.endpointInput.type = 'text'; + this.endpointInput.placeholder = 'http://localhost:3000'; + this.endpointInput.value = currentTracingConfig.endpoint || 'http://localhost:3000'; + tracingConfigContainer.appendChild(this.endpointInput); + + // Langfuse public key + const publicKeyLabel = document.createElement('div'); + publicKeyLabel.className = 'settings-label'; + publicKeyLabel.textContent = i18nString('langfusePublicKey'); + tracingConfigContainer.appendChild(publicKeyLabel); + + const publicKeyHint = document.createElement('div'); + publicKeyHint.className = 'settings-hint'; + publicKeyHint.textContent = i18nString('langfusePublicKeyHint'); + tracingConfigContainer.appendChild(publicKeyHint); + + this.publicKeyInput = document.createElement('input'); + this.publicKeyInput.className = 'settings-input'; + this.publicKeyInput.type = 'text'; + this.publicKeyInput.placeholder = 'pk-lf-...'; + this.publicKeyInput.value = currentTracingConfig.publicKey || ''; + tracingConfigContainer.appendChild(this.publicKeyInput); + + // Langfuse secret key + const secretKeyLabel = document.createElement('div'); + secretKeyLabel.className = 'settings-label'; + secretKeyLabel.textContent = i18nString('langfuseSecretKey'); + tracingConfigContainer.appendChild(secretKeyLabel); + + const secretKeyHint = document.createElement('div'); + secretKeyHint.className = 'settings-hint'; + secretKeyHint.textContent = i18nString('langfuseSecretKeyHint'); + tracingConfigContainer.appendChild(secretKeyHint); + + this.secretKeyInput = document.createElement('input'); + this.secretKeyInput.className = 'settings-input'; + this.secretKeyInput.type = 'password'; + this.secretKeyInput.placeholder = 'sk-lf-...'; + this.secretKeyInput.value = currentTracingConfig.secretKey || ''; + tracingConfigContainer.appendChild(this.secretKeyInput); + + // Test connection button + const testTracingButton = document.createElement('button'); + testTracingButton.className = 'settings-button test-button'; + testTracingButton.textContent = i18nString('testTracing'); + tracingConfigContainer.appendChild(testTracingButton); + + // Test status message + const testTracingStatus = document.createElement('div'); + testTracingStatus.className = 'settings-status'; + testTracingStatus.style.display = 'none'; + tracingConfigContainer.appendChild(testTracingStatus); + + // Toggle tracing config visibility + this.tracingEnabledCheckbox.addEventListener('change', () => { + tracingConfigContainer.style.display = this.tracingEnabledCheckbox!.checked ? 'block' : 'none'; + }); + + // Test tracing connection + testTracingButton.addEventListener('click', async () => { + testTracingButton.disabled = true; + testTracingStatus.style.display = 'block'; + testTracingStatus.textContent = 'Testing connection...'; + testTracingStatus.style.backgroundColor = 'var(--color-background-elevation-1)'; + testTracingStatus.style.color = 'var(--color-text-primary)'; + + try { + const endpoint = this.endpointInput!.value.trim(); + const publicKey = this.publicKeyInput!.value.trim(); + const secretKey = this.secretKeyInput!.value.trim(); + + if (!endpoint || !publicKey || !secretKey) { + throw new Error('All fields are required for testing'); + } + + // Test the connection with a simple trace + const testPayload = { + batch: [{ + id: `test-${Date.now()}`, + timestamp: new Date().toISOString(), + type: 'trace-create', + body: { + id: `trace-test-${Date.now()}`, + name: 'Connection Test', + timestamp: new Date().toISOString() + } + }] + }; + + const response = await fetch(`${endpoint}/api/public/ingestion`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + btoa(`${publicKey}:${secretKey}`) + }, + body: JSON.stringify(testPayload) + }); + + if (response.ok) { + testTracingStatus.textContent = '✓ Connection successful'; + testTracingStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + testTracingStatus.style.color = 'var(--color-accent-green)'; + } else { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + } catch (error) { + testTracingStatus.textContent = `✗ ${error instanceof Error ? error.message : 'Connection failed'}`; + testTracingStatus.style.backgroundColor = 'var(--color-accent-red-background)'; + testTracingStatus.style.color = 'var(--color-accent-red)'; + } finally { + testTracingButton.disabled = false; + setTimeout(() => { + testTracingStatus.style.display = 'none'; + }, 5000); + } + }); + } + + save(): void { + if (!this.tracingEnabledCheckbox || !this.endpointInput || !this.publicKeyInput || !this.secretKeyInput) { + return; + } + + if (this.tracingEnabledCheckbox.checked) { + const endpoint = this.endpointInput.value.trim(); + const publicKey = this.publicKeyInput.value.trim(); + const secretKey = this.secretKeyInput.value.trim(); + + if (endpoint && publicKey && secretKey) { + setTracingConfig({ + provider: 'langfuse', + endpoint, + publicKey, + secretKey + }); + } + } else { + setTracingConfig({ provider: 'disabled' }); + } + } + + cleanup(): void { + // No cleanup needed + } +} diff --git a/front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.ts new file mode 100644 index 0000000000..21d5e777ab --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.ts @@ -0,0 +1,290 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { i18nString } from '../i18n-strings.js'; +import { + VECTOR_DB_ENABLED_KEY, + MILVUS_ENDPOINT_KEY, + MILVUS_USERNAME_KEY, + MILVUS_PASSWORD_KEY, + MILVUS_COLLECTION_KEY, + MILVUS_OPENAI_KEY +} from '../constants.js'; + +/** + * Vector Database (Milvus) Settings + * + * Migrated from SettingsDialog.ts lines 2543-2778 + */ +export class VectorDBSettings { + private container: HTMLElement; + private vectorDBEnabledCheckbox: HTMLInputElement | null = null; + private vectorDBEndpointInput: HTMLInputElement | null = null; + private vectorDBApiKeyInput: HTMLInputElement | null = null; + private milvusPasswordInput: HTMLInputElement | null = null; + private milvusOpenAIInput: HTMLInputElement | null = null; + private vectorDBCollectionInput: HTMLInputElement | null = null; + + constructor(container: HTMLElement) { + this.container = container; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + this.container.className = 'settings-section vector-db-section'; + + // Title + const vectorDBTitle = document.createElement('h3'); + vectorDBTitle.textContent = i18nString('vectorDBLabel'); + vectorDBTitle.classList.add('settings-subtitle'); + this.container.appendChild(vectorDBTitle); + + // Vector DB enabled checkbox + const vectorDBEnabledContainer = document.createElement('div'); + vectorDBEnabledContainer.className = 'tracing-enabled-container'; + this.container.appendChild(vectorDBEnabledContainer); + + this.vectorDBEnabledCheckbox = document.createElement('input'); + this.vectorDBEnabledCheckbox.type = 'checkbox'; + this.vectorDBEnabledCheckbox.id = 'vector-db-enabled'; + this.vectorDBEnabledCheckbox.className = 'tracing-checkbox'; + this.vectorDBEnabledCheckbox.checked = localStorage.getItem(VECTOR_DB_ENABLED_KEY) === 'true'; + vectorDBEnabledContainer.appendChild(this.vectorDBEnabledCheckbox); + + const vectorDBEnabledLabel = document.createElement('label'); + vectorDBEnabledLabel.htmlFor = 'vector-db-enabled'; + vectorDBEnabledLabel.className = 'tracing-label'; + vectorDBEnabledLabel.textContent = i18nString('vectorDBEnabled'); + vectorDBEnabledContainer.appendChild(vectorDBEnabledLabel); + + const vectorDBEnabledHint = document.createElement('div'); + vectorDBEnabledHint.className = 'settings-hint'; + vectorDBEnabledHint.textContent = i18nString('vectorDBEnabledHint'); + this.container.appendChild(vectorDBEnabledHint); + + // Vector DB configuration container (shown when enabled) + const vectorDBConfigContainer = document.createElement('div'); + vectorDBConfigContainer.className = 'tracing-config-container'; + vectorDBConfigContainer.style.display = this.vectorDBEnabledCheckbox.checked ? 'block' : 'none'; + this.container.appendChild(vectorDBConfigContainer); + + // Vector DB Endpoint + const vectorDBEndpointDiv = document.createElement('div'); + vectorDBEndpointDiv.classList.add('settings-field'); + vectorDBConfigContainer.appendChild(vectorDBEndpointDiv); + + const vectorDBEndpointLabel = document.createElement('label'); + vectorDBEndpointLabel.textContent = i18nString('vectorDBEndpoint'); + vectorDBEndpointLabel.classList.add('settings-label'); + vectorDBEndpointDiv.appendChild(vectorDBEndpointLabel); + + const vectorDBEndpointHint = document.createElement('div'); + vectorDBEndpointHint.textContent = i18nString('vectorDBEndpointHint'); + vectorDBEndpointHint.classList.add('settings-hint'); + vectorDBEndpointDiv.appendChild(vectorDBEndpointHint); + + this.vectorDBEndpointInput = document.createElement('input'); + this.vectorDBEndpointInput.classList.add('settings-input'); + this.vectorDBEndpointInput.type = 'text'; + this.vectorDBEndpointInput.placeholder = 'http://localhost:19530'; + this.vectorDBEndpointInput.value = localStorage.getItem(MILVUS_ENDPOINT_KEY) || ''; + vectorDBEndpointDiv.appendChild(this.vectorDBEndpointInput); + + // Vector DB API Key (Username) + const vectorDBApiKeyDiv = document.createElement('div'); + vectorDBApiKeyDiv.classList.add('settings-field'); + vectorDBConfigContainer.appendChild(vectorDBApiKeyDiv); + + const vectorDBApiKeyLabel = document.createElement('label'); + vectorDBApiKeyLabel.textContent = i18nString('vectorDBApiKey'); + vectorDBApiKeyLabel.classList.add('settings-label'); + vectorDBApiKeyDiv.appendChild(vectorDBApiKeyLabel); + + const vectorDBApiKeyHint = document.createElement('div'); + vectorDBApiKeyHint.textContent = i18nString('vectorDBApiKeyHint'); + vectorDBApiKeyHint.classList.add('settings-hint'); + vectorDBApiKeyDiv.appendChild(vectorDBApiKeyHint); + + this.vectorDBApiKeyInput = document.createElement('input'); + this.vectorDBApiKeyInput.classList.add('settings-input'); + this.vectorDBApiKeyInput.type = 'text'; + this.vectorDBApiKeyInput.placeholder = 'root'; + this.vectorDBApiKeyInput.value = localStorage.getItem(MILVUS_USERNAME_KEY) || 'root'; + vectorDBApiKeyDiv.appendChild(this.vectorDBApiKeyInput); + + // Milvus Password + const milvusPasswordDiv = document.createElement('div'); + milvusPasswordDiv.classList.add('settings-field'); + vectorDBConfigContainer.appendChild(milvusPasswordDiv); + + const milvusPasswordLabel = document.createElement('label'); + milvusPasswordLabel.textContent = i18nString('milvusPassword'); + milvusPasswordLabel.classList.add('settings-label'); + milvusPasswordDiv.appendChild(milvusPasswordLabel); + + const milvusPasswordHint = document.createElement('div'); + milvusPasswordHint.textContent = i18nString('milvusPasswordHint'); + milvusPasswordHint.classList.add('settings-hint'); + milvusPasswordDiv.appendChild(milvusPasswordHint); + + this.milvusPasswordInput = document.createElement('input'); + this.milvusPasswordInput.classList.add('settings-input'); + this.milvusPasswordInput.type = 'password'; + this.milvusPasswordInput.placeholder = 'Milvus (self-hosted) or API token (cloud)'; + this.milvusPasswordInput.value = localStorage.getItem(MILVUS_PASSWORD_KEY) || 'Milvus'; + milvusPasswordDiv.appendChild(this.milvusPasswordInput); + + // OpenAI API Key for embeddings + const milvusOpenAIDiv = document.createElement('div'); + milvusOpenAIDiv.classList.add('settings-field'); + vectorDBConfigContainer.appendChild(milvusOpenAIDiv); + + const milvusOpenAILabel = document.createElement('label'); + milvusOpenAILabel.textContent = i18nString('milvusOpenAIKey'); + milvusOpenAILabel.classList.add('settings-label'); + milvusOpenAIDiv.appendChild(milvusOpenAILabel); + + const milvusOpenAIHint = document.createElement('div'); + milvusOpenAIHint.textContent = i18nString('milvusOpenAIKeyHint'); + milvusOpenAIHint.classList.add('settings-hint'); + milvusOpenAIDiv.appendChild(milvusOpenAIHint); + + this.milvusOpenAIInput = document.createElement('input'); + this.milvusOpenAIInput.classList.add('settings-input'); + this.milvusOpenAIInput.type = 'password'; + this.milvusOpenAIInput.placeholder = 'sk-...'; + this.milvusOpenAIInput.value = localStorage.getItem(MILVUS_OPENAI_KEY) || ''; + milvusOpenAIDiv.appendChild(this.milvusOpenAIInput); + + // Vector DB Collection Name + const vectorDBCollectionDiv = document.createElement('div'); + vectorDBCollectionDiv.classList.add('settings-field'); + vectorDBConfigContainer.appendChild(vectorDBCollectionDiv); + + const vectorDBCollectionLabel = document.createElement('label'); + vectorDBCollectionLabel.textContent = i18nString('vectorDBCollection'); + vectorDBCollectionLabel.classList.add('settings-label'); + vectorDBCollectionDiv.appendChild(vectorDBCollectionLabel); + + const vectorDBCollectionHint = document.createElement('div'); + vectorDBCollectionHint.textContent = i18nString('vectorDBCollectionHint'); + vectorDBCollectionHint.classList.add('settings-hint'); + vectorDBCollectionDiv.appendChild(vectorDBCollectionHint); + + this.vectorDBCollectionInput = document.createElement('input'); + this.vectorDBCollectionInput.classList.add('settings-input'); + this.vectorDBCollectionInput.type = 'text'; + this.vectorDBCollectionInput.placeholder = 'bookmarks'; + this.vectorDBCollectionInput.value = localStorage.getItem(MILVUS_COLLECTION_KEY) || 'bookmarks'; + vectorDBCollectionDiv.appendChild(this.vectorDBCollectionInput); + + // Test Vector DB Connection Button + const vectorDBTestDiv = document.createElement('div'); + vectorDBTestDiv.classList.add('settings-field', 'test-connection-field'); + vectorDBConfigContainer.appendChild(vectorDBTestDiv); + + const vectorDBTestButton = document.createElement('button'); + vectorDBTestButton.classList.add('settings-button', 'test-button'); + vectorDBTestButton.setAttribute('type', 'button'); + vectorDBTestButton.textContent = i18nString('testVectorDBConnection'); + vectorDBTestDiv.appendChild(vectorDBTestButton); + + const vectorDBTestStatus = document.createElement('div'); + vectorDBTestStatus.classList.add('settings-status'); + vectorDBTestStatus.style.display = 'none'; + vectorDBTestDiv.appendChild(vectorDBTestStatus); + + // Toggle vector DB config visibility + this.vectorDBEnabledCheckbox.addEventListener('change', () => { + vectorDBConfigContainer.style.display = this.vectorDBEnabledCheckbox!.checked ? 'block' : 'none'; + localStorage.setItem(VECTOR_DB_ENABLED_KEY, this.vectorDBEnabledCheckbox!.checked.toString()); + }); + + // Save Vector DB settings on input change + const saveVectorDBSettings = () => { + if (!this.vectorDBEnabledCheckbox || !this.vectorDBEndpointInput || !this.vectorDBApiKeyInput || + !this.milvusPasswordInput || !this.vectorDBCollectionInput || !this.milvusOpenAIInput) { + return; + } + + localStorage.setItem(VECTOR_DB_ENABLED_KEY, this.vectorDBEnabledCheckbox.checked.toString()); + localStorage.setItem(MILVUS_ENDPOINT_KEY, this.vectorDBEndpointInput.value); + localStorage.setItem(MILVUS_USERNAME_KEY, this.vectorDBApiKeyInput.value); + localStorage.setItem(MILVUS_PASSWORD_KEY, this.milvusPasswordInput.value); + localStorage.setItem(MILVUS_COLLECTION_KEY, this.vectorDBCollectionInput.value); + localStorage.setItem(MILVUS_OPENAI_KEY, this.milvusOpenAIInput.value); + }; + + this.vectorDBEndpointInput.addEventListener('input', saveVectorDBSettings); + this.vectorDBApiKeyInput.addEventListener('input', saveVectorDBSettings); + this.milvusPasswordInput.addEventListener('input', saveVectorDBSettings); + this.vectorDBCollectionInput.addEventListener('input', saveVectorDBSettings); + this.milvusOpenAIInput.addEventListener('input', saveVectorDBSettings); + + // Test Vector DB connection + vectorDBTestButton.addEventListener('click', async () => { + if (!this.vectorDBEndpointInput || !this.vectorDBApiKeyInput || + !this.milvusPasswordInput || !this.vectorDBCollectionInput || !this.milvusOpenAIInput) { + return; + } + + const endpoint = this.vectorDBEndpointInput.value.trim(); + + if (!endpoint) { + vectorDBTestStatus.textContent = 'Please enter an endpoint URL'; + vectorDBTestStatus.style.color = 'var(--color-accent-red)'; + vectorDBTestStatus.style.display = 'block'; + setTimeout(() => { + vectorDBTestStatus.style.display = 'none'; + }, 3000); + return; + } + + vectorDBTestButton.disabled = true; + vectorDBTestStatus.textContent = i18nString('testingVectorDBConnection'); + vectorDBTestStatus.style.color = 'var(--color-text-secondary)'; + vectorDBTestStatus.style.display = 'block'; + + try { + // Import and test the Vector DB client + const { VectorDBClient } = await import('../../../tools/VectorDBClient.js'); + const vectorClient = new VectorDBClient({ + endpoint, + username: this.vectorDBApiKeyInput.value || 'root', + password: this.milvusPasswordInput.value || 'Milvus', + collection: this.vectorDBCollectionInput.value || 'bookmarks', + openaiApiKey: this.milvusOpenAIInput.value || undefined + }); + + const testResult = await vectorClient.testConnection(); + + if (testResult.success) { + vectorDBTestStatus.textContent = i18nString('vectorDBConnectionSuccess'); + vectorDBTestStatus.style.color = 'var(--color-accent-green)'; + } else { + vectorDBTestStatus.textContent = `${i18nString('vectorDBConnectionFailed')}: ${testResult.error}`; + vectorDBTestStatus.style.color = 'var(--color-accent-red)'; + } + } catch (error: any) { + vectorDBTestStatus.textContent = `${i18nString('vectorDBConnectionFailed')}: ${error.message}`; + vectorDBTestStatus.style.color = 'var(--color-accent-red)'; + } finally { + vectorDBTestButton.disabled = false; + setTimeout(() => { + vectorDBTestStatus.style.display = 'none'; + }, 5000); + } + }); + } + + save(): void { + // Vector DB settings are auto-saved on input change + // No need to save on dialog save + } + + cleanup(): void { + // No cleanup needed + } +} diff --git a/front_end/panels/ai_chat/ui/settings/components/AdvancedToggle.ts b/front_end/panels/ai_chat/ui/settings/components/AdvancedToggle.ts new file mode 100644 index 0000000000..afcb48a53f --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/components/AdvancedToggle.ts @@ -0,0 +1,80 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { ADVANCED_SETTINGS_ENABLED_KEY } from '../constants.js'; +import { getStorageBoolean, setStorageBoolean } from '../utils/storage.js'; + +/** + * Advanced toggle elements + */ +export interface AdvancedToggleElements { + section: HTMLElement; + checkbox: HTMLInputElement; + label: HTMLLabelElement; +} + +/** + * Create the advanced settings toggle + * + * @param container - Parent element to append the toggle to + * @param onChange - Callback function when toggle state changes + * @returns Object containing toggle elements + */ +export function createAdvancedToggle( + container: HTMLElement, + onChange: (enabled: boolean) => void, +): AdvancedToggleElements { + const advancedToggleSection = document.createElement('div'); + advancedToggleSection.className = 'advanced-settings-toggle-section'; + container.appendChild(advancedToggleSection); + + const advancedToggleContainer = document.createElement('div'); + advancedToggleContainer.className = 'advanced-settings-toggle-container'; + advancedToggleSection.appendChild(advancedToggleContainer); + + const advancedToggleCheckbox = document.createElement('input'); + advancedToggleCheckbox.type = 'checkbox'; + advancedToggleCheckbox.id = 'advanced-settings-toggle'; + advancedToggleCheckbox.className = 'advanced-settings-checkbox'; + advancedToggleCheckbox.checked = getStorageBoolean(ADVANCED_SETTINGS_ENABLED_KEY, false); + advancedToggleContainer.appendChild(advancedToggleCheckbox); + + const advancedToggleLabel = document.createElement('label'); + advancedToggleLabel.htmlFor = 'advanced-settings-toggle'; + advancedToggleLabel.className = 'advanced-settings-label'; + advancedToggleLabel.textContent = '⚙️ Advanced Settings'; + advancedToggleContainer.appendChild(advancedToggleLabel); + + const advancedToggleHint = document.createElement('div'); + advancedToggleHint.className = 'settings-hint'; + advancedToggleHint.textContent = + 'Show advanced configuration options (Browsing History, Vector DB, Tracing, Evaluation)'; + advancedToggleSection.appendChild(advancedToggleHint); + + // Add event listener for toggle + advancedToggleCheckbox.addEventListener('change', () => { + const isEnabled = advancedToggleCheckbox.checked; + setStorageBoolean(ADVANCED_SETTINGS_ENABLED_KEY, isEnabled); + onChange(isEnabled); + }); + + return { + section: advancedToggleSection, + checkbox: advancedToggleCheckbox, + label: advancedToggleLabel, + }; +} + +/** + * Toggle visibility of advanced sections + * + * @param sections - Array of sections to toggle + * @param show - Whether to show or hide the sections + */ +export function toggleAdvancedSections(sections: HTMLElement[], show: boolean): void { + const display = show ? 'block' : 'none'; + sections.forEach(section => { + section.style.display = display; + }); +} diff --git a/front_end/panels/ai_chat/ui/settings/components/ModelSelectorFactory.ts b/front_end/panels/ai_chat/ui/settings/components/ModelSelectorFactory.ts new file mode 100644 index 0000000000..f8daad5a14 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/components/ModelSelectorFactory.ts @@ -0,0 +1,121 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import type { ModelOption, ModelSelectorElement } from '../types.js'; +import '../../model_selector/ModelSelector.js'; + +/** + * Create a model selector component + * + * @param container - Parent element to append the selector to + * @param labelText - Label text for the selector + * @param description - Description text below the label + * @param selectorType - Semantic identifier for the selector + * @param modelOptions - Available model options + * @param selectedModel - Currently selected model value + * @param defaultOptionText - Text for the default option + * @param onFocus - Optional callback for when the selector is opened/focused + * @returns The created model selector element + */ +export function createModelSelector( + container: HTMLElement, + labelText: string, + description: string, + selectorType: string, + modelOptions: ModelOption[], + selectedModel: string, + defaultOptionText: string, + onFocus?: () => void, +): HTMLElement { + const modelContainer = document.createElement('div'); + modelContainer.className = 'model-selection-container'; + container.appendChild(modelContainer); + + const modelLabel = document.createElement('div'); + modelLabel.className = 'settings-label'; + modelLabel.textContent = labelText; + modelContainer.appendChild(modelLabel); + + const modelDescription = document.createElement('div'); + modelDescription.className = 'settings-hint'; + modelDescription.textContent = description; + modelContainer.appendChild(modelDescription); + + const selectorEl = document.createElement('ai-model-selector') as unknown as ModelSelectorElement; + selectorEl.dataset.modelType = selectorType; + selectorEl.options = [{value: '', label: defaultOptionText}, ...modelOptions]; + selectorEl.selected = selectedModel || ''; + selectorEl.forceSearchable = true; // Ensure consistent UI in Settings + + // Expose a `.value` API similar to native fallback + const nativeSelect = select as HTMLSelectElement; + const previousValue = nativeSelect.value; + while (nativeSelect.options.length > 1) { + nativeSelect.remove(1); + } + models.forEach((option: ModelOption) => { + const optionElement = document.createElement('option'); + optionElement.value = option.value; + optionElement.textContent = option.label; + nativeSelect.appendChild(optionElement); + }); + if (previousValue && Array.from(nativeSelect.options).some((opt) => opt.value === previousValue)) { + nativeSelect.value = previousValue; + } else if (currentValue && Array.from(nativeSelect.options).some((opt) => opt.value === currentValue)) { + nativeSelect.value = currentValue; + } +} diff --git a/front_end/panels/ai_chat/ui/settings/components/SettingsFooter.ts b/front_end/panels/ai_chat/ui/settings/components/SettingsFooter.ts new file mode 100644 index 0000000000..e13213ff0e --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/components/SettingsFooter.ts @@ -0,0 +1,100 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Settings footer elements + */ +export interface SettingsFooterElements { + container: HTMLElement; + statusMessage: HTMLElement; + cancelButton: HTMLButtonElement; + saveButton: HTMLButtonElement; +} + +/** + * Create the settings dialog footer with save/cancel buttons + * + * @param container - Parent element to append the footer to + * @param onCancel - Callback function when cancel button is clicked + * @param onSave - Callback function when save button is clicked + * @returns Object containing footer elements + */ +export function createSettingsFooter( + container: HTMLElement, + onCancel: () => void, + onSave: () => void, +): SettingsFooterElements { + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'settings-footer'; + container.appendChild(buttonContainer); + + // Status message for save operation + const saveStatusMessage = document.createElement('div'); + saveStatusMessage.className = 'settings-status save-status'; + saveStatusMessage.style.display = 'none'; + saveStatusMessage.style.marginRight = 'auto'; // Push to left + buttonContainer.appendChild(saveStatusMessage); + + // Cancel button + const cancelButton = document.createElement('button'); + cancelButton.textContent = 'Cancel'; + cancelButton.className = 'settings-button cancel-button'; + cancelButton.setAttribute('type', 'button'); + cancelButton.addEventListener('click', onCancel); + buttonContainer.appendChild(cancelButton); + + // Save button + const saveButton = document.createElement('button'); + saveButton.textContent = 'Save'; + saveButton.className = 'settings-button save-button'; + saveButton.setAttribute('type', 'button'); + saveButton.addEventListener('click', onSave); + buttonContainer.appendChild(saveButton); + + return { + container: buttonContainer, + statusMessage: saveStatusMessage, + cancelButton, + saveButton, + }; +} + +/** + * Show footer status message + * + * @param statusElement - The status message element + * @param message - Message to display + * @param type - Type of message (info, success, error) + * @param duration - How long to show the message (ms), 0 = don't auto-hide + */ +export function showFooterStatus( + statusElement: HTMLElement, + message: string, + type: 'info' | 'success' | 'error' = 'info', + duration: number = 3000, +): void { + statusElement.textContent = message; + statusElement.style.display = 'block'; + + // Set colors based on type + switch (type) { + case 'success': + statusElement.style.backgroundColor = 'var(--color-accent-green-background)'; + statusElement.style.color = 'var(--color-accent-green)'; + break; + case 'error': + statusElement.style.backgroundColor = 'var(--color-accent-red-background)'; + statusElement.style.color = 'var(--color-accent-red)'; + break; + default: + statusElement.style.backgroundColor = 'var(--color-accent-blue-background)'; + statusElement.style.color = 'var(--color-accent-blue)'; + } + + if (duration > 0) { + setTimeout(() => { + statusElement.style.display = 'none'; + }, duration); + } +} diff --git a/front_end/panels/ai_chat/ui/settings/components/SettingsHeader.ts b/front_end/panels/ai_chat/ui/settings/components/SettingsHeader.ts new file mode 100644 index 0000000000..624bb0831e --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/components/SettingsHeader.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { i18nString } from '../i18n-strings.js'; + +/** + * Create the settings dialog header + * + * @param container - Parent element to append the header to + * @param onClose - Callback function when close button is clicked + * @returns The created header element + */ +export function createSettingsHeader( + container: HTMLElement, + onClose: () => void, +): HTMLElement { + const headerDiv = document.createElement('div'); + headerDiv.className = 'settings-header'; + container.appendChild(headerDiv); + + const title = document.createElement('h2'); + title.className = 'settings-title'; + title.textContent = i18nString({ settings: 'Settings' }.settings); + headerDiv.appendChild(title); + + const closeButton = document.createElement('button'); + closeButton.className = 'settings-close-button'; + closeButton.setAttribute('aria-label', 'Close settings'); + closeButton.textContent = '×'; + closeButton.addEventListener('click', onClose); + headerDiv.appendChild(closeButton); + + return headerDiv; +} diff --git a/front_end/panels/ai_chat/ui/settings/constants.ts b/front_end/panels/ai_chat/ui/settings/constants.ts new file mode 100644 index 0000000000..e3d610b577 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/constants.ts @@ -0,0 +1,43 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Local storage keys for model selection + */ +export const MINI_MODEL_STORAGE_KEY = 'ai_chat_mini_model'; +export const NANO_MODEL_STORAGE_KEY = 'ai_chat_nano_model'; + +/** + * Local storage keys for provider configuration + */ +export const PROVIDER_SELECTION_KEY = 'ai_chat_provider'; + +/** + * Local storage keys for API keys + */ +export const OPENAI_API_KEY_STORAGE_KEY = 'ai_chat_api_key'; +export const LITELLM_ENDPOINT_KEY = 'ai_chat_litellm_endpoint'; +export const LITELLM_API_KEY_STORAGE_KEY = 'ai_chat_litellm_api_key'; +export const GROQ_API_KEY_STORAGE_KEY = 'ai_chat_groq_api_key'; +export const OPENROUTER_API_KEY_STORAGE_KEY = 'ai_chat_openrouter_api_key'; + +/** + * Cache constants + */ +export const OPENROUTER_MODELS_CACHE_DURATION_MS = 60 * 60 * 1000; // 60 minutes + +/** + * Vector DB configuration keys - Milvus format + */ +export const VECTOR_DB_ENABLED_KEY = 'ai_chat_vector_db_enabled'; +export const MILVUS_ENDPOINT_KEY = 'ai_chat_milvus_endpoint'; +export const MILVUS_USERNAME_KEY = 'ai_chat_milvus_username'; +export const MILVUS_PASSWORD_KEY = 'ai_chat_milvus_password'; +export const MILVUS_COLLECTION_KEY = 'ai_chat_milvus_collection'; +export const MILVUS_OPENAI_KEY = 'ai_chat_milvus_openai_key'; + +/** + * Advanced settings toggle key + */ +export const ADVANCED_SETTINGS_ENABLED_KEY = 'ai_chat_advanced_settings_enabled'; diff --git a/front_end/panels/ai_chat/ui/settings/i18n-strings.ts b/front_end/panels/ai_chat/ui/settings/i18n-strings.ts new file mode 100644 index 0000000000..da4e21249b --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/i18n-strings.ts @@ -0,0 +1,458 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as i18n from '../../../../core/i18n/i18n.js'; + +/** + * UI Strings for Settings Dialog + */ +export const UIStrings = { + /** + *@description Settings dialog title + */ + settings: 'Settings', + /** + *@description Provider selection label + */ + providerLabel: 'Provider', + /** + *@description Provider selection hint + */ + providerHint: 'Select which AI provider to use', + /** + *@description OpenAI provider option + */ + openaiProvider: 'OpenAI', + /** + *@description LiteLLM provider option + */ + litellmProvider: 'LiteLLM', + /** + *@description Groq provider option + */ + groqProvider: 'Groq', + /** + *@description OpenRouter provider option + */ + openrouterProvider: 'OpenRouter', + /** + *@description LiteLLM API Key label + */ + liteLLMApiKey: 'LiteLLM API Key', + /** + *@description LiteLLM API Key hint + */ + liteLLMApiKeyHint: 'Your LiteLLM API key for authentication (optional)', + /** + *@description LiteLLM endpoint label + */ + litellmEndpointLabel: 'LiteLLM Endpoint', + /** + *@description LiteLLM endpoint hint + */ + litellmEndpointHint: 'Enter the URL for your LiteLLM server (e.g., http://localhost:4000 or https://your-litellm-server.com)', + /** + *@description Groq API Key label + */ + groqApiKeyLabel: 'Groq API Key', + /** + *@description Groq API Key hint + */ + groqApiKeyHint: 'Your Groq API key for authentication', + /** + *@description Fetch Groq models button text + */ + fetchGroqModelsButton: 'Fetch Groq Models', + /** + *@description OpenRouter API Key label + */ + openrouterApiKeyLabel: 'OpenRouter API Key', + /** + *@description OpenRouter API Key hint + */ + openrouterApiKeyHint: 'Your OpenRouter API key for authentication', + /** + *@description Fetch OpenRouter models button text + */ + fetchOpenRouterModelsButton: 'Fetch OpenRouter Models', + /** + *@description OpenAI API Key label + */ + apiKeyLabel: 'OpenAI API Key', + /** + *@description OpenAI API Key hint + */ + apiKeyHint: 'An OpenAI API key is required for OpenAI models (GPT-4.1, O4 Mini, etc.)', + /** + *@description Test button text + */ + testButton: 'Test', + /** + *@description Add button text + */ + addButton: 'Add', + /** + *@description Remove button text + */ + removeButton: 'Remove', + /** + *@description Fetch models button text + */ + fetchModelsButton: 'Fetch LiteLLM Models', + /** + *@description Fetching models status + */ + fetchingModels: 'Fetching models...', + /** + *@description Wildcard models only message + */ + wildcardModelsOnly: 'LiteLLM proxy returned wildcard model only. Please add custom models below.', + /** + *@description Wildcard and custom models message + */ + wildcardAndCustomModels: 'Fetched wildcard model (custom models available)', + /** + *@description Wildcard and other models message with count + */ + wildcardAndOtherModels: 'Fetched {PH1} models plus wildcard', + /** + *@description Fetched models message with count + */ + fetchedModels: 'Fetched {PH1} models', + /** + *@description LiteLLM endpoint required error + */ + endpointRequired: 'LiteLLM endpoint is required to test model', + /** + *@description Custom models label + */ + customModelsLabel: 'Custom Models', + /** + *@description Custom models hint + */ + customModelsHint: 'Add custom models one at a time.', + /** + *@description Mini model label + */ + miniModelLabel: 'Mini Model', + /** + *@description Mini model description + */ + miniModelDescription: 'Used for fast operations, tools, and sub-tasks', + /** + *@description Nano model label + */ + nanoModelLabel: 'Nano Model', + /** + *@description Nano model description + */ + nanoModelDescription: 'Used for very fast operations and simple tasks', + /** + *@description Default mini model option + */ + defaultMiniOption: 'Use default (main model)', + /** + *@description Default nano model option + */ + defaultNanoOption: 'Use default (mini model or main model)', + /** + *@description Browsing history section title + */ + browsingHistoryTitle: 'Browsing History', + /** + *@description Browsing history description + */ + browsingHistoryDescription: 'Your browsing history is stored locally to enable search by domains and keywords.', + /** + *@description Clear browsing history button + */ + clearHistoryButton: 'Clear Browsing History', + /** + *@description History cleared message + */ + historyCleared: 'Browsing history cleared successfully', + /** + *@description Important notice title + */ + importantNotice: 'Important Notice', + /** + *@description Vector DB section label + */ + vectorDBLabel: 'Vector Database Configuration', + /** + *@description Vector DB enabled label + */ + vectorDBEnabled: 'Enable Vector Database', + /** + *@description Vector DB enabled hint + */ + vectorDBEnabledHint: 'Enable Vector Database for semantic search of websites', + /** + *@description Milvus endpoint label + */ + vectorDBEndpoint: 'Milvus Endpoint', + /** + *@description Milvus endpoint hint + */ + vectorDBEndpointHint: 'Enter the URL for your Milvus server (e.g., http://localhost:19530 or https://your-milvus.com)', + /** + *@description Milvus username label + */ + vectorDBApiKey: 'Milvus Username', + /** + *@description Milvus username hint + */ + vectorDBApiKeyHint: 'For self-hosted: username (default: root). For Milvus Cloud: leave as root', + /** + *@description Vector DB collection label + */ + vectorDBCollection: 'Collection Name', + /** + *@description Vector DB collection hint + */ + vectorDBCollectionHint: 'Name of the collection to store websites (default: bookmarks)', + /** + *@description Milvus password/token label + */ + milvusPassword: 'Password/API Token', + /** + *@description Milvus password/token hint + */ + milvusPasswordHint: 'For self-hosted: password (default: Milvus). For Milvus Cloud: API token directly', + /** + *@description OpenAI API key for embeddings label + */ + milvusOpenAIKey: 'OpenAI API Key (for embeddings)', + /** + *@description OpenAI API key for embeddings hint + */ + milvusOpenAIKeyHint: 'Required for generating embeddings using OpenAI text-embedding-3-small model', + /** + *@description Test vector DB connection button + */ + testVectorDBConnection: 'Test Connection', + /** + *@description Vector DB connection testing status + */ + testingVectorDBConnection: 'Testing connection...', + /** + *@description Vector DB connection success message + */ + vectorDBConnectionSuccess: 'Vector DB connection successful!', + /** + *@description Vector DB connection failed message + */ + vectorDBConnectionFailed: 'Vector DB connection failed', + /** + *@description Tracing section title + */ + tracingSection: 'Tracing Configuration', + /** + *@description Tracing enabled label + */ + tracingEnabled: 'Enable Tracing', + /** + *@description Tracing enabled hint + */ + tracingEnabledHint: 'Enable observability tracing for AI Chat interactions', + /** + *@description Langfuse endpoint label + */ + langfuseEndpoint: 'Langfuse Endpoint', + /** + *@description Langfuse endpoint hint + */ + langfuseEndpointHint: 'URL of your Langfuse server (e.g., http://localhost:3000)', + /** + *@description Langfuse public key label + */ + langfusePublicKey: 'Langfuse Public Key', + /** + *@description Langfuse public key hint + */ + langfusePublicKeyHint: 'Your Langfuse project public key (starts with pk-lf-)', + /** + *@description Langfuse secret key label + */ + langfuseSecretKey: 'Langfuse Secret Key', + /** + *@description Langfuse secret key hint + */ + langfuseSecretKeyHint: 'Your Langfuse project secret key (starts with sk-lf-)', + /** + *@description Test tracing button + */ + testTracing: 'Test Connection', + /** + *@description Evaluation section title + */ + evaluationSection: 'Evaluation Configuration', + /** + *@description Evaluation enabled label + */ + evaluationEnabled: 'Enable Evaluation', + /** + *@description Evaluation enabled hint + */ + evaluationEnabledHint: 'Enable evaluation service connection for AI Chat interactions', + /** + *@description Evaluation endpoint label + */ + evaluationEndpoint: 'Evaluation Endpoint', + /** + *@description Evaluation endpoint hint + */ + evaluationEndpointHint: 'WebSocket endpoint for the evaluation service (e.g., ws://localhost:8080)', + /** + *@description Evaluation secret key label + */ + evaluationSecretKey: 'Evaluation Secret Key', + /** + *@description Evaluation secret key hint + */ + evaluationSecretKeyHint: 'Secret key for authentication with the evaluation service (optional)', + /** + *@description Evaluation connection status + */ + evaluationConnectionStatus: 'Connection Status', + /** + *@description MCP section title + */ + mcpSection: 'MCP Integration', + /** + *@description MCP enabled label + */ + mcpEnabled: 'Enable MCP Integration', + /** + *@description MCP enabled hint + */ + mcpEnabledHint: 'Enable MCP client to discover and call tools via Model Context Protocol', + /** + *@description MCP connections header label + */ + mcpConnectionsHeader: 'Connections', + /** + *@description MCP connections hint text + */ + mcpConnectionsHint: 'Configure one or more MCP servers. OAuth flows use PKCE automatically.', + /** + *@description MCP manage connections button text + */ + mcpManageConnections: 'Manage connections', + /** + *@description MCP refresh connections button text + */ + mcpRefreshConnections: 'Reconnect all', + /** + *@description MCP individual reconnect button text + */ + mcpReconnectButton: 'Reconnect', + /** + *@description MCP individual reconnect button text while in progress + */ + mcpReconnectInProgress: 'Reconnecting…', + /** + *@description MCP individual reconnect button failure state text + */ + mcpReconnectRetry: 'Retry reconnect', + /** + *@description MCP discovered tools label + */ + mcpDiscoveredTools: 'Discovered Tools', + /** + *@description MCP discovered tools hint + */ + mcpDiscoveredToolsHint: 'Select which MCP tools to make available to agents', + /** + *@description MCP no tools message + */ + mcpNoTools: 'No tools discovered. Connect to an MCP server first.', + + /** + *@description MCP tool mode label + */ + mcpToolMode: 'Tool Selection Mode', + /** + *@description MCP tool mode hint + */ + mcpToolModeHint: 'Choose how MCP tools are selected and surfaced to agents', + /** + *@description MCP tool mode all option + */ + mcpToolModeAll: 'All Tools - Surface all available MCP tools (may impact performance)', + /** + *@description MCP tool mode router option + */ + mcpToolModeRouter: 'Smart Router - Use LLM to select most relevant tools each turn (recommended)', + /** + *@description MCP tool mode meta option + */ + mcpToolModeMeta: 'Meta Tools - Use mcp.search/mcp.invoke for dynamic discovery (best for large catalogs)', + /** + *@description MCP max tools per turn label + */ + mcpMaxToolsPerTurn: 'Max Tools Per Turn', + /** + *@description MCP max tools per turn hint + */ + mcpMaxToolsPerTurnHint: 'Maximum number of tools to surface to agents in a single turn (default: 20)', + /** + *@description MCP max MCP tools per turn label + */ + mcpMaxMcpPerTurn: 'Max MCP Tools Per Turn', + /** + *@description MCP max MCP tools per turn hint + */ + mcpMaxMcpPerTurnHint: 'Maximum number of MCP tools to include in tool selection (default: 8)', + /** + *@description MCP auth type label + */ + mcpAuthType: 'Authentication Method', + /** + *@description MCP auth type hint + */ + mcpAuthTypeHint: 'Choose how to authenticate with your MCP server', + /** + *@description MCP bearer option + */ + mcpAuthBearer: 'Bearer token', + /** + *@description MCP OAuth option + */ + mcpAuthOAuth: 'OAuth (redirect to provider)', + /** + *@description MCP OAuth client ID label + */ + mcpOAuthClientId: 'OAuth Client ID', + /** + *@description MCP OAuth client ID hint + */ + mcpOAuthClientIdHint: 'Pre-registered public client ID for this MCP server (no secret).', + /** + *@description MCP OAuth redirect URL label + */ + mcpOAuthRedirect: 'OAuth Redirect URL', + /** + *@description MCP OAuth redirect URL hint + */ + mcpOAuthRedirectHint: 'Must match the redirect URI registered with the provider (default: https://localhost:3000/callback).', + /** + *@description MCP OAuth scope label + */ + mcpOAuthScope: 'OAuth Scope (optional)', + /** + *@description MCP OAuth scope hint + */ + mcpOAuthScopeHint: 'Provider-specific scopes, space-separated. Leave empty if unsure.', +}; + +/** + * Registered UI strings for i18n + */ +const str_ = i18n.i18n.registerUIStrings('panels/ai_chat/ui/settings/i18n-strings.ts', UIStrings); + +/** + * Get localized string function + */ +export const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); diff --git a/front_end/panels/ai_chat/ui/settings/providers/BaseProviderSettings.ts b/front_end/panels/ai_chat/ui/settings/providers/BaseProviderSettings.ts new file mode 100644 index 0000000000..f42e380f39 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/providers/BaseProviderSettings.ts @@ -0,0 +1,78 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import type { + ModelOption, + ProviderType, + GetModelOptionsFunction, + AddCustomModelOptionFunction, + RemoveCustomModelOptionFunction, +} from '../types.js'; +import { i18nString } from '../i18n-strings.js'; +import { createLogger } from '../../../core/Logger.js'; + +const logger = createLogger('BaseProviderSettings'); + +/** + * Base class for provider-specific settings + */ +export abstract class BaseProviderSettings { + protected container: HTMLElement; + protected providerType: ProviderType; + protected getModelOptions: GetModelOptionsFunction; + protected addCustomModelOption: AddCustomModelOptionFunction; + protected removeCustomModelOption: RemoveCustomModelOptionFunction; + protected miniModelSelector: HTMLElement | null = null; + protected nanoModelSelector: HTMLElement | null = null; + + constructor( + container: HTMLElement, + providerType: ProviderType, + getModelOptions: GetModelOptionsFunction, + addCustomModelOption: AddCustomModelOptionFunction, + removeCustomModelOption: RemoveCustomModelOptionFunction, + ) { + this.container = container; + this.providerType = providerType; + this.getModelOptions = getModelOptions; + this.addCustomModelOption = addCustomModelOption; + this.removeCustomModelOption = removeCustomModelOption; + } + + /** + * Render the provider settings UI + */ + abstract render(): void; + + /** + * Update model selectors with latest models + */ + abstract updateModelSelectors(): void; + + /** + * Get the currently selected mini model + */ + getMiniModel(): string { + return this.miniModelSelector ? (this.miniModelSelector as any).value || '' : ''; + } + + /** + * Get the currently selected nano model + */ + getNanoModel(): string { + return this.nanoModelSelector ? (this.nanoModelSelector as any).value || '' : ''; + } + + /** + * Save provider-specific settings to localStorage + */ + abstract save(): void; + + /** + * Clean up resources (event listeners, intervals, etc.) + */ + cleanup(): void { + // Base cleanup - subclasses can override + } +} diff --git a/front_end/panels/ai_chat/ui/settings/providers/GroqSettings.ts b/front_end/panels/ai_chat/ui/settings/providers/GroqSettings.ts new file mode 100644 index 0000000000..a74c118e65 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/providers/GroqSettings.ts @@ -0,0 +1,234 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { BaseProviderSettings } from './BaseProviderSettings.js'; +import { createModelSelector, refreshModelSelectOptions } from '../components/ModelSelectorFactory.js'; +import { i18nString } from '../i18n-strings.js'; +import { getValidModelForProvider } from '../utils/validation.js'; +import { getStorageItem, setStorageItem } from '../utils/storage.js'; +import { GROQ_API_KEY_STORAGE_KEY, MINI_MODEL_STORAGE_KEY, NANO_MODEL_STORAGE_KEY } from '../constants.js'; +import type { UpdateModelOptionsFunction, GetModelOptionsFunction, AddCustomModelOptionFunction, RemoveCustomModelOptionFunction, ModelOption } from '../types.js'; +import { LLMClient } from '../../../LLM/LLMClient.js'; +import { createLogger } from '../../../core/Logger.js'; + +const logger = createLogger('GroqSettings'); + +/** + * Groq provider settings + * + * Migrated from SettingsDialog.ts lines 1938-2125 + */ +export class GroqSettings extends BaseProviderSettings { + private apiKeyInput: HTMLInputElement | null = null; + private fetchModelsButton: HTMLButtonElement | null = null; + private fetchModelsStatus: HTMLElement | null = null; + private updateModelOptions: UpdateModelOptionsFunction; + + constructor( + container: HTMLElement, + getModelOptions: GetModelOptionsFunction, + addCustomModelOption: AddCustomModelOptionFunction, + removeCustomModelOption: RemoveCustomModelOptionFunction, + updateModelOptions: UpdateModelOptionsFunction + ) { + super(container, 'groq', getModelOptions, addCustomModelOption, removeCustomModelOption); + this.updateModelOptions = updateModelOptions; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + + // Setup Groq content + const groqSettingsSection = document.createElement('div'); + groqSettingsSection.className = 'settings-section'; + this.container.appendChild(groqSettingsSection); + + // Groq API Key + const groqApiKeyLabel = document.createElement('div'); + groqApiKeyLabel.className = 'settings-label'; + groqApiKeyLabel.textContent = i18nString('groqApiKeyLabel'); + groqSettingsSection.appendChild(groqApiKeyLabel); + + const groqApiKeyHint = document.createElement('div'); + groqApiKeyHint.className = 'settings-hint'; + groqApiKeyHint.textContent = i18nString('groqApiKeyHint'); + groqSettingsSection.appendChild(groqApiKeyHint); + + const settingsSavedGroqApiKey = getStorageItem(GROQ_API_KEY_STORAGE_KEY, ''); + this.apiKeyInput = document.createElement('input'); + this.apiKeyInput.className = 'settings-input groq-api-key-input'; + this.apiKeyInput.type = 'password'; + this.apiKeyInput.placeholder = 'Enter your Groq API key'; + this.apiKeyInput.value = settingsSavedGroqApiKey; + groqSettingsSection.appendChild(this.apiKeyInput); + + // Fetch Groq models button + const groqFetchButtonContainer = document.createElement('div'); + groqFetchButtonContainer.className = 'fetch-button-container'; + groqSettingsSection.appendChild(groqFetchButtonContainer); + + this.fetchModelsButton = document.createElement('button'); + this.fetchModelsButton.className = 'settings-button'; + this.fetchModelsButton.setAttribute('type', 'button'); + this.fetchModelsButton.textContent = i18nString('fetchGroqModelsButton'); + this.fetchModelsButton.disabled = !this.apiKeyInput.value.trim(); + groqFetchButtonContainer.appendChild(this.fetchModelsButton); + + this.fetchModelsStatus = document.createElement('div'); + this.fetchModelsStatus.className = 'settings-status'; + this.fetchModelsStatus.style.display = 'none'; + groqFetchButtonContainer.appendChild(this.fetchModelsStatus); + + // Update button state when API key changes + this.apiKeyInput.addEventListener('input', () => { + if (this.fetchModelsButton && this.apiKeyInput) { + this.fetchModelsButton.disabled = !this.apiKeyInput.value.trim(); + } + }); + + // Add click handler for fetch Groq models button + this.fetchModelsButton.addEventListener('click', async () => { + if (!this.fetchModelsButton || !this.fetchModelsStatus || !this.apiKeyInput) return; + + this.fetchModelsButton.disabled = true; + this.fetchModelsStatus.textContent = i18nString('fetchingModels'); + this.fetchModelsStatus.style.display = 'block'; + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-blue-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-blue)'; + + try { + const groqApiKey = this.apiKeyInput.value.trim(); + + // Fetch Groq models using LLMClient static method + const groqModels = await LLMClient.fetchGroqModels(groqApiKey); + + // Convert Groq models to ModelOption format + const modelOptions: ModelOption[] = groqModels.map(model => ({ + value: model.id, + label: model.id, + type: 'groq' as const + })); + + // Update model options with fetched Groq models + this.updateModelOptions(modelOptions, false); + + // Get all Groq models including any custom ones + const allGroqModels = this.getModelOptions('groq'); + const actualModelCount = groqModels.length; + + // Get current mini and nano models from storage + const miniModel = getStorageItem(MINI_MODEL_STORAGE_KEY, ''); + const nanoModel = getStorageItem(NANO_MODEL_STORAGE_KEY, ''); + + // Refresh existing model selectors with new options if they exist + if (this.miniModelSelector) { + refreshModelSelectOptions(this.miniModelSelector as any, allGroqModels, miniModel, i18nString('defaultMiniOption')); + } + if (this.nanoModelSelector) { + refreshModelSelectOptions(this.nanoModelSelector as any, allGroqModels, nanoModel, i18nString('defaultNanoOption')); + } + + this.fetchModelsStatus.textContent = i18nString('fetchedModels', {PH1: actualModelCount}); + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-green)'; + + // Update Groq model selections + this.updateModelSelectors(); + + } catch (error) { + logger.error('Failed to fetch Groq models:', error); + this.fetchModelsStatus.textContent = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-red-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-red)'; + } finally { + if (this.fetchModelsButton && this.apiKeyInput) { + this.fetchModelsButton.disabled = !this.apiKeyInput.value.trim(); + } + setTimeout(() => { + if (this.fetchModelsStatus) { + this.fetchModelsStatus.style.display = 'none'; + } + }, 3000); + } + }); + + // Initialize Groq model selectors + this.updateModelSelectors(); + } + + updateModelSelectors(): void { + if (!this.container) return; + + logger.debug('Updating Groq model selectors'); + + // Get the latest model options filtered for Groq provider + const groqModels = this.getModelOptions('groq'); + logger.debug('Groq models from getModelOptions:', groqModels); + + // Get current mini and nano models from storage + const miniModel = getStorageItem(MINI_MODEL_STORAGE_KEY, ''); + const nanoModel = getStorageItem(NANO_MODEL_STORAGE_KEY, ''); + + // Get valid models using generic helper + const validMiniModel = getValidModelForProvider(miniModel, groqModels, 'groq', 'mini'); + const validNanoModel = getValidModelForProvider(nanoModel, groqModels, 'groq', 'nano'); + + logger.debug('Groq model selection:', { originalMini: miniModel, validMini: validMiniModel, originalNano: nanoModel, validNano: validNanoModel }); + + // Clear any existing model selectors + const existingSelectors = this.container.querySelectorAll('.model-selection-section'); + existingSelectors.forEach(selector => selector.remove()); + + // Create a new model selection section + const groqModelSection = document.createElement('div'); + groqModelSection.className = 'settings-section model-selection-section'; + this.container.appendChild(groqModelSection); + + const groqModelSectionTitle = document.createElement('h3'); + groqModelSectionTitle.className = 'settings-subtitle'; + groqModelSectionTitle.textContent = 'Model Size Selection'; + groqModelSection.appendChild(groqModelSectionTitle); + + // Create Groq Mini Model selection and store reference + this.miniModelSelector = createModelSelector( + groqModelSection, + i18nString('miniModelLabel'), + i18nString('miniModelDescription'), + 'groq-mini-model-select', + groqModels, + validMiniModel, + i18nString('defaultMiniOption'), + undefined // No focus handler needed for Groq + ); + + logger.debug('Created Groq Mini Model Select:', this.miniModelSelector); + + // Create Groq Nano Model selection and store reference + this.nanoModelSelector = createModelSelector( + groqModelSection, + i18nString('nanoModelLabel'), + i18nString('nanoModelDescription'), + 'groq-nano-model-select', + groqModels, + validNanoModel, + i18nString('defaultNanoOption'), + undefined // No focus handler needed for Groq + ); + + logger.debug('Created Groq Nano Model Select:', this.nanoModelSelector); + } + + save(): void { + // Save Groq API key + if (this.apiKeyInput) { + const newApiKey = this.apiKeyInput.value.trim(); + if (newApiKey) { + setStorageItem(GROQ_API_KEY_STORAGE_KEY, newApiKey); + } else { + setStorageItem(GROQ_API_KEY_STORAGE_KEY, ''); + } + } + } +} diff --git a/front_end/panels/ai_chat/ui/settings/providers/LiteLLMSettings.ts b/front_end/panels/ai_chat/ui/settings/providers/LiteLLMSettings.ts new file mode 100644 index 0000000000..04d9503fe0 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/providers/LiteLLMSettings.ts @@ -0,0 +1,558 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { BaseProviderSettings } from './BaseProviderSettings.js'; +import { createModelSelector, refreshModelSelectOptions } from '../components/ModelSelectorFactory.js'; +import { i18nString } from '../i18n-strings.js'; +import { getValidModelForProvider } from '../utils/validation.js'; +import { getStorageItem, setStorageItem } from '../utils/storage.js'; +import { LITELLM_ENDPOINT_KEY, LITELLM_API_KEY_STORAGE_KEY, MINI_MODEL_STORAGE_KEY, NANO_MODEL_STORAGE_KEY } from '../constants.js'; +import type { UpdateModelOptionsFunction, GetModelOptionsFunction, AddCustomModelOptionFunction, RemoveCustomModelOptionFunction, FetchLiteLLMModelsFunction, ModelOption } from '../types.js'; +import { LLMClient } from '../../../LLM/LLMClient.js'; +import { createLogger } from '../../../core/Logger.js'; + +const logger = createLogger('LiteLLMSettings'); + +/** + * LiteLLM provider settings + * + * Migrated from SettingsDialog.ts lines 806-1307 + */ +export class LiteLLMSettings extends BaseProviderSettings { + private endpointInput: HTMLInputElement | null = null; + private apiKeyInput: HTMLInputElement | null = null; + private fetchModelsButton: HTMLButtonElement | null = null; + private fetchModelsStatus: HTMLElement | null = null; + private customModelsList: HTMLElement | null = null; + private customModelInput: HTMLInputElement | null = null; + private modelTestStatus: HTMLElement | null = null; + private testPassed: boolean = false; + private updateModelOptions: UpdateModelOptionsFunction; + private fetchLiteLLMModels: FetchLiteLLMModelsFunction; + + constructor( + container: HTMLElement, + getModelOptions: GetModelOptionsFunction, + addCustomModelOption: AddCustomModelOptionFunction, + removeCustomModelOption: RemoveCustomModelOptionFunction, + updateModelOptions: UpdateModelOptionsFunction, + fetchLiteLLMModels: FetchLiteLLMModelsFunction + ) { + super(container, 'litellm', getModelOptions, addCustomModelOption, removeCustomModelOption); + this.updateModelOptions = updateModelOptions; + this.fetchLiteLLMModels = fetchLiteLLMModels; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + + // Setup LiteLLM content + const litellmSettingsSection = document.createElement('div'); + litellmSettingsSection.className = 'settings-section'; + this.container.appendChild(litellmSettingsSection); + + // LiteLLM endpoint + const litellmEndpointLabel = document.createElement('div'); + litellmEndpointLabel.className = 'settings-label'; + litellmEndpointLabel.textContent = i18nString('litellmEndpointLabel'); + litellmSettingsSection.appendChild(litellmEndpointLabel); + + const litellmEndpointHint = document.createElement('div'); + litellmEndpointHint.className = 'settings-hint'; + litellmEndpointHint.textContent = i18nString('litellmEndpointHint'); + litellmSettingsSection.appendChild(litellmEndpointHint); + + const settingsSavedLiteLLMEndpoint = getStorageItem(LITELLM_ENDPOINT_KEY, ''); + this.endpointInput = document.createElement('input'); + this.endpointInput.className = 'settings-input litellm-endpoint-input'; + this.endpointInput.type = 'text'; + this.endpointInput.placeholder = 'http://localhost:4000'; + this.endpointInput.value = settingsSavedLiteLLMEndpoint; + litellmSettingsSection.appendChild(this.endpointInput); + + // LiteLLM API Key + const litellmAPIKeyLabel = document.createElement('div'); + litellmAPIKeyLabel.className = 'settings-label'; + litellmAPIKeyLabel.textContent = i18nString('liteLLMApiKey'); + litellmSettingsSection.appendChild(litellmAPIKeyLabel); + + const litellmAPIKeyHint = document.createElement('div'); + litellmAPIKeyHint.className = 'settings-hint'; + litellmAPIKeyHint.textContent = i18nString('liteLLMApiKeyHint'); + litellmSettingsSection.appendChild(litellmAPIKeyHint); + + const settingsSavedLiteLLMApiKey = getStorageItem(LITELLM_API_KEY_STORAGE_KEY, ''); + this.apiKeyInput = document.createElement('input'); + this.apiKeyInput.className = 'settings-input litellm-api-key-input'; + this.apiKeyInput.type = 'password'; + this.apiKeyInput.placeholder = 'Enter your LiteLLM API key'; + this.apiKeyInput.value = settingsSavedLiteLLMApiKey; + litellmSettingsSection.appendChild(this.apiKeyInput); + + // Create event handler function + const updateFetchButtonState = () => { + if (this.fetchModelsButton && this.endpointInput) { + this.fetchModelsButton.disabled = !this.endpointInput.value.trim(); + } + }; + + this.endpointInput.addEventListener('input', updateFetchButtonState); + + const fetchButtonContainer = document.createElement('div'); + fetchButtonContainer.className = 'fetch-button-container'; + litellmSettingsSection.appendChild(fetchButtonContainer); + + this.fetchModelsButton = document.createElement('button'); + this.fetchModelsButton.className = 'settings-button'; + this.fetchModelsButton.setAttribute('type', 'button'); + this.fetchModelsButton.textContent = i18nString('fetchModelsButton'); + this.fetchModelsButton.disabled = !this.endpointInput.value.trim(); + fetchButtonContainer.appendChild(this.fetchModelsButton); + + this.fetchModelsStatus = document.createElement('div'); + this.fetchModelsStatus.className = 'settings-status'; + this.fetchModelsStatus.style.display = 'none'; + fetchButtonContainer.appendChild(this.fetchModelsStatus); + + // Add click handler for fetch models button + this.fetchModelsButton.addEventListener('click', async () => { + if (!this.fetchModelsButton || !this.fetchModelsStatus || !this.endpointInput || !this.apiKeyInput) return; + + this.fetchModelsButton.disabled = true; + this.fetchModelsStatus.textContent = i18nString('fetchingModels'); + this.fetchModelsStatus.style.display = 'block'; + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-blue-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-blue)'; + + try { + const endpoint = this.endpointInput.value; + const liteLLMApiKey = this.apiKeyInput.value || getStorageItem(LITELLM_API_KEY_STORAGE_KEY, ''); + + const { models: litellmModels, hadWildcard } = await this.fetchLiteLLMModels(liteLLMApiKey, endpoint || undefined); + this.updateModelOptions(litellmModels, hadWildcard); + + // Get counts from centralized getModelOptions + const allLiteLLMModels = this.getModelOptions('litellm'); + const actualModelCount = litellmModels.length; + const hasCustomModels = allLiteLLMModels.length > actualModelCount; + + // Get current mini and nano models from storage + const miniModel = getStorageItem(MINI_MODEL_STORAGE_KEY, ''); + const nanoModel = getStorageItem(NANO_MODEL_STORAGE_KEY, ''); + + // Refresh existing model selectors with new options if they exist + if (this.miniModelSelector) { + refreshModelSelectOptions(this.miniModelSelector as any, allLiteLLMModels, miniModel, i18nString('defaultMiniOption')); + } + if (this.nanoModelSelector) { + refreshModelSelectOptions(this.nanoModelSelector as any, allLiteLLMModels, nanoModel, i18nString('defaultNanoOption')); + } + + if (hadWildcard && actualModelCount === 0 && !hasCustomModels) { + this.fetchModelsStatus.textContent = i18nString('wildcardModelsOnly'); + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-orange-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-orange)'; + } else if (hadWildcard && actualModelCount === 0) { + // Only wildcard was returned but we have custom models + this.fetchModelsStatus.textContent = i18nString('wildcardAndCustomModels'); + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-green)'; + } else if (hadWildcard) { + // Wildcard plus other models + this.fetchModelsStatus.textContent = i18nString('wildcardAndOtherModels', {PH1: actualModelCount}); + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-green)'; + } else { + // No wildcard, just regular models + this.fetchModelsStatus.textContent = i18nString('fetchedModels', {PH1: actualModelCount}); + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-green)'; + } + + // Update LiteLLM model selections + this.updateModelSelectors(); + + } catch (error) { + logger.error('Failed to fetch models:', error); + this.fetchModelsStatus.textContent = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-red-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-red)'; + } finally { + updateFetchButtonState(); + setTimeout(() => { + if (this.fetchModelsStatus) { + this.fetchModelsStatus.style.display = 'none'; + } + }, 3000); + } + }); + + // Custom model section with array support + const customModelsSection = document.createElement('div'); + customModelsSection.className = 'custom-models-section'; + this.container.appendChild(customModelsSection); + + const customModelsLabel = document.createElement('div'); + customModelsLabel.className = 'settings-label'; + customModelsLabel.textContent = i18nString('customModelsLabel'); + customModelsSection.appendChild(customModelsLabel); + + const customModelsHint = document.createElement('div'); + customModelsHint.className = 'settings-hint'; + customModelsHint.textContent = i18nString('customModelsHint'); + customModelsSection.appendChild(customModelsHint); + + // Current custom models list + this.customModelsList = document.createElement('div'); + this.customModelsList.className = 'custom-models-list'; + customModelsSection.appendChild(this.customModelsList); + + this.updateCustomModelsList(); + + // New model input with test and add + const newModelRow = document.createElement('div'); + newModelRow.className = 'new-model-row'; + customModelsSection.appendChild(newModelRow); + + this.customModelInput = document.createElement('input'); + this.customModelInput.className = 'settings-input custom-model-input'; + this.customModelInput.type = 'text'; + this.customModelInput.placeholder = 'Enter model name (e.g., gpt-4)'; + newModelRow.appendChild(this.customModelInput); + + const addModelButton = document.createElement('button'); + addModelButton.className = 'settings-button add-button'; + addModelButton.setAttribute('type', 'button'); + addModelButton.textContent = i18nString('addButton'); + newModelRow.appendChild(addModelButton); + + this.modelTestStatus = document.createElement('div'); + this.modelTestStatus.className = 'settings-status model-test-status'; + this.modelTestStatus.style.display = 'none'; + customModelsSection.appendChild(this.modelTestStatus); + + // Reset test passed state when input changes + this.customModelInput.addEventListener('input', () => { + this.testPassed = false; + if (this.modelTestStatus) { + this.modelTestStatus.style.display = 'none'; + } + }); + + // Add button click handler + addModelButton.addEventListener('click', async () => { + if (!this.customModelInput) return; + + const modelName = this.customModelInput.value.trim(); + + // Check if model already exists by querying all litellm models + const litellmModels = this.getModelOptions('litellm'); + const modelExists = litellmModels.some(m => m.value === modelName); + + if (modelExists) { + if (this.modelTestStatus) { + this.modelTestStatus.textContent = 'Model already exists'; + this.modelTestStatus.style.backgroundColor = 'var(--color-accent-orange-background)'; + this.modelTestStatus.style.color = 'var(--color-accent-orange)'; + this.modelTestStatus.style.display = 'block'; + } + return; + } + + // Always test the model before adding, regardless of previous test state + addModelButton.disabled = true; + const testSucceeded = await this.testModelConnection(modelName); + + if (testSucceeded) { + // Use the provided addCustomModelOption function to add the model + this.addCustomModelOption(modelName, 'litellm'); + + // Update success message + if (this.modelTestStatus) { + this.modelTestStatus.textContent = `Model "${modelName}" added successfully`; + this.modelTestStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.modelTestStatus.style.color = 'var(--color-accent-green)'; + } + + // Reset UI + this.updateCustomModelsList(); + this.customModelInput.value = ''; + this.testPassed = false; + + // Update model selectors + this.updateModelSelectors(); + + // Hide status after a delay + setTimeout(() => { + if (this.modelTestStatus) { + this.modelTestStatus.style.display = 'none'; + } + }, 3000); + } + + addModelButton.disabled = false; + }); + + // Initialize LiteLLM model selectors + this.updateModelSelectors(); + } + + private updateCustomModelsList(): void { + if (!this.customModelsList) return; + + // Clear existing list + this.customModelsList.innerHTML = ''; + + // Get custom models directly from local storage instead of using a heuristic filter + const savedCustomModels = JSON.parse(localStorage.getItem('ai_chat_custom_models') || '[]'); + const customModels = savedCustomModels; + + customModels.forEach((model: string) => { + // Create model row + const modelRow = document.createElement('div'); + modelRow.className = 'custom-model-row'; + this.customModelsList!.appendChild(modelRow); + + // Model name + const modelName = document.createElement('span'); + modelName.className = 'custom-model-name'; + modelName.textContent = model; + modelRow.appendChild(modelName); + + // Status element for test results + const testStatus = document.createElement('span'); + testStatus.className = 'test-status'; + testStatus.style.display = 'none'; + modelRow.appendChild(testStatus); + + // Test button as icon + const testButton = document.createElement('button'); + testButton.className = 'icon-button test-button'; + testButton.setAttribute('type', 'button'); + testButton.setAttribute('aria-label', i18nString('testButton')); + testButton.setAttribute('title', 'Test connection to this model'); + + // Create SVG check icon + const checkIcon = document.createElement('span'); + checkIcon.className = 'check-icon'; + checkIcon.innerHTML = ` + + + + + `; + testButton.appendChild(checkIcon); + modelRow.appendChild(testButton); + + // Remove button as a trash icon + const removeButton = document.createElement('button'); + removeButton.className = 'icon-button remove-button'; + removeButton.setAttribute('type', 'button'); + removeButton.setAttribute('aria-label', i18nString('removeButton')); + removeButton.setAttribute('title', 'Remove this model'); + + // Create SVG trash icon + const trashIcon = document.createElement('span'); + trashIcon.className = 'trash-icon'; + trashIcon.innerHTML = ` + + + + `; + removeButton.appendChild(trashIcon); + modelRow.appendChild(removeButton); + + // Add click handlers + testButton.addEventListener('click', async () => { + testButton.disabled = true; + testStatus.textContent = '...'; + testStatus.style.color = 'var(--color-accent-blue)'; + testStatus.style.display = 'inline'; + + try { + const endpoint = this.endpointInput?.value; + const liteLLMApiKey = this.apiKeyInput?.value || getStorageItem(LITELLM_API_KEY_STORAGE_KEY, ''); + + if (!endpoint) { + throw new Error(i18nString('endpointRequired')); + } + + const result = await LLMClient.testLiteLLMConnection(liteLLMApiKey, model, endpoint); + + if (result.success) { + testStatus.textContent = '✓'; + testStatus.style.color = 'var(--color-accent-green)'; + } else { + testStatus.textContent = '✗'; + testStatus.style.color = 'var(--color-accent-red)'; + testStatus.title = result.message; // Show error on hover + } + } catch (error) { + testStatus.textContent = '✗'; + testStatus.style.color = 'var(--color-accent-red)'; + testStatus.title = error instanceof Error ? error.message : 'Unknown error'; + } finally { + testButton.disabled = false; + setTimeout(() => { + testStatus.style.display = 'none'; + }, 5000); + } + }); + + removeButton.addEventListener('click', () => { + // Use the provided removeCustomModelOption function to remove the model + this.removeCustomModelOption(model); + + // Update the UI list + this.updateCustomModelsList(); + + // Update model selectors + this.updateModelSelectors(); + }); + }); + } + + private async testModelConnection(modelName: string): Promise { + if (!this.modelTestStatus) return false; + + if (!modelName) { + this.modelTestStatus.textContent = 'Please enter a model name'; + this.modelTestStatus.style.backgroundColor = 'var(--color-accent-red-background)'; + this.modelTestStatus.style.color = 'var(--color-accent-red)'; + this.modelTestStatus.style.display = 'block'; + return false; + } + + this.modelTestStatus.textContent = 'Testing model...'; + this.modelTestStatus.style.backgroundColor = 'var(--color-accent-blue-background)'; + this.modelTestStatus.style.color = 'var(--color-accent-blue)'; + this.modelTestStatus.style.display = 'block'; + + try { + const endpoint = this.endpointInput?.value; + const liteLLMApiKey = this.apiKeyInput?.value || getStorageItem(LITELLM_API_KEY_STORAGE_KEY, ''); + + if (!endpoint) { + throw new Error(i18nString('endpointRequired')); + } + + const result = await LLMClient.testLiteLLMConnection(liteLLMApiKey, modelName, endpoint); + + if (result.success) { + this.modelTestStatus.textContent = `Test passed: ${result.message}`; + this.modelTestStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.modelTestStatus.style.color = 'var(--color-accent-green)'; + this.testPassed = true; + return true; + } else { + this.modelTestStatus.textContent = `Test failed: ${result.message}`; + this.modelTestStatus.style.backgroundColor = 'var(--color-accent-red-background)'; + this.modelTestStatus.style.color = 'var(--color-accent-red)'; + this.testPassed = false; + return false; + } + } catch (error) { + this.modelTestStatus.textContent = `Test error: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.modelTestStatus.style.backgroundColor = 'var(--color-accent-red-background)'; + this.modelTestStatus.style.color = 'var(--color-accent-red)'; + this.testPassed = false; + return false; + } + } + + updateModelSelectors(): void { + if (!this.container) return; + + logger.debug('Updating LiteLLM model selectors'); + + // Get the latest model options filtered for LiteLLM provider + const litellmModels = this.getModelOptions('litellm'); + logger.debug('LiteLLM models from getModelOptions:', litellmModels); + + // Get current mini and nano models from storage + const miniModel = getStorageItem(MINI_MODEL_STORAGE_KEY, ''); + const nanoModel = getStorageItem(NANO_MODEL_STORAGE_KEY, ''); + + // Get valid models using generic helper + const validMiniModel = getValidModelForProvider(miniModel, litellmModels, 'litellm', 'mini'); + const validNanoModel = getValidModelForProvider(nanoModel, litellmModels, 'litellm', 'nano'); + + // Clear any existing model selectors + const existingSelectors = this.container.querySelectorAll('.model-selection-section'); + existingSelectors.forEach(selector => selector.remove()); + + // Create a new model selection section + const litellmModelSection = document.createElement('div'); + litellmModelSection.className = 'settings-section model-selection-section'; + this.container.appendChild(litellmModelSection); + + const litellmModelSectionTitle = document.createElement('h3'); + litellmModelSectionTitle.className = 'settings-subtitle'; + litellmModelSectionTitle.textContent = 'Model Size Selection'; + litellmModelSection.appendChild(litellmModelSectionTitle); + + // Create a focus handler for LiteLLM selectors + const onLiteLLMSelectorFocus = async () => { + // Only refresh if the provider is still litellm + const endpoint = this.endpointInput?.value.trim(); + const liteLLMApiKey = this.apiKeyInput?.value.trim() || getStorageItem(LITELLM_API_KEY_STORAGE_KEY, ''); + + if (endpoint) { + try { + logger.debug('Refreshing LiteLLM models on selector focus...'); + const { models: litellmModels, hadWildcard } = await this.fetchLiteLLMModels(liteLLMApiKey, endpoint); + this.updateModelOptions(litellmModels, hadWildcard); + // No need to update UI since refreshing would lose focus + logger.debug('Successfully refreshed LiteLLM models on selector focus'); + } catch (error) { + logger.error('Failed to refresh LiteLLM models on selector focus:', error); + } + } + }; + + // Create LiteLLM Mini Model selection and store reference + this.miniModelSelector = createModelSelector( + litellmModelSection, + i18nString('miniModelLabel'), + i18nString('miniModelDescription'), + 'litellm-mini-model-select', + litellmModels, + validMiniModel, + i18nString('defaultMiniOption'), + onLiteLLMSelectorFocus + ); + + logger.debug('Created LiteLLM Mini Model Select:', this.miniModelSelector); + + // Create LiteLLM Nano Model selection and store reference + this.nanoModelSelector = createModelSelector( + litellmModelSection, + i18nString('nanoModelLabel'), + i18nString('nanoModelDescription'), + 'litellm-nano-model-select', + litellmModels, + validNanoModel, + i18nString('defaultNanoOption'), + onLiteLLMSelectorFocus + ); + + logger.debug('Created LiteLLM Nano Model Select:', this.nanoModelSelector); + } + + save(): void { + // Save LiteLLM endpoint + if (this.endpointInput) { + const newEndpoint = this.endpointInput.value.trim(); + setStorageItem(LITELLM_ENDPOINT_KEY, newEndpoint); + } + + // Save LiteLLM API key + if (this.apiKeyInput) { + const newApiKey = this.apiKeyInput.value.trim(); + setStorageItem(LITELLM_API_KEY_STORAGE_KEY, newApiKey); + } + } +} diff --git a/front_end/panels/ai_chat/ui/settings/providers/OpenAISettings.ts b/front_end/panels/ai_chat/ui/settings/providers/OpenAISettings.ts new file mode 100644 index 0000000000..5e79fa152c --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/providers/OpenAISettings.ts @@ -0,0 +1,134 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { BaseProviderSettings } from './BaseProviderSettings.js'; +import { createModelSelector } from '../components/ModelSelectorFactory.js'; +import { i18nString } from '../i18n-strings.js'; +import { getValidModelForProvider } from '../utils/validation.js'; +import { getStorageItem, setStorageItem } from '../utils/storage.js'; +import { OPENAI_API_KEY_STORAGE_KEY, MINI_MODEL_STORAGE_KEY, NANO_MODEL_STORAGE_KEY } from '../constants.js'; +import type { GetModelOptionsFunction, AddCustomModelOptionFunction, RemoveCustomModelOptionFunction } from '../types.js'; + +/** + * OpenAI provider settings + * + * Migrated from SettingsDialog.ts lines 720-803 + */ +export class OpenAISettings extends BaseProviderSettings { + private apiKeyInput: HTMLInputElement | null = null; + private settingsSection: HTMLElement | null = null; + private apiKeyStatus: HTMLElement | null = null; + + constructor( + container: HTMLElement, + getModelOptions: GetModelOptionsFunction, + addCustomModelOption: AddCustomModelOptionFunction, + removeCustomModelOption: RemoveCustomModelOptionFunction + ) { + super(container, 'openai', getModelOptions, addCustomModelOption, removeCustomModelOption); + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + + // Setup OpenAI content + this.settingsSection = document.createElement('div'); + this.settingsSection.className = 'settings-section'; + this.container.appendChild(this.settingsSection); + + const apiKeyLabel = document.createElement('div'); + apiKeyLabel.className = 'settings-label'; + apiKeyLabel.textContent = i18nString('apiKeyLabel'); + this.settingsSection.appendChild(apiKeyLabel); + + const apiKeyHint = document.createElement('div'); + apiKeyHint.className = 'settings-hint'; + apiKeyHint.textContent = i18nString('apiKeyHint'); + this.settingsSection.appendChild(apiKeyHint); + + const settingsSavedApiKey = getStorageItem(OPENAI_API_KEY_STORAGE_KEY, ''); + this.apiKeyInput = document.createElement('input'); + this.apiKeyInput.className = 'settings-input'; + this.apiKeyInput.type = 'password'; + this.apiKeyInput.placeholder = 'Enter your OpenAI API key'; + this.apiKeyInput.value = settingsSavedApiKey; + this.settingsSection.appendChild(this.apiKeyInput); + + this.apiKeyStatus = document.createElement('div'); + this.apiKeyStatus.className = 'settings-status'; + this.apiKeyStatus.style.display = 'none'; + this.settingsSection.appendChild(this.apiKeyStatus); + + // Initialize OpenAI model selectors + this.updateModelSelectors(); + } + + updateModelSelectors(): void { + if (!this.container) return; + + // Get the latest model options filtered for OpenAI provider + const openaiModels = this.getModelOptions('openai'); + + // Get current mini and nano models from storage + const miniModel = getStorageItem(MINI_MODEL_STORAGE_KEY, ''); + const nanoModel = getStorageItem(NANO_MODEL_STORAGE_KEY, ''); + + // Get valid models using generic helper + const validMiniModel = getValidModelForProvider(miniModel, openaiModels, 'openai', 'mini'); + const validNanoModel = getValidModelForProvider(nanoModel, openaiModels, 'openai', 'nano'); + + // Clear any existing model selectors + const existingSelectors = this.container.querySelectorAll('.model-selection-section'); + existingSelectors.forEach(selector => selector.remove()); + + // Create a new model selection section + const openaiModelSection = document.createElement('div'); + openaiModelSection.className = 'settings-section model-selection-section'; + this.container.appendChild(openaiModelSection); + + const openaiModelSectionTitle = document.createElement('h3'); + openaiModelSectionTitle.className = 'settings-subtitle'; + openaiModelSectionTitle.textContent = 'Model Size Selection'; + openaiModelSection.appendChild(openaiModelSectionTitle); + + // No focus handler needed for OpenAI selectors as we don't need to fetch models on focus + + // Create OpenAI Mini Model selection and store reference + this.miniModelSelector = createModelSelector( + openaiModelSection, + i18nString('miniModelLabel'), + i18nString('miniModelDescription'), + 'mini-model-select', + openaiModels, + validMiniModel, + i18nString('defaultMiniOption'), + undefined // No focus handler for OpenAI + ); + + // Create OpenAI Nano Model selection and store reference + this.nanoModelSelector = createModelSelector( + openaiModelSection, + i18nString('nanoModelLabel'), + i18nString('nanoModelDescription'), + 'nano-model-select', + openaiModels, + validNanoModel, + i18nString('defaultNanoOption'), + undefined // No focus handler for OpenAI + ); + } + + save(): void { + // Save OpenAI API key + if (this.apiKeyInput) { + const newApiKey = this.apiKeyInput.value.trim(); + if (newApiKey) { + setStorageItem(OPENAI_API_KEY_STORAGE_KEY, newApiKey); + } else { + setStorageItem(OPENAI_API_KEY_STORAGE_KEY, ''); + } + } + } +} diff --git a/front_end/panels/ai_chat/ui/settings/providers/OpenRouterSettings.ts b/front_end/panels/ai_chat/ui/settings/providers/OpenRouterSettings.ts new file mode 100644 index 0000000000..e6aa0a727e --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/providers/OpenRouterSettings.ts @@ -0,0 +1,537 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { BaseProviderSettings } from './BaseProviderSettings.js'; +import { createModelSelector } from '../components/ModelSelectorFactory.js'; +import { i18nString } from '../i18n-strings.js'; +import { getValidModelForProvider } from '../utils/validation.js'; +import { getStorageItem, setStorageItem } from '../utils/storage.js'; +import { OPENROUTER_API_KEY_STORAGE_KEY, MINI_MODEL_STORAGE_KEY, NANO_MODEL_STORAGE_KEY, OPENROUTER_MODELS_CACHE_DURATION_MS } from '../constants.js'; +import type { UpdateModelOptionsFunction, GetModelOptionsFunction, AddCustomModelOptionFunction, RemoveCustomModelOptionFunction, ModelOption } from '../types.js'; +import { LLMClient } from '../../../LLM/LLMClient.js'; +import { createLogger } from '../../../core/Logger.js'; + +const logger = createLogger('OpenRouterSettings'); + +/** + * OpenRouter provider settings + * + * Migrated from SettingsDialog.ts lines 2127-2541 + * + * Special features: + * - OAuth PKCE authentication flow + * - Model caching with 60-minute expiration + * - Dynamic OAuth module import + */ +export class OpenRouterSettings extends BaseProviderSettings { + private apiKeyInput: HTMLInputElement | null = null; + private oauthButton: HTMLButtonElement | null = null; + private oauthStatus: HTMLElement | null = null; + private fetchModelsButton: HTMLButtonElement | null = null; + private fetchModelsStatus: HTMLElement | null = null; + private updateModelOptions: UpdateModelOptionsFunction; + private onSettingsSaved: () => void; + private onDialogHide: () => void; + + // OAuth event handlers (stored for cleanup) + private handleOAuthSuccess: (() => void) | null = null; + private handleOAuthError: ((event: Event) => void) | null = null; + private handleOAuthLogout: (() => void) | null = null; + + // Dynamically imported OAuth module + private OpenRouterOAuth: any = null; + + constructor( + container: HTMLElement, + getModelOptions: GetModelOptionsFunction, + addCustomModelOption: AddCustomModelOptionFunction, + removeCustomModelOption: RemoveCustomModelOptionFunction, + updateModelOptions: UpdateModelOptionsFunction, + onSettingsSaved: () => void, + onDialogHide: () => void + ) { + super(container, 'openrouter', getModelOptions, addCustomModelOption, removeCustomModelOption); + this.updateModelOptions = updateModelOptions; + this.onSettingsSaved = onSettingsSaved; + this.onDialogHide = onDialogHide; + } + + private async getOpenRouterOAuth(): Promise { + if (!this.OpenRouterOAuth) { + const module = await import('../../../auth/OpenRouterOAuth.js'); + this.OpenRouterOAuth = module.OpenRouterOAuth; + } + return this.OpenRouterOAuth; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + + // Setup OpenRouter content + const openrouterSettingsSection = document.createElement('div'); + openrouterSettingsSection.className = 'settings-section'; + this.container.appendChild(openrouterSettingsSection); + + // OpenRouter API Key + const openrouterApiKeyLabel = document.createElement('div'); + openrouterApiKeyLabel.className = 'settings-label'; + openrouterApiKeyLabel.textContent = i18nString('openrouterApiKeyLabel'); + openrouterSettingsSection.appendChild(openrouterApiKeyLabel); + + const openrouterApiKeyHint = document.createElement('div'); + openrouterApiKeyHint.className = 'settings-hint'; + openrouterApiKeyHint.textContent = i18nString('openrouterApiKeyHint'); + openrouterSettingsSection.appendChild(openrouterApiKeyHint); + + const settingsSavedOpenRouterApiKey = getStorageItem(OPENROUTER_API_KEY_STORAGE_KEY, ''); + this.apiKeyInput = document.createElement('input'); + this.apiKeyInput.className = 'settings-input openrouter-api-key-input'; + this.apiKeyInput.type = 'password'; + this.apiKeyInput.placeholder = 'Enter your OpenRouter API key'; + this.apiKeyInput.value = settingsSavedOpenRouterApiKey; + openrouterSettingsSection.appendChild(this.apiKeyInput); + + // OAuth section - alternative to API key + const oauthDivider = document.createElement('div'); + oauthDivider.className = 'settings-divider'; + oauthDivider.textContent = 'OR'; + openrouterSettingsSection.appendChild(oauthDivider); + + const oauthButtonContainer = document.createElement('div'); + oauthButtonContainer.className = 'oauth-button-container'; + openrouterSettingsSection.appendChild(oauthButtonContainer); + + this.oauthButton = document.createElement('button'); + this.oauthButton.className = 'settings-button oauth-button'; + this.oauthButton.setAttribute('type', 'button'); + this.oauthButton.textContent = 'Connect with OpenRouter'; + oauthButtonContainer.appendChild(this.oauthButton); + + this.oauthStatus = document.createElement('div'); + this.oauthStatus.className = 'oauth-status'; + this.oauthStatus.style.display = 'none'; + oauthButtonContainer.appendChild(this.oauthStatus); + + // Add OAuth-specific styles + const oauthStyles = document.createElement('style'); + oauthStyles.textContent = ` + .settings-divider { + text-align: center; + margin: 15px 0; + color: var(--color-text-secondary); + font-size: 12px; + font-weight: bold; + } + .oauth-button-container { + margin-bottom: 10px; + } + .oauth-button { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: all 0.3s ease; + width: 100%; + margin-bottom: 8px; + } + .oauth-button:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + } + .oauth-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; + } + .oauth-button.disconnect { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + } + .oauth-status { + font-size: 12px; + margin-top: 5px; + padding: 5px 8px; + border-radius: 4px; + background: var(--color-background-highlight); + } + `; + document.head.appendChild(oauthStyles); + + // Update OAuth button state + const updateOAuthButton = async () => { + const OAuth = await this.getOpenRouterOAuth(); + if (await OAuth.isOAuthAuthenticated()) { + if (this.oauthButton) { + this.oauthButton.textContent = 'Disconnect OpenRouter'; + this.oauthButton.classList.add('disconnect'); + } + if (this.oauthStatus) { + this.oauthStatus.textContent = '✓ Connected via OpenRouter account'; + this.oauthStatus.style.color = 'var(--color-accent-green)'; + this.oauthStatus.style.display = 'block'; + } + } else { + if (this.oauthButton) { + this.oauthButton.textContent = 'Connect with OpenRouter'; + this.oauthButton.classList.remove('disconnect'); + } + if (this.oauthStatus) { + this.oauthStatus.style.display = 'none'; + } + } + }; + + updateOAuthButton(); + + // OAuth button click handler + this.oauthButton.addEventListener('click', async () => { + if (!this.oauthButton) return; + + const OAuth = await this.getOpenRouterOAuth(); + this.oauthButton.disabled = true; + + try { + if (await OAuth.isOAuthAuthenticated()) { + // Disconnect + if (confirm('Are you sure you want to disconnect your OpenRouter account?')) { + await OAuth.revokeToken(); + updateOAuthButton(); + } + } else { + // Connect - provide clear feedback for tab-based flow + this.oauthButton.textContent = 'Redirecting to OpenRouter...'; + if (this.oauthStatus) { + this.oauthStatus.textContent = 'You will be redirected to OpenRouter to authorize access. The page will return here automatically after authorization.'; + this.oauthStatus.style.color = 'var(--color-text-secondary)'; + this.oauthStatus.style.display = 'block'; + } + + await OAuth.startAuthFlow(); + updateOAuthButton(); + } + } catch (error) { + logger.error('OAuth flow error:', error); + if (this.oauthStatus) { + this.oauthStatus.textContent = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.oauthStatus.style.color = 'var(--color-accent-red)'; + this.oauthStatus.style.display = 'block'; + } + } finally { + this.oauthButton.disabled = false; + if (!await OAuth.isOAuthAuthenticated()) { + this.oauthButton.textContent = 'Connect with OpenRouter'; + if (this.oauthStatus) { + this.oauthStatus.style.display = 'none'; + } + } + } + }); + + // Handle OAuth events + this.handleOAuthSuccess = () => { + updateOAuthButton(); + if (this.oauthStatus) { + this.oauthStatus.textContent = '✓ Successfully connected to OpenRouter'; + this.oauthStatus.style.color = 'var(--color-accent-green)'; + this.oauthStatus.style.display = 'block'; + } + + // Trigger chat panel refresh to recognize new credentials + const chatPanel = document.querySelector('ai-chat-panel') as any; + if (chatPanel && typeof chatPanel.refreshCredentials === 'function') { + chatPanel.refreshCredentials(); + } + + // Auto-save settings and close dialog after successful OAuth + this.onSettingsSaved(); + setTimeout(() => { + this.onDialogHide(); + }, 2000); + }; + + this.handleOAuthError = (event: Event) => { + const customEvent = event as CustomEvent; + if (this.oauthStatus) { + this.oauthStatus.textContent = `Error: ${customEvent.detail.error}`; + this.oauthStatus.style.color = 'var(--color-accent-red)'; + this.oauthStatus.style.display = 'block'; + } + }; + + this.handleOAuthLogout = () => { + // Clear the API key input field + if (this.apiKeyInput) { + this.apiKeyInput.value = ''; + } + + // Update OAuth button state + updateOAuthButton(); + + // Show logout confirmation + if (this.oauthStatus) { + this.oauthStatus.textContent = '✓ Disconnected from OpenRouter'; + this.oauthStatus.style.color = 'var(--color-text-secondary)'; + this.oauthStatus.style.display = 'block'; + } + + // Refresh chat panel to recognize credential removal + const chatPanel = document.querySelector('ai-chat-panel') as any; + if (chatPanel && typeof chatPanel.refreshCredentials === 'function') { + chatPanel.refreshCredentials(); + } + + // Auto-close dialog after showing disconnect message + setTimeout(() => { + this.onDialogHide(); + }, 2000); + }; + + window.addEventListener('openrouter-oauth-success', this.handleOAuthSuccess); + window.addEventListener('openrouter-oauth-error', this.handleOAuthError); + window.addEventListener('openrouter-oauth-logout', this.handleOAuthLogout); + + // Update API key input behavior for OAuth compatibility + this.apiKeyInput.addEventListener('input', async () => { + if (!this.apiKeyInput) return; + + if (this.apiKeyInput.value.trim()) { + // Switch to manual API key method + localStorage.setItem('openrouter_auth_method', 'api_key'); + const OAuth = await this.getOpenRouterOAuth(); + if (await OAuth.isOAuthAuthenticated()) { + OAuth.switchToManualApiKey(); + } + } + + // Update fetch button state + if (this.fetchModelsButton) { + this.fetchModelsButton.disabled = !this.apiKeyInput.value.trim(); + } + }); + + // Fetch OpenRouter models button + const openrouterFetchButtonContainer = document.createElement('div'); + openrouterFetchButtonContainer.className = 'fetch-button-container'; + openrouterSettingsSection.appendChild(openrouterFetchButtonContainer); + + this.fetchModelsButton = document.createElement('button'); + this.fetchModelsButton.className = 'settings-button'; + this.fetchModelsButton.setAttribute('type', 'button'); + this.fetchModelsButton.textContent = i18nString('fetchOpenRouterModelsButton'); + this.fetchModelsButton.disabled = !this.apiKeyInput.value.trim(); + openrouterFetchButtonContainer.appendChild(this.fetchModelsButton); + + this.fetchModelsStatus = document.createElement('div'); + this.fetchModelsStatus.className = 'settings-status'; + this.fetchModelsStatus.style.display = 'none'; + openrouterFetchButtonContainer.appendChild(this.fetchModelsStatus); + + // Add click handler for fetch OpenRouter models button + this.fetchModelsButton.addEventListener('click', async () => { + if (!this.fetchModelsButton || !this.fetchModelsStatus || !this.apiKeyInput) return; + + this.fetchModelsButton.disabled = true; + this.fetchModelsStatus.textContent = i18nString('fetchingModels'); + this.fetchModelsStatus.style.display = 'block'; + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-blue-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-blue)'; + + try { + const openrouterApiKey = this.apiKeyInput.value.trim(); + + // Fetch OpenRouter models using LLMClient static method + const openrouterModels = await LLMClient.fetchOpenRouterModels(openrouterApiKey); + + // Convert OpenRouter models to ModelOption format + const modelOptions: ModelOption[] = openrouterModels.map(model => ({ + value: model.id, + label: model.name || model.id, + type: 'openrouter' as const + })); + + // Update model options with fetched OpenRouter models + this.updateModelOptions(modelOptions, false); + + // Update timestamp for cache management + localStorage.setItem('openrouter_models_cache_timestamp', Date.now().toString()); + + const actualModelCount = openrouterModels.length; + + // Update the model selectors with the new models + await this.updateModelSelectors(); + + // Update status to show success + this.fetchModelsStatus.textContent = i18nString('fetchedModels', {PH1: actualModelCount}); + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-green)'; + + logger.debug(`Successfully fetched ${actualModelCount} OpenRouter models`); + } catch (error) { + logger.error('Error fetching OpenRouter models:', error); + this.fetchModelsStatus.textContent = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-red-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-red)'; + } finally { + this.fetchModelsButton.disabled = false; + + // Hide status message after 3 seconds + setTimeout(() => { + if (this.fetchModelsStatus) { + this.fetchModelsStatus.style.display = 'none'; + } + }, 3000); + } + }); + + // Initialize OpenRouter model selectors + this.updateModelSelectors(); + } + + private async checkAndRefreshOpenRouterCache(): Promise { + try { + const cacheTimestamp = localStorage.getItem('openrouter_models_cache_timestamp'); + const now = Date.now(); + + // If no timestamp, cache is considered stale + if (!cacheTimestamp) { + logger.debug('OpenRouter models cache has no timestamp, considering stale'); + await this.autoRefreshOpenRouterModels(); + return; + } + + const cacheAge = now - parseInt(cacheTimestamp, 10); + const isStale = cacheAge > OPENROUTER_MODELS_CACHE_DURATION_MS; + + if (isStale) { + const ageMinutes = Math.round(cacheAge / (1000 * 60)); + logger.debug(`OpenRouter models cache is stale (${ageMinutes} minutes old), auto-refreshing...`); + await this.autoRefreshOpenRouterModels(); + } else { + const remainingMinutes = Math.round((OPENROUTER_MODELS_CACHE_DURATION_MS - cacheAge) / (1000 * 60)); + logger.debug(`OpenRouter models cache is fresh (expires in ${remainingMinutes} minutes)`); + } + } catch (error) { + logger.warn('Failed to check OpenRouter models cache age:', error); + } + } + + private async autoRefreshOpenRouterModels(): Promise { + try { + const openrouterApiKey = this.apiKeyInput?.value.trim(); + + if (!openrouterApiKey) { + logger.debug('No OpenRouter API key available for auto-refresh'); + return; + } + + logger.debug('Auto-refreshing OpenRouter models...'); + const openrouterModels = await LLMClient.fetchOpenRouterModels(openrouterApiKey); + + // Convert OpenRouter models to ModelOption format + const modelOptions: ModelOption[] = openrouterModels.map(model => ({ + value: model.id, + label: model.name || model.id, + type: 'openrouter' as const + })); + + // Store in localStorage with timestamp + localStorage.setItem('openrouter_models_cache', JSON.stringify(modelOptions)); + localStorage.setItem('openrouter_models_cache_timestamp', Date.now().toString()); + + // Also update global model options so UI immediately sees models + this.updateModelOptions(modelOptions, false); + + logger.debug(`Auto-refreshed ${modelOptions.length} OpenRouter models`); + } catch (error) { + logger.warn('Failed to auto-refresh OpenRouter models:', error); + } + } + + async updateModelSelectors(): Promise { + if (!this.container) return; + + logger.debug('Updating OpenRouter model selectors'); + + // Check if OpenRouter models cache is stale and auto-refresh if needed + await this.checkAndRefreshOpenRouterCache(); + + // Get the latest model options filtered for OpenRouter provider + const openrouterModels = this.getModelOptions('openrouter'); + logger.debug('OpenRouter models from getModelOptions:', openrouterModels); + + // Get current mini and nano models from storage + const miniModel = getStorageItem(MINI_MODEL_STORAGE_KEY, ''); + const nanoModel = getStorageItem(NANO_MODEL_STORAGE_KEY, ''); + + // Get valid models using generic helper + const validMiniModel = getValidModelForProvider(miniModel, openrouterModels, 'openrouter', 'mini'); + const validNanoModel = getValidModelForProvider(nanoModel, openrouterModels, 'openrouter', 'nano'); + + logger.debug('OpenRouter model selection:', { originalMini: miniModel, validMini: validMiniModel, originalNano: nanoModel, validNano: validNanoModel }); + + // Clear any existing model selectors + const existingSelectors = this.container.querySelectorAll('.model-selection-section'); + existingSelectors.forEach(selector => selector.remove()); + + // Create a new model selection section + const openrouterModelSection = document.createElement('div'); + openrouterModelSection.className = 'model-selection-section'; + this.container.appendChild(openrouterModelSection); + + // Create Mini Model selection for OpenRouter and store reference + this.miniModelSelector = createModelSelector( + openrouterModelSection, + i18nString('miniModelLabel'), + i18nString('miniModelDescription'), + 'openrouter-mini-model-select', + openrouterModels, + validMiniModel, + i18nString('defaultMiniOption'), + undefined // No focus handler needed for OpenRouter + ); + + // Create Nano Model selection for OpenRouter and store reference + this.nanoModelSelector = createModelSelector( + openrouterModelSection, + i18nString('nanoModelLabel'), + i18nString('nanoModelDescription'), + 'openrouter-nano-model-select', + openrouterModels, + validNanoModel, + i18nString('defaultNanoOption'), + undefined // No focus handler needed for OpenRouter + ); + } + + save(): void { + // Save OpenRouter API key + if (this.apiKeyInput) { + const newApiKey = this.apiKeyInput.value.trim(); + if (newApiKey) { + setStorageItem(OPENROUTER_API_KEY_STORAGE_KEY, newApiKey); + } else { + setStorageItem(OPENROUTER_API_KEY_STORAGE_KEY, ''); + } + } + } + + override cleanup(): void { + // Remove OAuth event listeners + if (this.handleOAuthSuccess) { + window.removeEventListener('openrouter-oauth-success', this.handleOAuthSuccess); + } + if (this.handleOAuthError) { + window.removeEventListener('openrouter-oauth-error', this.handleOAuthError); + } + if (this.handleOAuthLogout) { + window.removeEventListener('openrouter-oauth-logout', this.handleOAuthLogout); + } + + super.cleanup(); + } +} diff --git a/front_end/panels/ai_chat/ui/settings/types.ts b/front_end/panels/ai_chat/ui/settings/types.ts new file mode 100644 index 0000000000..6ef6bfa5fc --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/types.ts @@ -0,0 +1,94 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Represents an AI model option in the settings dialog + */ +export interface ModelOption { + value: string; + label: string; + type: 'openai' | 'litellm' | 'groq' | 'openrouter'; +} + +/** + * Validation result for model selection + */ +export interface ValidationResult { + isValid: boolean; + error?: string; +} + +/** + * Provider type + */ +export type ProviderType = 'openai' | 'litellm' | 'groq' | 'openrouter'; + +/** + * Model tier type + */ +export type ModelTier = 'mini' | 'nano'; + +/** + * Model selector element interface + */ +export interface ModelSelectorElement extends HTMLElement { + value: string; + selected: string; + options: Array<{value: string, label: string}>; + dataset: { + modelType?: string; + }; + forceSearchable?: boolean; +} + +/** + * Settings save callback + */ +export type OnSettingsSavedCallback = () => void; + +/** + * Fetch LiteLLM models result + */ +export interface FetchLiteLLMModelsResult { + models: ModelOption[]; + hadWildcard: boolean; +} + +/** + * Fetch LiteLLM models function signature + */ +export type FetchLiteLLMModelsFunction = ( + apiKey: string | null, + endpoint?: string +) => Promise; + +/** + * Update model options function signature + */ +export type UpdateModelOptionsFunction = ( + litellmModels: ModelOption[], + hadWildcard?: boolean +) => void; + +/** + * Get model options function signature + */ +export type GetModelOptionsFunction = ( + provider?: ProviderType +) => ModelOption[]; + +/** + * Add custom model option function signature + */ +export type AddCustomModelOptionFunction = ( + modelName: string, + modelType?: ProviderType +) => ModelOption[]; + +/** + * Remove custom model option function signature + */ +export type RemoveCustomModelOptionFunction = ( + modelName: string +) => ModelOption[]; diff --git a/front_end/panels/ai_chat/ui/settings/utils/storage.ts b/front_end/panels/ai_chat/ui/settings/utils/storage.ts new file mode 100644 index 0000000000..7a981ac738 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/utils/storage.ts @@ -0,0 +1,75 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Get a value from localStorage + */ +export function getStorageItem(key: string, defaultValue: string = ''): string { + return localStorage.getItem(key) || defaultValue; +} + +/** + * Set a value in localStorage, or remove it if empty + */ +export function setStorageItem(key: string, value: string): void { + if (value.trim()) { + localStorage.setItem(key, value); + } else { + localStorage.removeItem(key); + } +} + +/** + * Get a boolean value from localStorage + */ +export function getStorageBoolean(key: string, defaultValue: boolean = false): boolean { + const value = localStorage.getItem(key); + if (value === null) { + return defaultValue; + } + return value === 'true'; +} + +/** + * Set a boolean value in localStorage + */ +export function setStorageBoolean(key: string, value: boolean): void { + localStorage.setItem(key, value.toString()); +} + +/** + * Get a JSON value from localStorage + */ +export function getStorageJSON(key: string, defaultValue: T): T { + try { + const value = localStorage.getItem(key); + if (value === null) { + return defaultValue; + } + return JSON.parse(value) as T; + } catch { + return defaultValue; + } +} + +/** + * Set a JSON value in localStorage + */ +export function setStorageJSON(key: string, value: T): void { + localStorage.setItem(key, JSON.stringify(value)); +} + +/** + * Remove a value from localStorage + */ +export function removeStorageItem(key: string): void { + localStorage.removeItem(key); +} + +/** + * Check if Vector DB is enabled + */ +export function isVectorDBEnabled(): boolean { + return getStorageBoolean('ai_chat_vector_db_enabled', false); +} diff --git a/front_end/panels/ai_chat/ui/settings/utils/styles.ts b/front_end/panels/ai_chat/ui/settings/utils/styles.ts new file mode 100644 index 0000000000..f7f219262b --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/utils/styles.ts @@ -0,0 +1,513 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Get CSS styles for settings dialog + */ +export function getSettingsStyles(): string { + return ` + .settings-dialog { + color: var(--color-text-primary); + background-color: var(--color-background); + } + + .settings-content { + padding: 0; + max-width: 100%; + } + + .settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + .settings-title { + font-size: 18px; + font-weight: 500; + margin: 0; + color: var(--color-text-primary); + } + + .settings-close-button { + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: var(--color-text-secondary); + padding: 4px 8px; + } + + .settings-close-button:hover { + color: var(--color-text-primary); + } + + .provider-selection-section { + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + .provider-select { + margin-top: 8px; + } + + .provider-content { + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + .settings-section { + margin-bottom: 24px; + } + + .settings-subtitle { + font-size: 16px; + font-weight: 500; + margin: 0 0 12px 0; + color: var(--color-text-primary); + } + + .settings-label { + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; + color: var(--color-text-primary); + } + + .settings-hint { + font-size: 12px; + color: var(--color-text-secondary); + margin-bottom: 8px; + } + + .settings-description { + font-size: 14px; + color: var(--color-text-secondary); + margin: 4px 0 12px 0; + } + + .settings-input, .settings-select { + width: 100%; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--color-details-hairline); + background-color: var(--color-background-elevation-2); + color: var(--color-text-primary); + font-size: 14px; + box-sizing: border-box; + height: 32px; + } + + .settings-input:focus, .settings-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary-opacity-30); + } + + .settings-status { + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + margin: 8px 0; + } + + .fetch-button-container { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 0; + } + + .custom-models-section { + margin-top: 16px; + } + + .custom-models-list { + margin-bottom: 16px; + } + + .custom-model-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + padding: 6px 0; + border-bottom: 1px solid var(--color-details-hairline); + } + + .custom-model-name { + flex: 1; + font-size: 14px; + } + + .new-model-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + .custom-model-input { + flex: 1; + margin-bottom: 0; + } + + /* Button spacing and layout */ + .button-group { + display: flex; + gap: 8px; + align-items: center; + } + + .test-status { + font-size: 12px; + margin-left: 4px; + } + + .model-test-status { + margin-top: 4px; + } + + .model-selection-container { + margin-bottom: 20px; + } + + /* Model selector component styles (shared with chat view) */ + .model-selection-container ai-model-selector { display: block; width: 100%; } + .model-selector.searchable { position: relative; } + .model-select-trigger { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border: 1px solid var(--color-details-hairline); + border-radius: 6px; + background: var(--color-background-elevation-1); + cursor: pointer; + width: 100%; + font-size: 12px; + color: var(--color-text-primary); + transition: all 0.2s ease; + box-sizing: border-box; + } + .model-select-trigger:hover:not(:disabled) { background: var(--color-background-elevation-2); border-color: #00a4fe; } + .model-select-trigger:disabled { opacity: 0.6; cursor: not-allowed; background-color: var(--color-background-elevation-0); } + .selected-model { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .dropdown-arrow { margin-left: 8px; font-size: 10px; color: var(--color-text-secondary); transition: transform 0.2s ease; } + .model-dropdown { position: absolute; left: 0; right: 0; background: var(--color-background-elevation-1); border: 1px solid var(--color-details-hairline); border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000; max-height: 300px; overflow: hidden; } + .model-dropdown.below { top: 100%; margin-top: 2px; } + .model-dropdown.above { bottom: 100%; margin-bottom: 2px; } + .model-search { width: 100%; padding: 8px 12px; border: none; border-bottom: 1px solid var(--color-details-hairline); outline: none; background: var(--color-background-elevation-1); color: var(--color-text-primary); font-size: 12px; box-sizing: border-box; } + .model-search::placeholder { color: var(--color-text-secondary); } + .model-options { max-height: 240px; overflow-y: auto; } + .model-option { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--color-details-hairline); font-size: 12px; color: var(--color-text-primary); transition: background-color 0.2s ease; } + .model-option:last-child { border-bottom: none; } + .model-option:hover, .model-option.highlighted { background: #def1fb; } + .model-option.selected { background: #00a4fe; color: white; } + .model-option.no-results { color: var(--color-text-secondary); cursor: default; font-style: italic; } + .model-option.no-results:hover { background: transparent; } + + .mini-model-description, .nano-model-description { + font-size: 12px; + font-style: italic; + } + + .history-section { + margin-top: 16px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + .disclaimer-section { + background-color: var(--color-background-elevation-1); + border-radius: 8px; + padding: 16px 20px; + margin: 16px 20px; + border: 1px solid var(--color-details-hairline); + } + + .disclaimer-warning { + color: var(--color-accent-orange); + margin-bottom: 8px; + } + + .disclaimer-note { + margin-bottom: 8px; + } + + .disclaimer-footer { + font-size: 12px; + color: var(--color-text-secondary); + margin-top: 8px; + } + + .settings-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid var(--color-details-hairline); + } + + .save-status { + margin: 0; + font-size: 13px; + padding: 6px 10px; + } + + .settings-button { + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + background-color: var(--color-background-elevation-1); + border: 1px solid var(--color-details-hairline); + color: var(--color-text-primary); + } + + .settings-button:hover { + background-color: var(--color-background-elevation-2); + } + + .settings-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Add button styling */ + .add-button { + min-width: 60px; + border-radius: 4px; + font-size: 12px; + background-color: var(--color-background-elevation-1); + } + + .add-button:hover { + background-color: var(--color-background-elevation-2); + } + + /* Icon button styling */ + .icon-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + color: var(--color-text-secondary); + transition: all 0.15s; + } + + .icon-button:hover { + background-color: var(--color-background-elevation-2); + } + + /* Specific icon button hover states */ + .remove-button:hover { + color: var(--color-accent-red); + } + + .test-button:hover { + color: var(--color-accent-green); + } + + .trash-icon, .check-icon { + display: flex; + align-items: center; + justify-content: center; + } + + /* Cancel button */ + .cancel-button { + background-color: var(--color-background-elevation-1); + border: 1px solid var(--color-details-hairline); + color: var(--color-text-primary); + } + + .cancel-button:hover { + background-color: var(--color-background-elevation-2); + } + + /* Save button */ + .save-button { + background-color: var(--color-primary); + border: 1px solid var(--color-primary); + color: white; + } + + .save-button:hover { + background-color: var(--color-primary-variant); + } + + .clear-button { + margin-top: 8px; + } + + /* Vector DB section styles */ + .vector-db-section { + margin-top: 16px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + /* Tracing section styles */ + .tracing-section { + margin-top: 16px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + .settings-section-title { + font-size: 16px; + font-weight: 500; + color: var(--color-text-primary); + margin: 0 0 16px 0; + } + + .tracing-enabled-container { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + .tracing-checkbox { + margin: 0; + } + + .tracing-label { + font-weight: 500; + color: var(--color-text-primary); + cursor: pointer; + } + + .tracing-config-container { + margin-top: 16px; + padding-left: 24px; + border-left: 2px solid var(--color-details-hairline); + } + + /* Apply tracing config visual style to Evaluation section */ + .evaluation-enabled-container { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + .evaluation-checkbox { margin: 0; } + .evaluation-label { + font-weight: 500; + color: var(--color-text-primary); + cursor: pointer; + } + .evaluation-config-container { + margin-top: 16px; + padding-left: 24px; + border-left: 2px solid var(--color-details-hairline); + } + + /* Apply tracing config visual style to MCP section */ + .mcp-enabled-container { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + .mcp-checkbox { margin: 0; } + .mcp-label { + font-weight: 500; + color: var(--color-text-primary); + cursor: pointer; + } + .mcp-config-container { + margin-top: 16px; + padding-left: 24px; + border-left: 2px solid var(--color-details-hairline); + } + + /* Advanced Settings Toggle styles */ + .advanced-settings-toggle-section { + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + background-color: var(--color-background-highlight); + } + + .advanced-settings-toggle-container { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + .advanced-settings-checkbox { + margin: 0; + transform: scale(1.1); + } + + .advanced-settings-label { + font-weight: 500; + color: var(--color-text-primary); + cursor: pointer; + font-size: 14px; + } + + /* Evaluation section styles */ + .evaluation-section { + margin-top: 16px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + .evaluation-buttons-container { + display: flex; + gap: 8px; + margin-top: 16px; + } + + .connect-button { + background-color: var(--color-accent-blue-background); + color: var(--color-accent-blue); + border: 1px solid var(--color-accent-blue); + } + + .connect-button:hover { + background-color: var(--color-accent-blue); + color: var(--color-background); + } + + .connect-button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .mcp-section { + margin-top: 16px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-details-hairline); + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + `; +} + +/** + * Apply settings dialog styles to a dialog element + */ +export function applySettingsStyles(dialogElement: HTMLElement): void { + const styleElement = document.createElement('style'); + styleElement.textContent = getSettingsStyles(); + dialogElement.appendChild(styleElement); +} diff --git a/front_end/panels/ai_chat/ui/settings/utils/validation.ts b/front_end/panels/ai_chat/ui/settings/utils/validation.ts new file mode 100644 index 0000000000..9c37e555ce --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/utils/validation.ts @@ -0,0 +1,69 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { DEFAULT_PROVIDER_MODELS } from '../../AIChatPanel.js'; +import type { ModelOption, ProviderType, ModelTier } from '../types.js'; + +/** + * Get a valid model for a specific provider, falling back to defaults if needed + */ +export function getValidModelForProvider( + currentModel: string, + providerModels: ModelOption[], + provider: ProviderType, + modelType: ModelTier, +): string { + // Check if current model is valid for this provider + if (providerModels.some(model => model.value === currentModel)) { + return currentModel; + } + + // Get defaults from AIChatPanel's DEFAULT_PROVIDER_MODELS + const defaults = DEFAULT_PROVIDER_MODELS[provider] || DEFAULT_PROVIDER_MODELS.openai; + const defaultModel = modelType === 'mini' ? defaults.mini : defaults.nano; + + // Return default if it exists in provider models + if (defaultModel && providerModels.some(model => model.value === defaultModel)) { + return defaultModel; + } + + // If no valid model found, return empty string to indicate no selection + // The UI should handle this by showing a placeholder or the first available option + return ''; +} + +/** + * Validate that a model selection is valid for the given provider + */ +export function validateModelSelection( + modelValue: string, + providerModels: ModelOption[], +): boolean { + if (!modelValue) { + return true; // Empty selection is valid (will use default) + } + return providerModels.some(model => model.value === modelValue); +} + +/** + * Validate API key format (basic check for non-empty string) + */ +export function validateApiKey(apiKey: string): boolean { + return apiKey.trim().length > 0; +} + +/** + * Validate endpoint URL format + */ +export function validateEndpoint(endpoint: string): boolean { + if (!endpoint.trim()) { + return false; + } + try { + new URL(endpoint); + return true; + } catch { + return false; + } +}