From 01f5b546b7d58028fc44b92355deb1d77bb8678c Mon Sep 17 00:00:00 2001 From: Tyson Thomas Date: Mon, 17 Nov 2025 22:18:09 -0800 Subject: [PATCH 1/4] Add support for more providers and conversation history --- front_end/panels/ai_chat/BUILD.gn | 26 +- .../panels/ai_chat/LLM/AnthropicProvider.ts | 469 +++++++++++++++ .../panels/ai_chat/LLM/CerebrasProvider.ts | 460 +++++++++++++++ .../ai_chat/LLM/GenericOpenAIProvider.ts | 447 ++++++++++++++ .../panels/ai_chat/LLM/GoogleAIProvider.ts | 548 ++++++++++++++++++ front_end/panels/ai_chat/LLM/LLMClient.ts | 257 ++++++-- .../panels/ai_chat/LLM/LLMProviderRegistry.ts | 237 ++++++++ front_end/panels/ai_chat/LLM/LLMTypes.ts | 85 ++- front_end/panels/ai_chat/core/AgentService.ts | 373 +++++++++--- .../ai_chat/core/CustomProviderManager.ts | 320 ++++++++++ .../ai_chat/core/LLMConfigurationManager.ts | 129 ++--- .../persistence/ConversationManager.ts | 184 ++++++ .../persistence/ConversationStorageManager.ts | 339 +++++++++++ .../ai_chat/persistence/ConversationTypes.ts | 288 +++++++++ .../ai_chat/tools/FileStorageManager.ts | 23 +- front_end/panels/ai_chat/ui/AIChatPanel.ts | 504 +++++++++------- front_end/panels/ai_chat/ui/ChatView.ts | 11 + .../ai_chat/ui/ConversationHistoryList.ts | 192 ++++++ .../panels/ai_chat/ui/CustomProviderDialog.ts | 540 +++++++++++++++++ front_end/panels/ai_chat/ui/SettingsDialog.ts | 468 ++++++++++----- front_end/panels/ai_chat/ui/chatView.css | 21 + .../ai_chat/ui/conversationHistoryStyles.ts | 171 ++++++ .../panels/ai_chat/ui/settings/constants.ts | 4 + .../ai_chat/ui/settings/i18n-strings.ts | 40 ++ .../ai_chat/ui/settings/providerConfigs.ts | 145 +++++ .../providers/BrowserOperatorSettings.ts | 80 --- .../providers/GenericProviderSettings.ts | 300 ++++++++++ .../ui/settings/providers/GroqSettings.ts | 234 -------- .../ui/settings/providers/OpenAISettings.ts | 134 ----- front_end/panels/ai_chat/ui/settings/types.ts | 6 +- 30 files changed, 5998 insertions(+), 1037 deletions(-) create mode 100644 front_end/panels/ai_chat/LLM/AnthropicProvider.ts create mode 100644 front_end/panels/ai_chat/LLM/CerebrasProvider.ts create mode 100644 front_end/panels/ai_chat/LLM/GenericOpenAIProvider.ts create mode 100644 front_end/panels/ai_chat/LLM/GoogleAIProvider.ts create mode 100644 front_end/panels/ai_chat/core/CustomProviderManager.ts create mode 100644 front_end/panels/ai_chat/persistence/ConversationManager.ts create mode 100644 front_end/panels/ai_chat/persistence/ConversationStorageManager.ts create mode 100644 front_end/panels/ai_chat/persistence/ConversationTypes.ts create mode 100644 front_end/panels/ai_chat/ui/ConversationHistoryList.ts create mode 100644 front_end/panels/ai_chat/ui/CustomProviderDialog.ts create mode 100644 front_end/panels/ai_chat/ui/conversationHistoryStyles.ts create mode 100644 front_end/panels/ai_chat/ui/settings/providerConfigs.ts delete mode 100644 front_end/panels/ai_chat/ui/settings/providers/BrowserOperatorSettings.ts create mode 100644 front_end/panels/ai_chat/ui/settings/providers/GenericProviderSettings.ts delete mode 100644 front_end/panels/ai_chat/ui/settings/providers/GroqSettings.ts delete mode 100644 front_end/panels/ai_chat/ui/settings/providers/OpenAISettings.ts diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index ede2191611..3bde86e174 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -51,11 +51,10 @@ devtools_module("ai_chat") { "ui/settings/components/SettingsFooter.ts", "ui/settings/components/AdvancedToggle.ts", "ui/settings/providers/BaseProviderSettings.ts", - "ui/settings/providers/OpenAISettings.ts", + "ui/settings/providers/GenericProviderSettings.ts", "ui/settings/providers/LiteLLMSettings.ts", - "ui/settings/providers/GroqSettings.ts", "ui/settings/providers/OpenRouterSettings.ts", - "ui/settings/providers/BrowserOperatorSettings.ts", + "ui/settings/providerConfigs.ts", "ui/settings/advanced/MCPSettings.ts", "ui/settings/advanced/BrowsingHistorySettings.ts", "ui/settings/advanced/VectorDBSettings.ts", @@ -67,8 +66,14 @@ devtools_module("ai_chat") { "ui/TodoListDisplay.ts", "ui/FileListDisplay.ts", "ui/FileContentViewer.ts", + "ui/ConversationHistoryList.ts", + "ui/conversationHistoryStyles.ts", + "ui/CustomProviderDialog.ts", "ai_chat_impl.ts", "models/ChatTypes.ts", + "persistence/ConversationTypes.ts", + "persistence/ConversationStorageManager.ts", + "persistence/ConversationManager.ts", "core/Graph.ts", "core/State.ts", "core/Types.ts", @@ -84,6 +89,7 @@ devtools_module("ai_chat") { "core/AgentNodes.ts", "core/GraphHelpers.ts", "core/LLMConfigurationManager.ts", + "core/CustomProviderManager.ts", "core/ToolNameMap.ts", "core/ToolSurfaceProvider.ts", "core/StateGraph.ts", @@ -101,6 +107,10 @@ devtools_module("ai_chat") { "LLM/GroqProvider.ts", "LLM/OpenRouterProvider.ts", "LLM/BrowserOperatorProvider.ts", + "LLM/CerebrasProvider.ts", + "LLM/AnthropicProvider.ts", + "LLM/GoogleAIProvider.ts", + "LLM/GenericOpenAIProvider.ts", "LLM/MessageSanitizer.ts", "LLM/LLMClient.ts", "tools/Tools.ts", @@ -245,11 +255,10 @@ _ai_chat_sources = [ "ui/settings/components/SettingsFooter.ts", "ui/settings/components/AdvancedToggle.ts", "ui/settings/providers/BaseProviderSettings.ts", - "ui/settings/providers/OpenAISettings.ts", + "ui/settings/providers/GenericProviderSettings.ts", "ui/settings/providers/LiteLLMSettings.ts", - "ui/settings/providers/GroqSettings.ts", "ui/settings/providers/OpenRouterSettings.ts", - "ui/settings/providers/BrowserOperatorSettings.ts", + "ui/settings/providerConfigs.ts", "ui/settings/advanced/MCPSettings.ts", "ui/settings/advanced/BrowsingHistorySettings.ts", "ui/settings/advanced/VectorDBSettings.ts", @@ -279,6 +288,7 @@ _ai_chat_sources = [ "core/AgentNodes.ts", "core/GraphHelpers.ts", "core/LLMConfigurationManager.ts", + "core/CustomProviderManager.ts", "core/ToolNameMap.ts", "core/ToolSurfaceProvider.ts", "core/StateGraph.ts", @@ -296,6 +306,10 @@ _ai_chat_sources = [ "LLM/GroqProvider.ts", "LLM/OpenRouterProvider.ts", "LLM/BrowserOperatorProvider.ts", + "LLM/CerebrasProvider.ts", + "LLM/AnthropicProvider.ts", + "LLM/GoogleAIProvider.ts", + "LLM/GenericOpenAIProvider.ts", "LLM/MessageSanitizer.ts", "LLM/LLMClient.ts", "tools/Tools.ts", diff --git a/front_end/panels/ai_chat/LLM/AnthropicProvider.ts b/front_end/panels/ai_chat/LLM/AnthropicProvider.ts new file mode 100644 index 0000000000..b3b6678a77 --- /dev/null +++ b/front_end/panels/ai_chat/LLM/AnthropicProvider.ts @@ -0,0 +1,469 @@ +// 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 { LLMMessage, LLMResponse, LLMCallOptions, LLMProvider, ModelInfo, MessageContent } from './LLMTypes.js'; +import { LLMBaseProvider } from './LLMProvider.js'; +import { LLMRetryManager } from './LLMErrorHandler.js'; +import { LLMResponseParser } from './LLMResponseParser.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('AnthropicProvider'); + +/** + * Anthropic provider implementation using the Messages API + * https://docs.anthropic.com/en/api/messages + */ +export class AnthropicProvider extends LLMBaseProvider { + private static readonly API_BASE_URL = 'https://api.anthropic.com/v1'; + private static readonly MESSAGES_PATH = '/messages'; + private static readonly API_VERSION = '2023-06-01'; + + readonly name: LLMProvider = 'anthropic'; + + constructor(private readonly apiKey: string) { + super(); + } + + /** + * Get the messages endpoint URL + */ + private getMessagesEndpoint(): string { + return `${AnthropicProvider.API_BASE_URL}${AnthropicProvider.MESSAGES_PATH}`; + } + + /** + * Convert MessageContent to Anthropic format + */ + private convertContentToAnthropic(content: MessageContent | undefined): any { + if (!content) { + return []; + } + + if (typeof content === 'string') { + return [{ type: 'text', text: content }]; + } + + if (Array.isArray(content)) { + return content.map(item => { + if (item.type === 'text') { + return { type: 'text', text: item.text }; + } else if (item.type === 'image_url') { + // Anthropic uses a different image format + const url = item.image_url.url; + if (url.startsWith('data:')) { + // Extract mime type and base64 data + const matches = url.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + return { + type: 'image', + source: { + type: 'base64', + media_type: matches[1], + data: matches[2] + } + }; + } + } + // For URLs, Anthropic supports URL type + return { + type: 'image', + source: { + type: 'url', + url: url + } + }; + } + return { type: 'text', text: String(item) }; + }); + } + + return [{ type: 'text', text: String(content) }]; + } + + /** + * Converts LLMMessage format to Anthropic Messages API format + */ + private convertMessagesToAnthropic(messages: LLMMessage[]): { system?: string, messages: any[] } { + let systemPrompt: string | undefined; + const anthropicMessages: any[] = []; + + for (const msg of messages) { + if (msg.role === 'system') { + // Anthropic uses a separate system parameter + systemPrompt = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + continue; + } + + if (msg.role === 'user') { + anthropicMessages.push({ + role: 'user', + content: this.convertContentToAnthropic(msg.content) + }); + } else if (msg.role === 'assistant') { + if (msg.tool_calls && msg.tool_calls.length > 0) { + // Convert tool calls to Anthropic format + const toolUseBlocks = msg.tool_calls.map(tc => ({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input: typeof tc.function.arguments === 'string' + ? JSON.parse(tc.function.arguments) + : tc.function.arguments + })); + anthropicMessages.push({ + role: 'assistant', + content: toolUseBlocks + }); + } else { + // Regular assistant message + anthropicMessages.push({ + role: 'assistant', + content: this.convertContentToAnthropic(msg.content) + }); + } + } else if (msg.role === 'tool') { + // Tool result - Anthropic expects this in a user message with tool_result type + anthropicMessages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: msg.tool_call_id, + content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + }] + }); + } + } + + return { system: systemPrompt, messages: anthropicMessages }; + } + + /** + * Convert OpenAI tool format to Anthropic tools format + */ + private convertToolsToAnthropic(tools: any[]): any[] { + return tools.map(tool => { + if (tool.type === 'function' && tool.function) { + return { + name: tool.function.name, + description: tool.function.description || '', + input_schema: tool.function.parameters || { type: 'object', properties: {} } + }; + } + return null; + }).filter(Boolean); + } + + /** + * Makes a request to the Anthropic API + */ + private async makeAPIRequest(endpoint: string, payloadBody: any, options?: { betaHeaders?: string[] }): Promise { + try { + logger.debug('Making Anthropic API request to:', endpoint); + + const headers: Record = { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': AnthropicProvider.API_VERSION, + }; + + // Add beta headers if provided + if (options?.betaHeaders && options.betaHeaders.length > 0) { + headers['anthropic-beta'] = options.betaHeaders.join(','); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(payloadBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + logger.error('Anthropic API error:', JSON.stringify(errorData, null, 2)); + throw new Error(`Anthropic API error: ${response.statusText} - ${errorData?.error?.message || errorData?.message || 'Unknown error'}`); + } + + const data = await response.json(); + logger.info('Anthropic Response:', data); + + if (data.usage) { + logger.info('Anthropic Usage:', { + inputTokens: data.usage.input_tokens, + outputTokens: data.usage.output_tokens + }); + } + + return data; + } catch (error) { + logger.error('Anthropic API request failed:', error); + throw error; + } + } + + /** + * Processes the Anthropic response and converts to LLMResponse format + */ + private processAnthropicResponse(data: any): LLMResponse { + const result: LLMResponse = { + rawResponse: data + }; + + if (!data?.content || data.content.length === 0) { + throw new Error('No content in Anthropic response'); + } + + // Process content blocks + for (const block of data.content) { + if (block.type === 'text') { + result.text = (result.text || '') + block.text; + } else if (block.type === 'tool_use') { + // First tool use becomes the function call + if (!result.functionCall) { + result.functionCall = { + name: block.name, + arguments: block.input || {} + }; + } + } + } + + if (result.text) { + result.text = result.text.trim(); + } + + return result; + } + + /** + * Call the Anthropic API with messages + */ + async callWithMessages( + modelName: string, + messages: LLMMessage[], + options?: LLMCallOptions + ): Promise { + return LLMRetryManager.simpleRetry(async () => { + logger.debug('Calling Anthropic with messages...', { model: modelName, messageCount: messages.length }); + + // Convert messages to Anthropic format + const { system, messages: anthropicMessages } = this.convertMessagesToAnthropic(messages); + + // Construct payload body + const payloadBody: any = { + model: modelName, + messages: anthropicMessages, + max_tokens: 4096, // Required parameter for Anthropic + }; + + // Add system prompt if present + if (system) { + payloadBody.system = system; + } + + // Add temperature if provided + if (options?.temperature !== undefined) { + payloadBody.temperature = options.temperature; + } + + // Add tools if provided + if (options?.tools && options.tools.length > 0) { + payloadBody.tools = this.convertToolsToAnthropic(options.tools); + } + + // Determine beta headers based on options + const betaHeaders: string[] = []; + // Add interleaved thinking if reasoning is requested + if (options?.reasoningLevel) { + betaHeaders.push('interleaved-thinking-2025-05-14'); + } + + logger.info('Request payload:', payloadBody); + + const data = await this.makeAPIRequest( + this.getMessagesEndpoint(), + payloadBody, + { betaHeaders: betaHeaders.length > 0 ? betaHeaders : undefined } + ); + return this.processAnthropicResponse(data); + }, options?.retryConfig); + } + + /** + * Simple call method for backward compatibility + */ + async call( + modelName: string, + prompt: string, + systemPrompt: string, + options?: LLMCallOptions + ): Promise { + const messages: LLMMessage[] = []; + + if (systemPrompt) { + messages.push({ + role: 'system', + content: systemPrompt + }); + } + + messages.push({ + role: 'user', + content: prompt + }); + + return this.callWithMessages(modelName, messages, options); + } + + /** + * Parse response into standardized action structure + */ + parseResponse(response: LLMResponse): ReturnType { + return LLMResponseParser.parseResponse(response); + } + + /** + * Get all models supported by this provider + */ + async getModels(): Promise { + // Anthropic doesn't provide a public models API endpoint + // Return hardcoded list of known models + return this.getDefaultModels(); + } + + /** + * Get default list of known Anthropic models + */ + private getDefaultModels(): ModelInfo[] { + return [ + { + id: 'claude-sonnet-4.5-20250514', + name: 'Claude Sonnet 4.5', + provider: 'anthropic' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: true, + vision: true, + structured: true + } + }, + { + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + provider: 'anthropic' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: true, + vision: true, + structured: true + } + }, + { + id: 'claude-opus-4-20250514', + name: 'Claude Opus 4', + provider: 'anthropic' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: true, + vision: true, + structured: true + } + }, + { + id: 'claude-haiku-4-20250514', + name: 'Claude Haiku 4', + provider: 'anthropic' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: true, + structured: true + } + }, + { + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet (Legacy)', + provider: 'anthropic' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: true, + structured: true + } + }, + { + id: 'claude-3-5-haiku-20241022', + name: 'Claude 3.5 Haiku', + provider: 'anthropic' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: false, + structured: true + } + } + ]; + } + + /** + * Test the Anthropic connection with a simple completion request + */ + async testConnection(modelName: string): Promise<{success: boolean, message: string}> { + logger.debug('Testing Anthropic connection...'); + + try { + const testPrompt = 'Please respond with "Connection successful!" to confirm the connection is working.'; + + const response = await this.call(modelName, testPrompt, '', { + temperature: 0.1, + }); + + if (response.text?.toLowerCase().includes('connection')) { + return { + success: true, + message: `Successfully connected to Anthropic with model ${modelName}`, + }; + } + return { + success: true, + message: `Connected to Anthropic, but received unexpected response: ${response.text || 'No response'}`, + }; + } catch (error) { + logger.error('Anthropic connection test failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Validate that required credentials are available for Anthropic + */ + validateCredentials(): {isValid: boolean, message: string, missingItems?: string[]} { + const storageKeys = this.getCredentialStorageKeys(); + const apiKey = localStorage.getItem(storageKeys.apiKey!); + + if (!apiKey) { + return { + isValid: false, + message: 'Anthropic API key is required. Please add your API key in Settings.', + missingItems: ['API Key'] + }; + } + + return { + isValid: true, + message: 'Anthropic credentials are configured correctly.' + }; + } + + /** + * Get the storage keys this provider uses for credentials + */ + getCredentialStorageKeys(): {apiKey: string} { + return { + apiKey: 'ai_chat_anthropic_api_key' + }; + } +} diff --git a/front_end/panels/ai_chat/LLM/CerebrasProvider.ts b/front_end/panels/ai_chat/LLM/CerebrasProvider.ts new file mode 100644 index 0000000000..1885b17c60 --- /dev/null +++ b/front_end/panels/ai_chat/LLM/CerebrasProvider.ts @@ -0,0 +1,460 @@ +// 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 { LLMMessage, LLMResponse, LLMCallOptions, LLMProvider, ModelInfo } from './LLMTypes.js'; +import { LLMBaseProvider } from './LLMProvider.js'; +import { LLMRetryManager } from './LLMErrorHandler.js'; +import { LLMResponseParser } from './LLMResponseParser.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('CerebrasProvider'); + +/** + * Cerebras model information + */ +export interface CerebrasModel { + id: string; + object: string; + created: number; + owned_by: string; +} + +export interface CerebrasModelsResponse { + object: string; + data: CerebrasModel[]; +} + +/** + * Cerebras provider implementation using OpenAI-compatible Chat Completions API + * https://inference-docs.cerebras.ai/api-reference/chat-completions + */ +export class CerebrasProvider extends LLMBaseProvider { + private static readonly API_BASE_URL = 'https://api.cerebras.ai/v1'; + private static readonly CHAT_COMPLETIONS_PATH = '/chat/completions'; + private static readonly MODELS_PATH = '/models'; + + readonly name: LLMProvider = 'cerebras'; + + constructor(private readonly apiKey: string) { + super(); + } + + /** + * Get the chat completions endpoint URL + */ + private getChatEndpoint(): string { + return `${CerebrasProvider.API_BASE_URL}${CerebrasProvider.CHAT_COMPLETIONS_PATH}`; + } + + /** + * Get the models endpoint URL + */ + private getModelsEndpoint(): string { + return `${CerebrasProvider.API_BASE_URL}${CerebrasProvider.MODELS_PATH}`; + } + + /** + * Converts LLMMessage format to Cerebras/OpenAI format + */ + private convertMessagesToCerebras(messages: LLMMessage[]): any[] { + return messages.map(msg => { + const baseMessage: any = { + role: msg.role, + content: msg.content + }; + + // Ensure tool call arguments are strings per OpenAI/Cerebras spec + if (msg.tool_calls && Array.isArray(msg.tool_calls)) { + baseMessage.tool_calls = msg.tool_calls.map(tc => { + const args = (tc.function as any).arguments; + const argsString = typeof args === 'string' ? args : JSON.stringify(args ?? {}); + return { + ...tc, + function: { + ...tc.function, + arguments: argsString, + }, + }; + }); + } + + // Add optional fields if present + if (msg.tool_call_id) { + baseMessage.tool_call_id = msg.tool_call_id; + } + if (msg.name) { + baseMessage.name = msg.name; + } + + // For tool role, content must be a string; stringify objects/arrays + if (msg.role === 'tool') { + if (typeof baseMessage.content !== 'string') { + baseMessage.content = JSON.stringify(baseMessage.content ?? ''); + } + } + + return baseMessage; + }); + } + + /** + * Makes a request to the Cerebras API + */ + private async makeAPIRequest(endpoint: string, payloadBody: any): Promise { + try { + logger.debug('Making Cerebras API request to:', endpoint); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(payloadBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + logger.error('Cerebras API error:', JSON.stringify(errorData, null, 2)); + throw new Error(`Cerebras API error: ${response.statusText} - ${errorData?.error?.message || 'Unknown error'}`); + } + + const data = await response.json(); + logger.info('Cerebras Response:', data); + + if (data.usage) { + logger.info('Cerebras Usage:', { + inputTokens: data.usage.prompt_tokens, + outputTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens + }); + } + + return data; + } catch (error) { + logger.error('Cerebras API request failed:', error); + throw error; + } + } + + /** + * Processes the Cerebras response and converts to LLMResponse format + */ + private processCerebrasResponse(data: any): LLMResponse { + const result: LLMResponse = { + rawResponse: data + }; + + if (!data?.choices || data.choices.length === 0) { + throw new Error('No choices in Cerebras response'); + } + + const choice = data.choices[0]; + const message = choice.message; + + if (!message) { + throw new Error('No message in Cerebras choice'); + } + + // Check for tool calls + if (message.tool_calls && message.tool_calls.length > 0) { + const toolCall = message.tool_calls[0]; + if (toolCall.function) { + try { + result.functionCall = { + name: toolCall.function.name, + arguments: JSON.parse(toolCall.function.arguments) + }; + } catch (error) { + logger.error('Error parsing function arguments:', error); + result.functionCall = { + name: toolCall.function.name, + arguments: toolCall.function.arguments // Keep as string if parsing fails + }; + } + } + } else if (message.content) { + // Plain text response + result.text = message.content.trim(); + } + + return result; + } + + /** + * Call the Cerebras API with messages + */ + async callWithMessages( + modelName: string, + messages: LLMMessage[], + options?: LLMCallOptions + ): Promise { + return LLMRetryManager.simpleRetry(async () => { + logger.debug('Calling Cerebras with messages...', { model: modelName, messageCount: messages.length }); + + // Construct payload body in OpenAI Chat Completions format + const payloadBody: any = { + model: modelName, + messages: this.convertMessagesToCerebras(messages), + }; + + // Add temperature if provided (Cerebras supports 0-1.5) + if (options?.temperature !== undefined) { + payloadBody.temperature = Math.min(1.5, Math.max(0, options.temperature)); + } + + // Add tools if provided + if (options?.tools) { + // Ensure all tools have valid parameters + payloadBody.tools = options.tools.map(tool => { + if (tool.type === 'function' && tool.function) { + return { + ...tool, + function: { + ...tool.function, + parameters: tool.function.parameters || { type: 'object', properties: {} } + } + }; + } + return tool; + }); + } + + // Ensure tool_choice is set to 'auto' when tools are present unless explicitly provided + if (options?.tools && !options?.tool_choice) { + payloadBody.tool_choice = 'auto'; + } else if (options?.tool_choice) { + payloadBody.tool_choice = options.tool_choice; + } + + logger.info('Request payload:', payloadBody); + + const data = await this.makeAPIRequest(this.getChatEndpoint(), payloadBody); + return this.processCerebrasResponse(data); + }, options?.retryConfig); + } + + /** + * Simple call method for backward compatibility + */ + async call( + modelName: string, + prompt: string, + systemPrompt: string, + options?: LLMCallOptions + ): Promise { + const messages: LLMMessage[] = []; + + if (systemPrompt) { + messages.push({ + role: 'system', + content: systemPrompt + }); + } + + messages.push({ + role: 'user', + content: prompt + }); + + return this.callWithMessages(modelName, messages, options); + } + + /** + * Parse response into standardized action structure + */ + parseResponse(response: LLMResponse): ReturnType { + return LLMResponseParser.parseResponse(response); + } + + /** + * Fetch available models from Cerebras API + */ + async fetchModels(): Promise { + logger.debug('Fetching available Cerebras models...'); + + try { + const response = await fetch(this.getModelsEndpoint(), { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + logger.error('Cerebras models API error:', JSON.stringify(errorData, null, 2)); + throw new Error(`Cerebras models API error: ${response.statusText} - ${errorData?.error?.message || 'Unknown error'}`); + } + + const data: CerebrasModelsResponse = await response.json(); + logger.debug('Cerebras Models Response:', data); + + if (!data?.data || !Array.isArray(data.data)) { + throw new Error('Invalid models response format'); + } + + return data.data; + } catch (error) { + logger.error('Failed to fetch Cerebras models:', error); + throw error; + } + } + + /** + * Get all models supported by this provider + */ + async getModels(): Promise { + try { + // Fetch models from Cerebras API + const cerebrasModels = await this.fetchModels(); + + return cerebrasModels.map(model => ({ + id: model.id, + name: model.id, // Use ID as name + provider: 'cerebras' as LLMProvider, + capabilities: { + functionCalling: this.modelSupportsFunctionCalling(model.id), + reasoning: false, // Cerebras models don't have reasoning capabilities like O-series + vision: false, // Cerebras currently doesn't support vision + structured: true // All Cerebras models support structured output + } + })); + } catch (error) { + logger.warn('Failed to fetch models from Cerebras API, using default list:', error); + + // Return default list of known Cerebras models as fallback + return this.getDefaultModels(); + } + } + + /** + * Check if a model supports function calling based on its ID + */ + private modelSupportsFunctionCalling(modelId: string): boolean { + // According to Cerebras docs, these models support function calling: + const functionCallingModels = [ + 'llama-3.3-70b', + 'llama-3.1-70b', + 'llama-3.1-8b', + 'qwen-3-32b' + ]; + + return functionCallingModels.some(model => modelId.includes(model)); + } + + /** + * Get default list of known Cerebras models + */ + private getDefaultModels(): ModelInfo[] { + return [ + { + id: 'llama-3.3-70b', + name: 'Llama 3.3 70B', + provider: 'cerebras' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: false, + structured: true + } + }, + { + id: 'llama-3.1-70b', + name: 'Llama 3.1 70B', + provider: 'cerebras' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: false, + structured: true + } + }, + { + id: 'llama-3.1-8b', + name: 'Llama 3.1 8B', + provider: 'cerebras' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: false, + structured: true + } + }, + { + id: 'qwen-3-32b', + name: 'Qwen 3 32B', + provider: 'cerebras' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: false, + structured: true + } + } + ]; + } + + /** + * Test the Cerebras connection with a simple completion request + */ + async testConnection(modelName: string): Promise<{success: boolean, message: string}> { + logger.debug('Testing Cerebras connection...'); + + try { + const testPrompt = 'Please respond with "Connection successful!" to confirm the connection is working.'; + + const response = await this.call(modelName, testPrompt, '', { + temperature: 0.1, + }); + + if (response.text?.toLowerCase().includes('connection')) { + return { + success: true, + message: `Successfully connected to Cerebras with model ${modelName}`, + }; + } + return { + success: true, + message: `Connected to Cerebras, but received unexpected response: ${response.text || 'No response'}`, + }; + } catch (error) { + logger.error('Cerebras connection test failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Validate that required credentials are available for Cerebras + */ + validateCredentials(): {isValid: boolean, message: string, missingItems?: string[]} { + const storageKeys = this.getCredentialStorageKeys(); + const apiKey = localStorage.getItem(storageKeys.apiKey!); + + if (!apiKey) { + return { + isValid: false, + message: 'Cerebras API key is required. Please add your API key in Settings.', + missingItems: ['API Key'] + }; + } + + return { + isValid: true, + message: 'Cerebras credentials are configured correctly.' + }; + } + + /** + * Get the storage keys this provider uses for credentials + */ + getCredentialStorageKeys(): {apiKey: string} { + return { + apiKey: 'ai_chat_cerebras_api_key' + }; + } +} diff --git a/front_end/panels/ai_chat/LLM/GenericOpenAIProvider.ts b/front_end/panels/ai_chat/LLM/GenericOpenAIProvider.ts new file mode 100644 index 0000000000..b9416cad5c --- /dev/null +++ b/front_end/panels/ai_chat/LLM/GenericOpenAIProvider.ts @@ -0,0 +1,447 @@ +// 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 { LLMMessage, LLMResponse, LLMCallOptions, LLMProvider, ModelInfo } from './LLMTypes.js'; +import { LLMBaseProvider } from './LLMProvider.js'; +import { LLMRetryManager } from './LLMErrorHandler.js'; +import { LLMResponseParser } from './LLMResponseParser.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('GenericOpenAIProvider'); + +/** + * Generic OpenAI-compatible model information + */ +export interface GenericOpenAIModel { + id: string; + object: string; + created?: number; + owned_by?: string; + [key: string]: any; +} + +export interface GenericOpenAIModelsResponse { + object: string; + data: GenericOpenAIModel[]; +} + +/** + * Configuration for a custom provider instance + */ +export interface CustomProviderConfig { + id: string; // Unique identifier (e.g., "custom:z-ai") + name: string; // Display name (e.g., "Z.AI") + baseURL: string; // Base URL (e.g., "https://api.z.ai/api/coding/paas/v4") + apiKey?: string; // Optional API key + models?: string[]; // Optional cached model list +} + +/** + * Generic OpenAI-compatible provider implementation + * Works with any API that follows the OpenAI API format + * https://platform.openai.com/docs/api-reference + */ +export class GenericOpenAIProvider extends LLMBaseProvider { + private static readonly CHAT_COMPLETIONS_PATH = '/chat/completions'; + private static readonly MODELS_PATH = '/models'; + + readonly name: LLMProvider; + private readonly providerId: string; + private readonly displayName: string; + private readonly baseURL: string; + + constructor(config: CustomProviderConfig, apiKey?: string) { + super(); + this.providerId = config.id; + this.displayName = config.name; + this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash + this.apiKey = apiKey || config.apiKey || ''; + // Use the provider ID as the name, but it will be treated as a custom provider + this.name = this.providerId as LLMProvider; + } + + private apiKey: string; + + /** + * Get the provider's unique identifier + */ + getProviderId(): string { + return this.providerId; + } + + /** + * Get the provider's display name + */ + getDisplayName(): string { + return this.displayName; + } + + /** + * Get the chat completions endpoint URL + */ + private getChatEndpoint(): string { + return `${this.baseURL}${GenericOpenAIProvider.CHAT_COMPLETIONS_PATH}`; + } + + /** + * Get the models endpoint URL + */ + private getModelsEndpoint(): string { + return `${this.baseURL}${GenericOpenAIProvider.MODELS_PATH}`; + } + + /** + * Converts LLMMessage format to OpenAI format + */ + private convertMessagesToOpenAI(messages: LLMMessage[]): any[] { + return messages.map(msg => { + const baseMessage: any = { + role: msg.role, + content: msg.content + }; + + // Ensure tool call arguments are strings per OpenAI spec + if (msg.tool_calls && Array.isArray(msg.tool_calls)) { + baseMessage.tool_calls = msg.tool_calls.map(tc => { + const args = (tc.function as any).arguments; + const argsString = typeof args === 'string' ? args : JSON.stringify(args ?? {}); + return { + ...tc, + function: { + ...tc.function, + arguments: argsString, + }, + }; + }); + } + + // Add optional fields if present + if (msg.tool_call_id) { + baseMessage.tool_call_id = msg.tool_call_id; + } + if (msg.name) { + baseMessage.name = msg.name; + } + + // For tool role, content must be a string; stringify objects/arrays + if (msg.role === 'tool') { + if (typeof baseMessage.content !== 'string') { + baseMessage.content = JSON.stringify(baseMessage.content ?? ''); + } + } + + return baseMessage; + }); + } + + /** + * Makes a request to the OpenAI-compatible API + */ + private async makeAPIRequest(endpoint: string, payloadBody: any): Promise { + try { + logger.debug(`Making ${this.displayName} API request to:`, endpoint); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add Authorization header if API key is provided + if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(payloadBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + logger.error(`${this.displayName} API error:`, JSON.stringify(errorData, null, 2)); + throw new Error(`${this.displayName} API error: ${response.statusText} - ${errorData?.error?.message || 'Unknown error'}`); + } + + const data = await response.json(); + logger.info(`${this.displayName} Response:`, data); + + if (data.usage) { + logger.info(`${this.displayName} Usage:`, { + inputTokens: data.usage.prompt_tokens, + outputTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens + }); + } + + return data; + } catch (error) { + logger.error(`${this.displayName} API request failed:`, error); + throw error; + } + } + + /** + * Processes the API response and converts to LLMResponse format + */ + private processOpenAIResponse(data: any): LLMResponse { + const result: LLMResponse = { + rawResponse: data + }; + + if (!data?.choices || data.choices.length === 0) { + throw new Error(`No choices in ${this.displayName} response`); + } + + const choice = data.choices[0]; + const message = choice.message; + + if (!message) { + throw new Error(`No message in ${this.displayName} choice`); + } + + // Check for tool calls + if (message.tool_calls && message.tool_calls.length > 0) { + const toolCall = message.tool_calls[0]; + if (toolCall.function) { + try { + result.functionCall = { + name: toolCall.function.name, + arguments: JSON.parse(toolCall.function.arguments) + }; + } catch (error) { + logger.error('Error parsing function arguments:', error); + result.functionCall = { + name: toolCall.function.name, + arguments: toolCall.function.arguments // Keep as string if parsing fails + }; + } + } + } else if (message.content) { + // Plain text response + result.text = message.content.trim(); + } + + return result; + } + + /** + * Call the API with messages + */ + async callWithMessages( + modelName: string, + messages: LLMMessage[], + options?: LLMCallOptions + ): Promise { + return LLMRetryManager.simpleRetry(async () => { + logger.debug(`Calling ${this.displayName} with messages...`, { model: modelName, messageCount: messages.length }); + + // Construct payload body in OpenAI Chat Completions format + const payloadBody: any = { + model: modelName, + messages: this.convertMessagesToOpenAI(messages), + }; + + // Add temperature if provided + if (options?.temperature !== undefined) { + payloadBody.temperature = options.temperature; + } + + // Add tools if provided + if (options?.tools) { + // Ensure all tools have valid parameters + payloadBody.tools = options.tools.map(tool => { + if (tool.type === 'function' && tool.function) { + return { + ...tool, + function: { + ...tool.function, + parameters: tool.function.parameters || { type: 'object', properties: {} } + } + }; + } + return tool; + }); + } + + // Ensure tool_choice is set to 'auto' when tools are present unless explicitly provided + if (options?.tools && !options?.tool_choice) { + payloadBody.tool_choice = 'auto'; + } else if (options?.tool_choice) { + payloadBody.tool_choice = options.tool_choice; + } + + logger.info('Request payload:', payloadBody); + + const data = await this.makeAPIRequest(this.getChatEndpoint(), payloadBody); + return this.processOpenAIResponse(data); + }, options?.retryConfig); + } + + /** + * Simple call method for backward compatibility + */ + async call( + modelName: string, + prompt: string, + systemPrompt: string, + options?: LLMCallOptions + ): Promise { + const messages: LLMMessage[] = []; + + if (systemPrompt) { + messages.push({ + role: 'system', + content: systemPrompt + }); + } + + messages.push({ + role: 'user', + content: prompt + }); + + return this.callWithMessages(modelName, messages, options); + } + + /** + * Parse response into standardized action structure + */ + parseResponse(response: LLMResponse): ReturnType { + return LLMResponseParser.parseResponse(response); + } + + /** + * Fetch available models from the API + */ + async fetchModels(): Promise { + logger.debug(`Fetching available ${this.displayName} models...`); + + try { + const headers: Record = {}; + + // Add Authorization header if API key is provided + if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + + const response = await fetch(this.getModelsEndpoint(), { + method: 'GET', + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + logger.error(`${this.displayName} models API error:`, JSON.stringify(errorData, null, 2)); + throw new Error(`${this.displayName} models API error: ${response.statusText} - ${errorData?.error?.message || 'Unknown error'}`); + } + + const data: GenericOpenAIModelsResponse = await response.json(); + logger.debug(`${this.displayName} Models Response:`, data); + + if (!data?.data || !Array.isArray(data.data)) { + throw new Error('Invalid models response format'); + } + + return data.data; + } catch (error) { + logger.error(`Failed to fetch ${this.displayName} models:`, error); + throw error; + } + } + + /** + * Get all models supported by this provider + */ + async getModels(): Promise { + try { + // Fetch models from the API + const apiModels = await this.fetchModels(); + + return apiModels.map(model => ({ + id: model.id, + name: model.id, // Use ID as name + provider: this.providerId as LLMProvider, + capabilities: { + functionCalling: true, // Assume support, can be refined later + reasoning: false, + vision: false, + structured: true + } + })); + } catch (error) { + logger.warn(`Failed to fetch models from ${this.displayName} API:`, error); + throw error; // Rethrow so caller can handle + } + } + + /** + * Test the connection with a simple completion request + */ + async testConnection(modelName?: string): Promise<{success: boolean, message: string, models?: string[]}> { + logger.debug(`Testing ${this.displayName} connection...`); + + try { + // First, try to fetch models + const models = await this.fetchModels(); + + if (!models || models.length === 0) { + return { + success: false, + message: 'Connection successful but no models found', + }; + } + + // If no model specified, use the first available model + const testModel = modelName || models[0].id; + + // Try a simple completion to verify the API works + const testPrompt = 'Respond with "OK" to confirm connection.'; + const response = await this.call(testModel, testPrompt, '', { + temperature: 0.1, + }); + + return { + success: true, + message: `Successfully connected to ${this.displayName}`, + models: models.map(m => m.id), + }; + } catch (error) { + logger.error(`${this.displayName} connection test failed:`, error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Validate that required credentials are available + */ + validateCredentials(): {isValid: boolean, message: string, missingItems?: string[]} { + // For custom providers, API key is optional (some APIs don't require auth) + // Base URL is always required + if (!this.baseURL) { + return { + isValid: false, + message: `${this.displayName} base URL is required.`, + missingItems: ['Base URL'] + }; + } + + return { + isValid: true, + message: `${this.displayName} credentials are configured correctly.` + }; + } + + /** + * Get the storage keys this provider uses for credentials + */ + getCredentialStorageKeys(): {apiKey?: string, endpoint?: string} { + return { + apiKey: `ai_chat_custom_${this.providerId}_api_key`, + endpoint: `ai_chat_custom_${this.providerId}_endpoint` + }; + } +} diff --git a/front_end/panels/ai_chat/LLM/GoogleAIProvider.ts b/front_end/panels/ai_chat/LLM/GoogleAIProvider.ts new file mode 100644 index 0000000000..00cb573058 --- /dev/null +++ b/front_end/panels/ai_chat/LLM/GoogleAIProvider.ts @@ -0,0 +1,548 @@ +// 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 { LLMMessage, LLMResponse, LLMCallOptions, LLMProvider, ModelInfo, MessageContent } from './LLMTypes.js'; +import { LLMBaseProvider } from './LLMProvider.js'; +import { LLMRetryManager } from './LLMErrorHandler.js'; +import { LLMResponseParser } from './LLMResponseParser.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('GoogleAIProvider'); + +/** + * Google AI model information + */ +export interface GoogleAIModel { + name: string; + displayName: string; + description: string; + supportedGenerationMethods: string[]; +} + +export interface GoogleAIModelsResponse { + models: GoogleAIModel[]; +} + +/** + * Google AI Provider implementation using Gemini API + * https://ai.google.dev/gemini-api/docs + */ +export class GoogleAIProvider extends LLMBaseProvider { + private static readonly API_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'; + + readonly name: LLMProvider = 'googleai'; + + constructor(private readonly apiKey: string) { + super(); + } + + /** + * Get the generate content endpoint URL for a specific model + */ + private getGenerateContentEndpoint(modelName: string): string { + // Model name format: "models/gemini-2.5-pro" or just "gemini-2.5-pro" + const normalizedModel = modelName.startsWith('models/') ? modelName : `models/${modelName}`; + return `${GoogleAIProvider.API_BASE_URL}/${normalizedModel}:generateContent?key=${this.apiKey}`; + } + + /** + * Get the models list endpoint URL + */ + private getModelsEndpoint(): string { + return `${GoogleAIProvider.API_BASE_URL}/models?key=${this.apiKey}`; + } + + /** + * Convert MessageContent to Google AI format + */ + private convertContentToGoogleAI(content: MessageContent | undefined): any { + if (!content) { + return { text: '' }; + } + + if (typeof content === 'string') { + return { text: content }; + } + + if (Array.isArray(content)) { + // Convert multimodal content + return content.map(item => { + if (item.type === 'text') { + return { text: item.text }; + } else if (item.type === 'image_url') { + // Google AI uses inline_data for images + const url = item.image_url.url; + if (url.startsWith('data:')) { + // Extract mime type and base64 data + const matches = url.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + return { + inline_data: { + mime_type: matches[1], + data: matches[2] + } + }; + } + } + // For URLs, Google AI expects blob_uri (not supported in all cases) + logger.warn('Image URLs are not fully supported, use base64 data URLs instead'); + return { text: '[Image URL not supported in this format]' }; + } + return { text: String(item) }; + }); + } + + return { text: String(content) }; + } + + /** + * Converts LLMMessage format to Google AI contents format + */ + private convertMessagesToGoogleAI(messages: LLMMessage[]): { contents: any[], tools?: any[] } { + const contents: any[] = []; + let systemInstruction: string | undefined; + + for (const msg of messages) { + if (msg.role === 'system') { + // Google AI uses systemInstruction separately + systemInstruction = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); + continue; + } + + if (msg.role === 'user') { + contents.push({ + role: 'user', + parts: Array.isArray(this.convertContentToGoogleAI(msg.content)) + ? this.convertContentToGoogleAI(msg.content) + : [this.convertContentToGoogleAI(msg.content)] + }); + } else if (msg.role === 'assistant') { + if (msg.tool_calls && msg.tool_calls.length > 0) { + // Convert tool calls + const functionCalls = msg.tool_calls.map(tc => ({ + functionCall: { + name: tc.function.name, + args: typeof tc.function.arguments === 'string' + ? JSON.parse(tc.function.arguments) + : tc.function.arguments + } + })); + contents.push({ + role: 'model', + parts: functionCalls + }); + } else { + // Regular assistant message + contents.push({ + role: 'model', + parts: Array.isArray(this.convertContentToGoogleAI(msg.content)) + ? this.convertContentToGoogleAI(msg.content) + : [this.convertContentToGoogleAI(msg.content)] + }); + } + } else if (msg.role === 'tool') { + // Tool response + contents.push({ + role: 'function', + parts: [{ + functionResponse: { + name: msg.name || 'unknown_function', + response: { + result: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + } + } + }] + }); + } + } + + // Add system instruction as the first message if present + if (systemInstruction) { + contents.unshift({ + role: 'user', + parts: [{ text: systemInstruction }] + }); + } + + return { contents }; + } + + /** + * Convert OpenAI tool format to Google AI function declarations + */ + private convertToolsToGoogleAI(tools: any[]): any { + const functionDeclarations = tools.map(tool => { + if (tool.type === 'function' && tool.function) { + return { + name: tool.function.name, + description: tool.function.description || '', + parameters: tool.function.parameters || { type: 'object', properties: {} } + }; + } + return null; + }).filter(Boolean); + + return functionDeclarations.length > 0 ? [{ + function_declarations: functionDeclarations + }] : undefined; + } + + /** + * Makes a request to the Google AI API + */ + private async makeAPIRequest(endpoint: string, payloadBody: any): Promise { + try { + logger.debug('Making Google AI API request to:', endpoint); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payloadBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + logger.error('Google AI API error:', JSON.stringify(errorData, null, 2)); + throw new Error(`Google AI API error: ${response.statusText} - ${errorData?.error?.message || 'Unknown error'}`); + } + + const data = await response.json(); + logger.info('Google AI Response:', data); + + if (data.usageMetadata) { + logger.info('Google AI Usage:', { + inputTokens: data.usageMetadata.promptTokenCount, + outputTokens: data.usageMetadata.candidatesTokenCount, + totalTokens: data.usageMetadata.totalTokenCount + }); + } + + return data; + } catch (error) { + logger.error('Google AI API request failed:', error); + throw error; + } + } + + /** + * Processes the Google AI response and converts to LLMResponse format + */ + private processGoogleAIResponse(data: any): LLMResponse { + const result: LLMResponse = { + rawResponse: data + }; + + if (!data?.candidates || data.candidates.length === 0) { + throw new Error('No candidates in Google AI response'); + } + + const candidate = data.candidates[0]; + const content = candidate.content; + + if (!content || !content.parts || content.parts.length === 0) { + throw new Error('No content parts in Google AI candidate'); + } + + const part = content.parts[0]; + + // Check for function call + if (part.functionCall) { + result.functionCall = { + name: part.functionCall.name, + arguments: part.functionCall.args || {} + }; + } else if (part.text) { + // Plain text response + result.text = part.text.trim(); + } + + return result; + } + + /** + * Call the Google AI API with messages + */ + async callWithMessages( + modelName: string, + messages: LLMMessage[], + options?: LLMCallOptions + ): Promise { + return LLMRetryManager.simpleRetry(async () => { + logger.debug('Calling Google AI with messages...', { model: modelName, messageCount: messages.length }); + + // Convert messages to Google AI format + const { contents } = this.convertMessagesToGoogleAI(messages); + + // Construct payload body + const payloadBody: any = { + contents, + }; + + // Add generation config + const generationConfig: any = {}; + if (options?.temperature !== undefined) { + generationConfig.temperature = options.temperature; + } + if (Object.keys(generationConfig).length > 0) { + payloadBody.generationConfig = generationConfig; + } + + // Add tools if provided + if (options?.tools && options.tools.length > 0) { + const tools = this.convertToolsToGoogleAI(options.tools); + if (tools) { + payloadBody.tools = tools; + } + } + + logger.info('Request payload:', payloadBody); + + const data = await this.makeAPIRequest( + this.getGenerateContentEndpoint(modelName), + payloadBody + ); + return this.processGoogleAIResponse(data); + }, options?.retryConfig); + } + + /** + * Simple call method for backward compatibility + */ + async call( + modelName: string, + prompt: string, + systemPrompt: string, + options?: LLMCallOptions + ): Promise { + const messages: LLMMessage[] = []; + + if (systemPrompt) { + messages.push({ + role: 'system', + content: systemPrompt + }); + } + + messages.push({ + role: 'user', + content: prompt + }); + + return this.callWithMessages(modelName, messages, options); + } + + /** + * Parse response into standardized action structure + */ + parseResponse(response: LLMResponse): ReturnType { + return LLMResponseParser.parseResponse(response); + } + + /** + * Fetch available models from Google AI API + */ + async fetchModels(): Promise { + logger.debug('Fetching available Google AI models...'); + + try { + const response = await fetch(this.getModelsEndpoint(), { + method: 'GET', + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + logger.error('Google AI models API error:', JSON.stringify(errorData, null, 2)); + throw new Error(`Google AI models API error: ${response.statusText} - ${errorData?.error?.message || 'Unknown error'}`); + } + + const data: GoogleAIModelsResponse = await response.json(); + logger.debug('Google AI Models Response:', data); + + if (!data?.models || !Array.isArray(data.models)) { + throw new Error('Invalid models response format'); + } + + // Filter to only models that support generateContent + return data.models.filter(model => + model.supportedGenerationMethods && + model.supportedGenerationMethods.includes('generateContent') + ); + } catch (error) { + logger.error('Failed to fetch Google AI models:', error); + throw error; + } + } + + /** + * Get all models supported by this provider + */ + async getModels(): Promise { + try { + // Fetch models from Google AI API + const googleModels = await this.fetchModels(); + + return googleModels.map(model => { + // Extract simple model ID from full name (e.g., "models/gemini-2.5-pro" -> "gemini-2.5-pro") + const modelId = model.name.replace('models/', ''); + + return { + id: modelId, + name: model.displayName || modelId, + provider: 'googleai' as LLMProvider, + capabilities: { + functionCalling: this.modelSupportsFunctionCalling(modelId), + reasoning: this.modelSupportsReasoning(modelId), + vision: this.modelSupportsVision(modelId), + structured: true + } + }; + }); + } catch (error) { + logger.warn('Failed to fetch models from Google AI API, using default list:', error); + + // Return default list of known Google AI models as fallback + return this.getDefaultModels(); + } + } + + /** + * Check if a model supports function calling based on its ID + */ + private modelSupportsFunctionCalling(modelId: string): boolean { + // Most Gemini models support function calling + return modelId.includes('gemini'); + } + + /** + * Check if a model supports reasoning based on its ID + */ + private modelSupportsReasoning(modelId: string): boolean { + // Gemini 2.5 Pro and later support thinking mode + return modelId.includes('gemini-2.5') || modelId.includes('gemini-2.0'); + } + + /** + * Check if a model supports vision based on its ID + */ + private modelSupportsVision(modelId: string): boolean { + // Most Gemini models support vision except for text-only variants + return modelId.includes('gemini') && !modelId.includes('text'); + } + + /** + * Get default list of known Google AI models + */ + private getDefaultModels(): ModelInfo[] { + return [ + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: 'googleai' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: true, + vision: true, + structured: true + } + }, + { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + provider: 'googleai' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: true, + vision: true, + structured: true + } + }, + { + id: 'gemini-2.5-nano', + name: 'Gemini 2.5 Nano', + provider: 'googleai' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: false, + vision: true, + structured: true + } + }, + { + id: 'gemini-2.0-flash', + name: 'Gemini 2.0 Flash', + provider: 'googleai' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: true, + vision: true, + structured: true + } + } + ]; + } + + /** + * Test the Google AI connection with a simple completion request + */ + async testConnection(modelName: string): Promise<{success: boolean, message: string}> { + logger.debug('Testing Google AI connection...'); + + try { + const testPrompt = 'Please respond with "Connection successful!" to confirm the connection is working.'; + + const response = await this.call(modelName, testPrompt, '', { + temperature: 0.1, + }); + + if (response.text?.toLowerCase().includes('connection')) { + return { + success: true, + message: `Successfully connected to Google AI with model ${modelName}`, + }; + } + return { + success: true, + message: `Connected to Google AI, but received unexpected response: ${response.text || 'No response'}`, + }; + } catch (error) { + logger.error('Google AI connection test failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } + } + + /** + * Validate that required credentials are available for Google AI + */ + validateCredentials(): {isValid: boolean, message: string, missingItems?: string[]} { + const storageKeys = this.getCredentialStorageKeys(); + const apiKey = localStorage.getItem(storageKeys.apiKey!); + + if (!apiKey) { + return { + isValid: false, + message: 'Google AI API key is required. Please add your API key in Settings.', + missingItems: ['API Key'] + }; + } + + return { + isValid: true, + message: 'Google AI credentials are configured correctly.' + }; + } + + /** + * Get the storage keys this provider uses for credentials + */ + getCredentialStorageKeys(): {apiKey: string} { + return { + apiKey: 'ai_chat_googleai_api_key' + }; + } +} diff --git a/front_end/panels/ai_chat/LLM/LLMClient.ts b/front_end/panels/ai_chat/LLM/LLMClient.ts index e04565303b..59e1a30465 100644 --- a/front_end/panels/ai_chat/LLM/LLMClient.ts +++ b/front_end/panels/ai_chat/LLM/LLMClient.ts @@ -3,12 +3,18 @@ // found in the LICENSE file. import type { LLMMessage, LLMResponse, LLMCallOptions, LLMProvider, ModelInfo, RetryConfig } from './LLMTypes.js'; +import { isCustomProvider } from './LLMTypes.js'; import { LLMProviderRegistry } from './LLMProviderRegistry.js'; import { OpenAIProvider } from './OpenAIProvider.js'; import { LiteLLMProvider } from './LiteLLMProvider.js'; import { GroqProvider } from './GroqProvider.js'; import { OpenRouterProvider } from './OpenRouterProvider.js'; import { BrowserOperatorProvider } from './BrowserOperatorProvider.js'; +import { CerebrasProvider } from './CerebrasProvider.js'; +import { AnthropicProvider } from './AnthropicProvider.js'; +import { GoogleAIProvider } from './GoogleAIProvider.js'; +import { GenericOpenAIProvider } from './GenericOpenAIProvider.js'; +import { CustomProviderManager } from '../core/CustomProviderManager.js'; import { LLMResponseParser } from './LLMResponseParser.js'; import { createLogger } from '../core/Logger.js'; @@ -18,7 +24,7 @@ const logger = createLogger('LLMClient'); * Configuration for individual LLM providers */ export interface LLMProviderConfig { - provider: LLMProvider; + provider: string; // Can be LLMProvider or custom provider ID (e.g., "custom:my-provider") apiKey: string; providerURL?: string; // Optional: for LiteLLM endpoint or custom OpenAI endpoint } @@ -100,6 +106,15 @@ export class LLMClient { providerConfig.providerURL // Optional override for testing ); break; + case 'cerebras': + providerInstance = new CerebrasProvider(providerConfig.apiKey); + break; + case 'anthropic': + providerInstance = new AnthropicProvider(providerConfig.apiKey); + break; + case 'googleai': + providerInstance = new GoogleAIProvider(providerConfig.apiKey); + break; default: logger.warn(`Unknown provider type: ${providerConfig.provider}`); continue; @@ -112,6 +127,31 @@ export class LLMClient { } } + // Load and register custom providers + try { + const customProviders = CustomProviderManager.listEnabledProviders(); + logger.info(`Loading ${customProviders.length} custom providers`); + + for (const customProviderConfig of customProviders) { + try { + const apiKey = CustomProviderManager.getApiKey(customProviderConfig.id); + const providerInstance = new GenericOpenAIProvider( + customProviderConfig, + apiKey || undefined + ); + LLMProviderRegistry.registerProvider( + customProviderConfig.id as LLMProvider, + providerInstance + ); + logger.info(`Registered custom provider: ${customProviderConfig.name} (${customProviderConfig.id})`); + } catch (error) { + logger.error(`Failed to initialize custom provider ${customProviderConfig.name}:`, error); + } + } + } catch (error) { + logger.error('Failed to load custom providers:', error); + } + this.initialized = true; logger.info('LLM client initialization complete'); } @@ -308,51 +348,42 @@ export class LLMClient { * Static method to fetch models from LiteLLM endpoint (for UI use without initialization) */ static async fetchLiteLLMModels(apiKey: string | null, baseUrl?: string): Promise { - const provider = new LiteLLMProvider(apiKey, baseUrl); - const models = await provider.fetchModels(); - return models; + return LLMProviderRegistry.fetchProviderModels('litellm', apiKey || '', baseUrl); } /** * Static method to test LiteLLM connection (for UI use without initialization) */ static async testLiteLLMConnection(apiKey: string | null, modelName: string, baseUrl?: string): Promise<{success: boolean, message: string}> { - const provider = new LiteLLMProvider(apiKey, baseUrl); - return provider.testConnection(modelName); + return LLMProviderRegistry.testProviderConnection('litellm', apiKey || '', baseUrl); } /** * Static method to fetch models from Groq API (for UI use without initialization) */ static async fetchGroqModels(apiKey: string): Promise { - const provider = new GroqProvider(apiKey); - const models = await provider.fetchModels(); - return models; + return LLMProviderRegistry.fetchProviderModels('groq', apiKey); } /** * Static method to test Groq connection (for UI use without initialization) */ static async testGroqConnection(apiKey: string, modelName: string): Promise<{success: boolean, message: string}> { - const provider = new GroqProvider(apiKey); - return provider.testConnection(modelName); + return LLMProviderRegistry.testProviderConnection('groq', apiKey); } /** * Static method to fetch models from OpenRouter API (for UI use without initialization) */ static async fetchOpenRouterModels(apiKey: string): Promise { - const provider = new OpenRouterProvider(apiKey); - const models = await provider.fetchModels(); - return models; + return LLMProviderRegistry.fetchProviderModels('openrouter', apiKey); } /** * Static method to test OpenRouter connection (for UI use without initialization) */ static async testOpenRouterConnection(apiKey: string, modelName: string): Promise<{success: boolean, message: string}> { - const provider = new OpenRouterProvider(apiKey); - return provider.testConnection(modelName); + return LLMProviderRegistry.testProviderConnection('openrouter', apiKey); } /** @@ -383,39 +414,91 @@ export class LLMClient { } } + /** + * Static method to fetch models from Cerebras API (for UI use without initialization) + */ + static async fetchCerebrasModels(apiKey: string): Promise { + return LLMProviderRegistry.fetchProviderModels('cerebras', apiKey); + } + + /** + * Static method to test Cerebras connection (for UI use without initialization) + */ + static async testCerebrasConnection(apiKey: string, modelName: string): Promise<{success: boolean, message: string}> { + return LLMProviderRegistry.testProviderConnection('cerebras', apiKey); + } + + /** + * Static method to fetch models from Anthropic API (for UI use without initialization) + */ + static async fetchAnthropicModels(apiKey: string): Promise { + return LLMProviderRegistry.fetchProviderModels('anthropic', apiKey); + } + + /** + * Static method to test Anthropic connection (for UI use without initialization) + */ + static async testAnthropicConnection(apiKey: string, modelName: string): Promise<{success: boolean, message: string}> { + return LLMProviderRegistry.testProviderConnection('anthropic', apiKey); + } + + /** + * Static method to fetch models from Google AI API (for UI use without initialization) + */ + static async fetchGoogleAIModels(apiKey: string): Promise { + return LLMProviderRegistry.fetchProviderModels('googleai', apiKey); + } + + /** + * Static method to test Google AI connection (for UI use without initialization) + */ + static async testGoogleAIConnection(apiKey: string, modelName: string): Promise<{success: boolean, message: string}> { + return LLMProviderRegistry.testProviderConnection('googleai', apiKey); + } + /** * Static method to validate credentials for a specific provider */ static validateProviderCredentials(providerType: string): {isValid: boolean, message: string, missingItems?: string[]} { try { - // Create temporary provider instance for validation (no API key needed for validation) - let provider; - - switch (providerType) { - case 'openai': - provider = new OpenAIProvider(''); - break; - case 'litellm': - provider = new LiteLLMProvider('', ''); - break; - case 'groq': - provider = new GroqProvider(''); - break; - case 'openrouter': - provider = new OpenRouterProvider(''); - break; - case 'browseroperator': - provider = new BrowserOperatorProvider(null, ''); - break; - default: + // Check if it's a custom provider + if (isCustomProvider(providerType)) { + // Validate that the custom provider exists + const customProvider = CustomProviderManager.getProvider(providerType); + if (!customProvider) { + return { + isValid: false, + message: 'Custom provider not found', + missingItems: ['Provider'] + }; + } + + // Validate that it has models configured + if (!customProvider.models || customProvider.models.length === 0) { + return { + isValid: false, + message: 'No models configured for this provider', + missingItems: ['Models'] + }; + } + + // Validate that it's enabled + if (!customProvider.enabled) { return { isValid: false, - message: `Unknown provider type: ${providerType}`, - missingItems: ['Valid provider selection'] + message: 'Provider is disabled', + missingItems: ['Enabled status'] }; + } + + return { + isValid: true, + message: 'Custom provider configuration valid' + }; } - - return provider.validateCredentials(); + + // Delegate to LLMProviderRegistry for standard providers + return LLMProviderRegistry.validateProviderCredentials(providerType as LLMProvider); } catch (error) { return { isValid: false, @@ -425,4 +508,98 @@ export class LLMClient { } } + /** + * Static method to get provider credentials from localStorage + * Combines validation and credential retrieval in one call + * @param providerType The provider type + * @returns Object with canProceed flag, apiKey, and optional endpoint + */ + static getProviderCredentials(providerType: string): { + canProceed: boolean; + apiKey: string | null; + endpoint?: string | null; + storageKeys?: {apiKey?: string; endpoint?: string; [key: string]: string | undefined}; + } { + try { + // Check if it's a custom provider + if (isCustomProvider(providerType)) { + // Validate the custom provider first + const validation = LLMClient.validateProviderCredentials(providerType); + if (!validation.isValid) { + return { + canProceed: false, + apiKey: null + }; + } + + // Get API key and storage key from CustomProviderManager + const apiKey = CustomProviderManager.getApiKey(providerType); + const apiKeyStorageKey = CustomProviderManager.getApiKeyStorageKey(providerType); + + return { + canProceed: true, + apiKey, + storageKeys: { + apiKey: apiKeyStorageKey + } + }; + } + + // Delegate to LLMProviderRegistry for standard providers + return LLMProviderRegistry.getProviderCredentials(providerType as LLMProvider); + } catch (error) { + logger.error(`Failed to get credentials for ${providerType}:`, error); + return { + canProceed: false, + apiKey: null + }; + } + } + + /** + * Static method to test custom provider connection and fetch models + */ + static async testCustomProviderConnection( + name: string, + baseURL: string, + apiKey?: string + ): Promise<{success: boolean, message: string, models?: string[]}> { + try { + // Create a temporary custom provider config + const tempConfig = { + id: `custom:${name.toLowerCase().replace(/\s+/g, '-')}`, + name, + baseURL, + models: [], + enabled: true + }; + + // Create a temporary GenericOpenAIProvider instance + const provider = new GenericOpenAIProvider(tempConfig, apiKey); + + // Test connection by fetching models + const modelObjects = await provider.fetchModels(); + + if (modelObjects && modelObjects.length > 0) { + // Extract model IDs from model objects + const modelIds = modelObjects.map(model => model.id); + return { + success: true, + message: `Successfully connected to ${name}. Found ${modelIds.length} models.`, + models: modelIds + }; + } else { + return { + success: false, + message: 'Connection successful but no models found' + }; + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error occurred' + }; + } + } + } \ No newline at end of file diff --git a/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts b/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts index 89d0f44106..0c187a8b4d 100644 --- a/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts +++ b/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts @@ -5,6 +5,14 @@ import { createLogger } from '../core/Logger.js'; import type { LLMProviderInterface } from './LLMProvider.js'; import type { LLMProvider, ModelInfo } from './LLMTypes.js'; +import { OpenAIProvider } from './OpenAIProvider.js'; +import { LiteLLMProvider } from './LiteLLMProvider.js'; +import { GroqProvider } from './GroqProvider.js'; +import { OpenRouterProvider } from './OpenRouterProvider.js'; +import { BrowserOperatorProvider } from './BrowserOperatorProvider.js'; +import { CerebrasProvider } from './CerebrasProvider.js'; +import { AnthropicProvider } from './AnthropicProvider.js'; +import { GoogleAIProvider } from './GoogleAIProvider.js'; const logger = createLogger('LLMProviderRegistry'); @@ -103,4 +111,233 @@ export class LLMProviderRegistry { providers: Array.from(this.providers.keys()), }; } + + /** + * Create a temporary provider instance for utility operations + * Used when provider isn't registered yet (e.g., during setup/validation) + */ + private static createTemporaryProvider(providerType: LLMProvider): LLMProviderInterface | null { + try { + switch (providerType) { + case 'openai': + return new OpenAIProvider(''); + case 'litellm': + return new LiteLLMProvider('', ''); + case 'groq': + return new GroqProvider(''); + case 'openrouter': + return new OpenRouterProvider(''); + case 'browseroperator': + return new BrowserOperatorProvider(null, ''); + case 'cerebras': + return new CerebrasProvider(''); + case 'anthropic': + return new AnthropicProvider(''); + case 'googleai': + return new GoogleAIProvider(''); + default: + logger.warn(`Unknown provider type: ${providerType}`); + return null; + } + } catch (error) { + logger.error(`Failed to create temporary provider ${providerType}:`, error); + return null; + } + } + + /** + * Get or create a provider instance for utility operations + * Prefers registered instance, falls back to temporary instance + */ + private static getOrCreateProvider(providerType: LLMProvider): LLMProviderInterface | null { + // Try to get registered provider first + const registered = this.getProvider(providerType); + if (registered) { + return registered; + } + + // Fall back to creating temporary instance + return this.createTemporaryProvider(providerType); + } + + /** + * Get storage keys for a provider + * Returns the localStorage keys used by the provider for credentials + */ + static getProviderStorageKeys(providerType: LLMProvider): {apiKey?: string; endpoint?: string; [key: string]: string | undefined} { + const provider = this.getOrCreateProvider(providerType); + if (!provider) { + logger.warn(`Provider ${providerType} not available`); + return {}; + } + return provider.getCredentialStorageKeys(); + } + + /** + * Get API key from localStorage for a provider + */ + static getProviderApiKey(providerType: LLMProvider): string { + const keys = this.getProviderStorageKeys(providerType); + if (!keys.apiKey) { + return ''; + } + return localStorage.getItem(keys.apiKey) || ''; + } + + /** + * Get endpoint from localStorage for a provider (if applicable) + */ + static getProviderEndpoint(providerType: LLMProvider): string | undefined { + const keys = this.getProviderStorageKeys(providerType); + if (!keys.endpoint) { + return undefined; + } + return localStorage.getItem(keys.endpoint) || undefined; + } + + /** + * Save API key for a provider to localStorage + */ + static saveProviderApiKey(providerType: LLMProvider, apiKey: string | null): void { + const keys = this.getProviderStorageKeys(providerType); + if (!keys.apiKey) { + logger.warn(`Provider ${providerType} does not have an API key storage key`); + return; + } + + if (apiKey) { + localStorage.setItem(keys.apiKey, apiKey); + logger.debug(`Saved API key for ${providerType}`); + } else { + localStorage.removeItem(keys.apiKey); + logger.debug(`Removed API key for ${providerType}`); + } + } + + /** + * Save endpoint for a provider to localStorage (if applicable) + */ + static saveProviderEndpoint(providerType: LLMProvider, endpoint: string | null): void { + const keys = this.getProviderStorageKeys(providerType); + if (!keys.endpoint) { + return; // Provider doesn't use endpoint + } + + if (endpoint) { + localStorage.setItem(keys.endpoint, endpoint); + logger.debug(`Saved endpoint for ${providerType}`); + } else { + localStorage.removeItem(keys.endpoint); + logger.debug(`Removed endpoint for ${providerType}`); + } + } + + /** + * Validate credentials for a provider + */ + static validateProviderCredentials(providerType: LLMProvider): {isValid: boolean; message: string; missingItems?: string[]} { + const provider = this.getOrCreateProvider(providerType); + if (!provider) { + return { + isValid: false, + message: `Provider ${providerType} not available`, + missingItems: ['Provider support'] + }; + } + + try { + return provider.validateCredentials(); + } catch (error) { + logger.error(`Failed to validate credentials for ${providerType}:`, error); + return { + isValid: false, + message: `Validation failed: ${error}`, + }; + } + } + + /** + * Get provider credentials from localStorage + */ + static getProviderCredentials(providerType: LLMProvider): { + canProceed: boolean; + apiKey: string | null; + endpoint?: string | null; + storageKeys?: {apiKey?: string; endpoint?: string; [key: string]: string | undefined}; + } { + // First validate credentials + const validation = this.validateProviderCredentials(providerType); + + if (!validation.isValid) { + return { canProceed: false, apiKey: null }; + } + + // Get storage keys + const storageKeys = this.getProviderStorageKeys(providerType); + + // Retrieve credentials from localStorage + const apiKey = storageKeys.apiKey ? (localStorage.getItem(storageKeys.apiKey) || null) : null; + const endpoint = storageKeys.endpoint ? (localStorage.getItem(storageKeys.endpoint) || null) : null; + + return { + canProceed: true, + apiKey, + endpoint, + storageKeys + }; + } + + /** + * Fetch models for a provider + * Uses registered provider or creates temporary instance with given credentials + */ + static async fetchProviderModels( + providerType: LLMProvider, + apiKey: string, + endpoint?: string + ): Promise { + const provider = this.getOrCreateProvider(providerType); + if (!provider) { + logger.warn(`Provider ${providerType} not available`); + return []; + } + + try { + // Use the provider's fetchModels method if available + if ('fetchModels' in provider && typeof provider.fetchModels === 'function') { + return await provider.fetchModels(apiKey, endpoint); + } + + // Fallback to getModels + return await provider.getModels(); + } catch (error) { + logger.error(`Failed to fetch models for ${providerType}:`, error); + throw error; + } + } + + /** + * Test connection for a provider + * Creates temporary instance with given credentials to test connection + */ + static async testProviderConnection( + providerType: LLMProvider, + apiKey: string, + endpoint?: string + ): Promise<{success: boolean; message: string}> { + try { + // Try to fetch models as a connection test + await this.fetchProviderModels(providerType, apiKey, endpoint); + return { + success: true, + message: `Successfully connected to ${providerType}` + }; + } catch (error) { + logger.error(`Connection test failed for ${providerType}:`, error); + return { + success: false, + message: error instanceof Error ? error.message : String(error) + }; + } + } } \ No newline at end of file diff --git a/front_end/panels/ai_chat/LLM/LLMTypes.ts b/front_end/panels/ai_chat/LLM/LLMTypes.ts index 5f86739572..b9197ff0c8 100644 --- a/front_end/panels/ai_chat/LLM/LLMTypes.ts +++ b/front_end/panels/ai_chat/LLM/LLMTypes.ts @@ -142,7 +142,7 @@ export interface ExtendedRetryConfig extends ErrorRetryConfig { /** * LLM Provider types */ -export type LLMProvider = 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator'; +export type LLMProvider = 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator' | 'cerebras' | 'anthropic' | 'googleai'; /** * Content types for multimodal messages (text + images + files) @@ -237,4 +237,87 @@ export interface ModelInfo { name: string; provider: LLMProvider; capabilities?: ModelCapabilities; +} + +/** + * Helper functions for custom provider handling + */ + +/** + * Check if a provider ID represents a custom provider + * Custom providers have IDs that start with "custom:" + */ +export function isCustomProvider(providerId: string): boolean { + return providerId.startsWith('custom:'); +} + +/** + * Check if a provider ID represents a built-in provider + */ +export function isBuiltInProvider(providerId: string): providerId is LLMProvider { + const builtInProviders: LLMProvider[] = [ + 'openai', + 'litellm', + 'groq', + 'openrouter', + 'browseroperator', + 'cerebras', + 'anthropic', + 'googleai' + ]; + return builtInProviders.includes(providerId as LLMProvider); +} + +/** + * Get the display name for a provider (handles both built-in and custom) + * For custom providers, extracts the name from the ID (e.g., "custom:z-ai" -> "Z.AI") + */ +export function getProviderDisplayName(providerId: string): string { + // Handle built-in providers + const builtInNames: Record = { + 'openai': 'OpenAI', + 'litellm': 'LiteLLM', + 'groq': 'Groq', + 'openrouter': 'OpenRouter', + 'browseroperator': 'BrowserOperator', + 'cerebras': 'Cerebras', + 'anthropic': 'Anthropic', + 'googleai': 'Google AI' + }; + + if (isBuiltInProvider(providerId)) { + return builtInNames[providerId as LLMProvider]; + } + + // Handle custom providers - extract name from ID + if (isCustomProvider(providerId)) { + const name = providerId.replace('custom:', ''); + // Capitalize first letter of each word + return name.split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + return providerId; +} + +/** + * Validate provider ID format + */ +export function isValidProviderId(providerId: string): boolean { + if (!providerId || typeof providerId !== 'string') { + return false; + } + + // Check if it's a built-in provider + if (isBuiltInProvider(providerId)) { + return true; + } + + // Check if it's a valid custom provider ID (starts with "custom:" and has content after) + if (isCustomProvider(providerId)) { + return providerId.length > 7; // "custom:" is 7 characters + } + + return false; } \ No newline at end of file diff --git a/front_end/panels/ai_chat/core/AgentService.ts b/front_end/panels/ai_chat/core/AgentService.ts index 683f9f88bc..0106071fbe 100644 --- a/front_end/panels/ai_chat/core/AgentService.ts +++ b/front_end/panels/ai_chat/core/AgentService.ts @@ -15,7 +15,10 @@ import { AgentDescriptorRegistry } from './AgentDescriptorRegistry.js'; import {type AgentState, createInitialState, createUserMessage} from './State.js'; import type {CompiledGraph} from './Types.js'; import { LLMClient } from '../LLM/LLMClient.js'; +import { LLMProviderRegistry } from '../LLM/LLMProviderRegistry.js'; import { LLMConfigurationManager } from './LLMConfigurationManager.js'; +import { CustomProviderManager } from './CustomProviderManager.js'; +import { isCustomProvider } from '../LLM/LLMTypes.js'; import { createTracingProvider, getCurrentTracingContext } from '../tracing/TracingConfig.js'; import type { TracingProvider, TracingContext } from '../tracing/TracingProvider.js'; import { AgentRunnerEventBus } from '../agent_framework/AgentRunnerEventBus.js'; @@ -24,6 +27,8 @@ import type { AgentSession, AgentMessage } from '../agent_framework/AgentSession import type { LLMProvider } from '../LLM/LLMTypes.js'; import { BUILD_CONFIG } from './BuildConfig.js'; import { VisualIndicatorManager } from '../tools/VisualIndicatorTool.js'; +import { ConversationManager } from '../persistence/ConversationManager.js'; +import type { ConversationMetadata } from '../persistence/ConversationTypes.js'; // Cache break: 2025-09-17T17:54:00Z - Force rebuild with AUTOMATED_MODE bypass const logger = createLogger('AgentService'); @@ -39,6 +44,8 @@ export enum Events { AGENT_SESSION_UPDATED = 'agent-session-updated', AGENT_SESSION_COMPLETED = 'agent-session-completed', CHILD_AGENT_STARTED = 'child-agent-started', + CONVERSATION_CHANGED = 'conversation-changed', + CONVERSATION_SAVED = 'conversation-saved', } /** @@ -52,6 +59,8 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ [Events.AGENT_SESSION_UPDATED]: AgentSession, [Events.AGENT_SESSION_COMPLETED]: AgentSession, [Events.CHILD_AGENT_STARTED]: { parentSession: AgentSession, childAgentName: string, childSessionId: string }, + [Events.CONVERSATION_CHANGED]: string | null, + [Events.CONVERSATION_SAVED]: string, }> { static instance: AgentService; @@ -70,6 +79,10 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ #sessionId: string; #activeAgentSessions = new Map(); #configManager: LLMConfigurationManager; + #conversationManager: ConversationManager; + #currentConversationId: string | null = null; + #autoSaveTimeoutId?: number; + #autoSaveDebounceMs = 1000; // Global registry for all active executions private static activeExecutions = new Map(); @@ -125,6 +138,9 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ // Initialize configuration manager this.#configManager = LLMConfigurationManager.getInstance(); + // Initialize conversation manager + this.#conversationManager = ConversationManager.getInstance(); + // Initialize tracing this.#sessionId = this.generateSessionId(); this.#initializeTracing(); @@ -137,7 +153,7 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ answer: i18nString(UIStrings.welcomeMessage), isFinalAnswer: true, }); - + // Initialize AgentRunner event system AgentRunner.initializeEventBus(); @@ -189,55 +205,15 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ throw new Error(`Configuration validation failed: ${validation.errors.join(', ')}`); } - // Only add the selected provider if it has valid configuration - switch (provider) { - case 'openai': - if (apiKey) { - providers.push({ - provider: 'openai' as const, - apiKey - }); - } - break; - case 'litellm': - if (endpoint) { - providers.push({ - provider: 'litellm' as const, - apiKey: apiKey || '', // Can be empty for some LiteLLM endpoints - providerURL: endpoint - }); - } - break; - case 'groq': - if (apiKey) { - providers.push({ - provider: 'groq' as const, - apiKey - }); - } - break; - case 'openrouter': - if (apiKey) { - providers.push({ - provider: 'openrouter' as const, - apiKey - }); - } - break; - case 'browseroperator': - // BrowserOperator doesn't require apiKey - // But we pass it if available for optional authentication - providers.push({ - provider: 'browseroperator' as const, - apiKey: apiKey || '' - }); - break; - } + // Build provider configuration using registry + const providerConfig = this.#buildProviderConfig(provider, apiKey, endpoint); - if (providers.length === 0) { + if (!providerConfig) { throw new Error(`No valid configuration found for provider ${provider}`); } + providers.push(providerConfig); + await llm.initialize({ providers }); logger.info('LLM client initialized successfully', { selectedProvider: provider, @@ -246,6 +222,64 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ }); } + /** + * Build provider configuration for LLM initialization + * Handles special cases like litellm endpoint and browseroperator optional key + */ + #buildProviderConfig( + provider: string, + apiKey: string | undefined, + endpoint: string | undefined + ): {provider: string; apiKey: string; providerURL?: string} | null { + // Check if it's a custom provider + if (isCustomProvider(provider)) { + const customConfig = CustomProviderManager.getProvider(provider); + if (!customConfig) { + logger.warn(`Custom provider ${provider} not found`); + return null; + } + + // Custom providers use their configured baseURL + return { + provider, + apiKey: apiKey || '', // API key is optional for custom providers + providerURL: customConfig.baseURL + }; + } + + // Special case: litellm requires endpoint + if (provider === 'litellm') { + if (!endpoint) { + logger.warn('LiteLLM provider requires endpoint'); + return null; + } + return { + provider: 'litellm', + apiKey: apiKey || '', // Can be empty for some LiteLLM endpoints + providerURL: endpoint + }; + } + + // Special case: browseroperator doesn't require apiKey + if (provider === 'browseroperator') { + return { + provider: 'browseroperator', + apiKey: apiKey || '' + }; + } + + // Default: provider requires apiKey + if (!apiKey) { + logger.warn(`Provider ${provider} requires API key`); + return null; + } + + return { + provider, + apiKey + }; + } + /** * Initializes the agent with the given API key */ @@ -447,6 +481,9 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ // Notify listeners of message update this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + // Schedule auto-save + void this.#scheduleAutoSave(); + // Get the user's current context (URL and title) const currentPageUrl = await this.#getCurrentPageUrl(); const currentPageTitle = await this.#getCurrentPageTitle(); @@ -604,8 +641,13 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ // Notify listeners of message update immediately this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + + // Don't auto-save during iteration - wait for completion } + // Schedule auto-save after loop completes with final state + void this.#scheduleAutoSave(); + // Check if the last message is an error (it might have been added in the loop) const finalMessage = this.#state.messages[this.#state.messages.length - 1]; if (!finalMessage) { @@ -677,6 +719,9 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ // Notify listeners of message update this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + // Schedule auto-save + void this.#scheduleAutoSave(); + // Create error completion event await this.#tracingProvider.createObservation({ id: `event-error-${Date.now()}`, @@ -718,17 +763,137 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ } /** - * Clears the conversation history + * Gets the current conversation ID */ - clearConversation(): void { - // Abort ALL running agent executions globally - logger.info('Aborting all running agent executions due to conversation clear'); - AgentService.abortAllExecutions(); + getCurrentConversationId(): string | null { + return this.#currentConversationId; + } - // Clear local state - this.#abortController = undefined; - this.#runningGraphStatePromise = undefined; - this.#executionId = undefined; + /** + * Gets the current conversation title (auto-generated from first message) + */ + getCurrentConversationTitle(): string { + const firstUserMessage = this.#state.messages.find(msg => msg.entity === ChatMessageEntity.USER); + if (firstUserMessage && 'text' in firstUserMessage) { + const text = firstUserMessage.text as string; + return text.length > 50 ? text.substring(0, 50) + '...' : text; + } + return 'New Chat'; + } + + /** + * Auto-saves the current conversation (debounced) + */ + async #scheduleAutoSave(): Promise { + // Clear existing timeout + if (this.#autoSaveTimeoutId !== undefined) { + clearTimeout(this.#autoSaveTimeoutId); + } + + // Schedule new auto-save + this.#autoSaveTimeoutId = setTimeout(async () => { + try { + const conversationId = await this.#conversationManager.autoSaveConversation( + this.#currentConversationId, + this.#state, + this.getActiveAgentSessions() + ); + + // Update current conversation ID if it was created + if (conversationId && conversationId !== this.#currentConversationId) { + this.#currentConversationId = conversationId; + this.dispatchEventToListeners(Events.CONVERSATION_CHANGED, conversationId); + } + + if (conversationId) { + this.dispatchEventToListeners(Events.CONVERSATION_SAVED, conversationId); + logger.debug('Auto-saved conversation', { conversationId }); + } + } catch (error) { + logger.error('Failed to auto-save conversation', { error }); + } + }, this.#autoSaveDebounceMs) as unknown as number; + } + + /** + * Manually saves the current conversation + */ + async saveConversation(): Promise { + try { + const conversationId = await this.#conversationManager.autoSaveConversation( + this.#currentConversationId, + this.#state, + this.getActiveAgentSessions() + ); + + if (conversationId && conversationId !== this.#currentConversationId) { + this.#currentConversationId = conversationId; + this.dispatchEventToListeners(Events.CONVERSATION_CHANGED, conversationId); + } + + if (conversationId) { + this.dispatchEventToListeners(Events.CONVERSATION_SAVED, conversationId); + logger.info('Saved conversation', { conversationId }); + } + + return conversationId; + } catch (error) { + logger.error('Failed to save conversation', { error }); + return null; + } + } + + /** + * Loads a conversation by ID + */ + async loadConversation(conversationId: string): Promise { + try { + const result = await this.#conversationManager.loadConversation(conversationId); + + if (!result) { + logger.warn('Conversation not found', { conversationId }); + return false; + } + + // Abort any running execution + this.cancelRun(); + + // Load the state + this.#state = result.state; + this.#currentConversationId = conversationId; + + // Restore agent sessions + this.#activeAgentSessions.clear(); + for (const session of result.agentSessions) { + this.#activeAgentSessions.set(session.sessionId, session); + } + + // Notify listeners + this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + this.dispatchEventToListeners(Events.CONVERSATION_CHANGED, conversationId); + + logger.info('Loaded conversation', { + conversationId, + messageCount: this.#state.messages.length, + title: result.conversation.title + }); + + return true; + } catch (error) { + logger.error('Failed to load conversation', { error, conversationId }); + return false; + } + } + + /** + * Starts a new conversation + */ + async newConversation(): Promise { + // Abort any running execution + this.cancelRun(); + + // Clear conversation ID + this.#currentConversationId = null; // Create a fresh state this.#state = createInitialState(); @@ -741,8 +906,68 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ isFinalAnswer: true, }); - // Notify listeners that messages have changed + // Clear agent sessions + this.#activeAgentSessions.clear(); + + // Notify listeners this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + this.dispatchEventToListeners(Events.CONVERSATION_CHANGED, null); + + logger.info('Started new conversation'); + } + + /** + * Lists all saved conversations + */ + async listConversations(): Promise { + try { + return await this.#conversationManager.listConversations(); + } catch (error) { + logger.error('Failed to list conversations', { error }); + return []; + } + } + + /** + * Deletes a conversation by ID + */ + async deleteConversation(conversationId: string): Promise { + try { + await this.#conversationManager.deleteConversation(conversationId); + + // If we deleted the current conversation, start a new one + if (conversationId === this.#currentConversationId) { + await this.newConversation(); + } + + logger.info('Deleted conversation', { conversationId }); + return true; + } catch (error) { + logger.error('Failed to delete conversation', { error, conversationId }); + return false; + } + } + + /** + * Updates the title of a conversation + */ + async updateConversationTitle(conversationId: string, newTitle: string): Promise { + try { + await this.#conversationManager.updateConversationTitle(conversationId, newTitle); + logger.info('Updated conversation title', { conversationId, newTitle }); + return true; + } catch (error) { + logger.error('Failed to update conversation title', { error, conversationId }); + return false; + } + } + + /** + * Clears the conversation history (creates a new conversation) + */ + clearConversation(): void { + // Use newConversation() for consistency + void this.newConversation(); } /** @@ -822,37 +1047,26 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ */ #doesCurrentConfigRequireApiKey(): boolean { try { - // Check the selected provider const selectedProvider = this.#configManager.getProvider(); - - // OpenAI provider always requires an API key - if (selectedProvider === 'openai') { - return true; - } - - // Groq provider always requires an API key - if (selectedProvider === 'groq') { - return true; - } - - // OpenRouter provider always requires an API key - if (selectedProvider === 'openrouter') { - return true; - } - // BrowserOperator provider doesn't require an API key (endpoint is hardcoded) + // Special case: browseroperator doesn't require API key if (selectedProvider === 'browseroperator') { return false; } - // For LiteLLM, only require API key if no endpoint is configured + // Special case: custom providers have optional API keys + if (isCustomProvider(selectedProvider)) { + return false; + } + + // Special case: litellm only requires API key if no endpoint is configured if (selectedProvider === 'litellm') { - const hasLiteLLMEndpoint = Boolean(localStorage.getItem('ai_chat_litellm_endpoint')); + const endpoint = LLMProviderRegistry.getProviderEndpoint(selectedProvider); // If we have an endpoint, API key is optional - return !hasLiteLLMEndpoint; + return !endpoint; } - // Default to requiring API key for any unknown provider + // All other providers require API key return true; } catch (error) { logger.error('Error checking if API key is required:', error); @@ -926,6 +1140,9 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ // Trigger messages changed to update the chat transcript this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + // Schedule auto-save after session completion + void this.#scheduleAutoSave(); + // Clean up after a short delay (5 seconds) to allow UI to finish rendering this.#cleanupCompletedSession(progressEvent.sessionId); } else { @@ -957,6 +1174,7 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ const updatedParent = { ...parentSession, nestedSessions: nested } as AgentSession; this.#state.messages[parentIdx] = { ...parentMsg, agentSession: updatedParent }; this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + void this.#scheduleAutoSave(); return; } } @@ -976,6 +1194,7 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ } } this.dispatchEventToListeners(Events.MESSAGES_CHANGED, [...this.#state.messages]); + void this.#scheduleAutoSave(); } /** diff --git a/front_end/panels/ai_chat/core/CustomProviderManager.ts b/front_end/panels/ai_chat/core/CustomProviderManager.ts new file mode 100644 index 0000000000..d922e7b73b --- /dev/null +++ b/front_end/panels/ai_chat/core/CustomProviderManager.ts @@ -0,0 +1,320 @@ +// 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 { createLogger } from './Logger.js'; + +const logger = createLogger('CustomProviderManager'); + +/** + * Configuration for a custom provider + */ +export interface CustomProviderConfig { + id: string; // Unique identifier (e.g., "custom:z-ai") + name: string; // Display name (e.g., "Z.AI") + baseURL: string; // Base URL (e.g., "https://api.z.ai/api/coding/paas/v4") + models: string[]; // Available models + enabled: boolean; // Whether the provider is enabled + createdAt: number; // Timestamp when created + updatedAt: number; // Timestamp when last updated +} + +/** + * Manager for custom OpenAI-compatible providers + * Handles CRUD operations and localStorage persistence + */ +export class CustomProviderManager { + private static readonly STORAGE_KEY = 'ai_chat_custom_providers'; + private static readonly ID_PREFIX = 'custom:'; + + /** + * Generate a unique ID from a provider name + */ + private static generateId(name: string): string { + // Convert name to lowercase, replace spaces with hyphens, remove special chars + const sanitized = name.toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + return `${CustomProviderManager.ID_PREFIX}${sanitized}`; + } + + /** + * Validate provider configuration + */ + private static validateConfig(config: Partial): {valid: boolean, errors: string[]} { + const errors: string[] = []; + + if (!config.name || config.name.trim().length === 0) { + errors.push('Provider name is required'); + } + + if (!config.baseURL || config.baseURL.trim().length === 0) { + errors.push('Base URL is required'); + } else { + // Validate URL format + try { + const url = new URL(config.baseURL); + if (!url.protocol.startsWith('http')) { + errors.push('Base URL must use HTTP or HTTPS protocol'); + } + } catch (e) { + errors.push('Base URL is not a valid URL'); + } + } + + if (!config.models || config.models.length === 0) { + errors.push('At least one model is required'); + } + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Load all custom providers from localStorage + */ + static loadProviders(): CustomProviderConfig[] { + try { + const stored = localStorage.getItem(CustomProviderManager.STORAGE_KEY); + if (!stored) { + return []; + } + + const providers = JSON.parse(stored); + if (!Array.isArray(providers)) { + logger.error('Invalid custom providers data in localStorage'); + return []; + } + + logger.debug('Loaded custom providers:', providers); + return providers; + } catch (error) { + logger.error('Failed to load custom providers:', error); + return []; + } + } + + /** + * Save providers to localStorage + */ + private static saveProviders(providers: CustomProviderConfig[]): void { + try { + localStorage.setItem(CustomProviderManager.STORAGE_KEY, JSON.stringify(providers)); + logger.debug('Saved custom providers:', providers); + } catch (error) { + logger.error('Failed to save custom providers:', error); + throw new Error('Failed to save custom providers to storage'); + } + } + + /** + * Get a provider by ID + */ + static getProvider(id: string): CustomProviderConfig | null { + const providers = CustomProviderManager.loadProviders(); + return providers.find(p => p.id === id) || null; + } + + /** + * Get a provider by name + */ + static getProviderByName(name: string): CustomProviderConfig | null { + const providers = CustomProviderManager.loadProviders(); + return providers.find(p => p.name.toLowerCase() === name.toLowerCase()) || null; + } + + /** + * Check if a provider ID exists + */ + static providerExists(id: string): boolean { + return CustomProviderManager.getProvider(id) !== null; + } + + /** + * Check if a provider name exists + */ + static providerNameExists(name: string, excludeId?: string): boolean { + const providers = CustomProviderManager.loadProviders(); + return providers.some(p => + p.name.toLowerCase() === name.toLowerCase() && + p.id !== excludeId + ); + } + + /** + * Add a new custom provider + */ + static addProvider(config: Omit): CustomProviderConfig { + // Validate config + const validation = CustomProviderManager.validateConfig(config); + if (!validation.valid) { + throw new Error(`Invalid provider configuration: ${validation.errors.join(', ')}`); + } + + // Check if name already exists + if (CustomProviderManager.providerNameExists(config.name)) { + throw new Error(`A provider with the name "${config.name}" already exists`); + } + + // Generate ID and timestamps + const id = CustomProviderManager.generateId(config.name); + const now = Date.now(); + + const newProvider: CustomProviderConfig = { + ...config, + id, + createdAt: now, + updatedAt: now, + }; + + // Load existing providers and add new one + const providers = CustomProviderManager.loadProviders(); + providers.push(newProvider); + + // Save back to localStorage + CustomProviderManager.saveProviders(providers); + + logger.info('Added custom provider:', newProvider); + return newProvider; + } + + /** + * Update an existing custom provider + */ + static updateProvider(id: string, updates: Partial>): CustomProviderConfig { + const providers = CustomProviderManager.loadProviders(); + const index = providers.findIndex(p => p.id === id); + + if (index === -1) { + throw new Error(`Provider with ID "${id}" not found`); + } + + const existingProvider = providers[index]; + + // If name is being changed, check for conflicts + if (updates.name && updates.name !== existingProvider.name) { + if (CustomProviderManager.providerNameExists(updates.name, id)) { + throw new Error(`A provider with the name "${updates.name}" already exists`); + } + } + + // Merge updates with existing provider + const updatedProvider: CustomProviderConfig = { + ...existingProvider, + ...updates, + id, // Preserve ID + createdAt: existingProvider.createdAt, // Preserve creation time + updatedAt: Date.now(), + }; + + // Validate the updated config + const validation = CustomProviderManager.validateConfig(updatedProvider); + if (!validation.valid) { + throw new Error(`Invalid provider configuration: ${validation.errors.join(', ')}`); + } + + // Update the provider in the array + providers[index] = updatedProvider; + + // Save back to localStorage + CustomProviderManager.saveProviders(providers); + + logger.info('Updated custom provider:', updatedProvider); + return updatedProvider; + } + + /** + * Delete a custom provider + */ + static deleteProvider(id: string): boolean { + const providers = CustomProviderManager.loadProviders(); + const index = providers.findIndex(p => p.id === id); + + if (index === -1) { + logger.warn(`Attempted to delete non-existent provider: ${id}`); + return false; + } + + // Remove the provider + providers.splice(index, 1); + + // Save back to localStorage + CustomProviderManager.saveProviders(providers); + + // Also clean up the API key from localStorage + const apiKeyStorageKey = `ai_chat_custom_${id}_api_key`; + localStorage.removeItem(apiKeyStorageKey); + + logger.info('Deleted custom provider:', id); + return true; + } + + /** + * List all custom providers + */ + static listProviders(): CustomProviderConfig[] { + return CustomProviderManager.loadProviders(); + } + + /** + * List all enabled custom providers + */ + static listEnabledProviders(): CustomProviderConfig[] { + return CustomProviderManager.loadProviders().filter(p => p.enabled); + } + + /** + * Enable or disable a provider + */ + static setProviderEnabled(id: string, enabled: boolean): void { + CustomProviderManager.updateProvider(id, { enabled }); + } + + /** + * Check if a provider ID is a custom provider + */ + static isCustomProvider(providerId: string): boolean { + return providerId.startsWith(CustomProviderManager.ID_PREFIX); + } + + /** + * Clear all custom providers (mainly for testing/reset) + */ + static clearAllProviders(): void { + localStorage.removeItem(CustomProviderManager.STORAGE_KEY); + + // Also clean up all custom provider API keys + const providers = CustomProviderManager.loadProviders(); + providers.forEach(provider => { + const apiKeyStorageKey = `ai_chat_custom_${provider.id}_api_key`; + localStorage.removeItem(apiKeyStorageKey); + }); + + logger.info('Cleared all custom providers'); + } + + /** + * Get the storage key for a custom provider's API key + */ + static getApiKeyStorageKey(providerId: string): string { + return `ai_chat_custom_${providerId}_api_key`; + } + + /** + * Get the API key for a custom provider + */ + static getApiKey(providerId: string): string | null { + const key = CustomProviderManager.getApiKeyStorageKey(providerId); + return localStorage.getItem(key); + } + + /** + * Set the API key for a custom provider + */ + static setApiKey(providerId: string, apiKey: string): void { + const key = CustomProviderManager.getApiKeyStorageKey(providerId); + localStorage.setItem(key, apiKey); + } +} diff --git a/front_end/panels/ai_chat/core/LLMConfigurationManager.ts b/front_end/panels/ai_chat/core/LLMConfigurationManager.ts index b9f6ac56a0..a291c3ffe4 100644 --- a/front_end/panels/ai_chat/core/LLMConfigurationManager.ts +++ b/front_end/panels/ai_chat/core/LLMConfigurationManager.ts @@ -5,6 +5,9 @@ import { createLogger } from './Logger.js'; import type { LLMProvider } from '../LLM/LLMTypes.js'; +import { isCustomProvider } from '../LLM/LLMTypes.js'; +import { LLMProviderRegistry } from '../LLM/LLMProviderRegistry.js'; +import { CustomProviderManager } from './CustomProviderManager.js'; const logger = createLogger('LLMConfigurationManager'); @@ -89,7 +92,12 @@ export class LLMConfigurationManager { if (this.overrideConfig?.miniModel) { return this.overrideConfig.miniModel; } - return localStorage.getItem(STORAGE_KEYS.MINI_MODEL) || ''; + const stored = localStorage.getItem(STORAGE_KEYS.MINI_MODEL) || ''; + // Fallback to mainModel if mini is not set + if (!stored) { + return this.getMainModel(); + } + return stored; } /** @@ -99,7 +107,12 @@ export class LLMConfigurationManager { if (this.overrideConfig?.nanoModel) { return this.overrideConfig.nanoModel; } - return localStorage.getItem(STORAGE_KEYS.NANO_MODEL) || ''; + const stored = localStorage.getItem(STORAGE_KEYS.NANO_MODEL) || ''; + // Fallback to miniModel, then mainModel if nano is not set + if (!stored) { + return this.getMiniModel() || this.getMainModel(); + } + return stored; } /** @@ -111,20 +124,7 @@ export class LLMConfigurationManager { } const provider = this.getProvider(); - switch (provider) { - case 'openai': - return localStorage.getItem(STORAGE_KEYS.OPENAI_API_KEY) || ''; - case 'litellm': - return localStorage.getItem(STORAGE_KEYS.LITELLM_API_KEY) || ''; - case 'groq': - return localStorage.getItem(STORAGE_KEYS.GROQ_API_KEY) || ''; - case 'openrouter': - return localStorage.getItem(STORAGE_KEYS.OPENROUTER_API_KEY) || ''; - case 'browseroperator': - return localStorage.getItem(STORAGE_KEYS.BROWSEROPERATOR_API_KEY) || ''; - default: - return ''; - } + return LLMProviderRegistry.getProviderApiKey(provider); } /** @@ -136,10 +136,7 @@ export class LLMConfigurationManager { } const provider = this.getProvider(); - if (provider === 'litellm') { - return localStorage.getItem(STORAGE_KEYS.LITELLM_ENDPOINT) || undefined; - } - return undefined; + return LLMProviderRegistry.getProviderEndpoint(provider); } /** @@ -312,20 +309,23 @@ export class LLMConfigurationManager { } // Provider-specific validation - skip credential checks in AUTOMATED_MODE - if (!skipCredentialChecks) { - switch (config.provider) { - case 'openai': - case 'groq': - case 'openrouter': - if (!config.apiKey) { - errors.push(`API key is required for ${config.provider}`); - } - break; - case 'litellm': - if (!config.endpoint) { - errors.push('Endpoint is required for LiteLLM'); - } - break; + if (!skipCredentialChecks && config.provider) { + // Check if it's a custom provider + if (isCustomProvider(config.provider)) { + const customProvider = CustomProviderManager.getProvider(config.provider); + if (!customProvider) { + errors.push(`Custom provider ${config.provider} not found`); + } else if (!customProvider.enabled) { + errors.push('Provider is disabled'); + } else if (!customProvider.models || customProvider.models.length === 0) { + errors.push('No models configured for this provider'); + } + } else { + // Built-in provider - use existing validation + const validation = LLMProviderRegistry.validateProviderCredentials(config.provider as LLMProvider); + if (!validation.isValid) { + errors.push(validation.message); + } } } @@ -340,41 +340,17 @@ export class LLMConfigurationManager { * Only modifies settings for the active provider, preserving other providers' credentials */ private saveProviderSpecificSettings(config: LLMConfig): void { - // Save current provider's settings only (do not clear others) - switch (config.provider) { - case 'openai': - if (config.apiKey) { - localStorage.setItem(STORAGE_KEYS.OPENAI_API_KEY, config.apiKey); - } else { - localStorage.removeItem(STORAGE_KEYS.OPENAI_API_KEY); - } - break; - case 'litellm': - if (config.endpoint) { - localStorage.setItem(STORAGE_KEYS.LITELLM_ENDPOINT, config.endpoint); - } else { - localStorage.removeItem(STORAGE_KEYS.LITELLM_ENDPOINT); - } - if (config.apiKey) { - localStorage.setItem(STORAGE_KEYS.LITELLM_API_KEY, config.apiKey); - } else { - localStorage.removeItem(STORAGE_KEYS.LITELLM_API_KEY); - } - break; - case 'groq': - if (config.apiKey) { - localStorage.setItem(STORAGE_KEYS.GROQ_API_KEY, config.apiKey); - } else { - localStorage.removeItem(STORAGE_KEYS.GROQ_API_KEY); - } - break; - case 'openrouter': - if (config.apiKey) { - localStorage.setItem(STORAGE_KEYS.OPENROUTER_API_KEY, config.apiKey); - } else { - localStorage.removeItem(STORAGE_KEYS.OPENROUTER_API_KEY); - } - break; + // Check if this is a custom provider + if (isCustomProvider(config.provider)) { + // Use CustomProviderManager for custom providers + if (config.apiKey) { + CustomProviderManager.setApiKey(config.provider, config.apiKey); + } + // Note: endpoint/baseURL for custom providers is stored in the provider config itself + } else { + // Use LLMProviderRegistry for built-in providers + LLMProviderRegistry.saveProviderApiKey(config.provider, config.apiKey || null); + LLMProviderRegistry.saveProviderEndpoint(config.provider, config.endpoint || null); } } @@ -383,12 +359,15 @@ export class LLMConfigurationManager { */ private handleStorageChange(event: StorageEvent): void { if (event.key && Object.values(STORAGE_KEYS).includes(event.key as any)) { - const sensitiveKeys = new Set([ - STORAGE_KEYS.OPENAI_API_KEY, - STORAGE_KEYS.LITELLM_API_KEY, - STORAGE_KEYS.GROQ_API_KEY, - STORAGE_KEYS.OPENROUTER_API_KEY, - ]); + // Get all API key storage keys from registered providers + const sensitiveKeys = new Set(); + for (const providerType of LLMProviderRegistry.getRegisteredProviders()) { + const keys = LLMProviderRegistry.getProviderStorageKeys(providerType); + if (keys.apiKey) { + sensitiveKeys.add(keys.apiKey); + } + } + const redacted = sensitiveKeys.has(event.key as any) ? '(redacted)' : (event.newValue ? `${event.newValue.slice(0, 8)}…` : null); diff --git a/front_end/panels/ai_chat/persistence/ConversationManager.ts b/front_end/panels/ai_chat/persistence/ConversationManager.ts new file mode 100644 index 0000000000..50ff286f1a --- /dev/null +++ b/front_end/panels/ai_chat/persistence/ConversationManager.ts @@ -0,0 +1,184 @@ +// 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 {createLogger} from '../core/Logger.js'; +import type {AgentState} from '../core/State.js'; +import type {AgentSession} from '../agent_framework/AgentSessionTypes.js'; +import {ConversationStorageManager} from './ConversationStorageManager.js'; +import { + type ConversationMetadata, + deserializeAgentSession, + deserializeAgentState, + extractMetadata, + generatePreview, + generateTitle, + serializeAgentSession, + serializeAgentState, + type StoredConversation, +} from './ConversationTypes.js'; + +const logger = createLogger('ConversationManager'); + +/** + * High-level manager for conversation lifecycle operations + */ +export class ConversationManager { + private static instance: ConversationManager|null = null; + private storageManager: ConversationStorageManager; + + private constructor() { + this.storageManager = ConversationStorageManager.getInstance(); + logger.info('Initialized ConversationManager'); + } + + static getInstance(): ConversationManager { + if (!ConversationManager.instance) { + ConversationManager.instance = new ConversationManager(); + } + return ConversationManager.instance; + } + + /** + * Creates a new conversation from the current agent state + */ + async createConversation(state: AgentState, agentSessions: AgentSession[] = []): Promise { + const now = Date.now(); + const id = this.storageManager.generateConversationId(); + + const conversation: StoredConversation = { + id, + title: generateTitle(state.messages), + createdAt: now, + updatedAt: now, + state: serializeAgentState(state), + agentSessions: agentSessions.map(serializeAgentSession), + preview: generatePreview(state.messages), + messageCount: state.messages.length, + }; + + await this.storageManager.saveConversation(conversation); + + logger.info('Created new conversation', {conversationId: id, title: conversation.title}); + return conversation; + } + + /** + * Saves the current conversation state + */ + async saveConversation( + conversationId: string, state: AgentState, agentSessions: AgentSession[] = []): Promise { + // Check if conversation exists + const existingConversation = await this.storageManager.loadConversation(conversationId); + + if (!existingConversation) { + throw new Error(`Conversation ${conversationId} not found`); + } + + // Update the conversation with current state + const updatedConversation: StoredConversation = { + ...existingConversation, + state: serializeAgentState(state), + agentSessions: agentSessions.map(serializeAgentSession), + preview: generatePreview(state.messages), + messageCount: state.messages.length, + // Keep the existing title unless it's still the default + title: existingConversation.title === 'New Chat' ? generateTitle(state.messages) : existingConversation.title, + }; + + await this.storageManager.saveConversation(updatedConversation); + + logger.info('Saved conversation', {conversationId, messageCount: state.messages.length}); + } + + /** + * Loads a conversation and returns its state and sessions + */ + async loadConversation(conversationId: string): + Promise<{state: AgentState, agentSessions: AgentSession[], conversation: StoredConversation}|null> { + const conversation = await this.storageManager.loadConversation(conversationId); + + if (!conversation) { + logger.warn('Conversation not found', {conversationId}); + return null; + } + + const state = deserializeAgentState(conversation.state); + const agentSessions = conversation.agentSessions.map(deserializeAgentSession); + + logger.info('Loaded conversation', {conversationId, messageCount: state.messages.length}); + + return { + state, + agentSessions, + conversation, + }; + } + + /** + * Lists all conversations + */ + async listConversations(): Promise { + return await this.storageManager.listConversations(); + } + + /** + * Deletes a conversation + */ + async deleteConversation(conversationId: string): Promise { + await this.storageManager.deleteConversation(conversationId); + logger.info('Deleted conversation', {conversationId}); + } + + /** + * Updates a conversation title + */ + async updateConversationTitle(conversationId: string, newTitle: string): Promise { + await this.storageManager.updateConversationTitle(conversationId, newTitle); + logger.info('Updated conversation title', {conversationId, newTitle}); + } + + /** + * Checks if a conversation exists + */ + async conversationExists(conversationId: string): Promise { + return await this.storageManager.conversationExists(conversationId); + } + + /** + * Auto-saves a conversation, creating it if it doesn't exist + * Returns the conversation ID + */ + async autoSaveConversation( + currentConversationId: string|null, state: AgentState, agentSessions: AgentSession[] = []): Promise { + // Don't save if there are no messages + if (state.messages.length === 0) { + logger.debug('Skipping auto-save: no messages'); + return currentConversationId || ''; + } + + try { + if (!currentConversationId) { + // Create new conversation + const conversation = await this.createConversation(state, agentSessions); + return conversation.id; + } else { + // Update existing conversation + await this.saveConversation(currentConversationId, state, agentSessions); + return currentConversationId; + } + } catch (error) { + logger.error('Failed to auto-save conversation', {error, currentConversationId}); + // Return the current ID even if save failed + return currentConversationId || ''; + } + } + + /** + * Clears all conversations (for testing or reset) + */ + async clearAllConversations(): Promise { + await this.storageManager.clearAllConversations(); + logger.info('Cleared all conversations'); + } +} diff --git a/front_end/panels/ai_chat/persistence/ConversationStorageManager.ts b/front_end/panels/ai_chat/persistence/ConversationStorageManager.ts new file mode 100644 index 0000000000..c5f609acb7 --- /dev/null +++ b/front_end/panels/ai_chat/persistence/ConversationStorageManager.ts @@ -0,0 +1,339 @@ +// 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 {createLogger} from '../core/Logger.js'; +import type {ConversationMetadata, StoredConversation} from './ConversationTypes.js'; + +const logger = createLogger('ConversationStorageManager'); + +const DATABASE_NAME = 'ai_chat_conversations'; +const DATABASE_VERSION = 1; +const STORE_CONVERSATIONS = 'conversations'; +const STORE_METADATA = 'conversation_metadata'; +const INDEX_UPDATED_AT = 'updatedAt'; +const INDEX_CREATED_AT = 'createdAt'; + +/** + * Manages IndexedDB-backed conversation storage for the AI Chat panel. + */ +export class ConversationStorageManager { + private static instance: ConversationStorageManager|null = null; + + private db: IDBDatabase|null = null; + private dbInitializationPromise: Promise|null = null; + + private constructor() { + logger.info('Initialized ConversationStorageManager'); + } + + static getInstance(): ConversationStorageManager { + if (!ConversationStorageManager.instance) { + ConversationStorageManager.instance = new ConversationStorageManager(); + } + return ConversationStorageManager.instance; + } + + /** + * Saves or updates a conversation + */ + async saveConversation(conversation: StoredConversation): Promise { + const db = await this.ensureDatabase(); + + // Update the updatedAt timestamp + conversation.updatedAt = Date.now(); + + const transaction = db.transaction([STORE_CONVERSATIONS, STORE_METADATA], 'readwrite'); + + try { + // Save full conversation + const conversationsStore = transaction.objectStore(STORE_CONVERSATIONS); + await this.requestToPromise(conversationsStore.put(conversation)); + + // Save metadata for quick list view + const metadata: ConversationMetadata = { + id: conversation.id, + title: conversation.title, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + preview: conversation.preview, + messageCount: conversation.messageCount, + }; + + const metadataStore = transaction.objectStore(STORE_METADATA); + await this.requestToPromise(metadataStore.put(metadata)); + + await this.transactionComplete(transaction); + + logger.info('Saved conversation', {conversationId: conversation.id, title: conversation.title}); + } catch (error) { + logger.error('Failed to save conversation', {error, conversationId: conversation.id}); + throw error; + } + } + + /** + * Loads a conversation by ID + */ + async loadConversation(id: string): Promise { + const db = await this.ensureDatabase(); + + const transaction = db.transaction(STORE_CONVERSATIONS, 'readonly'); + const store = transaction.objectStore(STORE_CONVERSATIONS); + + try { + const request = store.get(id); + const conversation = await this.requestToPromise(request); + await this.transactionComplete(transaction); + + if (conversation) { + logger.info('Loaded conversation', {conversationId: id, title: conversation.title}); + return conversation; + } else { + logger.warn('Conversation not found', {conversationId: id}); + return null; + } + } catch (error) { + logger.error('Failed to load conversation', {error, conversationId: id}); + throw error; + } + } + + /** + * Lists all conversation metadata, sorted by most recently updated + */ + async listConversations(): Promise { + const db = await this.ensureDatabase(); + + const transaction = db.transaction(STORE_METADATA, 'readonly'); + const store = transaction.objectStore(STORE_METADATA); + const index = store.index(INDEX_UPDATED_AT); + + try { + const request = index.getAll(); + const conversations = await this.requestToPromise(request); + await this.transactionComplete(transaction); + + // Sort by updatedAt descending (most recent first) + const sorted = (conversations || []).sort((a, b) => b.updatedAt - a.updatedAt); + + logger.info('Listed conversations', {count: sorted.length}); + return sorted; + } catch (error) { + logger.error('Failed to list conversations', {error}); + throw error; + } + } + + /** + * Deletes a conversation by ID + */ + async deleteConversation(id: string): Promise { + const db = await this.ensureDatabase(); + + const transaction = db.transaction([STORE_CONVERSATIONS, STORE_METADATA], 'readwrite'); + + try { + const conversationsStore = transaction.objectStore(STORE_CONVERSATIONS); + await this.requestToPromise(conversationsStore.delete(id)); + + const metadataStore = transaction.objectStore(STORE_METADATA); + await this.requestToPromise(metadataStore.delete(id)); + + await this.transactionComplete(transaction); + + logger.info('Deleted conversation', {conversationId: id}); + } catch (error) { + logger.error('Failed to delete conversation', {error, conversationId: id}); + throw error; + } + } + + /** + * Updates the title of a conversation + */ + async updateConversationTitle(id: string, newTitle: string): Promise { + const db = await this.ensureDatabase(); + + const transaction = db.transaction([STORE_CONVERSATIONS, STORE_METADATA], 'readwrite'); + + try { + // Update full conversation + const conversationsStore = transaction.objectStore(STORE_CONVERSATIONS); + const conversationRequest = conversationsStore.get(id); + const conversation = await this.requestToPromise(conversationRequest); + + if (!conversation) { + throw new Error(`Conversation ${id} not found`); + } + + conversation.title = newTitle; + conversation.updatedAt = Date.now(); + await this.requestToPromise(conversationsStore.put(conversation)); + + // Update metadata + const metadataStore = transaction.objectStore(STORE_METADATA); + const metadataRequest = metadataStore.get(id); + const metadata = await this.requestToPromise(metadataRequest); + + if (metadata) { + metadata.title = newTitle; + metadata.updatedAt = conversation.updatedAt; + await this.requestToPromise(metadataStore.put(metadata)); + } + + await this.transactionComplete(transaction); + + logger.info('Updated conversation title', {conversationId: id, newTitle}); + } catch (error) { + logger.error('Failed to update conversation title', {error, conversationId: id}); + throw error; + } + } + + /** + * Checks if a conversation exists + */ + async conversationExists(id: string): Promise { + const db = await this.ensureDatabase(); + + const transaction = db.transaction(STORE_METADATA, 'readonly'); + const store = transaction.objectStore(STORE_METADATA); + + try { + const request = store.get(id); + const metadata = await this.requestToPromise(request); + await this.transactionComplete(transaction); + + return Boolean(metadata); + } catch (error) { + logger.error('Failed to check conversation existence', {error, conversationId: id}); + return false; + } + } + + /** + * Clears all conversations (for testing or reset purposes) + */ + async clearAllConversations(): Promise { + const db = await this.ensureDatabase(); + + const transaction = db.transaction([STORE_CONVERSATIONS, STORE_METADATA], 'readwrite'); + + try { + const conversationsStore = transaction.objectStore(STORE_CONVERSATIONS); + await this.requestToPromise(conversationsStore.clear()); + + const metadataStore = transaction.objectStore(STORE_METADATA); + await this.requestToPromise(metadataStore.clear()); + + await this.transactionComplete(transaction); + + logger.info('Cleared all conversations'); + } catch (error) { + logger.error('Failed to clear conversations', {error}); + throw error; + } + } + + /** + * Ensures the database is open and initialized + */ + private async ensureDatabase(): Promise { + if (this.db) { + return this.db; + } + if (!('indexedDB' in globalThis)) { + throw new Error('IndexedDB is not supported in this environment.'); + } + if (this.dbInitializationPromise) { + this.db = await this.dbInitializationPromise; + return this.db; + } + this.dbInitializationPromise = this.openDatabase(); + try { + this.db = await this.dbInitializationPromise; + return this.db; + } catch (error) { + this.dbInitializationPromise = null; + logger.error('Failed to open IndexedDB database', {error}); + throw error; + } + } + + /** + * Opens the IndexedDB database and creates object stores if needed + */ + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + logger.info('Initializing conversation storage database'); + + // Create conversations object store + if (!db.objectStoreNames.contains(STORE_CONVERSATIONS)) { + const conversationsStore = db.createObjectStore(STORE_CONVERSATIONS, {keyPath: 'id'}); + conversationsStore.createIndex(INDEX_UPDATED_AT, 'updatedAt', {unique: false}); + conversationsStore.createIndex(INDEX_CREATED_AT, 'createdAt', {unique: false}); + } + + // Create metadata object store + if (!db.objectStoreNames.contains(STORE_METADATA)) { + const metadataStore = db.createObjectStore(STORE_METADATA, {keyPath: 'id'}); + metadataStore.createIndex(INDEX_UPDATED_AT, 'updatedAt', {unique: false}); + metadataStore.createIndex(INDEX_CREATED_AT, 'createdAt', {unique: false}); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error('Failed to open IndexedDB')); + }; + + request.onblocked = () => { + logger.warn('Conversation storage database open request was blocked.'); + }; + }); + } + + /** + * Converts an IDBRequest to a Promise + */ + private requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('IndexedDB request failed')); + }); + } + + /** + * Waits for a transaction to complete + */ + private transactionComplete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error || new Error('IndexedDB transaction failed')); + transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted')); + }); + } + + /** + * Generates a unique ID for conversations + */ + generateConversationId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; + return template.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } +} diff --git a/front_end/panels/ai_chat/persistence/ConversationTypes.ts b/front_end/panels/ai_chat/persistence/ConversationTypes.ts new file mode 100644 index 0000000000..71092ef63c --- /dev/null +++ b/front_end/panels/ai_chat/persistence/ConversationTypes.ts @@ -0,0 +1,288 @@ +// 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 {AgentState} from '../core/State.js'; +import type {ChatMessage} from '../models/ChatTypes.js'; +import {ChatMessageEntity} from '../models/ChatTypes.js'; +import type {AgentSession} from '../agent_framework/AgentSessionTypes.js'; + +/** + * Represents a fully stored conversation with all state and metadata + */ +export interface StoredConversation { + // Unique identifier for the conversation + id: string; + + // User-visible title (auto-generated or user-edited) + title: string; + + // Timestamps + createdAt: number; // Unix timestamp in milliseconds + updatedAt: number; // Unix timestamp in milliseconds + + // Full conversation state + state: SerializableAgentState; + + // Agent sessions that occurred in this conversation + agentSessions: SerializableAgentSession[]; + + // Preview text for the conversation list (first user message) + preview?: string; + + // Total number of messages in the conversation + messageCount: number; +} + +/** + * Lightweight metadata for conversation list display + */ +export interface ConversationMetadata { + id: string; + title: string; + createdAt: number; + updatedAt: number; + preview?: string; + messageCount: number; +} + +/** + * Serializable version of AgentState (dates converted to timestamps) + */ +export interface SerializableAgentState { + messages: ChatMessage[]; + context: SerializableDevToolsContext; + error?: string; + selectedAgentType?: string | null; + currentPageUrl?: string; + currentPageTitle?: string; +} + +/** + * Serializable version of DevToolsContext + */ +export interface SerializableDevToolsContext { + selectedElement?: string; + networkRequests?: Array<{ + url: string; + method: string; + status: number; + statusText: string; + responseTime: number; + size: number; + }>; + consoleMessages?: Array<{ + text: string; + level: 'log' | 'info' | 'warning' | 'error'; + timestamp: number; + }>; + performanceMetrics?: Array<{ + name: string; + value: number; + unit: string; + }>; + lastIntermediateResponse?: Record; + needsFollowUp?: boolean; + intermediateStepsCount?: number; + // Note: tracingContext, agentDescriptor, executionId, and abortSignal are not serialized +} + +/** + * Serializable version of AgentSession (dates converted to timestamps) + */ +export interface SerializableAgentSession { + agentName: string; + agentQuery?: string; + agentReasoning?: string; + agentDisplayName?: string; + agentDescription?: string; + sessionId: string; + parentSessionId?: string; + status: 'running' | 'completed' | 'error'; + startTime: number; // Unix timestamp + endTime?: number; // Unix timestamp + messages: SerializableAgentMessage[]; + nestedSessions: SerializableAgentSession[]; + reasoning?: string; + tools: string[]; + iterationCount?: number; + maxIterations?: number; + modelUsed?: string; + terminationReason?: string; +} + +/** + * Serializable version of AgentMessage (dates converted to timestamps) + */ +export interface SerializableAgentMessage { + id: string; + timestamp: number; // Unix timestamp + type: 'reasoning' | 'tool_call' | 'tool_result' | 'handoff' | 'final_answer'; + content: any; // Keep the content structure as-is +} + +/** + * Converts AgentState to a serializable format + */ +export function serializeAgentState(state: AgentState): SerializableAgentState { + return { + messages: state.messages.map(msg => { + // Handle AgentSessionMessage by serializing the nested AgentSession + if (msg.entity === ChatMessageEntity.AGENT_SESSION) { + const agentSessionMsg = msg as any; + return { + ...agentSessionMsg, + agentSession: serializeAgentSession(agentSessionMsg.agentSession), + }; + } + // Other message types pass through unchanged + return msg; + }), + context: { + selectedElement: state.context.selectedElement, + networkRequests: state.context.networkRequests, + consoleMessages: state.context.consoleMessages, + performanceMetrics: state.context.performanceMetrics, + lastIntermediateResponse: state.context.lastIntermediateResponse, + needsFollowUp: state.context.needsFollowUp, + intermediateStepsCount: state.context.intermediateStepsCount, + }, + error: state.error, + selectedAgentType: state.selectedAgentType, + currentPageUrl: state.currentPageUrl, + currentPageTitle: state.currentPageTitle, + }; +} + +/** + * Converts SerializableAgentState back to AgentState + */ +export function deserializeAgentState(serialized: SerializableAgentState): AgentState { + return { + messages: serialized.messages.map(msg => { + // Handle AgentSessionMessage by deserializing the nested AgentSession + if (msg.entity === ChatMessageEntity.AGENT_SESSION) { + const agentSessionMsg = msg as any; + return { + ...agentSessionMsg, + agentSession: deserializeAgentSession(agentSessionMsg.agentSession), + }; + } + // Other message types pass through unchanged + return msg; + }), + context: { + selectedElement: serialized.context.selectedElement, + networkRequests: serialized.context.networkRequests, + consoleMessages: serialized.context.consoleMessages, + performanceMetrics: serialized.context.performanceMetrics, + lastIntermediateResponse: serialized.context.lastIntermediateResponse, + needsFollowUp: serialized.context.needsFollowUp, + intermediateStepsCount: serialized.context.intermediateStepsCount || 0, + }, + error: serialized.error, + selectedAgentType: serialized.selectedAgentType, + currentPageUrl: serialized.currentPageUrl, + currentPageTitle: serialized.currentPageTitle, + }; +} + +/** + * Converts AgentSession to a serializable format + */ +export function serializeAgentSession(session: AgentSession): SerializableAgentSession { + return { + agentName: session.agentName, + agentQuery: session.agentQuery, + agentReasoning: session.agentReasoning, + agentDisplayName: session.agentDisplayName, + agentDescription: session.agentDescription, + sessionId: session.sessionId, + parentSessionId: session.parentSessionId, + status: session.status, + startTime: session.startTime.getTime(), + endTime: session.endTime ? session.endTime.getTime() : undefined, + messages: session.messages.map(msg => ({ + id: msg.id, + timestamp: msg.timestamp.getTime(), + type: msg.type, + content: msg.content, + })), + nestedSessions: session.nestedSessions.map(serializeAgentSession), + reasoning: session.reasoning, + tools: session.tools, + iterationCount: session.iterationCount, + maxIterations: session.maxIterations, + modelUsed: session.modelUsed, + terminationReason: session.terminationReason, + }; +} + +/** + * Converts SerializableAgentSession back to AgentSession + */ +export function deserializeAgentSession(serialized: SerializableAgentSession): AgentSession { + return { + agentName: serialized.agentName, + agentQuery: serialized.agentQuery, + agentReasoning: serialized.agentReasoning, + agentDisplayName: serialized.agentDisplayName, + agentDescription: serialized.agentDescription, + sessionId: serialized.sessionId, + parentSessionId: serialized.parentSessionId, + status: serialized.status, + startTime: new Date(serialized.startTime), + endTime: serialized.endTime ? new Date(serialized.endTime) : undefined, + messages: serialized.messages.map(msg => ({ + id: msg.id, + timestamp: new Date(msg.timestamp), + type: msg.type, + content: msg.content, + })), + nestedSessions: serialized.nestedSessions.map(deserializeAgentSession), + reasoning: serialized.reasoning, + tools: serialized.tools, + iterationCount: serialized.iterationCount, + maxIterations: serialized.maxIterations, + modelUsed: serialized.modelUsed, + terminationReason: serialized.terminationReason, + }; +} + +/** + * Generates a preview text from the first user message + */ +export function generatePreview(messages: ChatMessage[]): string { + const firstUserMessage = messages.find(msg => msg.entity === 'user'); + if (firstUserMessage && 'text' in firstUserMessage) { + const text = firstUserMessage.text as string; + return text.length > 100 ? text.substring(0, 100) + '...' : text; + } + return 'New conversation'; +} + +/** + * Generates a title from the first user message + */ +export function generateTitle(messages: ChatMessage[]): string { + const firstUserMessage = messages.find(msg => msg.entity === 'user'); + if (firstUserMessage && 'text' in firstUserMessage) { + const text = firstUserMessage.text as string; + return text.length > 50 ? text.substring(0, 50) + '...' : text; + } + return 'New Chat'; +} + +/** + * Extracts metadata from a stored conversation + */ +export function extractMetadata(conversation: StoredConversation): ConversationMetadata { + return { + id: conversation.id, + title: conversation.title, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + preview: conversation.preview, + messageCount: conversation.messageCount, + }; +} diff --git a/front_end/panels/ai_chat/tools/FileStorageManager.ts b/front_end/panels/ai_chat/tools/FileStorageManager.ts index b11c3d7f1d..3e431cbb20 100644 --- a/front_end/panels/ai_chat/tools/FileStorageManager.ts +++ b/front_end/panels/ai_chat/tools/FileStorageManager.ts @@ -44,13 +44,13 @@ interface ValidationResult { export class FileStorageManager { private static instance: FileStorageManager | null = null; - private readonly sessionId: string; + private sessionId: string; private db: IDBDatabase | null = null; private dbInitializationPromise: Promise | null = null; private constructor() { - this.sessionId = this.generateUUID(); - logger.info('Initialized FileStorageManager with session', { sessionId: this.sessionId }); + this.sessionId = 'default'; // Will be set to conversation ID when conversation is created/loaded + logger.info('Initialized FileStorageManager with default session'); } static getInstance(): FileStorageManager { @@ -60,6 +60,23 @@ export class FileStorageManager { return FileStorageManager.instance; } + /** + * Gets the current session ID + */ + getSessionId(): string { + return this.sessionId; + } + + /** + * Sets the session ID (used when loading a conversation) + */ + setSessionId(sessionId: string): void { + if (this.sessionId !== sessionId) { + logger.info('Restoring session ID', { oldSessionId: this.sessionId, newSessionId: sessionId }); + this.sessionId = sessionId; + } + } + async createFile(fileName: string, content: string, mimeType = 'text/plain'): Promise { const validation = this.validateFileName(fileName); if (!validation.valid) { diff --git a/front_end/panels/ai_chat/ui/AIChatPanel.ts b/front_end/panels/ai_chat/ui/AIChatPanel.ts index dc399d39af..c2311e360b 100644 --- a/front_end/panels/ai_chat/ui/AIChatPanel.ts +++ b/front_end/panels/ai_chat/ui/AIChatPanel.ts @@ -11,12 +11,10 @@ import {AgentService, Events as AgentEvents} from '../core/AgentService.js'; import { LLMClient } from '../LLM/LLMClient.js'; import { LLMConfigurationManager } from '../core/LLMConfigurationManager.js'; import { LLMProviderRegistry } from '../LLM/LLMProviderRegistry.js'; -import { OpenAIProvider } from '../LLM/OpenAIProvider.js'; -import { LiteLLMProvider } from '../LLM/LiteLLMProvider.js'; -import { GroqProvider } from '../LLM/GroqProvider.js'; -import { OpenRouterProvider } from '../LLM/OpenRouterProvider.js'; -import { BrowserOperatorProvider } from '../LLM/BrowserOperatorProvider.js'; import { createLogger } from '../core/Logger.js'; +import { CustomProviderManager } from '../core/CustomProviderManager.js'; +import type { LLMProvider } from '../LLM/LLMTypes.js'; +import type { ProviderType } from './settings/types.js'; import { isEvaluationEnabled, getEvaluationConfig } from '../common/EvaluationConfig.js'; import { EvaluationAgent } from '../evaluation/remote/EvaluationAgent.js'; import { BUILD_CONFIG } from '../core/BuildConfig.js'; @@ -90,13 +88,15 @@ import { MCPRegistry } from '../mcp/MCPRegistry.js'; import { getMCPConfig } from '../mcp/MCPConfig.js'; import { onMCPConfigChange } from '../mcp/MCPConfig.js'; import { MCPConnectorsCatalogDialog } from './mcp/MCPConnectorsCatalogDialog.js'; +// Conversation history +import { ConversationHistoryList } from './ConversationHistoryList.js'; // Model type definition export interface ModelOption { value: string; label: string; - type: 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator'; + type: string; // Supports standard providers and custom providers (e.g., 'custom:my-provider') } // Add model options constant - these are the default OpenAI models @@ -137,6 +137,21 @@ export const DEFAULT_PROVIDER_MODELS: Record option.value === modelName); - const originalProvider = (modelOption?.type as 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator') || 'openai'; - + const originalProvider = (modelOption?.type as LLMProvider) || 'openai'; + // Check if the model's original provider is available in the registry if (LLMProviderRegistry.hasProvider(originalProvider)) { return originalProvider; } - + // If the original provider isn't available, fall back to the currently selected provider const currentProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; logger.debug(`Provider ${originalProvider} not available for model ${modelName}, falling back to current provider: ${currentProvider}`); - return currentProvider as 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator'; + return currentProvider as LLMProvider; } /** @@ -369,7 +376,7 @@ export class AIChatPanel extends UI.Panel.Panel { * @param provider Optional provider to filter by * @returns Array of model options */ - static getModelOptions(provider?: 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator'): ModelOption[] { + static getModelOptions(provider?: ProviderType): ModelOption[] { // Try to get from all_model_options first (comprehensive list) const allModelOptionsStr = localStorage.getItem('ai_chat_all_model_options'); if (allModelOptionsStr) { @@ -435,112 +442,104 @@ export class AIChatPanel extends UI.Panel.Panel { type: 'litellm' as const })); - // Separate existing models by provider type - const existingOpenAIModels = existingAllModels.filter((m: ModelOption) => m.type === 'openai'); - const existingLiteLLMModels = existingAllModels.filter((m: ModelOption) => m.type === 'litellm'); - const existingGroqModels = existingAllModels.filter((m: ModelOption) => m.type === 'groq'); - const existingOpenRouterModels = existingAllModels.filter((m: ModelOption) => m.type === 'openrouter'); - const existingBrowserOperatorModels = existingAllModels.filter((m: ModelOption) => m.type === 'browseroperator'); - - // Update models based on what type of models we're adding - // Always use DEFAULT_OPENAI_MODELS for OpenAI to ensure we have the latest hardcoded list - let updatedOpenAIModels = DEFAULT_OPENAI_MODELS; - let updatedLiteLLMModels = existingLiteLLMModels; - let updatedGroqModels = existingGroqModels; - let updatedOpenRouterModels = existingOpenRouterModels; - let updatedBrowserOperatorModels = existingBrowserOperatorModels; - - // Replace models for the provider type we're updating + // Define standard provider types + const STANDARD_PROVIDER_TYPES: ProviderType[] = [ + 'openai', 'litellm', 'groq', 'openrouter', 'browseroperator', + 'cerebras', 'anthropic', 'googleai' + ]; + + // Get custom providers dynamically + const customProviders = CustomProviderManager.listEnabledProviders().map(p => p.id); + + // Combine standard and custom providers + const ALL_PROVIDER_TYPES = [...STANDARD_PROVIDER_TYPES, ...customProviders]; + + // Build a map of provider type -> models for generic handling + const modelsByProvider = new Map(); + + // Initialize with existing models for each provider + for (const providerType of ALL_PROVIDER_TYPES) { + const existingModels = existingAllModels.filter((m: ModelOption) => m.type === providerType); + modelsByProvider.set(providerType, existingModels); + } + + // Special case: OpenAI always uses DEFAULT_OPENAI_MODELS to ensure latest hardcoded list + modelsByProvider.set('openai', DEFAULT_OPENAI_MODELS); + + // Load models from custom providers + for (const customProviderId of customProviders) { + const customProvider = CustomProviderManager.getProvider(customProviderId); + if (customProvider && customProvider.models && customProvider.models.length > 0) { + const customProviderModels = customProvider.models.map(modelId => ({ + value: modelId, + label: `${customProvider.name}: ${modelId}`, + type: customProviderId as ProviderType + })); + modelsByProvider.set(customProviderId as ProviderType, customProviderModels); + } + } + + // Update models for the provider type we're adding (if any) if (providerModels.length > 0) { const firstModelType = providerModels[0].type; + if (firstModelType === 'litellm') { - updatedLiteLLMModels = [...customModels, ...providerModels]; - } else if (firstModelType === 'groq') { - updatedGroqModels = providerModels; - } else if (firstModelType === 'openrouter') { - updatedOpenRouterModels = providerModels; - } else if (firstModelType === 'openai') { - updatedOpenAIModels = providerModels; - } else if (firstModelType === 'browseroperator') { - updatedBrowserOperatorModels = providerModels; + // Special case: LiteLLM includes custom models + modelsByProvider.set('litellm', [...customModels, ...providerModels]); + } else { + // For all other providers, just replace with new models + modelsByProvider.set(firstModelType, providerModels); } } - - // Create the comprehensive model list with all models from all providers - const allModels = [ - ...updatedOpenAIModels, - ...updatedLiteLLMModels, - ...updatedGroqModels, - ...updatedOpenRouterModels, - ...updatedBrowserOperatorModels - ]; - - // Save the comprehensive list to localStorage - localStorage.setItem('ai_chat_all_model_options', JSON.stringify(allModels)); - - // For backwards compatibility, also update the MODEL_OPTIONS variable - // based on the currently selected provider - if (selectedProvider === 'openai') { - MODEL_OPTIONS = updatedOpenAIModels; - } else if (selectedProvider === 'groq') { - MODEL_OPTIONS = updatedGroqModels; - - // Add placeholder if no Groq models available - if (MODEL_OPTIONS.length === 0) { - MODEL_OPTIONS.push({ - value: MODEL_PLACEHOLDERS.NO_MODELS, - label: 'Groq: Please configure API key in settings', - type: 'groq' as const - }); - } - } else if (selectedProvider === 'openrouter') { - MODEL_OPTIONS = updatedOpenRouterModels; - // Add placeholder if no OpenRouter models available - if (MODEL_OPTIONS.length === 0) { - MODEL_OPTIONS.push({ - value: MODEL_PLACEHOLDERS.NO_MODELS, - label: 'OpenRouter: Please configure API key in settings', - type: 'openrouter' as const - }); - } - } else if (selectedProvider === 'browseroperator') { - MODEL_OPTIONS = updatedBrowserOperatorModels; + // Create comprehensive model list from all providers + const allModels: ModelOption[] = []; + for (const providerType of ALL_PROVIDER_TYPES) { + const models = modelsByProvider.get(providerType) || []; + allModels.push(...models); + } - // Add placeholder if no BrowserOperator models available - if (MODEL_OPTIONS.length === 0) { - MODEL_OPTIONS.push({ - value: MODEL_PLACEHOLDERS.NO_MODELS, - label: 'BrowserOperator: Models not loaded', - type: 'browseroperator' as const - }); - } - } else { - // For LiteLLM provider, include custom models and fetched models - MODEL_OPTIONS = updatedLiteLLMModels; + // Save comprehensive list to localStorage + localStorage.setItem('ai_chat_all_model_options', JSON.stringify(allModels)); - // Add placeholder if needed for LiteLLM when we have no models - if (hadWildcard && MODEL_OPTIONS.length === 0) { + // Set MODEL_OPTIONS based on currently selected provider + MODEL_OPTIONS = modelsByProvider.get(selectedProvider as ProviderType) || []; + + // Add placeholder if no models available for the selected provider + if (MODEL_OPTIONS.length === 0) { + // Special case for LiteLLM with wildcard + if (selectedProvider === 'litellm' && hadWildcard) { MODEL_OPTIONS.push({ value: MODEL_PLACEHOLDERS.ADD_CUSTOM, label: 'LiteLLM: Please add custom models in settings', type: 'litellm' as const }); + } else { + // Generic placeholder for all other providers + const providerLabel = selectedProvider.charAt(0).toUpperCase() + selectedProvider.slice(1); + MODEL_OPTIONS.push({ + value: MODEL_PLACEHOLDERS.NO_MODELS, + label: `${providerLabel}: Please configure in settings`, + type: selectedProvider as ProviderType + }); } } - + // Save MODEL_OPTIONS to localStorage for backwards compatibility localStorage.setItem('ai_chat_model_options', JSON.stringify(MODEL_OPTIONS)); - - logger.info('Updated model options:', { + + // Build log info dynamically for all providers + const logInfo: Record = { provider: selectedProvider, - openaiModels: updatedOpenAIModels.length, - litellmModels: updatedLiteLLMModels.length, - groqModels: updatedGroqModels.length, - openrouterModels: updatedOpenRouterModels.length, totalModelOptions: MODEL_OPTIONS.length, allModelsLength: allModels.length - }); + }; + for (const providerType of ALL_PROVIDER_TYPES) { + const models = modelsByProvider.get(providerType) || []; + logInfo[`${providerType}Models`] = models.length; + } + + logger.info('Updated model options:', logInfo); return allModels; } @@ -551,7 +550,10 @@ export class AIChatPanel extends UI.Panel.Panel { * @param modelType Type of the model ('openai' or 'litellm') * @returns Updated model options */ - static addCustomModelOption(modelName: string, modelType: 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator' = 'litellm'): ModelOption[] { + static addCustomModelOption(modelName: string, modelType?: ProviderType): ModelOption[] { + // Default to litellm if not specified + const finalModelType = modelType || 'litellm'; + // Get existing custom models const savedCustomModels = JSON.parse(localStorage.getItem('ai_chat_custom_models') || '[]'); @@ -568,11 +570,11 @@ export class AIChatPanel extends UI.Panel.Panel { // Create the model option object const newOption: ModelOption = { value: modelName, - label: modelType === 'litellm' ? `LiteLLM: ${modelName}` : - modelType === 'groq' ? `Groq: ${modelName}` : - modelType === 'openrouter' ? `OpenRouter: ${modelName}` : + label: finalModelType === 'litellm' ? `LiteLLM: ${modelName}` : + finalModelType === 'groq' ? `Groq: ${modelName}` : + finalModelType === 'openrouter' ? `OpenRouter: ${modelName}` : `OpenAI: ${modelName}`, - type: modelType + type: finalModelType }; // Get all existing model options @@ -654,7 +656,6 @@ export class AIChatPanel extends UI.Panel.Panel { #leftToolbar!: UI.Toolbar.Toolbar; #rightToolbar!: UI.Toolbar.Toolbar; #newChatButton!: UI.Toolbar.ToolbarButton; - #deleteButton!: UI.Toolbar.ToolbarButton; #bookmarkButton!: UI.Toolbar.ToolbarButton; #settingsMenuButton!: UI.Toolbar.ToolbarMenuButton; #closeButton!: UI.Toolbar.ToolbarButton; @@ -668,6 +669,8 @@ export class AIChatPanel extends UI.Panel.Panel { // Store bound event listeners to properly add/remove without duplications #boundOnMessagesChanged?: (e: Common.EventTarget.EventTargetEvent) => void; #boundOnAgentSessionStarted?: (e: Common.EventTarget.EventTargetEvent) => void; + #boundOnConversationSaved?: (e: Common.EventTarget.EventTargetEvent) => void; + #boundOnConversationChanged?: (e: Common.EventTarget.EventTargetEvent) => void; #boundOnAgentToolStarted?: (e: Common.EventTarget.EventTargetEvent<{ session: import('../agent_framework/AgentSessionTypes.js').AgentSession, toolCall: import('../agent_framework/AgentSessionTypes.js').AgentMessage }>) => void; #boundOnAgentToolCompleted?: (e: Common.EventTarget.EventTargetEvent<{ session: import('../agent_framework/AgentSessionTypes.js').AgentSession, toolResult: import('../agent_framework/AgentSessionTypes.js').AgentMessage }>) => void; #boundOnAgentSessionUpdated?: (e: Common.EventTarget.EventTargetEvent) => void; @@ -689,6 +692,8 @@ export class AIChatPanel extends UI.Panel.Panel { this.#boundOnAgentToolCompleted = this.#handleAgentToolCompleted.bind(this); this.#boundOnAgentSessionUpdated = this.#handleAgentSessionUpdated.bind(this); this.#boundOnChildAgentStarted = this.#handleChildAgentStarted.bind(this); + this.#boundOnConversationSaved = this.#handleConversationSaved.bind(this); + this.#boundOnConversationChanged = this.#handleConversationChanged.bind(this); this.#setupUI(); this.#setupInitialState(); @@ -740,18 +745,6 @@ export class AIChatPanel extends UI.Panel.Panel { this ); - this.#deleteButton = new UI.Toolbar.ToolbarButton( - i18nString(UIStrings.deleteChat), - 'bin', - undefined, - 'ai-chat.delete' - ); - this.#deleteButton.addEventListener( - UI.Toolbar.ToolbarButton.Events.CLICK, - this.#onDeleteClick, - this - ); - this.#bookmarkButton = new UI.Toolbar.ToolbarButton( i18nString(UIStrings.bookmarkPage), 'download', @@ -780,10 +773,6 @@ export class AIChatPanel extends UI.Panel.Panel { // Add buttons to toolbars ONCE (order matters for right toolbar) this.#leftToolbar.appendToolbarItem(this.#newChatButton); - - this.#rightToolbar.appendSeparator(); - this.#rightToolbar.appendToolbarItem(this.#deleteButton); - this.#rightToolbar.appendToolbarItem(this.#bookmarkButton); this.#rightToolbar.appendToolbarItem(this.#settingsMenuButton); this.#rightToolbar.appendToolbarItem(this.#closeButton); @@ -1408,6 +1397,8 @@ export class AIChatPanel extends UI.Panel.Panel { if (this.#boundOnAgentToolCompleted) this.#agentService.removeEventListener(AgentEvents.AGENT_TOOL_COMPLETED, this.#boundOnAgentToolCompleted); if (this.#boundOnAgentSessionUpdated) this.#agentService.removeEventListener(AgentEvents.AGENT_SESSION_UPDATED, this.#boundOnAgentSessionUpdated); if (this.#boundOnChildAgentStarted) this.#agentService.removeEventListener(AgentEvents.CHILD_AGENT_STARTED, this.#boundOnChildAgentStarted); + if (this.#boundOnConversationSaved) this.#agentService.removeEventListener(AgentEvents.CONVERSATION_SAVED, this.#boundOnConversationSaved); + if (this.#boundOnConversationChanged) this.#agentService.removeEventListener(AgentEvents.CONVERSATION_CHANGED, this.#boundOnConversationChanged); // Register for messages changed events if (this.#boundOnMessagesChanged) this.#agentService.addEventListener(AgentEvents.MESSAGES_CHANGED, this.#boundOnMessagesChanged); @@ -1416,6 +1407,8 @@ export class AIChatPanel extends UI.Panel.Panel { if (this.#boundOnAgentToolCompleted) this.#agentService.addEventListener(AgentEvents.AGENT_TOOL_COMPLETED, this.#boundOnAgentToolCompleted); if (this.#boundOnAgentSessionUpdated) this.#agentService.addEventListener(AgentEvents.AGENT_SESSION_UPDATED, this.#boundOnAgentSessionUpdated); if (this.#boundOnChildAgentStarted) this.#agentService.addEventListener(AgentEvents.CHILD_AGENT_STARTED, this.#boundOnChildAgentStarted); + if (this.#boundOnConversationSaved) this.#agentService.addEventListener(AgentEvents.CONVERSATION_SAVED, this.#boundOnConversationSaved); + if (this.#boundOnConversationChanged) this.#agentService.addEventListener(AgentEvents.CONVERSATION_CHANGED, this.#boundOnConversationChanged); // Initialize the agent service logger.info('Calling agentService.initialize()...'); @@ -1459,18 +1452,27 @@ export class AIChatPanel extends UI.Panel.Panel { * @returns true if at least one provider has valid credentials */ #hasAnyProviderCredentials(): boolean { - const selectedProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - // Check all providers except LiteLLM (unless LiteLLM is selected) - const providers = ['openai', 'groq', 'openrouter', 'browseroperator']; + // Define standard provider types + const STANDARD_PROVIDER_TYPES: ProviderType[] = [ + 'openai', 'litellm', 'groq', 'openrouter', 'browseroperator', + 'cerebras', 'anthropic', 'googleai' + ]; - // Only include LiteLLM if it's the selected provider - if (selectedProvider === 'litellm') { - providers.push('litellm'); - } + // Get custom providers dynamically + const customProviders = CustomProviderManager.listEnabledProviders().map(p => p.id); + + // Combine standard and custom providers + const ALL_PROVIDER_TYPES = [...STANDARD_PROVIDER_TYPES, ...customProviders]; - for (const provider of providers) { + // Check all providers except LiteLLM (unless LiteLLM is selected) + // LiteLLM is excluded by default because it requires endpoint configuration + const providersToCheck = ALL_PROVIDER_TYPES.filter(provider => + provider !== 'litellm' || selectedProvider === 'litellm' + ); + + for (const provider of providersToCheck) { const validation = LLMClient.validateProviderCredentials(provider); if (validation.isValid) { return true; @@ -1482,75 +1484,32 @@ export class AIChatPanel extends UI.Panel.Panel { /** * Checks if required credentials are available based on provider using provider-specific validation - * @param provider The selected provider ('openai', 'litellm', 'groq', 'openrouter') + * @param provider The selected provider ('openai', 'litellm', 'groq', 'openrouter', 'cerebras', 'anthropic', 'googleai', etc.) * @returns Object with canProceed flag and apiKey */ #checkCredentials(provider: string): {canProceed: boolean, apiKey: string | null} { logger.info('=== CHECKING CREDENTIALS FOR PROVIDER ==='); logger.info('Provider:', provider); logger.info('Timestamp:', new Date().toISOString()); - - // Use provider-specific validation - logger.info('Calling LLMClient.validateProviderCredentials()...'); - const validation = LLMClient.validateProviderCredentials(provider); - logger.info('Validation result:'); - logger.info('- Is valid:', validation.isValid); - logger.info('- Message:', validation.message); - logger.info('- Missing items:', validation.missingItems); - - let apiKey: string | null = null; - - if (validation.isValid) { - logger.info('Validation passed, retrieving API key...'); - - // Get the API key from the provider-specific storage - try { - // Create a temporary provider instance to get storage keys - let tempProvider; - switch (provider) { - case 'openai': - tempProvider = new OpenAIProvider(''); - break; - case 'litellm': - tempProvider = new LiteLLMProvider('', ''); - break; - case 'groq': - tempProvider = new GroqProvider(''); - break; - case 'openrouter': - tempProvider = new OpenRouterProvider(''); - break; - case 'browseroperator': - tempProvider = new BrowserOperatorProvider(null, ''); - break; - default: - logger.warn(`Unknown provider: ${provider}`); - return {canProceed: false, apiKey: null}; - } - - const storageKeys = tempProvider.getCredentialStorageKeys(); - logger.info('Storage keys for provider:'); - logger.info('- API key storage key:', storageKeys.apiKey); - - apiKey = localStorage.getItem(storageKeys.apiKey || '') || null; - logger.info('Retrieved API key:'); - logger.info('- Exists:', !!apiKey); - logger.info('- Length:', apiKey?.length || 0); - logger.info('- Prefix:', apiKey?.substring(0, 8) + '...' || 'none'); - - } catch (error) { - logger.error(`❌ Failed to get API key for ${provider}:`, error); - return {canProceed: false, apiKey: null}; - } - } else { - logger.warn('❌ Validation failed for provider:', provider); + + // Use centralized credential checking from LLMClient + logger.info('Calling LLMClient.getProviderCredentials()...'); + const result = LLMClient.getProviderCredentials(provider); + + logger.info('Credential check result:'); + logger.info('- Can proceed:', result.canProceed); + logger.info('- API key exists:', !!result.apiKey); + logger.info('- API key length:', result.apiKey?.length || 0); + if (result.endpoint) { + logger.info('- Endpoint:', result.endpoint); } - - const result = {canProceed: validation.isValid, apiKey}; + logger.info('=== CREDENTIAL CHECK COMPLETE ==='); - logger.info('Final result:', result); - - return result; + + return { + canProceed: result.canProceed, + apiKey: result.apiKey + }; } /** @@ -1749,6 +1708,34 @@ export class AIChatPanel extends UI.Panel.Panel { this.performUpdate(); } + /** + * Handle conversation saved event + */ + #handleConversationSaved(event: Common.EventTarget.EventTargetEvent): void { + const conversationId = event.data; + logger.debug('Conversation saved event', {conversationId}); + } + + /** + * Handle conversation changed event + */ + async #handleConversationChanged(event: Common.EventTarget.EventTargetEvent): Promise { + const conversationId = event.data; + logger.debug('Conversation changed event', {conversationId}); + + // Set the file storage session ID to the conversation ID + if (conversationId) { + const {FileStorageManager} = await import('../tools/FileStorageManager.js'); + FileStorageManager.getInstance().setSessionId(conversationId); + logger.info('Set file storage sessionId to conversationId', {conversationId}); + + // Refresh the file list to show files for this conversation + if (this.#chatView) { + await this.#chatView.refreshFileList(); + } + } + } + /** * Upsert an AGENT_SESSION message into the messages array by sessionId */ @@ -2005,7 +1992,6 @@ export class AIChatPanel extends UI.Panel.Panel { * Updates the UI components with the current state */ override performUpdate(): void { - this.#updateToolbar(); this.#updateSettingsButtonHighlight(); this.#updateChatViewState(); } @@ -2022,6 +2008,11 @@ export class AIChatPanel extends UI.Panel.Panel { () => this.#onSettingsClick(), {jslogContext: 'settings'} ); + contextMenu.defaultSection().appendItem( + i18nString(UIStrings.history), + () => void this.#onHistoryClick(), + {jslogContext: 'history'} + ); contextMenu.defaultSection().appendItem( 'Help', () => this.#onHelpClick(), @@ -2048,20 +2039,6 @@ export class AIChatPanel extends UI.Panel.Panel { return menuButton; } - /** - * Updates the toolbar UI - */ - #updateToolbar(): void { - // Update button visibility based on current state - // Delete button is only visible when there are messages to delete - this.#deleteButton.setVisible(this.#messages.length > 1); - - // Bookmark button visibility can be controlled here if needed - // this.#bookmarkButton.setVisible(someCondition); - - // All other buttons (New Chat, Settings Menu, Close) are always visible - } - /** * Updates the chat view with current state */ @@ -2205,22 +2182,105 @@ export class AIChatPanel extends UI.Panel.Panel { } } - #onNewChatClick(): void { + async #onNewChatClick(): Promise { this.#agentService.clearConversation(); this.#messages = this.#agentService.getMessages(); this.#isProcessing = false; this.#selectedAgentType = null; // Reset selected agent type - + + // Reset file storage session ID to default for new chat + const {FileStorageManager} = await import('../tools/FileStorageManager.js'); + FileStorageManager.getInstance().setSessionId('default'); + logger.info('Reset file storage sessionId to default for new chat'); + // Create new EvaluationAgent for new chat session this.#createEvaluationAgentIfNeeded(); - + this.performUpdate(); UI.ARIAUtils.LiveAnnouncer.alert(i18nString(UIStrings.newChatCreated)); } - #onDeleteClick(): void { - this.#onNewChatClick(); - UI.ARIAUtils.LiveAnnouncer.alert(i18nString(UIStrings.chatDeleted)); + /** + * Loads a conversation from history + */ + async #loadConversation(conversationId: string): Promise { + const success = await this.#agentService.loadConversation(conversationId); + + if (success) { + this.#messages = this.#agentService.getMessages(); + this.#selectedAgentType = this.#agentService.getState().selectedAgentType || null; + + // Set file storage session ID to the conversation ID + const {FileStorageManager} = await import('../tools/FileStorageManager.js'); + FileStorageManager.getInstance().setSessionId(conversationId); + logger.info('Set file storage sessionId to conversationId', {conversationId}); + + // Refresh the file list to show files for this conversation + if (this.#chatView) { + await this.#chatView.refreshFileList(); + } + + this.performUpdate(); + + logger.info('Conversation loaded from history', {conversationId}); + } else { + logger.error('Failed to load conversation', {conversationId}); + } + } + + /** + * Starts a new conversation + */ + async #startNewConversation(): Promise { + await this.#agentService.newConversation(); + this.#messages = this.#agentService.getMessages(); + this.#isProcessing = false; + this.#selectedAgentType = null; + this.#createEvaluationAgentIfNeeded(); + this.performUpdate(); + + logger.info('New conversation started from history dialog'); + } + + /** + * Deletes a conversation + */ + async #deleteConversation(conversationId: string): Promise { + const success = await this.#agentService.deleteConversation(conversationId); + + if (success) { + logger.info('Conversation deleted', {conversationId}); + } else { + logger.error('Failed to delete conversation', {conversationId}); + } + } + + + /** + * Handles history button click to show conversation history dialog + */ + async #onHistoryClick(): Promise { + const conversations = await this.#agentService.listConversations(); + const currentId = this.#agentService.getCurrentConversationId(); + + // Create dialog + const dialog = new UI.Dialog.Dialog(); + dialog.setDimmed(true); + dialog.contentElement.classList.add('conversation-history-dialog'); + + // Create the conversation history list component + const historyList = new ConversationHistoryList(); + historyList.conversations = conversations; + historyList.currentConversationId = currentId; + historyList.onConversationSelected = (id) => this.#loadConversation(id); + historyList.onDeleteConversation = (id) => this.#deleteConversation(id); + historyList.onClose = () => dialog.hide(); + + dialog.setOutsideClickCallback(() => dialog.hide()); + dialog.contentElement.appendChild(historyList); + dialog.show(); + + logger.info('Conversation history dialog opened'); } #onHelpClick(): void { @@ -2254,7 +2314,7 @@ export class AIChatPanel extends UI.Panel.Panel { await this.#handleSettingsChanged(); }, this.#fetchLiteLLMModels.bind(this), - AIChatPanel.updateModelOptions, + (providerModels, hadWildcard) => { AIChatPanel.updateModelOptions(providerModels, hadWildcard); }, AIChatPanel.getModelOptions, AIChatPanel.addCustomModelOption, AIChatPanel.removeCustomModelOption @@ -2487,8 +2547,6 @@ export class AIChatPanel extends UI.Panel.Panel { } catch (err) { logger.error('Failed to reinitialize MCP after settings change', err); } - // Update toolbar to reflect vector DB enabled state - this.#updateToolbar(); } /** diff --git a/front_end/panels/ai_chat/ui/ChatView.ts b/front_end/panels/ai_chat/ui/ChatView.ts index 53eed08f42..a06d980e13 100644 --- a/front_end/panels/ai_chat/ui/ChatView.ts +++ b/front_end/panels/ai_chat/ui/ChatView.ts @@ -282,6 +282,17 @@ export class ChatView extends HTMLElement { this.#structuredController.resetLastProcessed(); } + /** + * Refreshes the file list display + */ + async refreshFileList(): Promise { + const fileListDisplay = this.#shadow.querySelector('ai-file-list-display') as any; + if (fileListDisplay && typeof fileListDisplay.refresh === 'function') { + await fileListDisplay.refresh(); + logger.debug('FileListDisplay refreshed'); + } + } + // Lane-based routing: deprecated session-heuristics removed // Scroll behavior handled by diff --git a/front_end/panels/ai_chat/ui/ConversationHistoryList.ts b/front_end/panels/ai_chat/ui/ConversationHistoryList.ts new file mode 100644 index 0000000000..e50098ab0b --- /dev/null +++ b/front_end/panels/ai_chat/ui/ConversationHistoryList.ts @@ -0,0 +1,192 @@ +// 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 ComponentHelpers from '../../../ui/components/helpers/helpers.js'; +import * as Lit from '../../../ui/lit/lit.js'; +import {createLogger} from '../core/Logger.js'; +import type {ConversationMetadata} from '../persistence/ConversationTypes.js'; +import {getConversationHistoryStyles} from './conversationHistoryStyles.js'; + +const logger = createLogger('ConversationHistoryList'); + +const {html, nothing, Directives} = Lit; +const {unsafeHTML} = Directives; + +/** + * Component that displays conversation history + */ +export class ConversationHistoryList extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-conversation-history-list`; + readonly #shadow = this.attachShadow({mode: 'open'}); + readonly #boundRender = this.#render.bind(this); + + #conversations: ConversationMetadata[] = []; + #currentConversationId: string | null = null; + #onConversationSelected: ((id: string) => void) | null = null; + #onDeleteConversation: ((id: string) => void) | null = null; + #onClose: (() => void) | null = null; + + get conversations(): ConversationMetadata[] { + return this.#conversations; + } + + set conversations(value: ConversationMetadata[]) { + this.#conversations = value; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); + } + + get currentConversationId(): string | null { + return this.#currentConversationId; + } + + set currentConversationId(value: string | null) { + this.#currentConversationId = value; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); + } + + get onConversationSelected(): ((id: string) => void) | null { + return this.#onConversationSelected; + } + + set onConversationSelected(value: ((id: string) => void) | null) { + this.#onConversationSelected = value; + } + + get onDeleteConversation(): ((id: string) => void) | null { + return this.#onDeleteConversation; + } + + set onDeleteConversation(value: ((id: string) => void) | null) { + this.#onDeleteConversation = value; + } + + get onClose(): (() => void) | null { + return this.#onClose; + } + + set onClose(value: (() => void) | null) { + this.#onClose = value; + } + + connectedCallback(): void { + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); + } + + #handleClose(): void { + if (this.#onClose) { + this.#onClose(); + } + } + + #handleConversationSelected(id: string): void { + if (id !== this.#currentConversationId && this.#onConversationSelected) { + this.#onConversationSelected(id); + } + this.#handleClose(); + } + + #handleDeleteConversation(event: Event, conversation: ConversationMetadata): void { + event.stopPropagation(); + if (this.#onDeleteConversation) { + this.#onDeleteConversation(conversation.id); + } + this.#handleClose(); + } + + #formatDate(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'Just now'; + } else if (diffMins < 60) { + return `${diffMins}m ago`; + } else if (diffHours < 24) { + return `${diffHours}h ago`; + } else if (diffDays < 7) { + return `${diffDays}d ago`; + } else { + return date.toLocaleDateString(); + } + } + + #render(): void { + Lit.render( + html` + + +
+
+

Chat History

+ +
+ +
+ ${this.#conversations.length === 0 + ? html` +
+

No saved conversations yet

+

Start a new chat to begin

+
+ ` + : html` + ${this.#conversations.map( + conversation => html` +
this.#handleConversationSelected(conversation.id)} + > +
+
${conversation.title}
+ ${conversation.preview + ? html`
+ ${conversation.preview} +
` + : nothing} + +
+ +
+ `, + )} + `} +
+
+ `, + this.#shadow, + {host: this}, + ); + } +} + +customElements.define('ai-conversation-history-list', ConversationHistoryList); + +declare global { + interface HTMLElementTagNameMap { + 'ai-conversation-history-list': ConversationHistoryList; + } +} diff --git a/front_end/panels/ai_chat/ui/CustomProviderDialog.ts b/front_end/panels/ai_chat/ui/CustomProviderDialog.ts new file mode 100644 index 0000000000..4d63067eae --- /dev/null +++ b/front_end/panels/ai_chat/ui/CustomProviderDialog.ts @@ -0,0 +1,540 @@ +// 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 UI from '../../../ui/legacy/legacy.js'; +import { CustomProviderManager } from '../core/CustomProviderManager.js'; +import type { CustomProviderConfig } from '../core/CustomProviderManager.js'; +import { LLMClient } from '../LLM/LLMClient.js'; +import { createLogger } from '../core/Logger.js'; +import { PROVIDER_SELECTION_KEY } from './settings/constants.js'; + +const logger = createLogger('CustomProviderDialog'); + +/** + * Dialog for managing custom OpenAI-compatible providers + */ +export class CustomProviderDialog { + private dialog: UI.Dialog.Dialog | null = null; + private providers: CustomProviderConfig[] = []; + private onProvidersChanged?: () => void; + + constructor(onProvidersChanged?: () => void) { + this.onProvidersChanged = onProvidersChanged; + this.loadProviders(); + } + + /** + * Load providers from storage + */ + private loadProviders(): void { + this.providers = CustomProviderManager.listProviders(); + } + + /** + * Show the custom provider management dialog + */ + show(): void { + if (this.dialog) { + return; + } + + this.loadProviders(); + + // Create dialog + this.dialog = new UI.Dialog.Dialog(); + this.dialog.setSizeBehavior(UI.GlassPane.SizeBehavior.MEASURE_CONTENT); + this.dialog.setDimmed(true); + this.dialog.addCloseButton(); + + const container = document.createElement('div'); + container.style.cssText = 'min-width: 500px; max-width: 600px; padding: 20px;'; + + // Create header + const header = document.createElement('div'); + header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;'; + + const title = document.createElement('h2'); + title.textContent = 'Manage Custom Providers'; + title.style.cssText = 'margin: 0; font-size: 18px; font-weight: 500;'; + header.appendChild(title); + + const addButton = document.createElement('button'); + addButton.textContent = '+ Add Provider'; + addButton.className = 'devtools-button'; + addButton.style.cssText = 'padding: 6px 12px; cursor: pointer;'; + addButton.addEventListener('click', () => this.showAddEditDialog()); + header.appendChild(addButton); + + container.appendChild(header); + + // Create provider list + const listContainer = document.createElement('div'); + listContainer.style.cssText = 'max-height: 400px; overflow-y: auto;'; + + if (this.providers.length === 0) { + const emptyMessage = document.createElement('div'); + emptyMessage.textContent = 'No custom providers configured. Click "Add Provider" to add one.'; + emptyMessage.style.cssText = 'color: var(--sys-color-token-subtle); text-align: center; padding: 32px;'; + listContainer.appendChild(emptyMessage); + } else { + this.providers.forEach(provider => { + const providerItem = this.createProviderListItem(provider); + listContainer.appendChild(providerItem); + }); + } + + container.appendChild(listContainer); + + this.dialog.contentElement.appendChild(container); + this.dialog.setOutsideClickCallback(() => this.hide()); + this.dialog.show(); + } + + /** + * Create a list item for a provider + */ + private createProviderListItem(provider: CustomProviderConfig): HTMLElement { + const item = document.createElement('div'); + item.style.cssText = 'padding: 12px; margin-bottom: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; display: flex; justify-content: space-between; align-items: center;'; + + const info = document.createElement('div'); + info.style.cssText = 'flex: 1;'; + + const name = document.createElement('div'); + name.textContent = provider.name; + name.style.cssText = 'font-weight: 500; margin-bottom: 4px;'; + info.appendChild(name); + + const url = document.createElement('div'); + url.textContent = provider.baseURL; + url.style.cssText = 'font-size: 12px; color: var(--sys-color-token-subtle);'; + info.appendChild(url); + + const models = document.createElement('div'); + models.textContent = `Models: ${provider.models.length}`; + models.style.cssText = 'font-size: 11px; color: var(--sys-color-token-subtle); margin-top: 2px;'; + info.appendChild(models); + + item.appendChild(info); + + const actions = document.createElement('div'); + actions.style.cssText = 'display: flex; gap: 8px;'; + + const editButton = document.createElement('button'); + editButton.textContent = 'Edit'; + editButton.className = 'devtools-button'; + editButton.style.cssText = 'padding: 4px 8px; cursor: pointer;'; + editButton.addEventListener('click', () => this.showAddEditDialog(provider)); + actions.appendChild(editButton); + + const deleteButton = document.createElement('button'); + deleteButton.textContent = 'Delete'; + deleteButton.className = 'devtools-button'; + deleteButton.style.cssText = 'padding: 4px 8px; cursor: pointer; color: var(--sys-color-error);'; + deleteButton.addEventListener('click', () => this.deleteProvider(provider.id)); + actions.appendChild(deleteButton); + + item.appendChild(actions); + + return item; + } + + /** + * Show the add/edit provider dialog + */ + private showAddEditDialog(existingProvider?: CustomProviderConfig): void { + const addEditDialog = new UI.Dialog.Dialog(); + addEditDialog.setSizeBehavior(UI.GlassPane.SizeBehavior.MEASURE_CONTENT); + addEditDialog.setDimmed(true); + addEditDialog.addCloseButton(); + + const container = document.createElement('div'); + container.style.cssText = 'min-width: 450px; padding: 20px;'; + + // Title + const title = document.createElement('h2'); + title.textContent = existingProvider ? 'Edit Custom Provider' : 'Add Custom Provider'; + title.style.cssText = 'margin: 0 0 20px 0; font-size: 16px; font-weight: 500;'; + container.appendChild(title); + + // Form + const form = document.createElement('div'); + form.style.cssText = 'display: flex; flex-direction: column; gap: 16px;'; + + // Provider Name + const nameLabel = document.createElement('label'); + nameLabel.textContent = 'Provider Name'; + nameLabel.style.cssText = 'font-weight: 500; margin-bottom: 4px; display: block;'; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.value = existingProvider?.name || ''; + nameInput.placeholder = 'e.g., Z.AI'; + nameInput.style.cssText = 'padding: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; width: 100%;'; + nameInput.disabled = !!existingProvider; // Can't change name when editing + form.appendChild(nameLabel); + form.appendChild(nameInput); + + // Base URL + const urlLabel = document.createElement('label'); + urlLabel.textContent = 'Base URL'; + urlLabel.style.cssText = 'font-weight: 500; margin-bottom: 4px; display: block;'; + const urlInput = document.createElement('input'); + urlInput.type = 'text'; + urlInput.value = existingProvider?.baseURL || ''; + urlInput.placeholder = 'https://api.example.com/v1'; + urlInput.style.cssText = 'padding: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; width: 100%;'; + form.appendChild(urlLabel); + form.appendChild(urlInput); + + const urlHint = document.createElement('div'); + urlHint.textContent = 'The base URL for the OpenAI-compatible API (without /chat/completions)'; + urlHint.style.cssText = 'font-size: 12px; color: var(--sys-color-token-subtle); margin-top: -8px;'; + form.appendChild(urlHint); + + // API Key (optional) + const apiKeyLabel = document.createElement('label'); + apiKeyLabel.textContent = 'API Key (Optional)'; + apiKeyLabel.style.cssText = 'font-weight: 500; margin-bottom: 4px; display: block;'; + const apiKeyInput = document.createElement('input'); + apiKeyInput.type = 'password'; + apiKeyInput.value = existingProvider ? (CustomProviderManager.getApiKey(existingProvider.id) || '') : ''; + apiKeyInput.placeholder = 'Enter API key if required'; + apiKeyInput.style.cssText = 'padding: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; width: 100%;'; + form.appendChild(apiKeyLabel); + form.appendChild(apiKeyInput); + + // Test Connection Section + const testSection = document.createElement('div'); + testSection.style.cssText = 'padding: 12px; background: var(--sys-color-surface2); border-radius: 4px;'; + + const testButton = document.createElement('button'); + testButton.textContent = 'Test Connection & Fetch Models'; + testButton.className = 'devtools-button'; + testButton.style.cssText = 'padding: 8px 16px; cursor: pointer; width: 100%;'; + testSection.appendChild(testButton); + + const statusDiv = document.createElement('div'); + statusDiv.style.cssText = 'margin-top: 12px; padding: 8px; border-radius: 4px; display: none;'; + testSection.appendChild(statusDiv); + + const modelsDiv = document.createElement('div'); + modelsDiv.style.cssText = 'margin-top: 12px; display: none;'; + testSection.appendChild(modelsDiv); + + form.appendChild(testSection); + + container.appendChild(form); + + // Models Management Section + const modelsSection = document.createElement('div'); + modelsSection.style.cssText = 'margin-top: 16px; padding: 12px; background: var(--sys-color-surface2); border-radius: 4px;'; + + const modelsTitle = document.createElement('div'); + modelsTitle.textContent = 'Available Models'; + modelsTitle.style.cssText = 'font-weight: 500; margin-bottom: 8px;'; + modelsSection.appendChild(modelsTitle); + + const modelsListContainer = document.createElement('div'); + modelsListContainer.style.cssText = 'max-height: 200px; overflow-y: auto; margin-bottom: 12px; padding: 8px; background: var(--sys-color-surface1); border-radius: 4px; min-height: 40px;'; + modelsSection.appendChild(modelsListContainer); + + // Add Model Section + const addModelContainer = document.createElement('div'); + addModelContainer.style.cssText = 'display: flex; gap: 8px; align-items: center;'; + + const addModelLabel = document.createElement('label'); + addModelLabel.textContent = 'Add Model:'; + addModelLabel.style.cssText = 'font-weight: 500; min-width: 80px;'; + addModelContainer.appendChild(addModelLabel); + + const modelNameInput = document.createElement('input'); + modelNameInput.type = 'text'; + modelNameInput.placeholder = 'Enter model name'; + modelNameInput.style.cssText = 'flex: 1; padding: 6px; border: 1px solid var(--sys-color-divider); border-radius: 4px;'; + addModelContainer.appendChild(modelNameInput); + + const addModelButton = document.createElement('button'); + addModelButton.textContent = 'Add'; + addModelButton.className = 'devtools-button'; + addModelButton.style.cssText = 'padding: 6px 16px; cursor: pointer;'; + addModelContainer.appendChild(addModelButton); + + modelsSection.appendChild(addModelContainer); + container.appendChild(modelsSection); + + // Save button (disabled until at least one model exists) + const saveButton = document.createElement('button'); + saveButton.textContent = existingProvider ? 'Update Provider' : 'Add Provider'; + saveButton.className = 'devtools-button'; + saveButton.style.cssText = 'padding: 10px 20px; cursor: pointer; margin-top: 20px; width: 100%;'; + saveButton.disabled = !existingProvider; // Disabled for new providers until models exist + container.appendChild(saveButton); + + // Track all models (fetched + manually added) + let allModels: string[] = existingProvider ? [...existingProvider.models] : []; + + // Helper function to render models list + const renderModelsList = (): void => { + modelsListContainer.innerHTML = ''; + + if (allModels.length === 0) { + const emptyMessage = document.createElement('div'); + emptyMessage.textContent = 'No models added yet. Test connection to fetch models or add manually.'; + emptyMessage.style.cssText = 'color: var(--sys-color-token-subtle); font-size: 12px; padding: 8px; text-align: center;'; + modelsListContainer.appendChild(emptyMessage); + saveButton.disabled = true; + } else { + allModels.forEach(modelName => { + const modelItem = document.createElement('div'); + modelItem.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; margin-bottom: 4px; background: var(--sys-color-surface2); border-radius: 3px;'; + + const modelLabel = document.createElement('span'); + modelLabel.textContent = modelName; + modelLabel.style.cssText = 'font-family: monospace; font-size: 12px; flex: 1;'; + modelItem.appendChild(modelLabel); + + const removeButton = document.createElement('button'); + removeButton.textContent = '×'; + removeButton.className = 'devtools-button'; + removeButton.style.cssText = 'padding: 2px 8px; cursor: pointer; color: var(--sys-color-error); font-size: 16px; line-height: 1;'; + removeButton.title = 'Remove model'; + removeButton.addEventListener('click', () => { + allModels = allModels.filter(m => m !== modelName); + renderModelsList(); + }); + modelItem.appendChild(removeButton); + + modelsListContainer.appendChild(modelItem); + }); + saveButton.disabled = false; + } + }; + + // Initial render + renderModelsList(); + + // Test connection handler + testButton.addEventListener('click', async () => { + const name = nameInput.value.trim(); + const baseURL = urlInput.value.trim(); + const apiKey = apiKeyInput.value.trim(); + + if (!name) { + this.showStatus(statusDiv, 'Please enter a provider name', false); + return; + } + + if (!baseURL) { + this.showStatus(statusDiv, 'Please enter a base URL', false); + return; + } + + testButton.disabled = true; + testButton.textContent = 'Testing...'; + this.showStatus(statusDiv, 'Testing connection...', null); + + try { + const result = await LLMClient.testCustomProviderConnection(name, baseURL, apiKey || undefined); + + if (result.success && result.models && result.models.length > 0) { + // Merge fetched models with existing models (avoid duplicates) + const newModels = result.models.filter(m => !allModels.includes(m)); + allModels = [...allModels, ...newModels]; + renderModelsList(); + + this.showStatus(statusDiv, `✓ Connection successful! Found ${result.models.length} models${newModels.length < result.models.length ? ' (' + (result.models.length - newModels.length) + ' duplicates skipped)' : ''}.`, true); + this.showModels(modelsDiv, result.models); + } else if (result.success) { + this.showStatus(statusDiv, `✓ Connection successful but no models found. You can add models manually below.`, true); + } else { + this.showStatus(statusDiv, `✗ ${result.message}`, false); + } + } catch (error) { + this.showStatus(statusDiv, `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`, false); + } finally { + testButton.disabled = false; + testButton.textContent = 'Test Connection & Fetch Models'; + } + }); + + // Add Model button handler + addModelButton.addEventListener('click', () => { + const modelName = modelNameInput.value.trim(); + + if (!modelName) { + this.showStatus(statusDiv, 'Please enter a model name', false); + return; + } + + if (allModels.includes(modelName)) { + this.showStatus(statusDiv, `Model "${modelName}" already exists`, false); + return; + } + + allModels.push(modelName); + renderModelsList(); + modelNameInput.value = ''; + this.showStatus(statusDiv, `✓ Added model: ${modelName}`, true); + }); + + // Allow Enter key to add model + modelNameInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + addModelButton.click(); + } + }); + + // Save button handler + saveButton.addEventListener('click', () => { + const name = nameInput.value.trim(); + const baseURL = urlInput.value.trim(); + const apiKey = apiKeyInput.value.trim(); + + if (!name || !baseURL) { + this.showStatus(statusDiv, 'Please fill in all required fields', false); + return; + } + + if (allModels.length === 0) { + this.showStatus(statusDiv, 'Please add at least one model (test connection to fetch or add manually)', false); + return; + } + + try { + if (existingProvider) { + // Update existing provider + CustomProviderManager.updateProvider(existingProvider.id, { + baseURL, + models: allModels, + enabled: true + }); + + // Update API key if changed + if (apiKey) { + CustomProviderManager.setApiKey(existingProvider.id, apiKey); + } + + logger.info(`Updated custom provider: ${existingProvider.name}`); + } else { + // Add new provider + const newProvider = CustomProviderManager.addProvider({ + name, + baseURL, + models: allModels, + enabled: true + }); + + // Save API key if provided + if (apiKey) { + CustomProviderManager.setApiKey(newProvider.id, apiKey); + } + + logger.info(`Added custom provider: ${name}`); + } + + // Notify listeners + if (this.onProvidersChanged) { + this.onProvidersChanged(); + } + + // Close dialogs + addEditDialog.hide(); + this.hide(); + } catch (error) { + this.showStatus(statusDiv, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, false); + } + }); + + addEditDialog.contentElement.appendChild(container); + addEditDialog.setOutsideClickCallback(() => addEditDialog.hide()); + addEditDialog.show(); + } + + /** + * Show status message + */ + private showStatus(element: HTMLElement, message: string, success: boolean | null): void { + element.style.display = 'block'; + element.textContent = message; + + if (success === true) { + element.style.background = 'var(--sys-color-green-container)'; + element.style.color = 'var(--sys-color-on-green-container)'; + } else if (success === false) { + element.style.background = 'var(--sys-color-error-container)'; + element.style.color = 'var(--sys-color-on-error-container)'; + } else { + element.style.background = 'var(--sys-color-neutral-container)'; + element.style.color = 'var(--sys-color-on-surface)'; + } + } + + /** + * Show fetched models + */ + private showModels(element: HTMLElement, models: string[]): void { + element.style.display = 'block'; + element.innerHTML = ''; + + const title = document.createElement('div'); + title.textContent = 'Available Models:'; + title.style.cssText = 'font-weight: 500; margin-bottom: 8px;'; + element.appendChild(title); + + const modelList = document.createElement('div'); + modelList.style.cssText = 'max-height: 150px; overflow-y: auto; padding: 8px; background: var(--sys-color-surface1); border-radius: 4px;'; + + models.forEach(model => { + const modelItem = document.createElement('div'); + modelItem.textContent = `• ${model}`; + modelItem.style.cssText = 'padding: 4px 0; font-size: 12px; font-family: monospace;'; + modelList.appendChild(modelItem); + }); + + element.appendChild(modelList); + } + + /** + * Delete a provider + */ + private async deleteProvider(providerId: string): Promise { + const provider = CustomProviderManager.getProvider(providerId); + if (!provider) { + return; + } + + // Simple confirmation + if (confirm(`Are you sure you want to delete the provider "${provider.name}"?`)) { + CustomProviderManager.deleteProvider(providerId); + logger.info(`Deleted custom provider: ${provider.name}`); + + // Check if the deleted provider is currently selected + const currentProvider = localStorage.getItem(PROVIDER_SELECTION_KEY); + if (currentProvider === providerId) { + // Switch to default provider (openai) + localStorage.setItem(PROVIDER_SELECTION_KEY, 'openai'); + logger.info(`Switched from deleted provider ${providerId} to openai`); + } + + // Notify listeners + if (this.onProvidersChanged) { + this.onProvidersChanged(); + } + + // Refresh the list + this.hide(); + this.show(); + } + } + + /** + * Hide the dialog + */ + hide(): void { + if (this.dialog) { + this.dialog.hide(); + this.dialog = null; + } + } +} diff --git a/front_end/panels/ai_chat/ui/SettingsDialog.ts b/front_end/panels/ai_chat/ui/SettingsDialog.ts index 90530ca669..d593bcb1ff 100644 --- a/front_end/panels/ai_chat/ui/SettingsDialog.ts +++ b/front_end/panels/ai_chat/ui/SettingsDialog.ts @@ -5,6 +5,8 @@ import * as UI from '../../../ui/legacy/legacy.js'; import { createLogger } from '../core/Logger.js'; import { LLMClient } from '../LLM/LLMClient.js'; +import { CustomProviderDialog } from './CustomProviderDialog.js'; +import { CustomProviderManager } from '../core/CustomProviderManager.js'; // Import settings utilities import { i18nString, UIStrings } from './settings/i18n-strings.js'; @@ -17,11 +19,12 @@ import type { ModelOption, ProviderType, FetchLiteLLMModelsFunction, UpdateModel export { isVectorDBEnabled }; // Import provider settings classes -import { OpenAISettings } from './settings/providers/OpenAISettings.js'; +import { GenericProviderSettings } from './settings/providers/GenericProviderSettings.js'; import { LiteLLMSettings } from './settings/providers/LiteLLMSettings.js'; -import { GroqSettings } from './settings/providers/GroqSettings.js'; import { OpenRouterSettings } from './settings/providers/OpenRouterSettings.js'; -import { BrowserOperatorSettings } from './settings/providers/BrowserOperatorSettings.js'; + +// Import provider configurations +import { OpenAIConfig, BrowserOperatorConfig, GroqConfig, CerebrasConfig, AnthropicConfig, GoogleAIConfig } from './settings/providerConfigs.js'; // Import advanced feature settings classes import { MCPSettings } from './settings/advanced/MCPSettings.js'; @@ -34,6 +37,25 @@ import './model_selector/ModelSelector.js'; const logger = createLogger('SettingsDialog'); +// Provider configuration registry +interface ProviderConfig { + id: ProviderType; + i18nKey: keyof typeof UIStrings; + config?: any; + settingsClass?: 'generic' | 'litellm' | 'openrouter'; +} + +const PROVIDER_REGISTRY: ProviderConfig[] = [ + { id: 'openai', i18nKey: 'openaiProvider', config: OpenAIConfig, settingsClass: 'generic' }, + { id: 'litellm', i18nKey: 'litellmProvider', settingsClass: 'litellm' }, + { id: 'groq', i18nKey: 'groqProvider', config: GroqConfig, settingsClass: 'generic' }, + { id: 'openrouter', i18nKey: 'openrouterProvider', settingsClass: 'openrouter' }, + { id: 'browseroperator', i18nKey: 'browseroperatorProvider', config: BrowserOperatorConfig, settingsClass: 'generic' }, + { id: 'cerebras', i18nKey: 'cerebrasProvider', config: CerebrasConfig, settingsClass: 'generic' }, + { id: 'anthropic', i18nKey: 'anthropicProvider', config: AnthropicConfig, settingsClass: 'generic' }, + { id: 'googleai', i18nKey: 'googleaiProvider', config: GoogleAIConfig, settingsClass: 'generic' }, +]; + export class SettingsDialog { static async show( selectedModel: string, @@ -94,141 +116,267 @@ export class SettingsDialog { // Use the stored provider from localStorage const currentProvider = (localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai') as ProviderType; + // Helper function to create ProviderConfig from CustomProviderConfig + const createCustomProviderConfig = (customConfig: any): any => { + return { + id: customConfig.id, + displayName: customConfig.name, + apiKeyStorageKey: CustomProviderManager.getApiKeyStorageKey(customConfig.id), + apiKeyLabel: `${customConfig.name} API Key`, + apiKeyHint: `API key for ${customConfig.name} (optional)`, + apiKeyPlaceholder: 'Enter API key (optional)', + hasModelSelectors: true, + hasFetchButton: false, + apiKeyOptional: true + }; + }; + + // Helper function to create model options getter for custom providers + const createCustomModelOptionsGetter = (providerId: string) => { + return (_provider?: ProviderType) => { + const config = CustomProviderManager.getProvider(providerId); + if (!config) return []; + return config.models.map(modelId => ({ + value: modelId, + label: modelId, + type: providerId + })); + }; + }; + + // Helper function to initialize custom provider settings + const initializeCustomProviderSettings = ( + providerId: string, + container: HTMLElement + ): GenericProviderSettings | null => { + const customConfig = CustomProviderManager.getProvider(providerId); + if (!customConfig) return null; + + const providerConfig = createCustomProviderConfig(customConfig); + const settings = new GenericProviderSettings( + container, + providerConfig, + createCustomModelOptionsGetter(providerId), + addCustomModelOption, + removeCustomModelOption + ); + settings.render(); + return settings; + }; + + // Helper function to toggle provider content visibility + const toggleProviderVisibility = ( + selectedProvider: string, + providerContents: Map, + customContent: HTMLElement + ): void => { + const isCustom = CustomProviderManager.isCustomProvider(selectedProvider); + + // Hide all provider contents + providerContents.forEach((content, providerId) => { + content.style.display = providerId === selectedProvider ? 'block' : 'none'; + }); + + // Show/hide custom provider content + customContent.style.display = isCustom ? 'block' : 'none'; + }; + // 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.selected = currentProvider === 'openai'; - providerSelect.appendChild(openaiOption); - - const litellmOption = document.createElement('option'); - litellmOption.value = 'litellm'; - litellmOption.textContent = i18nString(UIStrings.litellmProvider); - litellmOption.selected = currentProvider === 'litellm'; - providerSelect.appendChild(litellmOption); - - const groqOption = document.createElement('option'); - groqOption.value = 'groq'; - groqOption.textContent = i18nString(UIStrings.groqProvider); - groqOption.selected = currentProvider === 'groq'; - providerSelect.appendChild(groqOption); - - const openrouterOption = document.createElement('option'); - openrouterOption.value = 'openrouter'; - openrouterOption.textContent = i18nString(UIStrings.openrouterProvider); - openrouterOption.selected = currentProvider === 'openrouter'; - providerSelect.appendChild(openrouterOption); - - const browseroperatorOption = document.createElement('option'); - browseroperatorOption.value = 'browseroperator'; - browseroperatorOption.textContent = i18nString(UIStrings.browseroperatorProvider); - browseroperatorOption.selected = currentProvider === 'browseroperator'; - providerSelect.appendChild(browseroperatorOption); + // Add provider options from registry + PROVIDER_REGISTRY.forEach(provider => { + const option = document.createElement('option'); + option.value = provider.id; + option.textContent = i18nString(UIStrings[provider.i18nKey]); + option.selected = currentProvider === provider.id; + providerSelect.appendChild(option); + }); + + // Add custom providers to the dropdown + const customProviders = CustomProviderManager.listEnabledProviders(); + if (customProviders.length > 0) { + // Add separator + const separator = document.createElement('option'); + separator.disabled = true; + separator.textContent = '──────────'; + providerSelect.appendChild(separator); + + // Add each custom provider + customProviders.forEach(provider => { + const customOption = document.createElement('option'); + customOption.value = provider.id; + customOption.textContent = `${provider.name} (Custom)`; + customOption.selected = currentProvider === provider.id; + providerSelect.appendChild(customOption); + }); + } // 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); - - const browseroperatorContent = document.createElement('div'); - browseroperatorContent.className = 'provider-content browseroperator-content'; - browseroperatorContent.style.display = currentProvider === 'browseroperator' ? 'block' : 'none'; - contentDiv.appendChild(browseroperatorContent); - - // Instantiate provider settings classes - const openaiSettings = new OpenAISettings( - openaiContent, - getModelOptions, - addCustomModelOption, - removeCustomModelOption - ); + // Add "Manage Custom Providers" button + const manageCustomButton = document.createElement('button'); + manageCustomButton.className = 'settings-button manage-custom-providers-button'; + manageCustomButton.textContent = i18nString(UIStrings.manageCustomProvidersButton); + manageCustomButton.style.cssText = 'margin-top: 8px; padding: 6px 12px; cursor: pointer;'; + manageCustomButton.addEventListener('click', () => { + // Create and show custom provider dialog + const customProviderDialog = new CustomProviderDialog(() => { + // Refresh the settings dialog to show updated custom providers + dialog.hide(); + SettingsDialog.show( + selectedModel, + miniModel, + nanoModel, + onSettingsSaved, + fetchLiteLLMModels, + updateModelOptions, + getModelOptions, + addCustomModelOption, + removeCustomModelOption + ); + }); + customProviderDialog.show(); + }); + providerSection.appendChild(manageCustomButton); - const litellmSettings = new LiteLLMSettings( - litellmContent, - getModelOptions, - addCustomModelOption, - removeCustomModelOption, - updateModelOptions, - fetchLiteLLMModels - ); + // Create provider-specific content containers using registry + const providerContents = new Map(); - const groqSettings = new GroqSettings( - groqContent, - getModelOptions, - addCustomModelOption, - removeCustomModelOption, - updateModelOptions - ); + PROVIDER_REGISTRY.forEach(provider => { + const content = document.createElement('div'); + content.className = `provider-content ${provider.id}-content`; + content.style.display = currentProvider === provider.id ? 'block' : 'none'; + contentDiv.appendChild(content); + providerContents.set(provider.id, content); + }); - const openrouterSettings = new OpenRouterSettings( - openrouterContent, - getModelOptions, - addCustomModelOption, - removeCustomModelOption, - updateModelOptions, - onSettingsSaved, - () => dialog.hide() - ); + // Create custom provider content container + const customProviderContent = document.createElement('div'); + customProviderContent.className = 'provider-content custom-provider-content'; + customProviderContent.style.display = CustomProviderManager.isCustomProvider(currentProvider) ? 'block' : 'none'; + contentDiv.appendChild(customProviderContent); + + // Variable to hold current custom provider settings instance + let customProviderSettings: GenericProviderSettings | null = null; + + // Instantiate provider settings classes using registry + const providerSettings = new Map(); + + PROVIDER_REGISTRY.forEach(provider => { + const content = providerContents.get(provider.id); + if (!content) return; + + let settings; + if (provider.settingsClass === 'litellm') { + settings = new LiteLLMSettings( + content, + getModelOptions, + addCustomModelOption, + removeCustomModelOption, + updateModelOptions, + fetchLiteLLMModels + ); + } else if (provider.settingsClass === 'openrouter') { + settings = new OpenRouterSettings( + content, + getModelOptions, + addCustomModelOption, + removeCustomModelOption, + updateModelOptions, + onSettingsSaved, + () => dialog.hide() + ); + } else { + // Generic provider settings + const hasUpdateModelOptions = ['groq', 'cerebras', 'anthropic', 'googleai'].includes(provider.id); + settings = new GenericProviderSettings( + content, + provider.config!, + getModelOptions, + addCustomModelOption, + removeCustomModelOption, + hasUpdateModelOptions ? updateModelOptions : undefined + ); + } - const browseroperatorSettings = new BrowserOperatorSettings( - browseroperatorContent, - getModelOptions, - addCustomModelOption, - removeCustomModelOption - ); + settings.render(); + providerSettings.set(provider.id, settings); + }); - // Render all providers (only visible one will be shown) - openaiSettings.render(); - litellmSettings.render(); - groqSettings.render(); - openrouterSettings.render(); - browseroperatorSettings.render(); - - // Store provider settings for later access - const providerSettings = new Map([ - ['openai', openaiSettings], - ['litellm', litellmSettings], - ['groq', groqSettings], - ['openrouter', openrouterSettings], - ['browseroperator', browseroperatorSettings], - ]); + // Initialize custom provider settings if current provider is custom + if (CustomProviderManager.isCustomProvider(currentProvider)) { + customProviderSettings = initializeCustomProviderSettings(currentProvider, customProviderContent); + } + + // Provider auto-fetch configuration for generic handling + interface ProviderAutoFetchConfig { + fetchMethod: (apiKey: string) => Promise; + storageKey: string; + hasNameField: boolean; + cacheConfig?: { + cacheKey: string; + timestampKey: string; + }; + } + + const providerAutoFetchMap: Record = { + groq: { + fetchMethod: LLMClient.fetchGroqModels, + storageKey: 'ai_chat_groq_api_key', + hasNameField: false + }, + openrouter: { + fetchMethod: LLMClient.fetchOpenRouterModels, + storageKey: 'ai_chat_openrouter_api_key', + hasNameField: true, + cacheConfig: { + cacheKey: 'openrouter_models_cache', + timestampKey: 'openrouter_models_cache_timestamp' + } + }, + cerebras: { + fetchMethod: LLMClient.fetchCerebrasModels, + storageKey: 'ai_chat_cerebras_api_key', + hasNameField: false + }, + anthropic: { + fetchMethod: LLMClient.fetchAnthropicModels, + storageKey: 'ai_chat_anthropic_api_key', + hasNameField: true + }, + googleai: { + fetchMethod: LLMClient.fetchGoogleAIModels, + storageKey: 'ai_chat_googleai_api_key', + hasNameField: true + } + }; // Event listener for provider change providerSelect.addEventListener('change', async () => { 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'; - browseroperatorContent.style.display = selectedProvider === 'browseroperator' ? 'block' : 'none'; + // Check if it's a custom provider + const isCustom = CustomProviderManager.isCustomProvider(selectedProvider); - // If switching to LiteLLM, fetch the latest models if endpoint is configured + // Toggle visibility using helper function + toggleProviderVisibility(selectedProvider, providerContents, customProviderContent); + + // Handle custom provider + if (isCustom) { + // Cleanup existing custom provider settings if any + if (customProviderSettings) { + customProviderSettings.cleanup(); + } + + // Initialize new custom provider settings + customProviderSettings = initializeCustomProviderSettings(selectedProvider, customProviderContent); + } + + // Handle LiteLLM separately (special case with endpoint and hadWildcard) if (selectedProvider === 'litellm') { const endpoint = localStorage.getItem('ai_chat_litellm_endpoint'); const liteLLMApiKey = localStorage.getItem('ai_chat_litellm_api_key') || ''; @@ -238,53 +386,45 @@ export class SettingsDialog { logger.debug('Fetching LiteLLM models after provider change...'); const { models: litellmModels, hadWildcard } = await fetchLiteLLMModels(liteLLMApiKey, endpoint); updateModelOptions(litellmModels, hadWildcard); - litellmSettings.updateModelSelectors(); + const litellmSettings = providerSettings.get('litellm'); + if (litellmSettings) { + litellmSettings.updateModelSelectors(); + } logger.debug('Successfully refreshed LiteLLM models after provider change'); } catch (error) { logger.error('Failed to fetch LiteLLM models after provider change:', error); } } - } else if (selectedProvider === 'groq') { - // If switching to Groq, fetch models if API key is configured - const groqApiKey = localStorage.getItem('ai_chat_groq_api_key') || ''; + } + // Generic handler for other providers + else if (providerAutoFetchMap[selectedProvider]) { + const config = providerAutoFetchMap[selectedProvider]; + const apiKey = localStorage.getItem(config.storageKey) || ''; - if (groqApiKey) { + if (apiKey) { try { - logger.debug('Fetching Groq models after provider change...'); - const groqModels = await LLMClient.fetchGroqModels(groqApiKey); - const modelOptions: ModelOption[] = groqModels.map(model => ({ + logger.debug(`Fetching ${selectedProvider} models after provider change...`); + const models = await config.fetchMethod(apiKey); + const modelOptions: ModelOption[] = models.map(model => ({ value: model.id, - label: model.id, - type: 'groq' as const + label: config.hasNameField ? (model.name || model.id) : model.id, + type: selectedProvider as any })); 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); - } - } - } else if (selectedProvider === 'openrouter') { - // If switching to OpenRouter, fetch models if API key is configured - const openrouterApiKey = localStorage.getItem('ai_chat_openrouter_api_key') || ''; - if (openrouterApiKey) { - try { - logger.debug('Fetching OpenRouter models after provider change...'); - const openrouterModels = await LLMClient.fetchOpenRouterModels(openrouterApiKey); - const modelOptions: ModelOption[] = openrouterModels.map(model => ({ - value: model.id, - label: model.name || model.id, - type: 'openrouter' as const - })); - updateModelOptions(modelOptions, false); - // 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'); + // Handle optional caching (OpenRouter) + if (config.cacheConfig) { + localStorage.setItem(config.cacheConfig.cacheKey, JSON.stringify(modelOptions)); + localStorage.setItem(config.cacheConfig.timestampKey, Date.now().toString()); + } + + const settings = providerSettings.get(selectedProvider); + if (settings) { + settings.updateModelSelectors(); + } + logger.debug(`Successfully refreshed ${selectedProvider} models after provider change`); } catch (error) { - logger.error('Failed to fetch OpenRouter models after provider change:', error); + logger.error(`Failed to fetch ${selectedProvider} models after provider change:`, error); } } } @@ -450,14 +590,20 @@ export class SettingsDialog { localStorage.setItem(PROVIDER_SELECTION_KEY, selectedProvider); // Save all provider settings - openaiSettings.save(); - litellmSettings.save(); - groqSettings.save(); - openrouterSettings.save(); - browseroperatorSettings.save(); - - // Save mini/nano model selections from current provider - const currentProviderSettings = providerSettings.get(selectedProvider as ProviderType); + providerSettings.forEach(settings => { + settings.save(); + }); + + // Save custom provider settings if active + if (customProviderSettings) { + customProviderSettings.save(); + } + + // Get current provider settings (either standard or custom) + let currentProviderSettings = CustomProviderManager.isCustomProvider(selectedProvider) + ? customProviderSettings + : providerSettings.get(selectedProvider as ProviderType); + if (currentProviderSettings) { const miniModelValue = currentProviderSettings.getMiniModel(); const nanoModelValue = currentProviderSettings.getNanoModel(); diff --git a/front_end/panels/ai_chat/ui/chatView.css b/front_end/panels/ai_chat/ui/chatView.css index f61eff8ab2..3f1701d4e5 100644 --- a/front_end/panels/ai_chat/ui/chatView.css +++ b/front_end/panels/ai_chat/ui/chatView.css @@ -1647,6 +1647,27 @@ details[open] .tool-details-content { max-height: none !important; } +/* Conversation history dialog styles */ +.conversation-history-dialog { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + max-width: none !important; + max-height: none !important; + margin: 0 !important; + border-radius: 0 !important; + box-shadow: none !important; +} + +.conversation-history-dialog .widget { + width: 100% !important; + height: 100% !important; + max-width: none !important; + max-height: none !important; +} + /* Deep research actions styling */ .deep-research-actions { margin-top: 12px; diff --git a/front_end/panels/ai_chat/ui/conversationHistoryStyles.ts b/front_end/panels/ai_chat/ui/conversationHistoryStyles.ts new file mode 100644 index 0000000000..ba43f26edd --- /dev/null +++ b/front_end/panels/ai_chat/ui/conversationHistoryStyles.ts @@ -0,0 +1,171 @@ +// 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 conversation history dialog + */ +export function getConversationHistoryStyles(): string { + return ` + :host { + display: block; + width: 100%; + height: 100%; + } + + .history-content { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + } + + .history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--sys-color-divider); + } + + .history-title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--color-text-primary); + } + + .history-close-button { + width: 32px; + height: 32px; + padding: 0; + background: none; + border: none; + cursor: pointer; + font-size: 24px; + color: var(--color-text-secondary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; + } + + .history-close-button:hover { + background-color: var(--color-background-elevation-1); + color: var(--color-text-primary); + } + + .history-conversations-list { + flex: 1; + overflow-y: auto; + padding: 8px 20px 20px 20px; + } + + .history-conversations-list::-webkit-scrollbar { + width: 8px; + } + + .history-conversations-list::-webkit-scrollbar-thumb { + background-color: var(--sys-color-divider); + border-radius: 4px; + } + + .history-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: var(--color-text-secondary); + } + + .history-empty-state p { + margin: 8px 0; + } + + .history-conversation-item { + position: relative; + padding: 12px 16px; + margin-bottom: 8px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + background-color: transparent; + border: 1px solid transparent; + } + + .history-conversation-item:hover { + background-color: var(--color-background-elevation-1); + border-color: var(--sys-color-divider); + } + + .history-conversation-item.active { + background-color: var(--color-primary-container); + border-color: var(--color-primary); + } + + .history-conversation-content { + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 32px; + } + + .history-conversation-title { + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .history-conversation-preview { + font-size: 12px; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .history-conversation-metadata { + display: flex; + gap: 8px; + font-size: 11px; + color: var(--color-text-secondary); + margin-top: 2px; + } + + .history-delete-button { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + width: 28px; + height: 28px; + padding: 0; + background: var(--sys-color-cdt-base-container); + border: 1px solid var(--sys-color-divider); + border-radius: 4px; + cursor: pointer; + font-size: 14px; + display: none; + align-items: center; + justify-content: center; + transition: all 0.2s; + } + + .history-conversation-item:hover .history-delete-button, + .history-conversation-item.active .history-delete-button { + display: flex; + } + + .history-delete-button:hover { + background-color: var(--sys-color-error-container); + border-color: var(--sys-color-error); + transform: translateY(-50%) scale(1.1); + } + `; +} diff --git a/front_end/panels/ai_chat/ui/settings/constants.ts b/front_end/panels/ai_chat/ui/settings/constants.ts index e3d610b577..915c4f412e 100644 --- a/front_end/panels/ai_chat/ui/settings/constants.ts +++ b/front_end/panels/ai_chat/ui/settings/constants.ts @@ -21,6 +21,10 @@ 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'; +export const BROWSEROPERATOR_API_KEY_STORAGE_KEY = 'ai_chat_browseroperator_api_key'; +export const CEREBRAS_API_KEY_STORAGE_KEY = 'ai_chat_cerebras_api_key'; +export const ANTHROPIC_API_KEY_STORAGE_KEY = 'ai_chat_anthropic_api_key'; +export const GOOGLEAI_API_KEY_STORAGE_KEY = 'ai_chat_googleai_api_key'; /** * Cache constants diff --git a/front_end/panels/ai_chat/ui/settings/i18n-strings.ts b/front_end/panels/ai_chat/ui/settings/i18n-strings.ts index b84c56d2a4..4693bac547 100644 --- a/front_end/panels/ai_chat/ui/settings/i18n-strings.ts +++ b/front_end/panels/ai_chat/ui/settings/i18n-strings.ts @@ -40,6 +40,18 @@ export const UIStrings = { *@description BrowserOperator provider option */ browseroperatorProvider: 'BrowserOperator', + /** + *@description Cerebras provider option + */ + cerebrasProvider: 'Cerebras', + /** + *@description Anthropic provider option + */ + anthropicProvider: 'Anthropic', + /** + *@description Google AI provider option + */ + googleaiProvider: 'Google AI', /** *@description LiteLLM API Key label */ @@ -469,6 +481,34 @@ export const UIStrings = { *@description Save button text */ saveButton: 'Save', + /** + *@description Manage custom providers button text + */ + manageCustomProvidersButton: '+ Manage Custom Providers', + /** + *@description Available models section label + */ + availableModelsLabel: 'Available Models', + /** + *@description Add model button text + */ + addModelButton: 'Add', + /** + *@description Model name input placeholder + */ + modelNamePlaceholder: 'Enter model name', + /** + *@description Remove model button text + */ + removeModelButton: '×', + /** + *@description Add model section label + */ + addModelLabel: 'Add Model', + /** + *@description Models count hint text + */ + modelsCountHint: '{n} model(s)', }; /** diff --git a/front_end/panels/ai_chat/ui/settings/providerConfigs.ts b/front_end/panels/ai_chat/ui/settings/providerConfigs.ts new file mode 100644 index 0000000000..0ad4c532d1 --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/providerConfigs.ts @@ -0,0 +1,145 @@ +// 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, UIStrings } from './i18n-strings.js'; +import type { ProviderConfig } from './providers/GenericProviderSettings.js'; +import { + OPENAI_API_KEY_STORAGE_KEY, + BROWSEROPERATOR_API_KEY_STORAGE_KEY, + GROQ_API_KEY_STORAGE_KEY, + CEREBRAS_API_KEY_STORAGE_KEY, + ANTHROPIC_API_KEY_STORAGE_KEY, + GOOGLEAI_API_KEY_STORAGE_KEY, +} from './constants.js'; + +/** + * OpenAI provider configuration + * - API key only + * - No model fetching (uses static model list) + * - Has model selectors + */ +export const OpenAIConfig: ProviderConfig = { + id: 'openai', + displayName: 'OpenAI', + apiKeyStorageKey: OPENAI_API_KEY_STORAGE_KEY, + apiKeyLabel: i18nString(UIStrings.apiKeyLabel), + apiKeyHint: i18nString(UIStrings.apiKeyHint), + apiKeyPlaceholder: 'Enter your OpenAI API key', + hasModelSelectors: true, + hasFetchButton: false, +}; + +/** + * BrowserOperator provider configuration + * - Optional API key + * - No model fetching + * - No model selectors (managed service) + */ +export const BrowserOperatorConfig: ProviderConfig = { + id: 'browseroperator', + displayName: 'BrowserOperator', + apiKeyStorageKey: BROWSEROPERATOR_API_KEY_STORAGE_KEY, + apiKeyLabel: i18nString(UIStrings.browseroperatorApiKeyLabel), + apiKeyHint: i18nString(UIStrings.browseroperatorApiKeyHint), + apiKeyPlaceholder: 'Enter your BrowserOperator API key (optional)', + hasModelSelectors: false, + hasFetchButton: false, + apiKeyOptional: true, +}; + +/** + * Groq provider configuration + * - API key required + * - Has model fetching + * - Has model selectors + * - Uses model.id for label (no name field) + */ +export const GroqConfig: ProviderConfig = { + id: 'groq', + displayName: 'Groq', + apiKeyStorageKey: GROQ_API_KEY_STORAGE_KEY, + apiKeyLabel: i18nString(UIStrings.groqApiKeyLabel), + apiKeyHint: i18nString(UIStrings.groqApiKeyHint), + apiKeyPlaceholder: 'Enter your Groq API key', + hasModelSelectors: true, + hasFetchButton: true, + fetchButtonLabel: i18nString(UIStrings.fetchGroqModelsButton), + fetchMethodName: 'fetchGroqModels', + useNameAsLabel: false, +}; + +/** + * Cerebras provider configuration + * - API key required + * - Has model fetching + * - Has model selectors + * - Uses model.id for label (no name field) + */ +export const CerebrasConfig: ProviderConfig = { + id: 'cerebras', + displayName: 'Cerebras', + apiKeyStorageKey: CEREBRAS_API_KEY_STORAGE_KEY, + apiKeyLabel: 'Cerebras API Key', + apiKeyHint: 'Your Cerebras API key for authentication', + apiKeyPlaceholder: 'Enter your Cerebras API key', + hasModelSelectors: true, + hasFetchButton: true, + fetchButtonLabel: 'Fetch Cerebras Models', + fetchMethodName: 'fetchCerebrasModels', + useNameAsLabel: false, +}; + +/** + * Anthropic provider configuration + * - API key required + * - Has model fetching + * - Has model selectors + * - Uses model.name || model.id for label + */ +export const AnthropicConfig: ProviderConfig = { + id: 'anthropic', + displayName: 'Anthropic', + apiKeyStorageKey: ANTHROPIC_API_KEY_STORAGE_KEY, + apiKeyLabel: 'Anthropic API Key', + apiKeyHint: 'Your Anthropic API key for authentication', + apiKeyPlaceholder: 'Enter your Anthropic API key', + hasModelSelectors: true, + hasFetchButton: true, + fetchButtonLabel: 'Fetch Anthropic Models', + fetchMethodName: 'fetchAnthropicModels', + useNameAsLabel: true, +}; + +/** + * Google AI provider configuration + * - API key required + * - Has model fetching + * - Has model selectors + * - Uses model.name || model.id for label + */ +export const GoogleAIConfig: ProviderConfig = { + id: 'googleai', + displayName: 'Google AI', + apiKeyStorageKey: GOOGLEAI_API_KEY_STORAGE_KEY, + apiKeyLabel: 'Google AI API Key', + apiKeyHint: 'Your Google AI API key for authentication', + apiKeyPlaceholder: 'Enter your Google AI API key', + hasModelSelectors: true, + hasFetchButton: true, + fetchButtonLabel: 'Fetch Google AI Models', + fetchMethodName: 'fetchGoogleAIModels', + useNameAsLabel: true, +}; + +/** + * List of all generic providers for easy iteration + */ +export const GENERIC_PROVIDERS = [ + OpenAIConfig, + BrowserOperatorConfig, + GroqConfig, + CerebrasConfig, + AnthropicConfig, + GoogleAIConfig, +]; diff --git a/front_end/panels/ai_chat/ui/settings/providers/BrowserOperatorSettings.ts b/front_end/panels/ai_chat/ui/settings/providers/BrowserOperatorSettings.ts deleted file mode 100644 index 2b48fb5467..0000000000 --- a/front_end/panels/ai_chat/ui/settings/providers/BrowserOperatorSettings.ts +++ /dev/null @@ -1,80 +0,0 @@ -// 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 { i18nString, UIStrings } from '../i18n-strings.js'; -import { getStorageItem, setStorageItem } from '../utils/storage.js'; -import type { GetModelOptionsFunction, AddCustomModelOptionFunction, RemoveCustomModelOptionFunction } from '../types.js'; - -// Storage key for BrowserOperator API key (defined in LLMConfigurationManager.ts) -const BROWSEROPERATOR_API_KEY_STORAGE_KEY = 'ai_chat_browseroperator_api_key'; - -/** - * BrowserOperator provider settings - * - * BrowserOperator is a managed hosted service with agent-based routing. - * - API key is optional - * - Endpoint is hardcoded to https://api.browseroperator.io/v1 - * - Models are static aliases (main, mini, nano) not real model names - */ -export class BrowserOperatorSettings extends BaseProviderSettings { - private apiKeyInput: HTMLInputElement | null = null; - private settingsSection: HTMLElement | null = null; - - constructor( - container: HTMLElement, - getModelOptions: GetModelOptionsFunction, - addCustomModelOption: AddCustomModelOptionFunction, - removeCustomModelOption: RemoveCustomModelOptionFunction - ) { - super(container, 'browseroperator', getModelOptions, addCustomModelOption, removeCustomModelOption); - } - - render(): void { - // Clear any existing content - this.container.innerHTML = ''; - - // Setup BrowserOperator content - this.settingsSection = document.createElement('div'); - this.settingsSection.className = 'settings-section'; - this.container.appendChild(this.settingsSection); - - // Optional API key section - const apiKeyLabel = document.createElement('div'); - apiKeyLabel.className = 'settings-label'; - apiKeyLabel.textContent = i18nString(UIStrings.browseroperatorApiKeyLabel); - this.settingsSection.appendChild(apiKeyLabel); - - const apiKeyHint = document.createElement('div'); - apiKeyHint.className = 'settings-hint'; - apiKeyHint.textContent = i18nString(UIStrings.browseroperatorApiKeyHint); - this.settingsSection.appendChild(apiKeyHint); - - const settingsSavedApiKey = getStorageItem(BROWSEROPERATOR_API_KEY_STORAGE_KEY, ''); - this.apiKeyInput = document.createElement('input'); - this.apiKeyInput.className = 'settings-input'; - this.apiKeyInput.type = 'password'; - this.apiKeyInput.placeholder = 'Enter your BrowserOperator API key (optional)'; - this.apiKeyInput.value = settingsSavedApiKey; - this.settingsSection.appendChild(this.apiKeyInput); - } - - updateModelSelectors(): void { - // BrowserOperator doesn't need model selectors - models are managed automatically - return; - } - - save(): void { - // Save BrowserOperator API key (optional) - if (this.apiKeyInput) { - const newApiKey = this.apiKeyInput.value.trim(); - if (newApiKey) { - setStorageItem(BROWSEROPERATOR_API_KEY_STORAGE_KEY, newApiKey); - } else { - // Remove from storage if empty (API key is optional) - localStorage.removeItem(BROWSEROPERATOR_API_KEY_STORAGE_KEY); - } - } - } -} diff --git a/front_end/panels/ai_chat/ui/settings/providers/GenericProviderSettings.ts b/front_end/panels/ai_chat/ui/settings/providers/GenericProviderSettings.ts new file mode 100644 index 0000000000..a0ab855dcc --- /dev/null +++ b/front_end/panels/ai_chat/ui/settings/providers/GenericProviderSettings.ts @@ -0,0 +1,300 @@ +// 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, UIStrings } from '../i18n-strings.js'; +import { getValidModelForProvider } from '../utils/validation.js'; +import { getStorageItem, setStorageItem } from '../utils/storage.js'; +import { MINI_MODEL_STORAGE_KEY, NANO_MODEL_STORAGE_KEY } from '../constants.js'; +import type { UpdateModelOptionsFunction, GetModelOptionsFunction, AddCustomModelOptionFunction, RemoveCustomModelOptionFunction, ModelOption, ProviderType } from '../types.js'; +import { LLMClient } from '../../../LLM/LLMClient.js'; +import { createLogger } from '../../../core/Logger.js'; + +/** + * Configuration for a generic provider + */ +export interface ProviderConfig { + id: ProviderType; + displayName: string; + apiKeyStorageKey: string; + apiKeyLabel: string; + apiKeyHint: string; + apiKeyPlaceholder: string; + hasModelSelectors?: boolean; // Default: true + hasFetchButton?: boolean; // Default: false + fetchButtonLabel?: string; + fetchMethodName?: keyof typeof LLMClient; // e.g., 'fetchGroqModels' + useNameAsLabel?: boolean; // Use model.name || model.id instead of just model.id (Default: false) + apiKeyOptional?: boolean; // Default: false +} + +/** + * Generic provider settings class + * Handles all simple providers through configuration + */ +export class GenericProviderSettings extends BaseProviderSettings { + private config: ProviderConfig; + private logger: any; + private apiKeyInput: HTMLInputElement | null = null; + private fetchModelsButton: HTMLButtonElement | null = null; + private fetchModelsStatus: HTMLElement | null = null; + private updateModelOptions: UpdateModelOptionsFunction | null = null; + + constructor( + container: HTMLElement, + config: ProviderConfig, + getModelOptions: GetModelOptionsFunction, + addCustomModelOption: AddCustomModelOptionFunction, + removeCustomModelOption: RemoveCustomModelOptionFunction, + updateModelOptions?: UpdateModelOptionsFunction + ) { + super(container, config.id, getModelOptions, addCustomModelOption, removeCustomModelOption); + this.config = config; + this.logger = createLogger(`${config.displayName}Settings`); + this.updateModelOptions = updateModelOptions || null; + } + + render(): void { + // Clear any existing content + this.container.innerHTML = ''; + + // Setup provider content section + const settingsSection = document.createElement('div'); + settingsSection.className = 'settings-section'; + this.container.appendChild(settingsSection); + + // API Key Label + const apiKeyLabel = document.createElement('div'); + apiKeyLabel.className = 'settings-label'; + apiKeyLabel.textContent = this.config.apiKeyLabel; + settingsSection.appendChild(apiKeyLabel); + + // API Key Hint + const apiKeyHint = document.createElement('div'); + apiKeyHint.className = 'settings-hint'; + apiKeyHint.textContent = this.config.apiKeyHint; + settingsSection.appendChild(apiKeyHint); + + // API Key Input + const savedApiKey = getStorageItem(this.config.apiKeyStorageKey, ''); + this.apiKeyInput = document.createElement('input'); + this.apiKeyInput.className = `settings-input ${this.config.id}-api-key-input`; + this.apiKeyInput.type = 'password'; + this.apiKeyInput.placeholder = this.config.apiKeyPlaceholder; + this.apiKeyInput.value = savedApiKey; + settingsSection.appendChild(this.apiKeyInput); + + // Fetch Models Button (if configured) + if (this.config.hasFetchButton && this.config.fetchMethodName) { + const fetchButtonContainer = document.createElement('div'); + fetchButtonContainer.className = 'fetch-button-container'; + settingsSection.appendChild(fetchButtonContainer); + + this.fetchModelsButton = document.createElement('button'); + this.fetchModelsButton.className = 'settings-button'; + this.fetchModelsButton.setAttribute('type', 'button'); + this.fetchModelsButton.textContent = this.config.fetchButtonLabel || `Fetch ${this.config.displayName} Models`; + this.fetchModelsButton.disabled = !this.apiKeyInput.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); + + // 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 models button + this.fetchModelsButton.addEventListener('click', async () => { + await this.handleFetchModels(); + }); + } + + // Initialize model selectors if configured + if (this.config.hasModelSelectors !== false) { + this.updateModelSelectors(); + } + } + + private async handleFetchModels(): Promise { + if (!this.fetchModelsButton || !this.fetchModelsStatus || !this.apiKeyInput || !this.config.fetchMethodName) { + return; + } + + this.fetchModelsButton.disabled = true; + this.fetchModelsStatus.textContent = i18nString(UIStrings.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 apiKey = this.apiKeyInput.value.trim(); + + // Call the appropriate LLMClient static method dynamically + const fetchMethod = LLMClient[this.config.fetchMethodName] as (apiKey: string) => Promise; + if (typeof fetchMethod !== 'function') { + throw new Error(`Invalid fetch method: ${this.config.fetchMethodName}`); + } + + const models = await fetchMethod.call(LLMClient, apiKey); + + // Convert models to ModelOption format + const modelOptions: ModelOption[] = models.map(model => ({ + value: model.id, + label: this.config.useNameAsLabel ? (model.name || model.id) : model.id, + type: this.config.id as any + })); + + // Update model options + if (this.updateModelOptions) { + this.updateModelOptions(modelOptions, false); + } + + // Get all provider models including any custom ones + const allModels = this.getModelOptions(this.config.id); + const actualModelCount = models.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, + allModels, + miniModel, + i18nString(UIStrings.defaultMiniOption) + ); + } + if (this.nanoModelSelector) { + refreshModelSelectOptions( + this.nanoModelSelector as any, + allModels, + nanoModel, + i18nString(UIStrings.defaultNanoOption) + ); + } + + this.fetchModelsStatus.textContent = i18nString(UIStrings.fetchedModels, {PH1: actualModelCount}); + this.fetchModelsStatus.style.backgroundColor = 'var(--color-accent-green-background)'; + this.fetchModelsStatus.style.color = 'var(--color-accent-green)'; + + // Update model selections + this.updateModelSelectors(); + + } catch (error) { + this.logger.error(`Failed to fetch ${this.config.displayName} 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); + } + } + + updateModelSelectors(): void { + // Skip if model selectors are not configured + if (this.config.hasModelSelectors === false) { + return; + } + + if (!this.container) { + return; + } + + this.logger.debug(`Updating ${this.config.displayName} model selectors`); + + // Get the latest model options filtered for this provider + const providerModels = this.getModelOptions(this.config.id); + this.logger.debug(`${this.config.displayName} models from getModelOptions:`, providerModels); + + // 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, providerModels, this.config.id, 'mini'); + const validNanoModel = getValidModelForProvider(nanoModel, providerModels, this.config.id, 'nano'); + + this.logger.debug(`${this.config.displayName} 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 modelSection = document.createElement('div'); + modelSection.className = 'settings-section model-selection-section'; + this.container.appendChild(modelSection); + + const modelSectionTitle = document.createElement('h3'); + modelSectionTitle.className = 'settings-subtitle'; + modelSectionTitle.textContent = 'Model Size Selection'; + modelSection.appendChild(modelSectionTitle); + + // Create Mini Model selection and store reference + this.miniModelSelector = createModelSelector( + modelSection, + i18nString(UIStrings.miniModelLabel), + i18nString(UIStrings.miniModelDescription), + `${this.config.id}-mini-model-select`, + providerModels, + validMiniModel, + i18nString(UIStrings.defaultMiniOption), + undefined + ); + + this.logger.debug(`Created ${this.config.displayName} Mini Model Select:`, this.miniModelSelector); + + // Create Nano Model selection and store reference + this.nanoModelSelector = createModelSelector( + modelSection, + i18nString(UIStrings.nanoModelLabel), + i18nString(UIStrings.nanoModelDescription), + `${this.config.id}-nano-model-select`, + providerModels, + validNanoModel, + i18nString(UIStrings.defaultNanoOption), + undefined + ); + + this.logger.debug(`Created ${this.config.displayName} Nano Model Select:`, this.nanoModelSelector); + } + + save(): void { + // Save API key + if (this.apiKeyInput) { + const newApiKey = this.apiKeyInput.value.trim(); + if (newApiKey) { + setStorageItem(this.config.apiKeyStorageKey, newApiKey); + } else { + // If API key is optional and empty, remove from storage + if (this.config.apiKeyOptional) { + localStorage.removeItem(this.config.apiKeyStorageKey); + } else { + setStorageItem(this.config.apiKeyStorageKey, ''); + } + } + } + } +} diff --git a/front_end/panels/ai_chat/ui/settings/providers/GroqSettings.ts b/front_end/panels/ai_chat/ui/settings/providers/GroqSettings.ts deleted file mode 100644 index 14cd00fc50..0000000000 --- a/front_end/panels/ai_chat/ui/settings/providers/GroqSettings.ts +++ /dev/null @@ -1,234 +0,0 @@ -// 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, UIStrings } 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(UIStrings.groqApiKeyLabel); - groqSettingsSection.appendChild(groqApiKeyLabel); - - const groqApiKeyHint = document.createElement('div'); - groqApiKeyHint.className = 'settings-hint'; - groqApiKeyHint.textContent = i18nString(UIStrings.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(UIStrings.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(UIStrings.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(UIStrings.defaultMiniOption)); - } - if (this.nanoModelSelector) { - refreshModelSelectOptions(this.nanoModelSelector as any, allGroqModels, nanoModel, i18nString(UIStrings.defaultNanoOption)); - } - - this.fetchModelsStatus.textContent = i18nString(UIStrings.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(UIStrings.miniModelLabel), - i18nString(UIStrings.miniModelDescription), - 'groq-mini-model-select', - groqModels, - validMiniModel, - i18nString(UIStrings.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(UIStrings.nanoModelLabel), - i18nString(UIStrings.nanoModelDescription), - 'groq-nano-model-select', - groqModels, - validNanoModel, - i18nString(UIStrings.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/OpenAISettings.ts b/front_end/panels/ai_chat/ui/settings/providers/OpenAISettings.ts deleted file mode 100644 index 07daeb6d75..0000000000 --- a/front_end/panels/ai_chat/ui/settings/providers/OpenAISettings.ts +++ /dev/null @@ -1,134 +0,0 @@ -// 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, UIStrings } 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(UIStrings.apiKeyLabel); - this.settingsSection.appendChild(apiKeyLabel); - - const apiKeyHint = document.createElement('div'); - apiKeyHint.className = 'settings-hint'; - apiKeyHint.textContent = i18nString(UIStrings.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(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 - this.nanoModelSelector = createModelSelector( - openaiModelSection, - i18nString(UIStrings.nanoModelLabel), - i18nString(UIStrings.nanoModelDescription), - 'nano-model-select', - openaiModels, - validNanoModel, - i18nString(UIStrings.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/types.ts b/front_end/panels/ai_chat/ui/settings/types.ts index 58fabfb2c9..2d366e36bb 100644 --- a/front_end/panels/ai_chat/ui/settings/types.ts +++ b/front_end/panels/ai_chat/ui/settings/types.ts @@ -8,7 +8,7 @@ export interface ModelOption { value: string; label: string; - type: 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator'; + type: string; // Supports standard providers and custom providers (e.g., 'custom:my-provider') } /** @@ -20,9 +20,9 @@ export interface ValidationResult { } /** - * Provider type + * Provider type - supports standard providers and custom providers with 'custom:' prefix */ -export type ProviderType = 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator'; +export type ProviderType = string; /** * Model tier type From 2b6720646409f7c5e9a43f8582ed66f0c756ee7d Mon Sep 17 00:00:00 2001 From: Tyson Thomas Date: Sun, 23 Nov 2025 10:19:23 -0800 Subject: [PATCH 2/4] Update styles and add chunking --- .../test-cases/html-to-markdown-tests.ts | 348 +++++++++++++ front_end/panels/ai_chat/tools/FetcherTool.ts | 13 +- .../ai_chat/tools/HTMLToMarkdownTool.ts | 139 ++++- .../panels/ai_chat/ui/CustomProviderDialog.ts | 124 ++--- .../panels/ai_chat/ui/EvaluationDialog.ts | 6 +- .../panels/ai_chat/ui/customProviderStyles.ts | 490 ++++++++++++++++++ .../panels/ai_chat/utils/ContentChunker.ts | 469 +++++++++++++++++ 7 files changed, 1512 insertions(+), 77 deletions(-) create mode 100644 front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts create mode 100644 front_end/panels/ai_chat/ui/customProviderStyles.ts create mode 100644 front_end/panels/ai_chat/utils/ContentChunker.ts diff --git a/front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts b/front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts new file mode 100644 index 0000000000..2f897c6c05 --- /dev/null +++ b/front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts @@ -0,0 +1,348 @@ +// 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 { TestCase } from '../framework/types.js'; +import type { HTMLToMarkdownArgs } from '../../tools/HTMLToMarkdownTool.js'; + +/** + * Test cases for HTMLToMarkdownTool evaluation + * + * These tests validate: + * - HTML-to-Markdown conversion quality + * - Content extraction and filtering + * - Accessibility tree chunking for large pages + * - Chunk boundary handling (new accessibility-tree strategy) + */ + +/** + * Simple stable page - baseline test without chunking + */ +export const simpleArticleTest: TestCase = { + id: 'html-to-markdown-simple-001', + name: 'Extract Simple Article', + description: 'Extract markdown from a simple, well-structured article page without chunking', + url: 'https://en.wikipedia.org/wiki/Markdown', + tool: 'html_to_markdown', + input: { + instruction: 'Convert the main article content to clean, well-formatted Markdown', + reasoning: 'Testing extraction from a stable Wikipedia page with clear structure' + }, + validation: { + type: 'llm-judge', + llmJudge: { + criteria: [ + 'Markdown output is well-formatted and readable', + 'Main article content is preserved completely', + 'Navigation and ads are removed', + 'Heading hierarchy (H1, H2, H3) is maintained', + 'Links are properly formatted as [text](url)', + 'Images are formatted as ![alt](src)', + 'No HTML artifacts or tags remain', + 'Code blocks and formatting are preserved' + ], + temperature: 0 // Deterministic evaluation + } + }, + metadata: { + tags: ['simple', 'wikipedia', 'stable', 'baseline'], + timeout: 45000, + retries: 2, + flaky: false + } +}; + +/** + * Large page requiring chunking - PRIMARY CHUNKING TEST + * This is the Wikipedia Australia page (100k+ tokens) that Tyson tested + */ +export const largeArticleChunkingTest: TestCase = { + id: 'html-to-markdown-chunking-001', + name: 'Extract Large Article with Accessibility Tree Chunking', + description: 'Extract markdown from Wikipedia Australia page (100k+ tokens) using new accessibility-tree chunking strategy that splits on [nodeId] boundaries', + url: 'https://en.wikipedia.org/wiki/Australia', + tool: 'html_to_markdown', + input: { + instruction: 'Convert the complete article to Markdown, ensuring all sections are captured without loss at chunk boundaries', + reasoning: 'Testing new accessibility-tree chunking strategy on confirmed 100k+ token page' + }, + validation: { + type: 'llm-judge', + llmJudge: { + criteria: [ + 'All major sections are included (Geography, History, Demographics, Culture, Economy, etc.)', + 'No content truncation occurs at chunk boundaries', + 'Chunking boundaries respect [nodeId] patterns without splitting mid-node', + 'Heading hierarchy is consistent across entire output', + 'No duplicate paragraphs from chunk overlaps', + 'Cross-references and internal links between sections are preserved', + 'Final markdown is coherent and reads as a complete article', + 'Section transitions are smooth (no jarring breaks between chunks)', + 'Lists and tables that span multiple nodes are complete', + 'Images and captions are properly associated' + ], + temperature: 0 + } + }, + metadata: { + tags: ['large', 'chunking', 'accessibility-tree', 'wikipedia', '100k-tokens'], + timeout: 90000, // 90s for chunked processing (13+ LLM calls) + retries: 2, + flaky: false + } +}; + +/** + * Test at chunking threshold boundary (exactly 10k tokens) + */ +export const chunkingThresholdTest: TestCase = { + id: 'html-to-markdown-threshold-001', + name: 'Test Chunking Threshold Detection', + description: 'Test with page near 10k token threshold to validate chunking trigger logic', + url: 'https://en.wikipedia.org/wiki/History_of_the_Internet', + tool: 'html_to_markdown', + input: { + instruction: 'Extract the complete article ensuring threshold detection works correctly', + reasoning: 'Validating that pages just over 10k tokens trigger chunking appropriately' + }, + validation: { + type: 'llm-judge', + llmJudge: { + criteria: [ + 'Complete article content is extracted', + 'All timeline sections are captured', + 'Historical events are in chronological order', + 'Technical details are preserved', + 'Output quality is consistent regardless of chunking decision' + ], + temperature: 0 + } + }, + metadata: { + tags: ['threshold', 'chunking', 'wikipedia', 'boundary-test'], + timeout: 60000, + retries: 2, + flaky: false + } +}; + +/** + * Complex real-world page with ads, sidebars, and dynamic content + */ +export const complexPageTest: TestCase = { + id: 'html-to-markdown-complex-001', + name: 'Extract Content from Complex Page', + description: 'Extract main content from page with sidebars, ads, navigation, and complex layout', + url: 'https://www.theguardian.com/technology', + tool: 'html_to_markdown', + input: { + instruction: 'Extract the main news articles and headlines, filtering out sidebars, ads, and navigation', + reasoning: 'Testing content filtering on real-world complex page layout' + }, + validation: { + type: 'llm-judge', + llmJudge: { + criteria: [ + 'Main article headlines are extracted correctly', + 'Article summaries/previews are included', + 'Related articles sidebar is filtered out', + 'Advertisement content is completely removed', + 'Navigation menus are excluded', + 'Recommended content sections are filtered', + 'Links to full articles are preserved', + 'Bylines and publication dates are captured' + ], + temperature: 0 + } + }, + metadata: { + tags: ['complex', 'real-world', 'filtering', 'dynamic', 'news'], + timeout: 60000, + retries: 3, + flaky: true // News sites have dynamic content + } +}; + +/** + * Technical documentation page with code blocks + */ +export const technicalDocsTest: TestCase = { + id: 'html-to-markdown-docs-001', + name: 'Extract Technical Documentation', + description: 'Extract documentation with code blocks, API references, and technical content', + url: 'https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API', + tool: 'html_to_markdown', + input: { + instruction: 'Convert the documentation to Markdown preserving code examples and syntax highlighting', + reasoning: 'Testing code block preservation and technical content extraction' + }, + validation: { + type: 'llm-judge', + llmJudge: { + criteria: [ + 'Code blocks are properly formatted with triple backticks', + 'Code syntax and indentation is preserved', + 'API method signatures are accurate', + 'Parameter descriptions are complete', + 'Example code is runnable and correct', + 'Technical terminology is preserved', + 'Navigation breadcrumbs are removed', + 'Related API links are included' + ], + temperature: 0 + } + }, + metadata: { + tags: ['technical', 'documentation', 'code-blocks', 'mdn'], + timeout: 45000, + retries: 2, + flaky: false + } +}; + +/** + * Chunking stress test - extremely large page + */ +export const massiveArticleTest: TestCase = { + id: 'html-to-markdown-massive-001', + name: 'Extract Massive Article (Stress Test)', + description: 'Extract extremely long article to stress-test chunking with 20+ chunks', + url: 'https://en.wikipedia.org/wiki/List_of_countries_by_population', + tool: 'html_to_markdown', + input: { + instruction: 'Extract the complete list maintaining table structure and country data', + reasoning: 'Stress testing chunking system with very large tabular data' + }, + validation: { + type: 'llm-judge', + llmJudge: { + criteria: [ + 'All countries in the list are included', + 'Table structure is preserved in markdown format', + 'Population data is accurate and aligned', + 'No countries are duplicated from chunk boundaries', + 'Headers and footers are included once', + 'References and notes section is complete' + ], + temperature: 0 + } + }, + metadata: { + tags: ['stress-test', 'massive', 'chunking', 'tables', 'wikipedia'], + timeout: 120000, // 2 minutes for very large content + retries: 2, + flaky: false + } +}; + +/** + * All HTMLToMarkdownTool test cases + */ +export const htmlToMarkdownTests: TestCase[] = [ + simpleArticleTest, + largeArticleChunkingTest, + chunkingThresholdTest, + complexPageTest, + technicalDocsTest, + massiveArticleTest, +]; + +/** + * Basic tests for quick validation (no chunking) + */ +export const basicHtmlToMarkdownTests: TestCase[] = [ + simpleArticleTest, + technicalDocsTest, +]; + +/** + * Chunking-specific tests + */ +export const chunkingTests: TestCase[] = [ + largeArticleChunkingTest, + chunkingThresholdTest, + massiveArticleTest, +]; + +/** + * Comprehensive test suite including dynamic content + */ +export const comprehensiveHtmlToMarkdownTests: TestCase[] = [ + simpleArticleTest, + largeArticleChunkingTest, + chunkingThresholdTest, + complexPageTest, + technicalDocsTest, +]; + +/** + * Stable tests only (no flaky dynamic content) + */ +export const stableHtmlToMarkdownTests: TestCase[] = + htmlToMarkdownTests.filter(test => !test.metadata.flaky); + +/** + * Get a specific test by ID + */ +export function getHtmlToMarkdownTestById( + id: string +): TestCase | undefined { + return htmlToMarkdownTests.find(test => test.id === id); +} + +/** + * Get tests by tag + */ +export function getHtmlToMarkdownTestsByTag( + tag: string +): TestCase[] { + return htmlToMarkdownTests.filter(test => + test.metadata.tags.includes(tag) + ); +} + +/** + * Get only chunking-related tests + */ +export function getChunkingSpecificTests(): TestCase[] { + return htmlToMarkdownTests.filter(test => + test.metadata.tags.includes('chunking') || + test.metadata.tags.includes('large') || + test.metadata.tags.includes('massive') + ); +} + +/** + * Get tests by expected duration (for CI optimization) + */ +export function getTestsByDuration( + maxTimeout: number +): TestCase[] { + return htmlToMarkdownTests.filter(test => + (test.metadata.timeout || 45000) <= maxTimeout + ); +} + +/** + * CommonJS export for Node.js compatibility + * Allows backend evaluation runner to import test cases + */ +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + simpleArticleTest, + largeArticleChunkingTest, + chunkingThresholdTest, + complexPageTest, + technicalDocsTest, + massiveArticleTest, + htmlToMarkdownTests, + basicHtmlToMarkdownTests, + chunkingTests, + comprehensiveHtmlToMarkdownTests, + stableHtmlToMarkdownTests, + getHtmlToMarkdownTestById, + getHtmlToMarkdownTestsByTag, + getChunkingSpecificTests, + getTestsByDuration, + }; +} diff --git a/front_end/panels/ai_chat/tools/FetcherTool.ts b/front_end/panels/ai_chat/tools/FetcherTool.ts index 2a85897d39..4c848312d1 100644 --- a/front_end/panels/ai_chat/tools/FetcherTool.ts +++ b/front_end/panels/ai_chat/tools/FetcherTool.ts @@ -42,10 +42,13 @@ export interface FetcherToolResult { * This agent takes a list of URLs, navigates to each one, and extracts * the main content as markdown. It uses NavigateURLTool for navigation * and HTMLToMarkdownTool for content extraction. + * + * Content extraction is handled by HTMLToMarkdownTool, which + * automatically chunks large pages for efficient processing. */ export class FetcherTool implements Tool { name = 'fetcher_tool'; - description = 'Navigates to URLs, extracts and cleans the main content, returning markdown for each source'; + description = 'Navigates to URLs, extracts and cleans the main content, returning markdown for each source.'; schema = { @@ -124,7 +127,11 @@ export class FetcherTool implements Tool { /** * Fetch and extract content from a single URL */ - private async fetchContentFromUrl(url: string, reasoning: string, ctx?: LLMContext): Promise { + private async fetchContentFromUrl( + url: string, + reasoning: string, + ctx?: LLMContext + ): Promise { const signal = ctx?.abortSignal; const throwIfAborted = () => { if (signal?.aborted) { @@ -201,7 +208,7 @@ export class FetcherTool implements Tool { }; } - // Return the fetched content + // Return the fetched content (HTMLToMarkdownTool handles chunking) return { url: metadata?.url || url, title: metadata?.title || '', diff --git a/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts b/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts index 025406d96b..e151326477 100644 --- a/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts +++ b/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts @@ -10,6 +10,7 @@ import { createLogger } from '../core/Logger.js'; import { callLLMWithTracing } from './LLMTracingWrapper.js'; import { waitForPageLoad, type Tool, type LLMContext } from './Tools.js'; import type { LLMProvider } from '../LLM/LLMTypes.js'; +import { ContentChunker } from '../utils/ContentChunker.js'; const logger = createLogger('Tool:HTMLToMarkdown'); @@ -34,8 +35,15 @@ export interface HTMLToMarkdownArgs { * Tool for extracting the main article content from a webpage and converting it to Markdown */ export class HTMLToMarkdownTool implements Tool { + // Chunking configuration + private readonly TOKEN_LIMIT_FOR_CHUNKING = 10000; // Auto-chunk if tree exceeds this (40k chars) + private readonly CHUNK_TOKEN_LIMIT = 8000; // Max tokens per chunk (32k chars) + private readonly CHARS_PER_TOKEN = 4; // Conservative estimate + + private contentChunker = new ContentChunker(); + name = 'html_to_markdown'; - description = 'Extracts the main article content from a webpage and converts it to well-formatted Markdown, removing ads, navigation, and other distracting elements.'; + description = 'Extracts the main article content from a webpage and converts it to well-formatted Markdown, removing ads, navigation, and other distracting elements. Automatically chunks large pages for efficient processing.'; schema = { @@ -106,12 +114,10 @@ export class HTMLToMarkdownTool implements Tool this.TOKEN_LIMIT_FOR_CHUNKING) { + logger.info('Content exceeds token limit, using chunked processing', { + estimatedTokens, + limit: this.TOKEN_LIMIT_FOR_CHUNKING + }); + + markdownContent = await this.processWithChunking(content, instruction, apiKey || '', ctx.provider, ctx.nanoModel); + } else { + // Normal processing for smaller content + logger.info('Using standard processing'); + const systemPrompt = this.createSystemPrompt(); + const userPrompt = this.createUserPrompt(content, instruction); + + const extractionResult = await this.callExtractionLLM({ + systemPrompt, + userPrompt, + apiKey: apiKey || '', + provider: ctx.provider, + model: ctx.nanoModel, + }); + + markdownContent = extractionResult.markdownContent; + } logger.info('Extraction completed successfully'); // Return the result return { success: true, - markdownContent: extractionResult.markdownContent + markdownContent }; } catch (error: any) { @@ -323,6 +349,91 @@ ${instruction} `; } + /** + * Process large content by chunking the raw accessibility tree + * and extracting markdown from each chunk separately + */ + private async processWithChunking( + content: string, + instruction: string | undefined, + apiKey: string, + provider: LLMProvider, + model: string + ): Promise { + // Chunk the raw accessibility tree content + logger.info('Chunking raw accessibility tree content'); + const chunks = this.contentChunker.chunk(content, { + maxTokensPerChunk: this.CHUNK_TOKEN_LIMIT, + strategy: 'accessibility-tree', // Split on [nodeId] boundaries + preserveContext: false + }); + + logger.info('Created chunks from accessibility tree', { chunkCount: chunks.length }); + + // Extract markdown from each accessibility tree chunk in parallel (4 at a time) + const markdownChunks: string[] = new Array(chunks.length); + const BATCH_SIZE = 4; // Process 4 chunks concurrently + + for (let i = 0; i < chunks.length; i += BATCH_SIZE) { + const batchPromises: Promise[] = []; + + // Create batch of up to 4 promises + for (let j = 0; j < BATCH_SIZE && i + j < chunks.length; j++) { + const chunkIndex = i + j; + const chunk = chunks[chunkIndex]; + + logger.info(`Processing chunk ${chunkIndex + 1}/${chunks.length} in parallel batch`, { + batchStart: i + 1, + batchEnd: Math.min(i + BATCH_SIZE, chunks.length), + tokenEstimate: chunk.tokenEstimate + }); + + const systemPrompt = this.createSystemPrompt(); + const userPrompt = this.createUserPrompt(chunk.content, instruction); + + // Create promise and handle errors per chunk + const promise = this.callExtractionLLM({ + systemPrompt, + userPrompt, + apiKey, + provider, + model, + }).then(result => { + // Store result at correct index to maintain order + markdownChunks[chunkIndex] = result.markdownContent; + return result.markdownContent; + }).catch(error => { + logger.error(`Error processing chunk ${chunkIndex + 1}`, { error }); + // Store empty string on error to maintain order + markdownChunks[chunkIndex] = ''; + return ''; + }); + + batchPromises.push(promise); + } + + // Wait for current batch to complete before starting next batch + logger.info(`Waiting for batch to complete`, { + batchStart: i + 1, + batchSize: batchPromises.length + }); + await Promise.all(batchPromises); + logger.info(`Batch completed`, { + batchStart: i + 1, + completedChunks: i + batchPromises.length + }); + } + + // Combine markdown results + const mergedMarkdown = markdownChunks.join('\n\n'); + logger.info('Combined markdown from all chunks', { + totalChunks: chunks.length, + finalLength: mergedMarkdown.length + }); + + return mergedMarkdown; + } + /** * Call LLM for extraction */ diff --git a/front_end/panels/ai_chat/ui/CustomProviderDialog.ts b/front_end/panels/ai_chat/ui/CustomProviderDialog.ts index 4d63067eae..29c5bb0ced 100644 --- a/front_end/panels/ai_chat/ui/CustomProviderDialog.ts +++ b/front_end/panels/ai_chat/ui/CustomProviderDialog.ts @@ -3,11 +3,13 @@ // found in the LICENSE file. import * as UI from '../../../ui/legacy/legacy.js'; +import * as Geometry from '../../../models/geometry/geometry.js'; import { CustomProviderManager } from '../core/CustomProviderManager.js'; import type { CustomProviderConfig } from '../core/CustomProviderManager.js'; import { LLMClient } from '../LLM/LLMClient.js'; import { createLogger } from '../core/Logger.js'; import { PROVIDER_SELECTION_KEY } from './settings/constants.js'; +import { applyCustomProviderStyles } from './customProviderStyles.js'; const logger = createLogger('CustomProviderDialog'); @@ -46,23 +48,23 @@ export class CustomProviderDialog { this.dialog.setSizeBehavior(UI.GlassPane.SizeBehavior.MEASURE_CONTENT); this.dialog.setDimmed(true); this.dialog.addCloseButton(); + this.dialog.contentElement.classList.add('custom-provider-dialog'); const container = document.createElement('div'); - container.style.cssText = 'min-width: 500px; max-width: 600px; padding: 20px;'; + container.className = 'custom-provider-container'; // Create header const header = document.createElement('div'); - header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;'; + header.className = 'custom-provider-header'; const title = document.createElement('h2'); title.textContent = 'Manage Custom Providers'; - title.style.cssText = 'margin: 0; font-size: 18px; font-weight: 500;'; + title.className = 'custom-provider-title'; header.appendChild(title); const addButton = document.createElement('button'); addButton.textContent = '+ Add Provider'; - addButton.className = 'devtools-button'; - addButton.style.cssText = 'padding: 6px 12px; cursor: pointer;'; + addButton.className = 'custom-provider-add-button'; addButton.addEventListener('click', () => this.showAddEditDialog()); header.appendChild(addButton); @@ -70,12 +72,12 @@ export class CustomProviderDialog { // Create provider list const listContainer = document.createElement('div'); - listContainer.style.cssText = 'max-height: 400px; overflow-y: auto;'; + listContainer.className = 'provider-list-container'; if (this.providers.length === 0) { const emptyMessage = document.createElement('div'); emptyMessage.textContent = 'No custom providers configured. Click "Add Provider" to add one.'; - emptyMessage.style.cssText = 'color: var(--sys-color-token-subtle); text-align: center; padding: 32px;'; + emptyMessage.className = 'provider-empty-message'; listContainer.appendChild(emptyMessage); } else { this.providers.forEach(provider => { @@ -88,6 +90,10 @@ export class CustomProviderDialog { this.dialog.contentElement.appendChild(container); this.dialog.setOutsideClickCallback(() => this.hide()); + + // Apply styles + applyCustomProviderStyles(this.dialog.contentElement); + this.dialog.show(); } @@ -96,42 +102,40 @@ export class CustomProviderDialog { */ private createProviderListItem(provider: CustomProviderConfig): HTMLElement { const item = document.createElement('div'); - item.style.cssText = 'padding: 12px; margin-bottom: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; display: flex; justify-content: space-between; align-items: center;'; + item.className = 'provider-list-item'; const info = document.createElement('div'); - info.style.cssText = 'flex: 1;'; + info.className = 'provider-info'; const name = document.createElement('div'); name.textContent = provider.name; - name.style.cssText = 'font-weight: 500; margin-bottom: 4px;'; + name.className = 'provider-name'; info.appendChild(name); const url = document.createElement('div'); url.textContent = provider.baseURL; - url.style.cssText = 'font-size: 12px; color: var(--sys-color-token-subtle);'; + url.className = 'provider-url'; info.appendChild(url); const models = document.createElement('div'); models.textContent = `Models: ${provider.models.length}`; - models.style.cssText = 'font-size: 11px; color: var(--sys-color-token-subtle); margin-top: 2px;'; + models.className = 'provider-models-count'; info.appendChild(models); item.appendChild(info); const actions = document.createElement('div'); - actions.style.cssText = 'display: flex; gap: 8px;'; + actions.className = 'provider-actions'; const editButton = document.createElement('button'); editButton.textContent = 'Edit'; - editButton.className = 'devtools-button'; - editButton.style.cssText = 'padding: 4px 8px; cursor: pointer;'; + editButton.className = 'provider-edit-button'; editButton.addEventListener('click', () => this.showAddEditDialog(provider)); actions.appendChild(editButton); const deleteButton = document.createElement('button'); deleteButton.textContent = 'Delete'; - deleteButton.className = 'devtools-button'; - deleteButton.style.cssText = 'padding: 4px 8px; cursor: pointer; color: var(--sys-color-error);'; + deleteButton.className = 'provider-delete-button'; deleteButton.addEventListener('click', () => this.deleteProvider(provider.id)); actions.appendChild(deleteButton); @@ -145,32 +149,34 @@ export class CustomProviderDialog { */ private showAddEditDialog(existingProvider?: CustomProviderConfig): void { const addEditDialog = new UI.Dialog.Dialog(); - addEditDialog.setSizeBehavior(UI.GlassPane.SizeBehavior.MEASURE_CONTENT); + addEditDialog.setSizeBehavior(UI.GlassPane.SizeBehavior.SET_EXACT_SIZE); + addEditDialog.setMaxContentSize(new Geometry.Size(window.innerWidth, window.innerHeight)); addEditDialog.setDimmed(true); addEditDialog.addCloseButton(); + addEditDialog.contentElement.classList.add('custom-provider-dialog', 'full-screen'); const container = document.createElement('div'); - container.style.cssText = 'min-width: 450px; padding: 20px;'; + container.className = 'add-edit-container'; // Title const title = document.createElement('h2'); title.textContent = existingProvider ? 'Edit Custom Provider' : 'Add Custom Provider'; - title.style.cssText = 'margin: 0 0 20px 0; font-size: 16px; font-weight: 500;'; + title.className = 'add-edit-title'; container.appendChild(title); // Form const form = document.createElement('div'); - form.style.cssText = 'display: flex; flex-direction: column; gap: 16px;'; + form.className = 'add-edit-form'; // Provider Name const nameLabel = document.createElement('label'); nameLabel.textContent = 'Provider Name'; - nameLabel.style.cssText = 'font-weight: 500; margin-bottom: 4px; display: block;'; + nameLabel.className = 'form-field-label'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.value = existingProvider?.name || ''; nameInput.placeholder = 'e.g., Z.AI'; - nameInput.style.cssText = 'padding: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; width: 100%;'; + nameInput.className = 'form-field-input'; nameInput.disabled = !!existingProvider; // Can't change name when editing form.appendChild(nameLabel); form.appendChild(nameInput); @@ -178,48 +184,47 @@ export class CustomProviderDialog { // Base URL const urlLabel = document.createElement('label'); urlLabel.textContent = 'Base URL'; - urlLabel.style.cssText = 'font-weight: 500; margin-bottom: 4px; display: block;'; + urlLabel.className = 'form-field-label'; const urlInput = document.createElement('input'); urlInput.type = 'text'; urlInput.value = existingProvider?.baseURL || ''; urlInput.placeholder = 'https://api.example.com/v1'; - urlInput.style.cssText = 'padding: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; width: 100%;'; + urlInput.className = 'form-field-input'; form.appendChild(urlLabel); form.appendChild(urlInput); const urlHint = document.createElement('div'); urlHint.textContent = 'The base URL for the OpenAI-compatible API (without /chat/completions)'; - urlHint.style.cssText = 'font-size: 12px; color: var(--sys-color-token-subtle); margin-top: -8px;'; + urlHint.className = 'form-field-hint'; form.appendChild(urlHint); // API Key (optional) const apiKeyLabel = document.createElement('label'); apiKeyLabel.textContent = 'API Key (Optional)'; - apiKeyLabel.style.cssText = 'font-weight: 500; margin-bottom: 4px; display: block;'; + apiKeyLabel.className = 'form-field-label'; const apiKeyInput = document.createElement('input'); apiKeyInput.type = 'password'; apiKeyInput.value = existingProvider ? (CustomProviderManager.getApiKey(existingProvider.id) || '') : ''; apiKeyInput.placeholder = 'Enter API key if required'; - apiKeyInput.style.cssText = 'padding: 8px; border: 1px solid var(--sys-color-divider); border-radius: 4px; width: 100%;'; + apiKeyInput.className = 'form-field-input'; form.appendChild(apiKeyLabel); form.appendChild(apiKeyInput); // Test Connection Section const testSection = document.createElement('div'); - testSection.style.cssText = 'padding: 12px; background: var(--sys-color-surface2); border-radius: 4px;'; + testSection.className = 'test-connection-section'; const testButton = document.createElement('button'); testButton.textContent = 'Test Connection & Fetch Models'; - testButton.className = 'devtools-button'; - testButton.style.cssText = 'padding: 8px 16px; cursor: pointer; width: 100%;'; + testButton.className = 'test-connection-button'; testSection.appendChild(testButton); const statusDiv = document.createElement('div'); - statusDiv.style.cssText = 'margin-top: 12px; padding: 8px; border-radius: 4px; display: none;'; + statusDiv.className = 'status-message'; testSection.appendChild(statusDiv); const modelsDiv = document.createElement('div'); - modelsDiv.style.cssText = 'margin-top: 12px; display: none;'; + modelsDiv.className = 'fetched-models-display'; testSection.appendChild(modelsDiv); form.appendChild(testSection); @@ -228,36 +233,35 @@ export class CustomProviderDialog { // Models Management Section const modelsSection = document.createElement('div'); - modelsSection.style.cssText = 'margin-top: 16px; padding: 12px; background: var(--sys-color-surface2); border-radius: 4px;'; + modelsSection.className = 'models-management-section'; const modelsTitle = document.createElement('div'); modelsTitle.textContent = 'Available Models'; - modelsTitle.style.cssText = 'font-weight: 500; margin-bottom: 8px;'; + modelsTitle.className = 'models-section-title'; modelsSection.appendChild(modelsTitle); const modelsListContainer = document.createElement('div'); - modelsListContainer.style.cssText = 'max-height: 200px; overflow-y: auto; margin-bottom: 12px; padding: 8px; background: var(--sys-color-surface1); border-radius: 4px; min-height: 40px;'; + modelsListContainer.className = 'models-list-container'; modelsSection.appendChild(modelsListContainer); // Add Model Section const addModelContainer = document.createElement('div'); - addModelContainer.style.cssText = 'display: flex; gap: 8px; align-items: center;'; + addModelContainer.className = 'add-model-container'; const addModelLabel = document.createElement('label'); addModelLabel.textContent = 'Add Model:'; - addModelLabel.style.cssText = 'font-weight: 500; min-width: 80px;'; + addModelLabel.className = 'add-model-label'; addModelContainer.appendChild(addModelLabel); const modelNameInput = document.createElement('input'); modelNameInput.type = 'text'; modelNameInput.placeholder = 'Enter model name'; - modelNameInput.style.cssText = 'flex: 1; padding: 6px; border: 1px solid var(--sys-color-divider); border-radius: 4px;'; + modelNameInput.className = 'add-model-input'; addModelContainer.appendChild(modelNameInput); const addModelButton = document.createElement('button'); addModelButton.textContent = 'Add'; - addModelButton.className = 'devtools-button'; - addModelButton.style.cssText = 'padding: 6px 16px; cursor: pointer;'; + addModelButton.className = 'add-model-button'; addModelContainer.appendChild(addModelButton); modelsSection.appendChild(addModelContainer); @@ -266,8 +270,7 @@ export class CustomProviderDialog { // Save button (disabled until at least one model exists) const saveButton = document.createElement('button'); saveButton.textContent = existingProvider ? 'Update Provider' : 'Add Provider'; - saveButton.className = 'devtools-button'; - saveButton.style.cssText = 'padding: 10px 20px; cursor: pointer; margin-top: 20px; width: 100%;'; + saveButton.className = 'save-provider-button'; saveButton.disabled = !existingProvider; // Disabled for new providers until models exist container.appendChild(saveButton); @@ -281,23 +284,22 @@ export class CustomProviderDialog { if (allModels.length === 0) { const emptyMessage = document.createElement('div'); emptyMessage.textContent = 'No models added yet. Test connection to fetch models or add manually.'; - emptyMessage.style.cssText = 'color: var(--sys-color-token-subtle); font-size: 12px; padding: 8px; text-align: center;'; + emptyMessage.className = 'models-empty-message'; modelsListContainer.appendChild(emptyMessage); saveButton.disabled = true; } else { allModels.forEach(modelName => { const modelItem = document.createElement('div'); - modelItem.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; margin-bottom: 4px; background: var(--sys-color-surface2); border-radius: 3px;'; + modelItem.className = 'model-item'; const modelLabel = document.createElement('span'); modelLabel.textContent = modelName; - modelLabel.style.cssText = 'font-family: monospace; font-size: 12px; flex: 1;'; + modelLabel.className = 'model-name'; modelItem.appendChild(modelLabel); const removeButton = document.createElement('button'); removeButton.textContent = '×'; - removeButton.className = 'devtools-button'; - removeButton.style.cssText = 'padding: 2px 8px; cursor: pointer; color: var(--sys-color-error); font-size: 16px; line-height: 1;'; + removeButton.className = 'model-remove-button'; removeButton.title = 'Remove model'; removeButton.addEventListener('click', () => { allModels = allModels.filter(m => m !== modelName); @@ -448,6 +450,10 @@ export class CustomProviderDialog { addEditDialog.contentElement.appendChild(container); addEditDialog.setOutsideClickCallback(() => addEditDialog.hide()); + + // Apply styles + applyCustomProviderStyles(addEditDialog.contentElement); + addEditDialog.show(); } @@ -455,18 +461,18 @@ export class CustomProviderDialog { * Show status message */ private showStatus(element: HTMLElement, message: string, success: boolean | null): void { - element.style.display = 'block'; + element.classList.add('visible'); element.textContent = message; + // Remove existing status classes + element.classList.remove('status-success', 'status-error', 'status-neutral'); + if (success === true) { - element.style.background = 'var(--sys-color-green-container)'; - element.style.color = 'var(--sys-color-on-green-container)'; + element.classList.add('status-success'); } else if (success === false) { - element.style.background = 'var(--sys-color-error-container)'; - element.style.color = 'var(--sys-color-on-error-container)'; + element.classList.add('status-error'); } else { - element.style.background = 'var(--sys-color-neutral-container)'; - element.style.color = 'var(--sys-color-on-surface)'; + element.classList.add('status-neutral'); } } @@ -474,21 +480,21 @@ export class CustomProviderDialog { * Show fetched models */ private showModels(element: HTMLElement, models: string[]): void { - element.style.display = 'block'; + element.classList.add('visible'); element.innerHTML = ''; const title = document.createElement('div'); title.textContent = 'Available Models:'; - title.style.cssText = 'font-weight: 500; margin-bottom: 8px;'; + title.className = 'fetched-models-title'; element.appendChild(title); const modelList = document.createElement('div'); - modelList.style.cssText = 'max-height: 150px; overflow-y: auto; padding: 8px; background: var(--sys-color-surface1); border-radius: 4px;'; + modelList.className = 'fetched-models-list'; models.forEach(model => { const modelItem = document.createElement('div'); modelItem.textContent = `• ${model}`; - modelItem.style.cssText = 'padding: 4px 0; font-size: 12px; font-family: monospace;'; + modelItem.className = 'fetched-model-item'; modelList.appendChild(modelItem); }); diff --git a/front_end/panels/ai_chat/ui/EvaluationDialog.ts b/front_end/panels/ai_chat/ui/EvaluationDialog.ts index 2d3561ad87..d535f6853e 100644 --- a/front_end/panels/ai_chat/ui/EvaluationDialog.ts +++ b/front_end/panels/ai_chat/ui/EvaluationDialog.ts @@ -10,6 +10,7 @@ import { MarkdownReportGenerator } from '../evaluation/framework/MarkdownReportG import { MarkdownViewerUtil } from '../common/MarkdownViewerUtil.js'; import { schemaExtractorTests } from '../evaluation/test-cases/schema-extractor-tests.js'; import { streamlinedSchemaExtractorTests } from '../evaluation/test-cases/streamlined-schema-extractor-tests.js'; +import { htmlToMarkdownTests } from '../evaluation/test-cases/html-to-markdown-tests.js'; import { researchAgentTests } from '../evaluation/test-cases/research-agent-tests.js'; import { actionAgentTests } from '../evaluation/test-cases/action-agent-tests.js'; import { webTaskAgentTests } from '../evaluation/test-cases/web-task-agent-tests.js'; @@ -33,8 +34,11 @@ const TOOL_TEST_MAPPING: Record = tests: streamlinedSchemaExtractorTests, displayName: 'Streamlined Schema Extractor' }, + 'html_to_markdown': { + tests: htmlToMarkdownTests, + displayName: 'HTML to Markdown' + }, // Future tools can be added here: - // 'html_to_markdown': { tests: htmlToMarkdownTests, displayName: 'HTML to Markdown' }, // 'fetcher_tool': { tests: fetcherToolTests, displayName: 'Fetcher Tool' }, }; diff --git a/front_end/panels/ai_chat/ui/customProviderStyles.ts b/front_end/panels/ai_chat/ui/customProviderStyles.ts new file mode 100644 index 0000000000..092b68640c --- /dev/null +++ b/front_end/panels/ai_chat/ui/customProviderStyles.ts @@ -0,0 +1,490 @@ +// 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 custom provider dialog + */ +export function getCustomProviderStyles(): string { + return ` + .custom-provider-dialog { + color: var(--color-text-primary); + background-color: var(--color-background); + } + + .custom-provider-dialog.full-screen { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow-y: auto; + } + + .custom-provider-container { + min-width: 500px; + max-width: 600px; + padding: 20px; + } + + .custom-provider-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + } + + .custom-provider-title { + margin: 0; + font-size: 18px; + font-weight: 500; + color: var(--color-text-primary); + } + + .custom-provider-add-button { + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + background-color: var(--color-primary); + border: 1px solid var(--color-primary); + color: white; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + } + + .custom-provider-add-button:hover { + background-color: var(--color-primary-variant); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 164, 254, 0.2); + } + + .provider-list-container { + max-height: 400px; + overflow-y: auto; + } + + .provider-list-item { + padding: 12px; + margin-bottom: 8px; + border: 1px solid var(--color-details-hairline); + border-radius: 6px; + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--color-background-elevation-1); + transition: all 0.2s ease; + } + + .provider-list-item:hover { + background-color: var(--color-background-elevation-2); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); + transform: translateX(2px); + } + + .provider-info { + flex: 1; + } + + .provider-name { + font-weight: 500; + margin-bottom: 4px; + font-size: 14px; + color: var(--color-text-primary); + } + + .provider-url { + font-size: 12px; + color: var(--color-text-secondary); + font-family: monospace; + } + + .provider-models-count { + font-size: 11px; + color: var(--color-text-secondary); + margin-top: 2px; + } + + .provider-actions { + display: flex; + gap: 8px; + } + + .provider-edit-button { + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + border: 1px solid var(--color-details-hairline); + background-color: var(--color-background-elevation-1); + color: var(--color-text-primary); + transition: all 0.2s ease; + } + + .provider-edit-button:hover { + background-color: var(--color-background-elevation-2); + border-color: var(--color-primary); + } + + .provider-delete-button { + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + border: 1px solid var(--color-error); + background-color: var(--color-background-elevation-1); + color: var(--color-error); + transition: all 0.2s ease; + } + + .provider-delete-button:hover { + background-color: var(--color-error); + color: white; + } + + .provider-empty-message { + color: var(--color-text-secondary); + text-align: center; + padding: 32px; + font-size: 14px; + } + + /* Add/Edit Dialog Styles */ + .add-edit-container { + width: 100%; + max-width: 800px; + padding: 40px; + margin: 20px auto; + box-sizing: border-box; + overflow-y: auto; + max-height: calc(100vh - 40px); + } + + .add-edit-title { + margin: 0 0 20px 0; + font-size: 16px; + font-weight: 500; + color: var(--color-text-primary); + } + + .add-edit-form { + display: flex; + flex-direction: column; + gap: 16px; + } + + .form-field-label { + font-weight: 500; + margin-bottom: 4px; + display: block; + font-size: 14px; + color: var(--color-text-primary); + } + + .form-field-input { + padding: 8px 12px; + border: 1px solid var(--color-details-hairline); + border-radius: 4px; + width: 100%; + background-color: var(--color-background-elevation-2); + color: var(--color-text-primary); + font-size: 14px; + box-sizing: border-box; + transition: all 0.2s ease; + } + + .form-field-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 1px rgba(0, 164, 254, 0.3); + } + + .form-field-input:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: var(--color-background-elevation-0); + } + + .form-field-hint { + font-size: 12px; + color: var(--color-text-secondary); + margin-top: -8px; + } + + .test-connection-section { + padding: 12px; + background: var(--color-background-elevation-1); + border-radius: 6px; + border: 1px solid var(--color-details-hairline); + } + + .test-connection-button { + padding: 8px 16px; + cursor: pointer; + width: 100%; + border-radius: 4px; + background-color: var(--color-primary); + border: 1px solid var(--color-primary); + color: white; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + } + + .test-connection-button:hover:not(:disabled) { + background-color: var(--color-primary-variant); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 164, 254, 0.2); + } + + .test-connection-button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .status-message { + margin-top: 12px; + padding: 8px; + border-radius: 4px; + font-size: 13px; + display: none; + transition: all 0.3s ease; + } + + .status-message.visible { + display: block; + } + + .status-success { + background: var(--sys-color-green-container); + color: var(--sys-color-on-green-container); + border: 1px solid var(--sys-color-accent-green); + } + + .status-error { + background: var(--sys-color-error-container); + color: var(--sys-color-on-error-container); + border: 1px solid var(--sys-color-error); + } + + .status-neutral { + background: var(--sys-color-neutral-container); + color: var(--color-text-primary); + border: 1px solid var(--color-details-hairline); + } + + .fetched-models-display { + margin-top: 12px; + display: none; + } + + .fetched-models-display.visible { + display: block; + } + + .fetched-models-title { + font-weight: 500; + margin-bottom: 8px; + font-size: 14px; + color: var(--color-text-primary); + } + + .fetched-models-list { + max-height: 150px; + overflow-y: auto; + padding: 8px; + background: var(--color-background); + border-radius: 4px; + border: 1px solid var(--color-details-hairline); + } + + .fetched-model-item { + padding: 4px 0; + font-size: 12px; + font-family: monospace; + color: var(--color-text-primary); + } + + /* Models Management Section */ + .models-management-section { + margin-top: 16px; + padding: 12px; + background: var(--color-background-elevation-1); + border-radius: 6px; + border: 1px solid var(--color-details-hairline); + } + + .models-section-title { + font-weight: 500; + margin-bottom: 8px; + font-size: 14px; + color: var(--color-text-primary); + } + + .models-list-container { + max-height: 200px; + overflow-y: auto; + margin-bottom: 12px; + padding: 8px; + background: var(--color-background); + border-radius: 4px; + min-height: 40px; + border: 1px solid var(--color-details-hairline); + } + + .models-empty-message { + color: var(--color-text-secondary); + font-size: 12px; + padding: 8px; + text-align: center; + } + + .model-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + margin-bottom: 4px; + background: var(--color-background-elevation-1); + border-radius: 4px; + transition: all 0.2s ease; + } + + .model-item:hover { + background: var(--color-background-elevation-2); + } + + .model-name { + font-family: monospace; + font-size: 12px; + flex: 1; + color: var(--color-text-primary); + } + + .model-remove-button { + padding: 2px 8px; + cursor: pointer; + color: var(--color-error); + font-size: 16px; + line-height: 1; + background: transparent; + border: 1px solid transparent; + border-radius: 3px; + transition: all 0.2s ease; + } + + .model-remove-button:hover { + background: var(--color-error); + color: white; + border-color: var(--color-error); + } + + .add-model-container { + display: flex; + gap: 8px; + align-items: center; + } + + .add-model-label { + font-weight: 500; + min-width: 80px; + font-size: 14px; + color: var(--color-text-primary); + } + + .add-model-input { + flex: 1; + padding: 6px 12px; + border: 1px solid var(--color-details-hairline); + border-radius: 4px; + background-color: var(--color-background-elevation-2); + color: var(--color-text-primary); + font-size: 14px; + transition: all 0.2s ease; + } + + .add-model-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 1px rgba(0, 164, 254, 0.3); + } + + .add-model-button { + padding: 6px 16px; + cursor: pointer; + border-radius: 4px; + background-color: var(--color-background-elevation-1); + border: 1px solid var(--color-details-hairline); + color: var(--color-text-primary); + font-size: 14px; + transition: all 0.2s ease; + } + + .add-model-button:hover { + background-color: var(--color-background-elevation-2); + border-color: var(--color-primary); + } + + .save-provider-button { + padding: 10px 20px; + cursor: pointer; + margin-top: 20px; + width: 100%; + border-radius: 4px; + background-color: var(--color-primary); + border: 1px solid var(--color-primary); + color: white; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + } + + .save-provider-button:hover:not(:disabled) { + background-color: var(--color-primary-variant); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 164, 254, 0.2); + } + + .save-provider-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + /* Scrollbar styling for better consistency */ + .provider-list-container::-webkit-scrollbar, + .models-list-container::-webkit-scrollbar, + .fetched-models-list::-webkit-scrollbar { + width: 8px; + } + + .provider-list-container::-webkit-scrollbar-track, + .models-list-container::-webkit-scrollbar-track, + .fetched-models-list::-webkit-scrollbar-track { + background: var(--color-background); + border-radius: 4px; + } + + .provider-list-container::-webkit-scrollbar-thumb, + .models-list-container::-webkit-scrollbar-thumb, + .fetched-models-list::-webkit-scrollbar-thumb { + background: var(--color-details-hairline); + border-radius: 4px; + } + + .provider-list-container::-webkit-scrollbar-thumb:hover, + .models-list-container::-webkit-scrollbar-thumb:hover, + .fetched-models-list::-webkit-scrollbar-thumb:hover { + background: var(--color-text-secondary); + } + `; +} + +/** + * Apply custom provider dialog styles to a dialog element + */ +export function applyCustomProviderStyles(dialogElement: HTMLElement): void { + const styleElement = document.createElement('style'); + styleElement.textContent = getCustomProviderStyles(); + dialogElement.appendChild(styleElement); +} diff --git a/front_end/panels/ai_chat/utils/ContentChunker.ts b/front_end/panels/ai_chat/utils/ContentChunker.ts new file mode 100644 index 0000000000..d2fb168c6a --- /dev/null +++ b/front_end/panels/ai_chat/utils/ContentChunker.ts @@ -0,0 +1,469 @@ +// 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 { createLogger } from '../core/Logger.js'; + +const logger = createLogger('Utils:ContentChunker'); + +/** + * Represents a chunk of content with metadata + */ +export interface ContentChunk { + id: string; + content: string; + heading: string; + startPosition: number; + endPosition: number; + tokenEstimate: number; + level?: number; // Heading level (1 for H1, 2 for H2, etc.) + // Future extensions for LLM×MapReduce: + // confidence?: number; + // keyFacts?: string[]; + // reasoning?: string; +} + +/** + * Options for content chunking + */ +export interface ChunkingOptions { + maxTokensPerChunk?: number; // Default: 40000 + strategy?: 'headings' | 'paragraphs' | 'hybrid' | 'accessibility-tree'; // Default: 'hybrid' + preserveContext?: boolean; // Include parent headings in chunk content + charsPerToken?: number; // Default: 4 (conservative estimate) +} + +/** + * Utility class for semantically chunking markdown content + * + * This chunker splits content at semantic boundaries (headings, paragraphs) + * while respecting token limits. Designed to be extensible for future + * LLM×MapReduce implementations. + */ +export class ContentChunker { + private readonly DEFAULT_MAX_TOKENS = 40000; + private readonly DEFAULT_CHARS_PER_TOKEN = 4; + private readonly DEFAULT_STRATEGY = 'hybrid'; + + /** + * Chunk markdown content into semantic pieces + */ + chunk(content: string, options: ChunkingOptions = {}): ContentChunk[] { + const maxTokens = options.maxTokensPerChunk ?? this.DEFAULT_MAX_TOKENS; + const strategy = options.strategy ?? this.DEFAULT_STRATEGY; + const preserveContext = options.preserveContext ?? true; + const charsPerToken = options.charsPerToken ?? this.DEFAULT_CHARS_PER_TOKEN; + + logger.info('Chunking content', { + contentLength: content.length, + estimatedTokens: Math.ceil(content.length / charsPerToken), + maxTokens, + strategy + }); + + // If content is small enough, return as single chunk + const totalTokens = this.estimateTokens(content, charsPerToken); + if (totalTokens <= maxTokens) { + logger.info('Content fits in single chunk, no splitting needed'); + return [{ + id: 'chunk-0', + content, + heading: '', + startPosition: 0, + endPosition: content.length, + tokenEstimate: totalTokens, + }]; + } + + // Choose chunking strategy + switch (strategy) { + case 'headings': + return this.chunkByHeadings(content, maxTokens, charsPerToken, preserveContext); + case 'paragraphs': + return this.chunkByParagraphs(content, maxTokens, charsPerToken); + case 'accessibility-tree': + return this.chunkByAccessibilityNodes(content, maxTokens, charsPerToken); + case 'hybrid': + default: + return this.chunkHybrid(content, maxTokens, charsPerToken, preserveContext); + } + } + + /** + * Chunk content by heading boundaries (H1, H2, H3) + */ + private chunkByHeadings( + content: string, + maxTokens: number, + charsPerToken: number, + preserveContext: boolean + ): ContentChunk[] { + const sections = this.parseHeadings(content); + const chunks: ContentChunk[] = []; + let currentChunk = ''; + let currentHeading = ''; + let currentLevel = 0; + let currentStart = 0; + let chunkId = 0; + + for (const section of sections) { + const sectionTokens = this.estimateTokens(section.content, charsPerToken); + const currentChunkTokens = this.estimateTokens(currentChunk, charsPerToken); + + // If section itself is too large, split it further + if (sectionTokens > maxTokens) { + // Flush current chunk if it has content + if (currentChunk) { + chunks.push({ + id: `chunk-${chunkId++}`, + content: currentChunk, + heading: currentHeading, + startPosition: currentStart, + endPosition: currentStart + currentChunk.length, + tokenEstimate: currentChunkTokens, + level: currentLevel, + }); + currentChunk = ''; + } + + // Split large section by paragraphs + const subChunks = this.chunkByParagraphs(section.content, maxTokens, charsPerToken); + subChunks.forEach((subChunk) => { + chunks.push({ + ...subChunk, + id: `chunk-${chunkId++}`, + heading: section.heading, + level: section.level, + }); + }); + currentStart = section.end; + continue; + } + + // If adding this section would exceed limit, flush current chunk + if (currentChunk && currentChunkTokens + sectionTokens > maxTokens) { + chunks.push({ + id: `chunk-${chunkId++}`, + content: currentChunk, + heading: currentHeading, + startPosition: currentStart, + endPosition: currentStart + currentChunk.length, + tokenEstimate: currentChunkTokens, + level: currentLevel, + }); + currentChunk = ''; + currentStart = section.start; + } + + // Add section to current chunk + if (!currentChunk) { + currentHeading = section.heading; + currentLevel = section.level; + currentStart = section.start; + } + currentChunk += section.content; + } + + // Flush final chunk + if (currentChunk) { + chunks.push({ + id: `chunk-${chunkId++}`, + content: currentChunk, + heading: currentHeading, + startPosition: currentStart, + endPosition: currentStart + currentChunk.length, + tokenEstimate: this.estimateTokens(currentChunk, charsPerToken), + level: currentLevel, + }); + } + + logger.info('Chunked by headings', { chunkCount: chunks.length }); + return chunks; + } + + /** + * Chunk content by accessibility tree node boundaries + * Splits before lines starting with [nodeId] pattern + */ + private chunkByAccessibilityNodes( + content: string, + maxTokens: number, + charsPerToken: number + ): ContentChunk[] { + const lines = content.split('\n'); + const chunks: ContentChunk[] = []; + let currentChunk: string[] = []; + let currentTokens = 0; + let chunkId = 0; + let startPosition = 0; + let position = 0; + + for (const line of lines) { + // Check if line starts with [nodeId] pattern (including indented nodes) + const isNodeStart = /^\s*\[(\d+)\]/.test(line); + const lineTokens = this.estimateTokens(line + '\n', charsPerToken); + + // If adding this line exceeds limit AND we're at a node boundary, flush chunk + if (isNodeStart && currentTokens + lineTokens > maxTokens && currentChunk.length > 0) { + const chunkContent = currentChunk.join('\n'); + chunks.push({ + id: `chunk-${chunkId++}`, + content: chunkContent, + heading: '', + startPosition, + endPosition: position, + tokenEstimate: currentTokens, + }); + + currentChunk = []; + currentTokens = 0; + startPosition = position; + } + + currentChunk.push(line); + currentTokens += lineTokens; + position += line.length + 1; // +1 for newline + } + + // Flush final chunk + if (currentChunk.length > 0) { + const chunkContent = currentChunk.join('\n'); + chunks.push({ + id: `chunk-${chunkId++}`, + content: chunkContent, + heading: '', + startPosition, + endPosition: content.length, + tokenEstimate: currentTokens, + }); + } + + logger.info('Chunked by accessibility nodes', { chunkCount: chunks.length }); + return chunks; + } + + /** + * Chunk content by paragraph boundaries + */ + private chunkByParagraphs(content: string, maxTokens: number, charsPerToken: number): ContentChunk[] { + const paragraphs = content.split(/\n\n+/); + const chunks: ContentChunk[] = []; + let currentChunk = ''; + let currentStart = 0; + let chunkId = 0; + let position = 0; + + for (const paragraph of paragraphs) { + const paragraphTokens = this.estimateTokens(paragraph, charsPerToken); + const currentChunkTokens = this.estimateTokens(currentChunk, charsPerToken); + + // If single paragraph exceeds limit, we have to include it anyway + // (This is a fallback - in practice, we chunk before this happens) + if (paragraphTokens > maxTokens && !currentChunk) { + chunks.push({ + id: `chunk-${chunkId++}`, + content: paragraph, + heading: '', + startPosition: position, + endPosition: position + paragraph.length, + tokenEstimate: paragraphTokens, + }); + position += paragraph.length + 2; // +2 for \n\n + continue; + } + + // If adding this paragraph would exceed limit, flush current chunk + if (currentChunk && currentChunkTokens + paragraphTokens > maxTokens) { + chunks.push({ + id: `chunk-${chunkId++}`, + content: currentChunk, + heading: '', + startPosition: currentStart, + endPosition: position - 2, // -2 for \n\n before current paragraph + tokenEstimate: currentChunkTokens, + }); + currentChunk = ''; + currentStart = position; + } + + // Add paragraph to current chunk + currentChunk += (currentChunk ? '\n\n' : '') + paragraph; + position += paragraph.length + 2; + } + + // Flush final chunk + if (currentChunk) { + chunks.push({ + id: `chunk-${chunkId++}`, + content: currentChunk, + heading: '', + startPosition: currentStart, + endPosition: content.length, + tokenEstimate: this.estimateTokens(currentChunk, charsPerToken), + }); + } + + logger.info('Chunked by paragraphs', { chunkCount: chunks.length }); + return chunks; + } + + /** + * Hybrid chunking: Try headings first, fall back to paragraphs for large sections + */ + private chunkHybrid( + content: string, + maxTokens: number, + charsPerToken: number, + preserveContext: boolean + ): ContentChunk[] { + const sections = this.parseHeadings(content); + + // If no headings found, fall back to paragraph chunking + if (sections.length === 0 || (sections.length === 1 && !sections[0].heading)) { + logger.info('No headings found, falling back to paragraph chunking'); + return this.chunkByParagraphs(content, maxTokens, charsPerToken); + } + + // Use heading-based chunking + return this.chunkByHeadings(content, maxTokens, charsPerToken, preserveContext); + } + + /** + * Parse markdown content to identify heading sections + */ + private parseHeadings(content: string): Array<{ + heading: string; + level: number; + content: string; + start: number; + end: number; + }> { + const lines = content.split('\n'); + const sections: Array<{ + heading: string; + level: number; + content: string; + start: number; + end: number; + }> = []; + + let currentSection: { + heading: string; + level: number; + contentLines: string[]; + start: number; + } | null = null; + let position = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const headingMatch = line.match(/^(#{1,3})\s+(.+)$/); + + if (headingMatch) { + // Flush previous section + if (currentSection) { + const content = currentSection.contentLines.join('\n'); + sections.push({ + heading: currentSection.heading, + level: currentSection.level, + content, + start: currentSection.start, + end: position - 1, // -1 to not include newline before next heading + }); + } + + // Start new section + currentSection = { + heading: headingMatch[2].trim(), + level: headingMatch[1].length, + contentLines: [line], // Include the heading line itself + start: position, + }; + } else if (currentSection) { + // Add line to current section + currentSection.contentLines.push(line); + } else { + // Content before first heading - treat as section with empty heading + if (!currentSection) { + currentSection = { + heading: '', + level: 0, + contentLines: [line], + start: 0, + }; + } + } + + position += line.length + 1; // +1 for newline + } + + // Flush final section + if (currentSection) { + const content = currentSection.contentLines.join('\n'); + sections.push({ + heading: currentSection.heading, + level: currentSection.level, + content, + start: currentSection.start, + end: position, + }); + } + + // If no sections found, return entire content as single section + if (sections.length === 0) { + sections.push({ + heading: '', + level: 0, + content, + start: 0, + end: content.length, + }); + } + + logger.info('Parsed headings', { + sectionCount: sections.length, + headings: sections.map(s => ({ heading: s.heading, level: s.level })) + }); + + return sections; + } + + /** + * Estimate token count for content + */ + private estimateTokens(content: string, charsPerToken: number): number { + return Math.ceil(content.length / charsPerToken); + } + + /** + * Get summary statistics about chunks + */ + getChunkStats(chunks: ContentChunk[]): { + totalChunks: number; + totalTokens: number; + avgTokensPerChunk: number; + minTokens: number; + maxTokens: number; + } { + if (chunks.length === 0) { + return { + totalChunks: 0, + totalTokens: 0, + avgTokensPerChunk: 0, + minTokens: 0, + maxTokens: 0, + }; + } + + const totalTokens = chunks.reduce((sum, chunk) => sum + chunk.tokenEstimate, 0); + const tokenCounts = chunks.map(c => c.tokenEstimate); + + return { + totalChunks: chunks.length, + totalTokens, + avgTokensPerChunk: Math.round(totalTokens / chunks.length), + minTokens: Math.min(...tokenCounts), + maxTokens: Math.max(...tokenCounts), + }; + } +} From 10a82aad9c8ebb1703e86f265ca5124ca8dedbc1 Mon Sep 17 00:00:00 2001 From: Tyson Thomas Date: Sun, 23 Nov 2025 22:23:31 -0800 Subject: [PATCH 3/4] Add support for readability and more optimization --- front_end/panels/ai_chat/BUILD.gn | 9 + .../implementation/ConfiguredAgents.ts | 2 + .../implementation/agents/ResearchAgent.ts | 65 +- .../implementation/agents/SearchAgent.ts | 8 +- .../test-cases/html-to-markdown-tests.ts | 2 + front_end/panels/ai_chat/tools/FetcherTool.ts | 60 +- .../ai_chat/tools/HTMLToMarkdownTool.ts | 4 +- .../ai_chat/tools/ReadabilityExtractorTool.ts | 227 ++ .../ai_chat/tools/SchemaBasedExtractorTool.ts | 66 +- front_end/panels/ai_chat/tools/Tools.ts | 44 +- .../ai_chat/vendor/readability-source.ts | 2799 +++++++++++++++++ 11 files changed, 3182 insertions(+), 104 deletions(-) create mode 100644 front_end/panels/ai_chat/tools/ReadabilityExtractorTool.ts create mode 100644 front_end/panels/ai_chat/vendor/readability-source.ts diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index 3bde86e174..d8deb34ac0 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -69,6 +69,7 @@ devtools_module("ai_chat") { "ui/ConversationHistoryList.ts", "ui/conversationHistoryStyles.ts", "ui/CustomProviderDialog.ts", + "ui/customProviderStyles.ts", "ai_chat_impl.ts", "models/ChatTypes.ts", "persistence/ConversationTypes.ts", @@ -120,6 +121,7 @@ devtools_module("ai_chat") { "tools/FinalizeWithCritiqueTool.ts", "tools/VisitHistoryManager.ts", "tools/HTMLToMarkdownTool.ts", + "tools/ReadabilityExtractorTool.ts", "tools/SchemaBasedExtractorTool.ts", "tools/StreamlinedSchemaExtractorTool.ts", "tools/CombinedExtractionTool.ts", @@ -174,6 +176,7 @@ devtools_module("ai_chat") { "evaluation/test-cases/research-agent-tests.ts", "evaluation/test-cases/action-agent-tests.ts", "evaluation/test-cases/web-task-agent-tests.ts", + "evaluation/test-cases/html-to-markdown-tests.ts", "evaluation/runner/EvaluationRunner.ts", "evaluation/runner/VisionAgentEvaluationRunner.ts", "common/MarkdownViewerUtil.ts", @@ -183,6 +186,8 @@ devtools_module("ai_chat") { "common/page.ts", "common/WebSocketRPCClient.ts", "common/EvaluationConfig.ts", + "utils/ContentChunker.ts", + "vendor/readability-source.ts", "evaluation/remote/EvaluationProtocol.ts", "evaluation/remote/EvaluationAgent.ts", "tracing/TracingProvider.ts", @@ -319,6 +324,7 @@ _ai_chat_sources = [ "tools/FinalizeWithCritiqueTool.ts", "tools/VisitHistoryManager.ts", "tools/HTMLToMarkdownTool.ts", + "tools/ReadabilityExtractorTool.ts", "tools/SchemaBasedExtractorTool.ts", "tools/StreamlinedSchemaExtractorTool.ts", "tools/CombinedExtractionTool.ts", @@ -373,6 +379,7 @@ _ai_chat_sources = [ "evaluation/test-cases/research-agent-tests.ts", "evaluation/test-cases/action-agent-tests.ts", "evaluation/test-cases/web-task-agent-tests.ts", + "evaluation/test-cases/html-to-markdown-tests.ts", "evaluation/runner/EvaluationRunner.ts", "evaluation/runner/VisionAgentEvaluationRunner.ts", "common/MarkdownViewerUtil.ts", @@ -382,6 +389,8 @@ _ai_chat_sources = [ "common/page.ts", "common/WebSocketRPCClient.ts", "common/EvaluationConfig.ts", + "utils/ContentChunker.ts", + "vendor/readability-source.ts", "evaluation/remote/EvaluationProtocol.ts", "evaluation/remote/EvaluationAgent.ts", "tracing/TracingProvider.ts", diff --git a/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts b/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts index b79a7e64c2..5d252e533f 100644 --- a/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts +++ b/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts @@ -12,6 +12,7 @@ import { NavigateURLTool, PerformActionTool, GetAccessibilityTreeTool, SearchCon import { UpdateTodoTool } from '../../tools/UpdateTodoTool.js'; import { ExecuteCodeTool } from '../../tools/ExecuteCodeTool.js'; import { HTMLToMarkdownTool } from '../../tools/HTMLToMarkdownTool.js'; +import { ReadabilityExtractorTool } from '../../tools/ReadabilityExtractorTool.js'; import { ConfigurableAgentTool, ToolRegistry } from '../ConfigurableAgentTool.js'; import { ThinkingTool } from '../../tools/ThinkingTool.js'; import { registerMCPMetaTools } from '../../mcp/MCPMetaTools.js'; @@ -48,6 +49,7 @@ export function initializeConfiguredAgents(): void { ToolRegistry.registerToolFactory('search_content', () => new SearchContentTool()); ToolRegistry.registerToolFactory('take_screenshot', () => new TakeScreenshotTool()); ToolRegistry.registerToolFactory('html_to_markdown', () => new HTMLToMarkdownTool()); + ToolRegistry.registerToolFactory('readability_extractor', () => new ReadabilityExtractorTool()); ToolRegistry.registerToolFactory('scroll_page', () => new ScrollPageTool()); ToolRegistry.registerToolFactory('wait_for_page_load', () => new WaitTool()); ToolRegistry.registerToolFactory('thinking', () => new ThinkingTool()); diff --git a/front_end/panels/ai_chat/agent_framework/implementation/agents/ResearchAgent.ts b/front_end/panels/ai_chat/agent_framework/implementation/agents/ResearchAgent.ts index 937244f9db..5126b741ed 100644 --- a/front_end/panels/ai_chat/agent_framework/implementation/agents/ResearchAgent.ts +++ b/front_end/panels/ai_chat/agent_framework/implementation/agents/ResearchAgent.ts @@ -45,7 +45,7 @@ export function createResearchAgentConfig(): AgentToolConfig { ## Key Tools - **navigate_url + fetcher_tool**: Primary research loop - **extract_data**: Structured data extraction with JSON schema -- **html_to_markdown**: Clean page text extraction +- **readability_extractor**: Fast plain text extraction - **create_file/update_file/read_file/list_files**: Persist and track findings across iterations ## Quality Standards @@ -97,7 +97,7 @@ Example for "AI trends in 2025": ai-trends-2025_research.md, ai-trends-2025_sour 'fetcher_tool', 'extract_data', 'node_ids_to_urls', - 'html_to_markdown', + 'readability_extractor', 'create_file', 'update_file', 'read_file', @@ -173,8 +173,8 @@ ${args.scope ? `The scope of research expected: ${args.scope}` : ''} // Only save successful fetches with content if (source.success && source.markdownContent && source.markdownContent.trim().length > 0) { try { - // Create a sanitized filename from the URL - const filename = sanitizeUrlToFilename(source.url); + // Create a sanitized filename from the URL and title + const filename = sanitizeUrlToFilename(source.url, source.title); // Create file content with metadata header const fileContent = `# ${source.title || 'Untitled'} @@ -232,31 +232,46 @@ ${source.markdownContent}`; } /** - * Sanitize a URL to create a safe filename + * Sanitize a URL and optional title to create a safe filename + * Prefers title-based names for readability, falls back to URL-based names */ -function sanitizeUrlToFilename(url: string): string { +function sanitizeUrlToFilename(url: string, title?: string): string { try { - const urlObj = new URL(url); - - // Extract domain and path - let domain = urlObj.hostname.replace(/^www\./, ''); - let path = urlObj.pathname.replace(/^\//, '').replace(/\/$/, ''); - - // Create a base name from domain and path - let baseName = domain; - if (path) { - // Take first 2 path segments for readability - const pathParts = path.split('/').filter(p => p.length > 0); - if (pathParts.length > 0) { - baseName += '-' + pathParts.slice(0, 2).join('-'); - } + let baseName = ''; + + // Prefer title if available + if (title && title.trim()) { + baseName = title + .trim() + .toLowerCase() + .replace(/[^a-zA-Z0-9\s-]/g, '') // Remove special characters + .replace(/\s+/g, '-') // Convert spaces to dashes + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/^-|-$/g, '') // Remove leading/trailing dashes + .substring(0, 60); // Limit length for readability } - // Remove special characters and limit length - baseName = baseName - .replace(/[^a-zA-Z0-9-_]/g, '-') - .replace(/-+/g, '-') - .substring(0, 80); + // Fallback to URL-based name if no title or title is empty after sanitization + if (!baseName) { + const urlObj = new URL(url); + let domain = urlObj.hostname.replace(/^www\./, ''); + let path = urlObj.pathname.replace(/^\//, '').replace(/\/$/, ''); + + baseName = domain; + if (path) { + // Take first 2 path segments for readability + const pathParts = path.split('/').filter(p => p.length > 0); + if (pathParts.length > 0) { + baseName += '-' + pathParts.slice(0, 2).join('-'); + } + } + + // Remove special characters and limit length + baseName = baseName + .replace(/[^a-zA-Z0-9-_]/g, '-') + .replace(/-+/g, '-') + .substring(0, 60); + } // Add a short hash of the full URL to prevent collisions const hash = simpleHash(url).substring(0, 8); diff --git a/front_end/panels/ai_chat/agent_framework/implementation/agents/SearchAgent.ts b/front_end/panels/ai_chat/agent_framework/implementation/agents/SearchAgent.ts index 1fea4960ba..15536a4569 100644 --- a/front_end/panels/ai_chat/agent_framework/implementation/agents/SearchAgent.ts +++ b/front_end/panels/ai_chat/agent_framework/implementation/agents/SearchAgent.ts @@ -32,7 +32,7 @@ export function createSearchAgentConfig(): AgentToolConfig { 3. **Collect leads**: - Use navigate_url to reach the most relevant search entry point (search engines, directories, LinkedIn public results, company pages, press releases). - Use extract_data with an explicit JSON schema every time you capture structured search results. Prefer capturing multiple leads in one call. - - Batch follow-up pages with fetcher_tool, and use html_to_markdown when you need to confirm context inside long documents. + - Batch follow-up pages with fetcher_tool, and use readability_extractor when you need to confirm context inside long documents. - After each significant batch of new leads or fetcher_tool response, immediately persist the harvested candidates (including query, timestamp, and confidence notes) by appending to a coordination file via 'create_file'/'update_file'. This keeps other subtasks aligned and prevents redundant scraping. 4. **Mandatory Pagination Loop (ENFORCED)**: - Harvest target per task: collect 30–50 unique candidates before enrichment (unless the user specifies otherwise). Absolute minimum 25 when the request requires it. @@ -58,7 +58,7 @@ export function createSearchAgentConfig(): AgentToolConfig { "name": "extract_data", "arguments": "{\"instruction\":\"From the currently loaded Google News results page for query 'OpenAI September 2025 news', extract the top 15 news items visible in the search results. For each item extract: title (string), snippet (string), url (string, format:url), source (string), and publishDate (string). Return a JSON object with property 'results' which is an array of these items.\",\"reasoning\":\"Collect structured list of recent news articles about OpenAI in September 2025 so we can batch-fetch the full content for comprehensive research.\",\"schema\":{\"type\":\"object\",\"properties\":{\"results\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"},\"snippet\":{\"type\":\"string\"},\"url\":{\"type\":\"string\",\"format\":\"url\"},\"source\":{\"type\":\"string\"},\"publishDate\":{\"type\":\"string\"}},\"required\":[\"title\",\"url\",\"source\"]}}},\"required\":[\"results\"]}}" }) -- Use html_to_markdown when you need high-quality page text in addition to (not instead of) structured extractions. +- Use readability_extractor when you need fast plain text extraction in addition to (not instead of) structured extractions. - Never call extract_data or fetcher_tool without a clear plan for how the results will fill gaps in the objective. - Before starting new queries, call 'list_files'/'read_file' to review previous batches and avoid duplicating work; always append incremental findings to the existing coordination file for the current objective. @@ -132,7 +132,7 @@ If you absolutely cannot find any reliable leads, return status "failed" with ga 'extract_data', 'scroll_page', 'action_agent', - 'html_to_markdown', + 'readability_extractor', 'create_file', 'update_file', 'delete_file', @@ -273,7 +273,7 @@ If you absolutely cannot find any reliable leads, return status "failed" with ga ], next_actions: [ 'Continue pagination on current queries (Next/numeric page or query params).', - 'Batch fetcher_tool on shortlisted URLs; use html_to_markdown + document_search to extract location, availability, portfolio, and contact.', + 'Batch fetcher_tool on shortlisted URLs; use readability_extractor + document_search to extract location, availability, portfolio, and contact.', 'Deduplicate by normalized name + hostname and canonical URL.' ] }; diff --git a/front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts b/front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts index 2f897c6c05..afde0c43bf 100644 --- a/front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts +++ b/front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.ts @@ -327,7 +327,9 @@ export function getTestsByDuration( * CommonJS export for Node.js compatibility * Allows backend evaluation runner to import test cases */ +// @ts-ignore - module is not defined in browser context if (typeof module !== 'undefined' && module.exports) { + // @ts-ignore module.exports = { simpleArticleTest, largeArticleChunkingTest, diff --git a/front_end/panels/ai_chat/tools/FetcherTool.ts b/front_end/panels/ai_chat/tools/FetcherTool.ts index 4c848312d1..5e3bf5283d 100644 --- a/front_end/panels/ai_chat/tools/FetcherTool.ts +++ b/front_end/panels/ai_chat/tools/FetcherTool.ts @@ -3,7 +3,7 @@ // found in the LICENSE file. import { createLogger } from '../core/Logger.js'; -import { HTMLToMarkdownTool, type HTMLToMarkdownResult } from './HTMLToMarkdownTool.js'; +import { ReadabilityExtractorTool, type ReadabilityExtractorResult } from './ReadabilityExtractorTool.js'; import { NavigateURLTool, type Tool, type LLMContext } from './Tools.js'; const logger = createLogger('Tool:Fetcher'); @@ -14,7 +14,7 @@ const logger = createLogger('Tool:Fetcher'); export interface FetchedContent { url: string; title: string; - markdownContent: string; + markdownContent: string; // Plain text content (for backwards compatibility, named markdownContent) success: boolean; error?: string; } @@ -40,15 +40,15 @@ export interface FetcherToolResult { * Agent that fetches and extracts content from URLs * * This agent takes a list of URLs, navigates to each one, and extracts - * the main content as markdown. It uses NavigateURLTool for navigation - * and HTMLToMarkdownTool for content extraction. + * the main content as plain text. It uses NavigateURLTool for navigation + * and ReadabilityExtractorTool for fast content extraction. * - * Content extraction is handled by HTMLToMarkdownTool, which - * automatically chunks large pages for efficient processing. + * Content extraction is handled by ReadabilityExtractorTool, which uses + * Mozilla Readability for deterministic extraction without LLM calls. */ export class FetcherTool implements Tool { name = 'fetcher_tool'; - description = 'Navigates to URLs, extracts and cleans the main content, returning markdown for each source.'; + description = 'Navigates to URLs, extracts and cleans the main content, returning plain text for each source'; schema = { @@ -70,7 +70,7 @@ export class FetcherTool implements Tool { }; private navigateURLTool = new NavigateURLTool(); - private htmlToMarkdownTool = new HTMLToMarkdownTool(); + private readabilityExtractorTool = new ReadabilityExtractorTool(); /** * Execute the fetcher agent to process multiple URLs @@ -138,29 +138,6 @@ export class FetcherTool implements Tool { throw new DOMException('The operation was aborted', 'AbortError'); } }; - const sleep = (ms: number) => new Promise((resolve, reject) => { - if (!ms) return resolve(); - const timer = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - cleanup(); - reject(new DOMException('The operation was aborted', 'AbortError')); - }; - const cleanup = () => { - signal?.removeEventListener('abort', onAbort); - }; - if (signal) { - if (signal.aborted) { - clearTimeout(timer); - cleanup(); - return reject(new DOMException('The operation was aborted', 'AbortError')); - } - signal.addEventListener('abort', onAbort, { once: true }); - } - }); try { // Step 1: Navigate to the URL logger.info('Navigating to URL', { url }); @@ -182,37 +159,34 @@ export class FetcherTool implements Tool { }; } - // Wait for 1 second to ensure the page has time to load - await sleep(1000); - throwIfAborted(); - // Get metadata from navigation result const metadata = navigationResult.metadata ? navigationResult.metadata : { url: '', title: '' }; - // Step 2: Extract markdown content using HTMLToMarkdownTool + // Step 2: Extract content using ReadabilityExtractorTool (with automatic LLM fallback) logger.info('Extracting content from URL', { url }); throwIfAborted(); - const extractionResult = await this.htmlToMarkdownTool.execute({ - instruction: 'Extract the main content focusing on article text, headings, and important information. Remove ads, navigation, and distracting elements.', + + // Always pass ctx for LLM fallback capability + const extractionResult = await this.readabilityExtractorTool.execute({ reasoning }, ctx); // Check for extraction errors - if (!extractionResult.success || !extractionResult.markdownContent) { + if (!extractionResult.success || !extractionResult.textContent) { return { url, - title: metadata?.title || '', + title: metadata?.title || extractionResult.title || '', markdownContent: '', success: false, error: extractionResult.error || 'Failed to extract content' }; } - // Return the fetched content (HTMLToMarkdownTool handles chunking) + // Return the fetched content (plain text from Readability) return { url: metadata?.url || url, - title: metadata?.title || '', - markdownContent: extractionResult.markdownContent, + title: extractionResult.title || metadata?.title || '', + markdownContent: extractionResult.textContent, // Plain text content success: true }; } catch (error: any) { diff --git a/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts b/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts index e151326477..217fe04108 100644 --- a/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts +++ b/front_end/panels/ai_chat/tools/HTMLToMarkdownTool.ts @@ -36,8 +36,8 @@ export interface HTMLToMarkdownArgs { */ export class HTMLToMarkdownTool implements Tool { // Chunking configuration - private readonly TOKEN_LIMIT_FOR_CHUNKING = 10000; // Auto-chunk if tree exceeds this (40k chars) - private readonly CHUNK_TOKEN_LIMIT = 8000; // Max tokens per chunk (32k chars) + private readonly TOKEN_LIMIT_FOR_CHUNKING = 65000; // Auto-chunk if tree exceeds this (~260k chars) + private readonly CHUNK_TOKEN_LIMIT = 40000; // Max tokens per chunk (~160k chars) private readonly CHARS_PER_TOKEN = 4; // Conservative estimate private contentChunker = new ContentChunker(); diff --git a/front_end/panels/ai_chat/tools/ReadabilityExtractorTool.ts b/front_end/panels/ai_chat/tools/ReadabilityExtractorTool.ts new file mode 100644 index 0000000000..5515a07c78 --- /dev/null +++ b/front_end/panels/ai_chat/tools/ReadabilityExtractorTool.ts @@ -0,0 +1,227 @@ +// 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 SDK from '../../../core/sdk/sdk.js'; +import { createLogger } from '../core/Logger.js'; +import { waitForPageLoad, type Tool, type LLMContext } from './Tools.js'; +import { READABILITY_SOURCE } from '../vendor/readability-source.js'; +import { HTMLToMarkdownTool } from './HTMLToMarkdownTool.js'; + +const logger = createLogger('Tool:ReadabilityExtractor'); + +// Minimum content length to consider Readability extraction successful +const MIN_CONTENT_LENGTH = 3000; + +/** + * Result interface for Readability extraction + */ +export interface ReadabilityExtractorResult { + success: boolean; + textContent: string | null; + title?: string; + byline?: string; + excerpt?: string; + siteName?: string; + error?: string; +} + +/** + * Arguments for Readability extraction + */ +export interface ReadabilityExtractorArgs { + instruction?: string; + reasoning: string; +} + +/** + * Tool for extracting the main article content from a webpage using Mozilla Readability. + * Returns plain text content without requiring LLM calls. + * Uses bundled Readability.js library (no CDN dependency). + */ +export class ReadabilityExtractorTool implements Tool { + name = 'readability_extractor'; + description = 'Extracts the main article content from a webpage using Mozilla Readability library. Returns plain text content, removing ads, navigation, and other distracting elements. Does not require LLM calls. Uses bundled library with no external dependencies.'; + + schema = { + type: 'object', + properties: { + instruction: { + type: 'string', + description: 'Optional natural language instruction for context' + }, + reasoning: { + type: 'string', + description: 'Reasoning about the extraction process displayed to the user' + } + }, + required: ['reasoning'] + }; + + /** + * Execute the Readability extraction with automatic LLM fallback + */ + async execute(args: ReadabilityExtractorArgs, ctx?: LLMContext): Promise { + logger.info('Executing with args', { args }); + const READINESS_TIMEOUT_MS = 15000; // 15 seconds timeout for page readiness + + try { + // Wait for page load + const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); + if (!target) { + throw new Error('No page target available'); + } + + try { + logger.debug('Waiting for page to be ready...'); + await waitForPageLoad(target, READINESS_TIMEOUT_MS); + logger.debug('Page is ready'); + } catch (error) { + logger.warn('Page readiness timeout, proceeding anyway', { error }); + } + + // Run extraction in the page context using bundled Readability + logger.debug('Running Readability extraction in page context...'); + + // Escape the bundled source for injection + const escapedSource = READABILITY_SOURCE.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$'); + + const extractionExpression = ` + (function() { + try { + // Inject Readability library if not already loaded + if (typeof Readability === 'undefined') { + eval(\`${escapedSource}\`); + } + + // Verify Readability is now available + if (typeof Readability === 'undefined') { + return { error: 'Readability library failed to load' }; + } + + // Create Trusted Types policy to allow Readability to use innerHTML + // This is required in Chromium-based browsers with Trusted Types enabled + if (window.trustedTypes && window.trustedTypes.createPolicy) { + try { + if (!window.trustedTypes.defaultPolicy) { + window.trustedTypes.createPolicy('default', { + createHTML: (string) => string, + createScript: (string) => string, + createScriptURL: (string) => string + }); + } + } catch (policyError) { + // Policy might already exist or creation might be blocked + // Continue anyway as Readability might still work + } + } + + // Clone the document and parse with Readability + const documentClone = document.cloneNode(true); + const reader = new Readability(documentClone); + const article = reader.parse(); + + // Check if parsing was successful + if (!article) { + return { error: 'Readability could not parse this page - may not be an article' }; + } + + // Return the parsed article data + return { + title: article.title || '', + textContent: article.textContent || '', + byline: article.byline || '', + excerpt: article.excerpt || '', + siteName: article.siteName || '' + }; + } catch (error) { + return { error: error.message }; + } + })() + `; + + const readabilityResult = await target.runtimeAgent().invoke_evaluate({ + expression: extractionExpression, + returnByValue: true + }); + + if (!readabilityResult || readabilityResult.exceptionDetails) { + throw new Error(`Readability extraction failed: ${readabilityResult?.exceptionDetails?.text || 'Unknown error'}`); + } + + const result = readabilityResult.result?.value as any; + + if (result.error) { + throw new Error(`Readability extraction error: ${result.error}`); + } + + // Check if content is sufficient + const contentLength = result.textContent?.length || 0; + const isContentSufficient = contentLength >= MIN_CONTENT_LENGTH; + + if (!isContentSufficient) { + logger.warn(`Readability content insufficient (${contentLength} chars < ${MIN_CONTENT_LENGTH}), falling back to LLM`); + + try { + const htmlToMarkdownTool = new HTMLToMarkdownTool(); + const llmResult = await htmlToMarkdownTool.execute({ + instruction: args.instruction || 'Extract the main content focusing on article text, headings, and important information. Remove ads, navigation, and distracting elements.', + reasoning: args.reasoning + }, ctx); + + if (llmResult.success && llmResult.markdownContent) { + const llmContentLength = llmResult.markdownContent.length; + logger.info(`LLM fallback successful (${llmContentLength} chars)`); + + return { + success: true, + textContent: llmResult.markdownContent, + title: result.title, // Keep Readability metadata if available + byline: result.byline, + excerpt: result.excerpt, + siteName: result.siteName + }; + } else { + logger.warn('LLM fallback also failed, returning original Readability result'); + } + } catch (llmError) { + logger.error('LLM fallback error', { error: llmError }); + // Continue and return Readability result below + } + } + + // Return Readability result (either successful or best effort) + if (!result.textContent) { + logger.warn('Readability returned no content and LLM fallback unavailable/failed'); + return { + success: false, + textContent: null, + error: 'Failed to extract content from page - no readable content found' + }; + } + + logger.info('Extraction successful', { + titleLength: result.title?.length || 0, + contentLength: result.textContent?.length || 0, + method: isContentSufficient ? 'readability' : 'readability-insufficient' + }); + + return { + success: true, + textContent: result.textContent, + title: result.title, + byline: result.byline, + excerpt: result.excerpt, + siteName: result.siteName + }; + + } catch (error) { + logger.error('Extraction failed', { error }); + return { + success: false, + textContent: null, + error: error instanceof Error ? error.message : String(error) + }; + } + } +} diff --git a/front_end/panels/ai_chat/tools/SchemaBasedExtractorTool.ts b/front_end/panels/ai_chat/tools/SchemaBasedExtractorTool.ts index 0de86cea20..4263aa765c 100644 --- a/front_end/panels/ai_chat/tools/SchemaBasedExtractorTool.ts +++ b/front_end/panels/ai_chat/tools/SchemaBasedExtractorTool.ts @@ -212,31 +212,67 @@ Schema Examples: heading: c.sectionInfo?.heading }))); - // Extract from each chunk - const chunkResults: any[] = []; - for (const chunk of chunks) { - logger.info(`Processing chunk ${chunk.id + 1}/${chunks.length}...`); - - try { - const extractedData = await this.extractFromChunk( + // Extract from each chunk in parallel (4 at a time) + // Optimized based on Mind2Web validation results (EXTENDED_VALIDATION_RESULTS.md) + const chunkResults: any[] = new Array(chunks.length); + const BATCH_SIZE = 4; // Process 4 chunks concurrently + + for (let i = 0; i < chunks.length; i += BATCH_SIZE) { + const batchPromises: Promise[] = []; + + // Create batch of up to 4 promises + for (let j = 0; j < BATCH_SIZE && i + j < chunks.length; j++) { + const chunkIndex = i + j; + const chunk = chunks[chunkIndex]; + + logger.info(`Processing chunk ${chunk.id + 1}/${chunks.length} in parallel batch`, { + batchStart: i + 1, + batchEnd: Math.min(i + BATCH_SIZE, chunks.length) + }); + + // Create promise and handle errors per chunk + const promise = this.extractFromChunk( chunk, transformedSchema, instruction || 'Extract data according to schema', apiKey || '', ctx - ); - chunkResults.push(extractedData); - logger.info(`Chunk ${chunk.id + 1} extraction complete`); - } catch (error) { - logger.error(`Error extracting from chunk ${chunk.id}:`, error); - // Continue with other chunks even if one fails + ).then(extractedData => { + // Store result at correct index to maintain order + chunkResults[chunkIndex] = extractedData; + logger.info(`Chunk ${chunk.id + 1} extraction complete`); + }).catch(error => { + logger.error(`Error extracting from chunk ${chunk.id}:`, error); + // Store null on error to maintain order, will be filtered during merge + chunkResults[chunkIndex] = null; + }); + + batchPromises.push(promise); } + + // Wait for current batch to complete before starting next batch + logger.info(`Waiting for batch to complete`, { + batchStart: i + 1, + batchSize: batchPromises.length + }); + await Promise.all(batchPromises); + logger.info(`Batch completed`, { + batchStart: i + 1, + completedChunks: i + batchPromises.length + }); } + // Filter out null results from failed chunks + const validChunkResults = chunkResults.filter(result => result !== null); + // Merge results using LLM - logger.info('Merging chunk results with LLM...'); + logger.info('Merging chunk results with LLM...', { + totalChunks: chunks.length, + validChunks: validChunkResults.length, + failedChunks: chunks.length - validChunkResults.length + }); const mergedData = await this.callMergeLLM({ - chunkResults, + chunkResults: validChunkResults, schema: transformedSchema, instruction: instruction || 'Extract data according to schema', apiKey: apiKey || '', diff --git a/front_end/panels/ai_chat/tools/Tools.ts b/front_end/panels/ai_chat/tools/Tools.ts index 6178af79fb..e282b068a8 100644 --- a/front_end/panels/ai_chat/tools/Tools.ts +++ b/front_end/panels/ai_chat/tools/Tools.ts @@ -526,10 +526,13 @@ export async function waitForPageLoad(target: SDK.Target.Target, timeoutMs: numb throw new Error('RuntimeAgent not found for target.'); } - let loadEventListener: Common.EventTarget.EventDescriptor | null = null; + let lifecycleEventListener: Common.EventTarget.EventDescriptor | null = null; let overallTimeoutId: ReturnType | null = null; try { + // Enable lifecycle events for networkAlmostIdle detection + await resourceTreeModel.setLifecycleEventsEnabled(true); + // 1. Overall Timeout Promise const timeoutPromise = new Promise((_, reject) => { overallTimeoutId = setTimeout(() => { @@ -538,13 +541,19 @@ export async function waitForPageLoad(target: SDK.Target.Target, timeoutMs: numb }, timeoutMs); }); - // 2. Load Event Promise - const loadPromise = new Promise(resolve => { - // Attach listener - Load event should fire even if already loaded. - loadEventListener = resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.Load, () => { - logger.info('waitForPageLoad: Load event received.'); - resolve(); - }); + // 2. Network Almost Idle Promise (via lifecycle events) + const networkIdlePromise = new Promise(resolve => { + lifecycleEventListener = resourceTreeModel.addEventListener( + SDK.ResourceTreeModel.Events.LifecycleEvent, + (event: Common.EventTarget.EventTargetEvent<{frameId: Protocol.Page.FrameId, name: string}>) => { + const {name} = event.data; + // networkAlmostIdle means ≤2 network connections for 500ms + if (name === 'networkAlmostIdle' || name === 'networkIdle') { + logger.info(`waitForPageLoad: ${name} lifecycle event received.`); + resolve(); + } + } + ); }); // 3. LCP Promise (via injected script) @@ -613,10 +622,10 @@ export async function waitForPageLoad(target: SDK.Target.Target, timeoutMs: numb } })(); - // 4. Race the promises: Wait for the first of load, LCP success, or overall timeout - logger.info(`waitForPageLoad: Waiting for Load event, LCP, or timeout (${timeoutMs}ms)...`); - await Promise.race([loadPromise, lcpPromise, timeoutPromise]); - logger.info('waitForPageLoad: Race finished (Load, LCP, or Timeout).'); + // 4. Race the promises: Wait for the first of networkIdle, LCP, or timeout + logger.info(`waitForPageLoad: Waiting for networkIdle, LCP, or timeout (${timeoutMs}ms)...`); + await Promise.race([networkIdlePromise, lcpPromise, timeoutPromise]); + logger.info('waitForPageLoad: Race finished (networkIdle, LCP, or Timeout).'); } catch (error) { // This catch block will primarily handle the overall timeout rejection @@ -628,9 +637,9 @@ export async function waitForPageLoad(target: SDK.Target.Target, timeoutMs: numb if (overallTimeoutId !== null) { clearTimeout(overallTimeoutId); } - if (loadEventListener) { - Common.EventTarget.removeEventListeners([loadEventListener]); - logger.info('waitForPageLoad: Load event listener removed.'); + if (lifecycleEventListener) { + Common.EventTarget.removeEventListeners([lifecycleEventListener]); + logger.info('waitForPageLoad: Lifecycle event listener removed.'); } // The LCP observer should disconnect itself within the injected script. } @@ -3574,6 +3583,11 @@ export type { RemoveWebAppArgs, RemoveWebAppResult } from './RemoveWebAppTool.js // Export visual indicator manager export { VisualIndicatorManager } from './VisualIndicatorTool.js'; + +// Export ReadabilityExtractorTool +export { ReadabilityExtractorTool } from './ReadabilityExtractorTool.js'; +export type { ReadabilityExtractorArgs, ReadabilityExtractorResult } from './ReadabilityExtractorTool.js'; + export { CreateFileTool } from './CreateFileTool.js'; export type { CreateFileArgs, CreateFileResult } from './CreateFileTool.js'; export { UpdateFileTool } from './UpdateFileTool.js'; diff --git a/front_end/panels/ai_chat/vendor/readability-source.ts b/front_end/panels/ai_chat/vendor/readability-source.ts new file mode 100644 index 0000000000..b7489f2272 --- /dev/null +++ b/front_end/panels/ai_chat/vendor/readability-source.ts @@ -0,0 +1,2799 @@ +// 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. + +/** + * Mozilla Readability library bundled as a string for runtime injection. + * Version: 0.6.0 + * Source: https://github.com/mozilla/readability + * License: Apache-2.0 + */ + +// @ts-nocheck - Large bundled library source +export const READABILITY_SOURCE = `/* + * Copyright (c) 2010 Arc90 Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This code is heavily based on Arc90's readability.js (1.7.1) script + * available at: http://code.google.com/p/arc90labs-readability + */ + +/** + * Public constructor. + * @param {HTMLDocument} doc The document to parse. + * @param {Object} options The options object. + */ +function Readability(doc, options) { + // In some older versions, people passed a URI as the first argument. Cope: + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error( + "First argument to Readability constructor should be a document object." + ); + } + options = options || {}; + + this._doc = doc; + this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + this._metadata = {}; + + // Configurable options + this._debug = !!options.debug; + this._maxElemsToParse = + options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = + options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat( + options.classesToPreserve || [] + ); + this._keepClasses = !!options.keepClasses; + this._serializer = + options.serializer || + function (el) { + return el.innerHTML; + }; + this._disableJSONLD = !!options.disableJSONLD; + this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos; + this._linkDensityModifier = options.linkDensityModifier || 0; + + // Start with all flags set + this._flags = + this.FLAG_STRIP_UNLIKELYS | + this.FLAG_WEIGHT_CLASSES | + this.FLAG_CLEAN_CONDITIONALLY; + + // Control whether log messages are sent to the console + if (this._debug) { + let logNode = function (node) { + if (node.nodeType == node.TEXT_NODE) { + return \`\${node.nodeName} ("\${node.textContent}")\`; + } + let attrPairs = Array.from(node.attributes || [], function (attr) { + return \`\${attr.name}="\${attr.value}"\`; + }).join(" "); + return \`<\${node.localName} \${attrPairs}>\`; + }; + this.log = function () { + if (typeof console !== "undefined") { + let args = Array.from(arguments, arg => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift("Reader: (Readability)"); + // eslint-disable-next-line no-console + console.log(...args); + } else if (typeof dump !== "undefined") { + /* global dump */ + var msg = Array.prototype.map + .call(arguments, function (x) { + return x && x.nodeName ? logNode(x) : x; + }) + .join(" "); + dump("Reader: (Readability) " + msg + "\\n"); + } + }; + } else { + this.log = function () {}; + } +} + +Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 0x1, + FLAG_WEIGHT_CLASSES: 0x2, + FLAG_CLEAN_CONDITIONALLY: 0x4, + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre" + .toUpperCase() + .split(","), + + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: + /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, + + positive: + /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: + /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i, + extraneous: + /print|archive|comment|discuss|e[\\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\\/?)font[^>]*>/gi, + normalize: /\\s{2,}/g, + videos: + /\\/\\/(www\\.)?((dailymotion|youtube|youtube-nocookie|player\\.vimeo|v\\.qq)\\.com|(archive|upload\\.wikimedia)\\.org|player\\.twitch\\.tv)/i, + shareElements: /(\\b|_)(share|sharedaddy)(\\b|_)/i, + nextLink: /(next|weiter|continue|>([^\\|]|$)|»([^\\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\\W+/g, + whitespace: /^\\s*$/, + hasContent: /\\S$/, + hashUrl: /^#.+/, + srcsetUrl: /(\\S+)(\\s+[\\d.]+[xw])?(\\s*(?:,|$))/g, + b64DataUrl: /^data:\\s*([^\\s;,]+)\\s*;\\s*base64\\s*,/i, + // Commas as used in Latin, Sindhi, Chinese and various other scripts. + // see: https://en.wikipedia.org/wiki/Comma#Comma_variants + commas: /\\u002C|\\u060C|\\uFE50|\\uFE10|\\uFE11|\\u2E41|\\u2E34|\\u2E32|\\uFF0C/g, + // See: https://schema.org/Article + jsonLdArticleTypes: + /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/, + // used to see if a node's content matches words commonly used for ad blocks or loading indicators + adWords: + /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu, + loadingWords: + /^((loading|正在加载|Загрузка|chargement|cargando)(…|\\.\\.\\.)?)$/iu, + }, + + UNLIKELY_ROLES: [ + "menu", + "menubar", + "complementary", + "navigation", + "alert", + "alertdialog", + "dialog", + ], + + DIV_TO_P_ELEMS: new Set([ + "BLOCKQUOTE", + "DL", + "DIV", + "IMG", + "OL", + "P", + "PRE", + "TABLE", + "UL", + ]), + + ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P", "OL", "UL"], + + PRESENTATIONAL_ATTRIBUTES: [ + "align", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "frame", + "hspace", + "rules", + "style", + "valign", + "vspace", + ], + + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ["TABLE", "TH", "TD", "HR", "PRE"], + + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + "ABBR", + "AUDIO", + "B", + "BDO", + "BR", + "BUTTON", + "CITE", + "CODE", + "DATA", + "DATALIST", + "DFN", + "EM", + "EMBED", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MARK", + "MATH", + "METER", + "NOSCRIPT", + "OBJECT", + "OUTPUT", + "PROGRESS", + "Q", + "RUBY", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "VAR", + "WBR", + ], + + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: ["page"], + + // These are the list of HTML entities that need to be escaped. + HTML_ESCAPE_MAP: { + lt: "<", + gt: ">", + amp: "&", + quot: '"', + apos: "'", + }, + + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent(articleContent) { + // Readability cannot open relative uris so we convert them to absolute uris. + this._fixRelativeUris(articleContent); + + this._simplifyNestedElements(articleContent); + + if (!this._keepClasses) { + // Remove classes. + this._cleanClasses(articleContent); + } + }, + + /** + * Iterates over a NodeList, calls \`filterFn\` for each node and removes node + * if function returned \`true\`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes(nodeList, filterFn) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _removeNodes"); + } + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags(nodeList, newTagName) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _replaceNodeTags"); + } + for (const node of nodeList) { + this._setNodeTag(node, newTagName); + } + }, + + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode(nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, and return the first node that passes + * the supplied test function + * + * For convenience, the current object context is applied to the provided + * test function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The test function. + * @return void + */ + _findNode(nodeList, fn) { + return Array.prototype.find.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode(nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode(nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + + _getAllNodesWithTag(node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(",")); + } + return [].concat.apply( + [], + tagNames.map(function (tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + }) + ); + }, + + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses(node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute("class") || "") + .split(/\\s+/) + .filter(cls => classesToPreserve.includes(cls)) + .join(" "); + + if (className) { + node.setAttribute("class", className); + } else { + node.removeAttribute("class"); + } + + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + + /** + * Tests whether a string is a URL or not. + * + * @param {string} str The string to test + * @return {boolean} true if str is a URL, false if not + */ + _isUrl(str) { + try { + new URL(str); + return true; + } catch { + return false; + } + }, + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris(articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + function toAbsoluteURI(uri) { + // Leave hash links alone if the base URI matches the document URI: + if (baseURI == documentURI && uri.charAt(0) == "#") { + return uri; + } + + // Otherwise, resolve against base URI: + try { + return new URL(uri, baseURI).href; + } catch (ex) { + // Something went wrong, just return the original: + } + return uri; + } + + var links = this._getAllNodesWithTag(articleContent, ["a"]); + this._forEachNode(links, function (link) { + var href = link.getAttribute("href"); + if (href) { + // Remove links with javascript: URIs, since + // they won't work after scripts have been removed from the page. + if (href.indexOf("javascript:") === 0) { + // if the link only contains simple text content, it can be converted to a text node + if ( + link.childNodes.length === 1 && + link.childNodes[0].nodeType === this.TEXT_NODE + ) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + // if the link has multiple children, they should all be preserved + var container = this._doc.createElement("span"); + while (link.firstChild) { + container.appendChild(link.firstChild); + } + link.parentNode.replaceChild(container, link); + } + } else { + link.setAttribute("href", toAbsoluteURI(href)); + } + } + }); + + var medias = this._getAllNodesWithTag(articleContent, [ + "img", + "picture", + "figure", + "video", + "audio", + "source", + ]); + + this._forEachNode(medias, function (media) { + var src = media.getAttribute("src"); + var poster = media.getAttribute("poster"); + var srcset = media.getAttribute("srcset"); + + if (src) { + media.setAttribute("src", toAbsoluteURI(src)); + } + + if (poster) { + media.setAttribute("poster", toAbsoluteURI(poster)); + } + + if (srcset) { + var newSrcset = srcset.replace( + this.REGEXPS.srcsetUrl, + function (_, p1, p2, p3) { + return toAbsoluteURI(p1) + (p2 || "") + p3; + } + ); + + media.setAttribute("srcset", newSrcset); + } + }); + }, + + _simplifyNestedElements(articleContent) { + var node = articleContent; + + while (node) { + if ( + node.parentNode && + ["DIV", "SECTION"].includes(node.tagName) && + !(node.id && node.id.startsWith("readability")) + ) { + if (this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } else if ( + this._hasSingleTagInsideElement(node, "DIV") || + this._hasSingleTagInsideElement(node, "SECTION") + ) { + var child = node.children[0]; + for (var i = 0; i < node.attributes.length; i++) { + child.setAttributeNode(node.attributes[i].cloneNode()); + } + node.parentNode.replaceChild(child, node); + node = child; + continue; + } + } + + node = this._getNextNode(node); + } + }, + + /** + * Get the article title as an H1. + * + * @return string + **/ + _getArticleTitle() { + var doc = this._doc; + var curTitle = ""; + var origTitle = ""; + + try { + curTitle = origTitle = doc.title.trim(); + + // If they had an element with id "title" in their HTML + if (typeof curTitle !== "string") { + curTitle = origTitle = this._getInnerText( + doc.getElementsByTagName("title")[0] + ); + } + } catch (e) { + /* ignore exceptions setting the title. */ + } + + var titleHadHierarchicalSeparators = false; + function wordCount(str) { + return str.split(/\\s+/).length; + } + + // If there's a separator in the title, first remove the final part + if (/ [\\|\\-\\\\\\/>»] /.test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\\\\/>»] /.test(curTitle); + let allSeparators = Array.from(origTitle.matchAll(/ [\\|\\-\\\\\\/>»] /gi)); + curTitle = origTitle.substring(0, allSeparators.pop().index); + + // If the resulting title is too short, remove the first part instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.replace(/^[^\\|\\-\\\\\\/>»]*[\\|\\-\\\\\\/>»]/gi, ""); + } + } else if (curTitle.includes(": ")) { + // Check if we have an heading containing this exact string, so we + // could assume it's the full title. + var headings = this._getAllNodesWithTag(doc, ["h1", "h2"]); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function (heading) { + return heading.textContent.trim() === trimmedTitle; + }); + + // If we don't, let's extract the title out of the original title string. + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); + + // If the title is now too short, try the first colon instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(":") + 1); + // But if we have too many words before the colon there's something weird + // with the titles and the H tags so let's just use the original title instead + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName("h1"); + + if (hOnes.length === 1) { + curTitle = this._getInnerText(hOnes[0]); + } + } + + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); + // If we now have 4 words or fewer as our title, and either no + // 'hierarchical' separators (\\, /, > or ») were found in the original + // title or we decreased the number of words by more than 1 word, use + // the original title. + var curTitleWordCount = wordCount(curTitle); + if ( + curTitleWordCount <= 4 && + (!titleHadHierarchicalSeparators || + curTitleWordCount != + wordCount(origTitle.replace(/[\\|\\-\\\\\\/>»]+/g, "")) - 1) + ) { + curTitle = origTitle; + } + + return curTitle; + }, + + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument() { + var doc = this._doc; + + // Remove all style tags in head + this._removeNodes(this._getAllNodesWithTag(doc, ["style"])); + + if (doc.body) { + this._replaceBrs(doc.body); + } + + this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN"); + }, + + /** + * Finds the next node, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextNode(node) { + var next = node; + while ( + next && + next.nodeType != this.ELEMENT_NODE && + this.REGEXPS.whitespace.test(next.textContent) + ) { + next = next.nextSibling; + } + return next; + }, + + /** + * Replaces 2 or more successive
elements with a single

. + * Whitespace between
elements are ignored. For example: + *

foo
bar


abc
+ * will become: + *
foo
bar

abc

+ */ + _replaceBrs(elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function (br) { + var next = br.nextSibling; + + // Whether 2 or more
elements have been found and replaced with a + //

block. + var replaced = false; + + // If we find a
chain, remove the
s until we hit another node + // or non-whitespace. This leaves behind the first
in the chain + // (which will be replaced with a

later). + while ((next = this._nextNode(next)) && next.tagName == "BR") { + replaced = true; + var brSibling = next.nextSibling; + next.remove(); + next = brSibling; + } + + // If we removed a
chain, replace the remaining
with a

. Add + // all sibling nodes as children of the

until we hit another
+ // chain. + if (replaced) { + var p = this._doc.createElement("p"); + br.parentNode.replaceChild(p, br); + + next = p.nextSibling; + while (next) { + // If we've hit another

, we're done adding children to this

. + if (next.tagName == "BR") { + var nextElem = this._nextNode(next.nextSibling); + if (nextElem && nextElem.tagName == "BR") { + break; + } + } + + if (!this._isPhrasingContent(next)) { + break; + } + + // Otherwise, make this node a child of the new

. + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + + if (p.parentNode.tagName === "P") { + this._setNodeTag(p.parentNode, "DIV"); + } + } + }); + }, + + _setNodeTag(node, tag) { + this.log("_setNodeTag", node, tag); + if (this._docJSDOMParser) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) { + replacement.readability = node.readability; + } + + for (var i = 0; i < node.attributes.length; i++) { + replacement.setAttributeNode(node.attributes[i].cloneNode()); + } + return replacement; + }, + + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle(articleContent) { + this._cleanStyles(articleContent); + + // Check for data tables before we continue, to avoid removing items in + // those tables, which will often be isolated even though they're + // visually linked to other content-ful elements (text, images, etc.). + this._markDataTables(articleContent); + + this._fixLazyImages(articleContent); + + // Clean out junk from the article content + this._cleanConditionally(articleContent, "form"); + this._cleanConditionally(articleContent, "fieldset"); + this._clean(articleContent, "object"); + this._clean(articleContent, "embed"); + this._clean(articleContent, "footer"); + this._clean(articleContent, "link"); + this._clean(articleContent, "aside"); + + // Clean out elements with little content that have "share" in their id/class combinations from final top candidates, + // which means we don't remove the top candidates even they have "share". + + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + + this._forEachNode(articleContent.children, function (topCandidate) { + this._cleanMatchedNodes(topCandidate, function (node, matchString) { + return ( + this.REGEXPS.shareElements.test(matchString) && + node.textContent.length < shareElementThreshold + ); + }); + }); + + this._clean(articleContent, "iframe"); + this._clean(articleContent, "input"); + this._clean(articleContent, "textarea"); + this._clean(articleContent, "select"); + this._clean(articleContent, "button"); + this._cleanHeaders(articleContent); + + // Do these last as the previous stuff may have removed junk + // that will affect these + this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "ul"); + this._cleanConditionally(articleContent, "div"); + + // replace H1 with H2 as H1 should be only title that is displayed separately + this._replaceNodeTags( + this._getAllNodesWithTag(articleContent, ["h1"]), + "h2" + ); + + // Remove extra paragraphs + this._removeNodes( + this._getAllNodesWithTag(articleContent, ["p"]), + function (paragraph) { + // At this point, nasty iframes have been removed; only embedded video + // ones remain. + var contentElementCount = this._getAllNodesWithTag(paragraph, [ + "img", + "embed", + "object", + "iframe", + ]).length; + return ( + contentElementCount === 0 && !this._getInnerText(paragraph, false) + ); + } + ); + + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["br"]), + function (br) { + var next = this._nextNode(br.nextSibling); + if (next && next.tagName == "P") { + br.remove(); + } + } + ); + + // Remove single-cell tables + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["table"]), + function (table) { + var tbody = this._hasSingleTagInsideElement(table, "TBODY") + ? table.firstElementChild + : table; + if (this._hasSingleTagInsideElement(tbody, "TR")) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, "TD")) { + var cell = row.firstElementChild; + cell = this._setNodeTag( + cell, + this._everyNode(cell.childNodes, this._isPhrasingContent) + ? "P" + : "DIV" + ); + table.parentNode.replaceChild(cell, table); + } + } + } + ); + }, + + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode(node) { + node.readability = { contentScore: 0 }; + + switch (node.tagName) { + case "DIV": + node.readability.contentScore += 5; + break; + + case "PRE": + case "TD": + case "BLOCKQUOTE": + node.readability.contentScore += 3; + break; + + case "ADDRESS": + case "OL": + case "UL": + case "DL": + case "DD": + case "DT": + case "LI": + case "FORM": + node.readability.contentScore -= 3; + break; + + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "TH": + node.readability.contentScore -= 5; + break; + } + + node.readability.contentScore += this._getClassWeight(node); + }, + + _removeAndGetNext(node) { + var nextNode = this._getNextNode(node, true); + node.remove(); + return nextNode; + }, + + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + * + * @param {Element} node + * @param {boolean} ignoreSelfAndKids + * @return {Element} + */ + _getNextNode(node, ignoreSelfAndKids) { + // First check for kids if those aren't being ignored + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + // Then for siblings... + if (node.nextElementSibling) { + return node.nextElementSibling; + } + // And finally, move up the parent chain *and* find a sibling + // (because this is depth-first traversal, we will have already + // seen the parent nodes themselves). + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity(textA, textB) { + var tokensA = textA + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + var tokensB = textB + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter(token => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; + return 1 - distanceB; + }, + + /** + * Checks whether an element node contains a valid byline + * + * @param node {Element} + * @param matchString {string} + * @return boolean + */ + _isValidByline(node, matchString) { + var rel = node.getAttribute("rel"); + var itemprop = node.getAttribute("itemprop"); + var bylineLength = node.textContent.trim().length; + + return ( + (rel === "author" || + (itemprop && itemprop.includes("author")) || + this.REGEXPS.byline.test(matchString)) && + !!bylineLength && + bylineLength < 100 + ); + }, + + _getNodeAncestors(node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, + ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) { + break; + } + node = node.parentNode; + } + return ancestors; + }, + + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + /* eslint-disable-next-line complexity */ + _grabArticle(page) { + this.log("**** grabArticle ****"); + var doc = this._doc; + var isPaging = page !== null; + page = page ? page : this._doc.body; + + // We can't grab an article if we don't have a page! + if (!page) { + this.log("No body found in document. Abort."); + return null; + } + + var pageCacheHtml = page.innerHTML; + + while (true) { + this.log("Starting grabArticle loop"); + var stripUnlikelyCandidates = this._flagIsActive( + this.FLAG_STRIP_UNLIKELYS + ); + + // First, node prepping. Trash nodes that look cruddy (like ones with the + // class name "comment", etc), and turn divs into P tags where they have been + // used inappropriately (as in, where they contain no other block level elements.) + var elementsToScore = []; + var node = this._doc.documentElement; + + let shouldRemoveTitleHeader = true; + + while (node) { + if (node.tagName === "HTML") { + this._articleLang = node.getAttribute("lang"); + } + + var matchString = node.className + " " + node.id; + + if (!this._isProbablyVisible(node)) { + this.log("Removing hidden node - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + // User is not able to see elements applied with both "aria-modal = true" and "role = dialog" + if ( + node.getAttribute("aria-modal") == "true" && + node.getAttribute("role") == "dialog" + ) { + node = this._removeAndGetNext(node); + continue; + } + + // If we don't have a byline yet check to see if this node is a byline; if it is store the byline and remove the node. + if ( + !this._articleByline && + !this._metadata.byline && + this._isValidByline(node, matchString) + ) { + // Find child node matching [itemprop="name"] and use that if it exists for a more accurate author name byline + var endOfSearchMarkerNode = this._getNextNode(node, true); + var next = this._getNextNode(node); + var itemPropNameNode = null; + while (next && next != endOfSearchMarkerNode) { + var itemprop = next.getAttribute("itemprop"); + if (itemprop && itemprop.includes("name")) { + itemPropNameNode = next; + break; + } else { + next = this._getNextNode(next); + } + } + this._articleByline = (itemPropNameNode ?? node).textContent.trim(); + node = this._removeAndGetNext(node); + continue; + } + + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log( + "Removing header: ", + node.textContent.trim(), + this._articleTitle.trim() + ); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + + // Remove unlikely candidates + if (stripUnlikelyCandidates) { + if ( + this.REGEXPS.unlikelyCandidates.test(matchString) && + !this.REGEXPS.okMaybeItsACandidate.test(matchString) && + !this._hasAncestorTag(node, "table") && + !this._hasAncestorTag(node, "code") && + node.tagName !== "BODY" && + node.tagName !== "A" + ) { + this.log("Removing unlikely candidate - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) { + this.log( + "Removing content with role " + + node.getAttribute("role") + + " - " + + matchString + ); + node = this._removeAndGetNext(node); + continue; + } + } + + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). + if ( + (node.tagName === "DIV" || + node.tagName === "SECTION" || + node.tagName === "HEADER" || + node.tagName === "H1" || + node.tagName === "H2" || + node.tagName === "H3" || + node.tagName === "H4" || + node.tagName === "H5" || + node.tagName === "H6") && + this._isElementWithoutContent(node) + ) { + node = this._removeAndGetNext(node); + continue; + } + + if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) { + elementsToScore.push(node); + } + + // Turn all divs that don't have children block level elements into p's + if (node.tagName === "DIV") { + // Put phrasing content into paragraphs. + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement("p"); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + p = null; + } + childNode = nextSibling; + } + + // Sites like http://mobile.slate.com encloses each paragraph with a DIV + // element. DIVs with only a P element inside and no text content can be + // safely converted into plain P elements to avoid confusing the scoring + // algorithm with DIVs with are, in practice, paragraphs. + if ( + this._hasSingleTagInsideElement(node, "P") && + this._getLinkDensity(node) < 0.25 + ) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, "P"); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + + /** + * Loop through all paragraphs, and assign a score to them based on how content-y they look. + * Then add their score to their parent node. + * + * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. + **/ + var candidates = []; + this._forEachNode(elementsToScore, function (elementToScore) { + if ( + !elementToScore.parentNode || + typeof elementToScore.parentNode.tagName === "undefined" + ) { + return; + } + + // If this paragraph is less than 25 characters, don't even count it. + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) { + return; + } + + // Exclude nodes with no ancestor. + var ancestors = this._getNodeAncestors(elementToScore, 5); + if (ancestors.length === 0) { + return; + } + + var contentScore = 0; + + // Add a point for the paragraph itself as a base. + contentScore += 1; + + // Add points for any commas within this paragraph. + contentScore += innerText.split(this.REGEXPS.commas).length; + + // For every 100 characters in this paragraph, add another point. Up to 3 points. + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + + // Initialize and score ancestors. + this._forEachNode(ancestors, function (ancestor, level) { + if ( + !ancestor.tagName || + !ancestor.parentNode || + typeof ancestor.parentNode.tagName === "undefined" + ) { + return; + } + + if (typeof ancestor.readability === "undefined") { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + + // Node score divider: + // - parent: 1 (no division) + // - grandparent: 2 + // - great grandparent+: ancestor level * 3 + if (level === 0) { + var scoreDivider = 1; + } else if (level === 1) { + scoreDivider = 2; + } else { + scoreDivider = level * 3; + } + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + + // After we've calculated scores, loop through all of the possible + // candidate nodes we found and find the one with the highest score. + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + + // Scale the final candidates score based on link density. Good content + // should have a relatively small link density (5% or less) and be mostly + // unaffected by this operation. + var candidateScore = + candidate.readability.contentScore * + (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + + this.log("Candidate:", candidate, "with score " + candidateScore); + + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + + if ( + !aTopCandidate || + candidateScore > aTopCandidate.readability.contentScore + ) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) { + topCandidates.pop(); + } + break; + } + } + } + + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + + // If we still have no top candidate, just use the body as a last resort. + // We also have to copy the body node so it is something we can modify. + if (topCandidate === null || topCandidate.tagName === "BODY") { + // Move all of the page's children into topCandidate + topCandidate = doc.createElement("DIV"); + neededToCreateTopCandidate = true; + // Move everything (not just elements, also text nodes etc.) into the container + // so we even include text directly in the body: + while (page.firstChild) { + this.log("Moving child out:", page.firstChild); + topCandidate.appendChild(page.firstChild); + } + + page.appendChild(topCandidate); + + this._initializeNode(topCandidate); + } else if (topCandidate) { + // Find a better top candidate node if it contains (at least three) nodes which belong to \`topCandidates\` array + // and whose scores are quite closed with current \`topCandidate\` node. + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if ( + topCandidates[i].readability.contentScore / + topCandidate.readability.contentScore >= + 0.75 + ) { + alternativeCandidateAncestors.push( + this._getNodeAncestors(topCandidates[i]) + ); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName !== "BODY") { + var listsContainingThisAncestor = 0; + for ( + var ancestorIndex = 0; + ancestorIndex < alternativeCandidateAncestors.length && + listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; + ancestorIndex++ + ) { + listsContainingThisAncestor += Number( + alternativeCandidateAncestors[ancestorIndex].includes( + parentOfTopCandidate + ) + ); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + + // Because of our bonus system, parents of candidates might have scores + // themselves. They get half of the node. There won't be nodes with higher + // scores than our topCandidate, but if we see the score going *up* in the first + // few steps up the tree, that's a decent sign that there might be more content + // lurking in other places that we want to unify in. The sibling stuff + // below does some of that - but only if we've looked high enough up the DOM + // tree. + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + // The scores shouldn't get too low. + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate.tagName !== "BODY") { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) { + break; + } + if (parentScore > lastScore) { + // Alright! We found a better parent to use. + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + + // If the top candidate is the only child, use parent instead. This will help sibling + // joining logic when adjacent content is actually located in parent's sibling node. + parentOfTopCandidate = topCandidate.parentNode; + while ( + parentOfTopCandidate.tagName != "BODY" && + parentOfTopCandidate.children.length == 1 + ) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + + // Now that we have the top candidate, look through its siblings for content + // that might also be related. Things like preambles, content split by ads + // that we removed, etc. + var articleContent = doc.createElement("DIV"); + if (isPaging) { + articleContent.id = "readability-content"; + } + + var siblingScoreThreshold = Math.max( + 10, + topCandidate.readability.contentScore * 0.2 + ); + // Keep potential top candidate's parent node to try to get text direction of it later. + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + + this.log( + "Looking at sibling node:", + sibling, + sibling.readability + ? "with score " + sibling.readability.contentScore + : "" + ); + this.log( + "Sibling has score", + sibling.readability ? sibling.readability.contentScore : "Unknown" + ); + + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + + // Give a bonus if sibling nodes and top candidates have the example same classname + if ( + sibling.className === topCandidate.className && + topCandidate.className !== "" + ) { + contentBonus += topCandidate.readability.contentScore * 0.2; + } + + if ( + sibling.readability && + sibling.readability.contentScore + contentBonus >= + siblingScoreThreshold + ) { + append = true; + } else if (sibling.nodeName === "P") { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if ( + nodeLength < 80 && + nodeLength > 0 && + linkDensity === 0 && + nodeContent.search(/\\.( |$)/) !== -1 + ) { + append = true; + } + } + } + + if (append) { + this.log("Appending node:", sibling); + + if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) { + // We have a node that isn't a common block level element, like a form or td tag. + // Turn it into a div so it doesn't get filtered out later by accident. + this.log("Altering sibling:", sibling, "to div."); + + sibling = this._setNodeTag(sibling, "DIV"); + } + + articleContent.appendChild(sibling); + // Fetch children again to make it compatible + // with DOM parsers without live collection support. + siblings = parentOfTopCandidate.children; + // siblings is a reference to the children array, and + // sibling is removed from the array when we call appendChild(). + // As a result, we must revisit this index since the nodes + // have been shifted. + s -= 1; + sl -= 1; + } + } + + if (this._debug) { + this.log("Article content pre-prep: " + articleContent.innerHTML); + } + // So we have all of the content that we need. Now we clean it up for presentation. + this._prepArticle(articleContent); + if (this._debug) { + this.log("Article content post-prep: " + articleContent.innerHTML); + } + + if (neededToCreateTopCandidate) { + // We already created a fake div thing, and there wouldn't have been any siblings left + // for the previous loop, so there's no point trying to create a new div, and then + // move all the children over. Just assign IDs and class names here. No need to append + // because that already happened anyway. + topCandidate.id = "readability-page-1"; + topCandidate.className = "page"; + } else { + var div = doc.createElement("DIV"); + div.id = "readability-page-1"; + div.className = "page"; + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); + } + articleContent.appendChild(div); + } + + if (this._debug) { + this.log("Article content after paging: " + articleContent.innerHTML); + } + + var parseSuccessful = true; + + // Now that we've gone through the full algorithm, check to see if + // we got any meaningful content. If we didn't, we may need to re-run + // grabArticle with different flags set. This gives us a higher likelihood of + // finding the content, and the sieve approach gives us a higher likelihood of + // finding the -right- content. + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + // eslint-disable-next-line no-unsanitized/property + page.innerHTML = pageCacheHtml; + + this._attempts.push({ + articleContent, + textLength, + }); + + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + } else { + // No luck after removing flags, just return the longest text we found during the different loops + this._attempts.sort(function (a, b) { + return b.textLength - a.textLength; + }); + + // But first check if we actually have something + if (!this._attempts[0].textLength) { + return null; + } + + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + + if (parseSuccessful) { + // Find out text direction from ancestors of final top candidate. + var ancestors = [parentOfTopCandidate, topCandidate].concat( + this._getNodeAncestors(parentOfTopCandidate) + ); + this._someNode(ancestors, function (ancestor) { + if (!ancestor.tagName) { + return false; + } + var articleDir = ancestor.getAttribute("dir"); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + + /** + * Converts some of the common HTML entities in string to their corresponding characters. + * + * @param str {string} - a string to unescape. + * @return string without HTML entity. + */ + _unescapeHtmlEntities(str) { + if (!str) { + return str; + } + + var htmlEscapeMap = this.HTML_ESCAPE_MAP; + return str + .replace(/&(quot|amp|apos|lt|gt);/g, function (_, tag) { + return htmlEscapeMap[tag]; + }) + .replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function (_, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); + + // these character references are replaced by a conforming HTML parser + if (num == 0 || num > 0x10ffff || (num >= 0xd800 && num <= 0xdfff)) { + num = 0xfffd; + } + + return String.fromCodePoint(num); + }); + }, + + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + _getJSONLD(doc) { + var scripts = this._getAllNodesWithTag(doc, ["script"]); + + var metadata; + + this._forEachNode(scripts, function (jsonLdElement) { + if ( + !metadata && + jsonLdElement.getAttribute("type") === "application/ld+json" + ) { + try { + // Strip CDATA markers if present + var content = jsonLdElement.textContent.replace( + /^\\s*\\s*$/g, + "" + ); + var parsed = JSON.parse(content); + + if (Array.isArray(parsed)) { + parsed = parsed.find(it => { + return ( + it["@type"] && + it["@type"].match(this.REGEXPS.jsonLdArticleTypes) + ); + }); + if (!parsed) { + return; + } + } + + var schemaDotOrgRegex = /^https?\\:\\/\\/schema\\.org\\/?$/; + var matches = + (typeof parsed["@context"] === "string" && + parsed["@context"].match(schemaDotOrgRegex)) || + (typeof parsed["@context"] === "object" && + typeof parsed["@context"]["@vocab"] == "string" && + parsed["@context"]["@vocab"].match(schemaDotOrgRegex)); + + if (!matches) { + return; + } + + if (!parsed["@type"] && Array.isArray(parsed["@graph"])) { + parsed = parsed["@graph"].find(it => { + return (it["@type"] || "").match(this.REGEXPS.jsonLdArticleTypes); + }); + } + + if ( + !parsed || + !parsed["@type"] || + !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes) + ) { + return; + } + + metadata = {}; + + if ( + typeof parsed.name === "string" && + typeof parsed.headline === "string" && + parsed.name !== parsed.headline + ) { + // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz + // put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either + // "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default. + + var title = this._getArticleTitle(); + var nameMatches = this._textSimilarity(parsed.name, title) > 0.75; + var headlineMatches = + this._textSimilarity(parsed.headline, title) > 0.75; + + if (headlineMatches && !nameMatches) { + metadata.title = parsed.headline; + } else { + metadata.title = parsed.name; + } + } else if (typeof parsed.name === "string") { + metadata.title = parsed.name.trim(); + } else if (typeof parsed.headline === "string") { + metadata.title = parsed.headline.trim(); + } + if (parsed.author) { + if (typeof parsed.author.name === "string") { + metadata.byline = parsed.author.name.trim(); + } else if ( + Array.isArray(parsed.author) && + parsed.author[0] && + typeof parsed.author[0].name === "string" + ) { + metadata.byline = parsed.author + .filter(function (author) { + return author && typeof author.name === "string"; + }) + .map(function (author) { + return author.name.trim(); + }) + .join(", "); + } + } + if (typeof parsed.description === "string") { + metadata.excerpt = parsed.description.trim(); + } + if (parsed.publisher && typeof parsed.publisher.name === "string") { + metadata.siteName = parsed.publisher.name.trim(); + } + if (typeof parsed.datePublished === "string") { + metadata.datePublished = parsed.datePublished.trim(); + } + } catch (err) { + this.log(err.message); + } + } + }); + return metadata ? metadata : {}; + }, + + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param {Object} jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata(jsonld) { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName("meta"); + + // property is a space-separated list of values + var propertyPattern = + /\\s*(article|dc|dcterm|og|twitter)\\s*:\\s*(author|creator|description|published_time|title|site_name)\\s*/gi; + + // name is a single value + var namePattern = + /^\\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\\s*[-\\.:]\\s*)?(author|creator|pub-date|description|title|site_name)\\s*$/i; + + // Find description tags. + this._forEachNode(metaElements, function (element) { + var elementName = element.getAttribute("name"); + var elementProperty = element.getAttribute("property"); + var content = element.getAttribute("content"); + if (!content) { + return; + } + var matches = null; + var name = null; + + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + // Convert to lowercase, and remove any whitespace + // so we can match below. + name = matches[0].toLowerCase().replace(/\\s/g, ""); + // multiple authors + values[name] = content.trim(); + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + // Convert to lowercase, remove any whitespace, and convert dots + // to colons so we can match below. + name = name.toLowerCase().replace(/\\s/g, "").replace(/\\./g, ":"); + values[name] = content.trim(); + } + } + }); + + // get title + metadata.title = + jsonld.title || + values["dc:title"] || + values["dcterm:title"] || + values["og:title"] || + values["weibo:article:title"] || + values["weibo:webpage:title"] || + values.title || + values["twitter:title"] || + values["parsely-title"]; + + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + + const articleAuthor = + typeof values["article:author"] === "string" && + !this._isUrl(values["article:author"]) + ? values["article:author"] + : undefined; + + // get author + metadata.byline = + jsonld.byline || + values["dc:creator"] || + values["dcterm:creator"] || + values.author || + values["parsely-author"] || + articleAuthor; + + // get description + metadata.excerpt = + jsonld.excerpt || + values["dc:description"] || + values["dcterm:description"] || + values["og:description"] || + values["weibo:article:description"] || + values["weibo:webpage:description"] || + values.description || + values["twitter:description"]; + + // get site name + metadata.siteName = jsonld.siteName || values["og:site_name"]; + + // get article published time + metadata.publishedTime = + jsonld.datePublished || + values["article:published_time"] || + values["parsely-pub-date"] || + null; + + // in many sites the meta value is escaped with HTML entities, + // so here we need to unescape it + metadata.title = this._unescapeHtmlEntities(metadata.title); + metadata.byline = this._unescapeHtmlEntities(metadata.byline); + metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); + metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); + metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime); + + return metadata; + }, + + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param Element + **/ + _isSingleImage(node) { + while (node) { + if (node.tagName === "IMG") { + return true; + } + if (node.children.length !== 1 || node.textContent.trim() !== "") { + return false; + } + node = node.children[0]; + } + return false; + }, + + /** + * Find all