diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni index 7f68872504..d6a8f14204 100644 --- a/config/gni/devtools_grd_files.gni +++ b/config/gni/devtools_grd_files.gni @@ -654,6 +654,8 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/ui/HelpDialog.js", "front_end/panels/ai_chat/ui/PromptEditDialog.js", "front_end/panels/ai_chat/ui/SettingsDialog.js", + "front_end/panels/ai_chat/ui/OnboardingDialog.js", + "front_end/panels/ai_chat/ui/onboardingStyles.js", "front_end/panels/ai_chat/ui/mcp/MCPConnectionsDialog.js", "front_end/panels/ai_chat/ui/mcp/MCPConnectorsCatalogDialog.js", "front_end/panels/ai_chat/ui/EvaluationDialog.js", @@ -687,6 +689,7 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/LLM/LLMProviderRegistry.js", "front_end/panels/ai_chat/LLM/LLMErrorHandler.js", "front_end/panels/ai_chat/LLM/LLMResponseParser.js", + "front_end/panels/ai_chat/LLM/FuzzyModelMatcher.js", "front_end/panels/ai_chat/LLM/OpenAIProvider.js", "front_end/panels/ai_chat/LLM/LiteLLMProvider.js", "front_end/panels/ai_chat/LLM/GroqProvider.js", diff --git a/config/gni/devtools_image_files.gni b/config/gni/devtools_image_files.gni index 8dcb26105c..3b6d896ee6 100644 --- a/config/gni/devtools_image_files.gni +++ b/config/gni/devtools_image_files.gni @@ -20,6 +20,8 @@ devtools_image_files = [ "touchCursor.png", "gdp-logo-light.png", "gdp-logo-dark.png", + "browser-operator-logo.png", + "demo.gif", ] devtools_svg_sources = [ diff --git a/front_end/Images/browser-operator-logo.png b/front_end/Images/browser-operator-logo.png new file mode 100644 index 0000000000..a97b47b597 Binary files /dev/null and b/front_end/Images/browser-operator-logo.png differ diff --git a/front_end/Images/demo.gif b/front_end/Images/demo.gif new file mode 100644 index 0000000000..799e849e33 Binary files /dev/null and b/front_end/Images/demo.gif differ diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index d8deb34ac0..8964429a68 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -40,6 +40,8 @@ devtools_module("ai_chat") { "ui/ToolDescriptionFormatter.ts", "ui/HelpDialog.ts", "ui/SettingsDialog.ts", + "ui/OnboardingDialog.ts", + "ui/onboardingStyles.ts", "ui/settings/types.ts", "ui/settings/constants.ts", "ui/settings/i18n-strings.ts", @@ -60,6 +62,7 @@ devtools_module("ai_chat") { "ui/settings/advanced/VectorDBSettings.ts", "ui/settings/advanced/TracingSettings.ts", "ui/settings/advanced/EvaluationSettings.ts", + "ui/settings/advanced/MemorySettings.ts", "ui/PromptEditDialog.ts", "ui/EvaluationDialog.ts", "ui/WebAppCodeViewer.ts", @@ -75,6 +78,11 @@ devtools_module("ai_chat") { "persistence/ConversationTypes.ts", "persistence/ConversationStorageManager.ts", "persistence/ConversationManager.ts", + "memory/types.ts", + "memory/MemoryModule.ts", + "memory/MemoryBlockManager.ts", + "memory/MemoryAgentConfig.ts", + "memory/index.ts", "core/Graph.ts", "core/State.ts", "core/Types.ts", @@ -103,6 +111,7 @@ devtools_module("ai_chat") { "LLM/LLMProviderRegistry.ts", "LLM/LLMErrorHandler.ts", "LLM/LLMResponseParser.ts", + "LLM/FuzzyModelMatcher.ts", "LLM/OpenAIProvider.ts", "LLM/LiteLLMProvider.ts", "LLM/GroqProvider.ts", @@ -135,6 +144,9 @@ devtools_module("ai_chat") { "tools/DeleteFileTool.ts", "tools/ReadFileTool.ts", "tools/ListFilesTool.ts", + "memory/SearchMemoryTool.ts", + "memory/UpdateMemoryTool.ts", + "memory/ListMemoryBlocksTool.ts", "tools/UpdateTodoTool.ts", "tools/ExecuteCodeTool.ts", "tools/SequentialThinkingTool.ts", @@ -278,6 +290,11 @@ _ai_chat_sources = [ "ui/mcp/MCPConnectorsCatalogDialog.ts", "ai_chat_impl.ts", "models/ChatTypes.ts", + "memory/types.ts", + "memory/MemoryModule.ts", + "memory/MemoryBlockManager.ts", + "memory/MemoryAgentConfig.ts", + "memory/index.ts", "core/Graph.ts", "core/State.ts", "core/Types.ts", @@ -306,6 +323,7 @@ _ai_chat_sources = [ "LLM/LLMProviderRegistry.ts", "LLM/LLMErrorHandler.ts", "LLM/LLMResponseParser.ts", + "LLM/FuzzyModelMatcher.ts", "LLM/OpenAIProvider.ts", "LLM/LiteLLMProvider.ts", "LLM/GroqProvider.ts", @@ -338,6 +356,9 @@ _ai_chat_sources = [ "tools/DeleteFileTool.ts", "tools/ReadFileTool.ts", "tools/ListFilesTool.ts", + "memory/SearchMemoryTool.ts", + "memory/UpdateMemoryTool.ts", + "memory/ListMemoryBlocksTool.ts", "tools/UpdateTodoTool.ts", "tools/ExecuteCodeTool.ts", "tools/SequentialThinkingTool.ts", diff --git a/front_end/panels/ai_chat/LLM/FuzzyModelMatcher.ts b/front_end/panels/ai_chat/LLM/FuzzyModelMatcher.ts new file mode 100644 index 0000000000..8640484b95 --- /dev/null +++ b/front_end/panels/ai_chat/LLM/FuzzyModelMatcher.ts @@ -0,0 +1,185 @@ +// 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. + +/** + * Fuzzy model name matcher for finding the closest available model + * when an exact match isn't found. + */ + +/** + * Calculate Levenshtein distance between two strings + */ +function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = []; + + // Initialize matrix + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + + // Fill matrix + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, // substitution + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j] + 1 // deletion + ); + } + } + } + + return matrix[b.length][a.length]; +} + +/** + * Calculate similarity score between two strings (0-1) + */ +function similarity(a: string, b: string): number { + const distance = levenshteinDistance(a, b); + const maxLen = Math.max(a.length, b.length); + return maxLen === 0 ? 1 : 1 - distance / maxLen; +} + +/** + * Normalize model name for comparison by removing dates, versions, and separators + */ +function normalizeModelName(name: string): string { + return name + .toLowerCase() + .replace(/[-_]/g, '') // Remove separators + .replace(/\d{4}-?\d{2}-?\d{2}$/g, '') // Remove date suffixes (2025-04-14 or 20250514) + .replace(/\d{8}$/g, '') // Remove date suffixes without dashes + .trim(); +} + +/** + * Check if target is a prefix of candidate (case-insensitive) + */ +function isPrefixMatch(target: string, candidate: string): boolean { + const normalizedTarget = target.toLowerCase().replace(/[._]/g, '-'); + const normalizedCandidate = candidate.toLowerCase().replace(/[._]/g, '-'); + return normalizedCandidate.startsWith(normalizedTarget); +} + +/** + * Find the closest matching model from available options + * + * Matching strategy (in priority order): + * 1. Exact match - return immediately + * 2. Prefix match - if target is prefix of an available model + * 3. Normalized match - strip dates/versions and compare base names + * 4. Levenshtein similarity - if similarity > threshold, return best match + * + * @param targetModel - The model name to find a match for + * @param availableModels - Array of available model names + * @param threshold - Minimum similarity score (0-1) for fuzzy matching (default: 0.5) + * @returns The closest matching model name, or null if no good match found + */ +export function findClosestModel( + targetModel: string, + availableModels: string[], + threshold: number = 0.5 +): string | null { + if (!targetModel || availableModels.length === 0) { + return null; + } + + // 1. Exact match + if (availableModels.includes(targetModel)) { + return targetModel; + } + + // 2. Prefix match - find models where target is a prefix + const prefixMatches = availableModels.filter(model => isPrefixMatch(targetModel, model)); + if (prefixMatches.length > 0) { + // Return the shortest prefix match (most specific) + return prefixMatches.sort((a, b) => a.length - b.length)[0]; + } + + // 3. Normalized match - compare base names without dates/versions + const normalizedTarget = normalizeModelName(targetModel); + for (const model of availableModels) { + if (normalizeModelName(model) === normalizedTarget) { + return model; + } + } + + // 4. Levenshtein similarity on normalized names + let bestMatch: string | null = null; + let bestScore = 0; + + for (const model of availableModels) { + const score = similarity(normalizedTarget, normalizeModelName(model)); + if (score > bestScore && score >= threshold) { + bestScore = score; + bestMatch = model; + } + } + + return bestMatch; +} + +/** + * Find closest model with detailed match info for logging + */ +export interface FuzzyMatchResult { + match: string | null; + matchType: 'exact' | 'prefix' | 'normalized' | 'similarity' | 'none'; + score: number; +} + +export function findClosestModelWithInfo( + targetModel: string, + availableModels: string[], + threshold: number = 0.5 +): FuzzyMatchResult { + if (!targetModel || availableModels.length === 0) { + return { match: null, matchType: 'none', score: 0 }; + } + + // 1. Exact match + if (availableModels.includes(targetModel)) { + return { match: targetModel, matchType: 'exact', score: 1 }; + } + + // 2. Prefix match + const prefixMatches = availableModels.filter(model => isPrefixMatch(targetModel, model)); + if (prefixMatches.length > 0) { + const match = prefixMatches.sort((a, b) => a.length - b.length)[0]; + return { match, matchType: 'prefix', score: targetModel.length / match.length }; + } + + // 3. Normalized match + const normalizedTarget = normalizeModelName(targetModel); + for (const model of availableModels) { + if (normalizeModelName(model) === normalizedTarget) { + return { match: model, matchType: 'normalized', score: 1 }; + } + } + + // 4. Levenshtein similarity + let bestMatch: string | null = null; + let bestScore = 0; + + for (const model of availableModels) { + const score = similarity(normalizedTarget, normalizeModelName(model)); + if (score > bestScore && score >= threshold) { + bestScore = score; + bestMatch = model; + } + } + + if (bestMatch) { + return { match: bestMatch, matchType: 'similarity', score: bestScore }; + } + + return { match: null, matchType: 'none', score: 0 }; +} diff --git a/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts b/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts index 0c187a8b4d..630e9caa4c 100644 --- a/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts +++ b/front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts @@ -5,6 +5,7 @@ import { createLogger } from '../core/Logger.js'; import type { LLMProviderInterface } from './LLMProvider.js'; import type { LLMProvider, ModelInfo } from './LLMTypes.js'; +import { isCustomProvider } from './LLMTypes.js'; import { OpenAIProvider } from './OpenAIProvider.js'; import { LiteLLMProvider } from './LiteLLMProvider.js'; import { GroqProvider } from './GroqProvider.js'; @@ -13,6 +14,8 @@ 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'; const logger = createLogger('LLMProviderRegistry'); @@ -116,25 +119,39 @@ export class LLMProviderRegistry { * 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 { + private static createTemporaryProvider( + providerType: LLMProvider, + apiKey: string = '', + endpoint?: string + ): LLMProviderInterface | null { try { + // Handle custom providers - create GenericOpenAIProvider with config from CustomProviderManager + if (isCustomProvider(providerType)) { + const config = CustomProviderManager.getProvider(providerType); + if (!config) { + logger.warn(`Custom provider ${providerType} not found in CustomProviderManager`); + return null; + } + return new GenericOpenAIProvider(config, apiKey || undefined); + } + switch (providerType) { case 'openai': - return new OpenAIProvider(''); + return new OpenAIProvider(apiKey); case 'litellm': - return new LiteLLMProvider('', ''); + return new LiteLLMProvider(apiKey, endpoint || ''); case 'groq': - return new GroqProvider(''); + return new GroqProvider(apiKey); case 'openrouter': - return new OpenRouterProvider(''); + return new OpenRouterProvider(apiKey); case 'browseroperator': - return new BrowserOperatorProvider(null, ''); + return new BrowserOperatorProvider(null, apiKey); case 'cerebras': - return new CerebrasProvider(''); + return new CerebrasProvider(apiKey); case 'anthropic': - return new AnthropicProvider(''); + return new AnthropicProvider(apiKey); case 'googleai': - return new GoogleAIProvider(''); + return new GoogleAIProvider(apiKey); default: logger.warn(`Unknown provider type: ${providerType}`); return null; @@ -148,16 +165,23 @@ export class LLMProviderRegistry { /** * Get or create a provider instance for utility operations * Prefers registered instance, falls back to temporary instance + * @param providerType The type of provider to get/create + * @param apiKey Optional API key for temporary provider creation + * @param endpoint Optional endpoint for temporary provider creation */ - private static getOrCreateProvider(providerType: LLMProvider): LLMProviderInterface | null { + private static getOrCreateProvider( + providerType: LLMProvider, + apiKey?: string, + endpoint?: string + ): 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); + // Fall back to creating temporary instance with provided credentials + return this.createTemporaryProvider(providerType, apiKey || '', endpoint); } /** @@ -165,6 +189,13 @@ export class LLMProviderRegistry { * Returns the localStorage keys used by the provider for credentials */ static getProviderStorageKeys(providerType: LLMProvider): {apiKey?: string; endpoint?: string; [key: string]: string | undefined} { + // Handle custom providers - they use CustomProviderManager for storage + if (isCustomProvider(providerType)) { + return { + apiKey: CustomProviderManager.getApiKeyStorageKey(providerType), + }; + } + const provider = this.getOrCreateProvider(providerType); if (!provider) { logger.warn(`Provider ${providerType} not available`); @@ -177,6 +208,11 @@ export class LLMProviderRegistry { * Get API key from localStorage for a provider */ static getProviderApiKey(providerType: LLMProvider): string { + // Handle custom providers - they use CustomProviderManager for API key storage + if (isCustomProvider(providerType)) { + return CustomProviderManager.getApiKey(providerType) || ''; + } + const keys = this.getProviderStorageKeys(providerType); if (!keys.apiKey) { return ''; @@ -296,19 +332,65 @@ export class LLMProviderRegistry { apiKey: string, endpoint?: string ): Promise { - const provider = this.getOrCreateProvider(providerType); + // Handle custom providers - check if models were manually configured + if (isCustomProvider(providerType)) { + const config = CustomProviderManager.getProvider(providerType); + if (!config) { + logger.warn(`Custom provider ${providerType} not found`); + return []; + } + + // If models were manually added by user, return them as-is + if (config.modelsManuallyAdded && config.models.length > 0) { + logger.debug(`Returning ${config.models.length} manually configured models for ${providerType}`); + return config.models.map(modelId => ({ + id: modelId, + name: modelId, + provider: providerType, + })); + } + + // Otherwise, fetch from the custom provider's API (OpenAI-compatible) + logger.debug(`Fetching models from API for custom provider ${providerType}`); + const provider = new GenericOpenAIProvider(config, apiKey || undefined); + try { + if (typeof provider.fetchModels === 'function') { + const models = await provider.fetchModels(); + return models.map((m: any) => ({ + id: m.id || m.name, + name: m.name || m.id, + provider: providerType, + ...(m.capabilities ? { capabilities: m.capabilities } : {}), + })); + } + return await provider.getModels(); + } catch (error) { + logger.error(`Failed to fetch models for custom provider ${providerType}:`, error); + throw error; + } + } + + // Built-in providers: always create a fresh provider instance with the provided credentials for testing + // Don't use getOrCreateProvider() which returns the registered instance with old/no API key + const provider = this.createTemporaryProvider(providerType, apiKey, endpoint); 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); + // Use fetchModels() if available - it throws on API errors (good for validation) + // Fall back to getModels() which may swallow errors and return defaults + if (typeof (provider as any).fetchModels === 'function') { + const models = await (provider as any).fetchModels(); + // Convert to ModelInfo format if needed + return models.map((m: any) => ({ + id: m.id || m.name, + name: m.name || m.id, + provider: providerType, + ...(m.capabilities ? { capabilities: m.capabilities } : {}), + })); } - - // Fallback to getModels return await provider.getModels(); } catch (error) { logger.error(`Failed to fetch models for ${providerType}:`, error); diff --git a/front_end/panels/ai_chat/LLM/OpenAIProvider.ts b/front_end/panels/ai_chat/LLM/OpenAIProvider.ts index dbfc03b3b3..0bb88ded0f 100644 --- a/front_end/panels/ai_chat/LLM/OpenAIProvider.ts +++ b/front_end/panels/ai_chat/LLM/OpenAIProvider.ts @@ -415,11 +415,89 @@ export class OpenAIProvider extends LLMBaseProvider { return this.callWithMessages(modelName, messages, options); } + /** + * Fetch available models from OpenAI API + * This method makes an actual API call and throws on error (good for validation) + */ + async fetchModels(): Promise> { + const response = await fetch('https://api.openai.com/v1/models', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })); + throw new Error(`OpenAI API error: ${response.statusText} - ${errorData?.error?.message || 'Unknown error'}`); + } + + const data = await response.json(); + + // Models to include (chat/reasoning models that work with Responses API) + const SUPPORTED_PREFIXES = ['gpt-4.1', 'gpt-4o', 'gpt-5', 'o1', 'o3', 'o4']; + + // Models to exclude (non-chat models like TTS, STT, embeddings, etc.) + const EXCLUDED_PATTERNS = ['transcribe', 'tts', 'audio', 'image', 'embedding', 'moderation', 'whisper', 'dall-e', 'realtime', 'codex', 'chat', 'search']; + + return data.data + .filter((model: any) => { + const id = model.id.toLowerCase(); + // Must start with a supported prefix + const hasPrefix = SUPPORTED_PREFIXES.some(prefix => id.startsWith(prefix)); + // Must not contain excluded patterns + const isExcluded = EXCLUDED_PATTERNS.some(pattern => id.includes(pattern)); + return hasPrefix && !isExcluded; + }) + .map((model: any) => ({ + id: model.id, + name: model.id + })); + } + /** * Get all OpenAI models supported by this provider */ async getModels(): Promise { - // Return hardcoded OpenAI models with their capabilities + try { + const models = await this.fetchModels(); + + return models.map(model => ({ + id: model.id, + name: model.name, + provider: 'openai' as LLMProvider, + capabilities: { + functionCalling: true, + reasoning: this.modelSupportsReasoning(model.id), + vision: this.modelSupportsVision(model.id), + structured: true + } + })); + } catch (error) { + logger.warn('Failed to fetch models from OpenAI API, using default list:', error); + return this.getDefaultModels(); + } + } + + /** + * Check if model supports reasoning (O-series and GPT-5) + */ + private modelSupportsReasoning(modelId: string): boolean { + return modelId.startsWith('o') || modelId.includes('gpt-5'); + } + + /** + * Check if model supports vision + */ + private modelSupportsVision(modelId: string): boolean { + // O3-mini doesn't support vision, most others do + return !modelId.includes('o3-mini'); + } + + /** + * Get default list of known OpenAI models (fallback) + */ + private getDefaultModels(): ModelInfo[] { return [ { id: 'gpt-4.1-2025-04-14', diff --git a/front_end/panels/ai_chat/LLM/__tests__/FuzzyModelMatcher.test.ts b/front_end/panels/ai_chat/LLM/__tests__/FuzzyModelMatcher.test.ts new file mode 100644 index 0000000000..19c0ea36f2 --- /dev/null +++ b/front_end/panels/ai_chat/LLM/__tests__/FuzzyModelMatcher.test.ts @@ -0,0 +1,162 @@ +// 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 { findClosestModel, findClosestModelWithInfo } from '../FuzzyModelMatcher.js'; + +describe('ai_chat: FuzzyModelMatcher', () => { + describe('findClosestModel', () => { + describe('exact match', () => { + it('returns exact match when available', () => { + const available = ['gpt-4.1-2025-04-14', 'gpt-4.1-mini-2025-04-14']; + assert.strictEqual(findClosestModel('gpt-4.1-2025-04-14', available), 'gpt-4.1-2025-04-14'); + }); + + it('returns null for empty target', () => { + const available = ['gpt-4.1-2025-04-14']; + assert.isNull(findClosestModel('', available)); + }); + + it('returns null for empty available list', () => { + assert.isNull(findClosestModel('gpt-4.1', [])); + }); + }); + + describe('prefix match', () => { + it('matches when target is prefix of available model', () => { + const available = ['claude-sonnet-4-5-20250514', 'claude-haiku-4-5-20250514']; + assert.strictEqual(findClosestModel('claude-sonnet-4-5', available), 'claude-sonnet-4-5-20250514'); + }); + + it('matches gpt model prefix with date suffix', () => { + const available = ['gpt-4.1-2025-04-14', 'gpt-4.1-mini-2025-04-14']; + assert.strictEqual(findClosestModel('gpt-4.1', available), 'gpt-4.1-2025-04-14'); + }); + + it('returns shortest prefix match when multiple matches exist', () => { + const available = ['claude-sonnet-4', 'claude-sonnet-4-5', 'claude-sonnet-4-5-20250514']; + assert.strictEqual(findClosestModel('claude-sonnet', available), 'claude-sonnet-4'); + }); + + it('handles dot vs dash variations in prefix', () => { + const available = ['claude-sonnet-4-5-20250514']; + assert.strictEqual(findClosestModel('claude-sonnet-4.5', available), 'claude-sonnet-4-5-20250514'); + }); + }); + + describe('normalized match', () => { + it('matches models ignoring date suffix', () => { + const available = ['gemini-2.5-pro-20250514']; + // After normalization, 'gemini25pro' should match + assert.strictEqual(findClosestModel('gemini-2.5-pro', available), 'gemini-2.5-pro-20250514'); + }); + + it('matches models ignoring separators', () => { + const available = ['claude-sonnet-4-5-20250514']; + // 'claude_sonnet_4_5' normalized becomes 'claudesonnet45' + assert.strictEqual(findClosestModel('claude_sonnet_4_5', available), 'claude-sonnet-4-5-20250514'); + }); + }); + + describe('similarity match', () => { + it('matches similar model names above threshold', () => { + const available = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gpt-4.1']; + // 'gemini-pro' should fuzzy match to 'gemini-2.5-pro' + const result = findClosestModel('gemini-pro', available); + assert.strictEqual(result, 'gemini-2.5-pro'); + }); + + it('returns null for dissimilar models below threshold', () => { + const available = ['gpt-4.1-2025-04-14', 'claude-sonnet-4-5-20250514']; + assert.isNull(findClosestModel('completely-different-model', available)); + }); + + it('respects custom threshold', () => { + const available = ['gpt-4.1-2025-04-14']; + // With very high threshold, even similar names won't match + assert.isNull(findClosestModel('gpt-4', available, 0.99)); + }); + }); + + describe('real-world model names', () => { + const anthropicModels = [ + 'claude-sonnet-4-5-20250514', + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-haiku-4-20250514', + 'claude-3-5-sonnet-20241022', + ]; + + const googleModels = [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-1.5-pro', + ]; + + const openaiModels = [ + 'gpt-4.1-2025-04-14', + 'gpt-4.1-mini-2025-04-14', + 'gpt-4.1-nano-2025-04-14', + 'o4-mini-2025-04-16', + ]; + + it('matches Anthropic short names to full names', () => { + assert.strictEqual(findClosestModel('claude-sonnet-4-5', anthropicModels), 'claude-sonnet-4-5-20250514'); + assert.strictEqual(findClosestModel('claude-haiku-4', anthropicModels), 'claude-haiku-4-20250514'); + assert.strictEqual(findClosestModel('claude-opus-4', anthropicModels), 'claude-opus-4-20250514'); + }); + + it('matches Google AI model variations', () => { + assert.strictEqual(findClosestModel('gemini-2.5-pro', googleModels), 'gemini-2.5-pro'); + assert.strictEqual(findClosestModel('gemini-flash', googleModels), 'gemini-2.5-flash'); + }); + + it('matches OpenAI model variations', () => { + assert.strictEqual(findClosestModel('gpt-4.1', openaiModels), 'gpt-4.1-2025-04-14'); + assert.strictEqual(findClosestModel('gpt-4.1-mini', openaiModels), 'gpt-4.1-mini-2025-04-14'); + }); + }); + }); + + describe('findClosestModelWithInfo', () => { + it('returns exact match type', () => { + const available = ['gpt-4.1-2025-04-14']; + const result = findClosestModelWithInfo('gpt-4.1-2025-04-14', available); + assert.strictEqual(result.match, 'gpt-4.1-2025-04-14'); + assert.strictEqual(result.matchType, 'exact'); + assert.strictEqual(result.score, 1); + }); + + it('returns prefix match type', () => { + const available = ['claude-sonnet-4-5-20250514']; + const result = findClosestModelWithInfo('claude-sonnet-4-5', available); + assert.strictEqual(result.match, 'claude-sonnet-4-5-20250514'); + assert.strictEqual(result.matchType, 'prefix'); + assert.isAbove(result.score, 0); + }); + + it('returns normalized match type', () => { + const available = ['claude-sonnet-4-5-20250514']; + const result = findClosestModelWithInfo('claude_sonnet_4_5', available); + assert.strictEqual(result.match, 'claude-sonnet-4-5-20250514'); + assert.strictEqual(result.matchType, 'normalized'); + }); + + it('returns similarity match type', () => { + const available = ['gemini-2.5-pro']; + const result = findClosestModelWithInfo('gemini-pro', available); + assert.strictEqual(result.match, 'gemini-2.5-pro'); + assert.strictEqual(result.matchType, 'similarity'); + assert.isAbove(result.score, 0.5); + }); + + it('returns none match type when no match found', () => { + const available = ['gpt-4.1-2025-04-14']; + const result = findClosestModelWithInfo('completely-unrelated', available); + assert.isNull(result.match); + assert.strictEqual(result.matchType, 'none'); + assert.strictEqual(result.score, 0); + }); + }); +}); diff --git a/front_end/panels/ai_chat/Readme.md b/front_end/panels/ai_chat/Readme.md index 4561c34080..5bc732b764 100644 --- a/front_end/panels/ai_chat/Readme.md +++ b/front_end/panels/ai_chat/Readme.md @@ -32,6 +32,7 @@ fetch devtools-frontend # Build steps cd devtools-frontend gclient sync +cd devtools-frontend npm run build ``` diff --git a/front_end/panels/ai_chat/agent_framework/AgentRunner.ts b/front_end/panels/ai_chat/agent_framework/AgentRunner.ts index 71a8a541c1..48ec30d96e 100644 --- a/front_end/panels/ai_chat/agent_framework/AgentRunner.ts +++ b/front_end/panels/ai_chat/agent_framework/AgentRunner.ts @@ -430,12 +430,13 @@ export class AgentRunner { hooks: AgentRunnerHooks, executingAgent: ConfigurableAgentTool | null, parentSession?: AgentSession, // For natural nesting - overrides?: { sessionId?: string; parentSessionId?: string; traceId?: string }, + overrides?: { sessionId?: string; parentSessionId?: string; traceId?: string; background?: boolean }, abortSignal?: AbortSignal ): Promise { const agentName = executingAgent?.name || 'Unknown'; logger.info(`Starting execution loop for agent: ${agentName}`); const { apiKey, modelName, systemPrompt, tools, maxIterations, temperature, agentDescriptor } = config; + const isBackground = overrides?.background === true; const { prepareInitialMessages, createSuccessResult, createErrorResult, afterExecute } = hooks; @@ -463,17 +464,15 @@ export class AgentRunner { // Use local session variable instead of static let currentSession = agentSession; - // Emit session started event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_started', - sessionId: agentSession.sessionId, - parentSessionId: agentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: agentSession } - }); - } + // Emit session started event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_started', + sessionId: agentSession.sessionId, + parentSessionId: agentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: agentSession } + }, isBackground); // Create local function that captures the correct session const addSessionMessage = (message: Partial): void => { @@ -485,21 +484,21 @@ export class AgentRunner { currentSession.messages.push(fullMessage); - // Emit progress events based on message type - if (AgentRunner.eventBus && fullMessage.type === 'tool_call') { - AgentRunner.eventBus.emitProgress({ + // Emit progress events based on message type (skip for background agents) + if (fullMessage.type === 'tool_call') { + AgentRunner.eventBus?.emitProgress({ type: 'tool_started', sessionId: currentSession.sessionId, parentSessionId: currentSession.parentSessionId, agentName: currentSession.agentName, timestamp: new Date(), - data: { + data: { session: currentSession, toolCall: fullMessage } - }); - } else if (AgentRunner.eventBus && fullMessage.type === 'tool_result') { - AgentRunner.eventBus.emitProgress({ + }, isBackground); + } else if (fullMessage.type === 'tool_result') { + AgentRunner.eventBus?.emitProgress({ type: 'tool_completed', sessionId: currentSession.sessionId, parentSessionId: currentSession.parentSessionId, @@ -509,7 +508,7 @@ export class AgentRunner { session: currentSession, toolResult: fullMessage } - }); + }, isBackground); } }; @@ -591,17 +590,15 @@ export class AgentRunner { currentSession.endTime = new Date(); currentSession.terminationReason = 'error'; - // Emit session completed event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_completed', - sessionId: currentSession.sessionId, - parentSessionId: currentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: currentSession, reason: 'aborted' } - }); - } + // Emit session completed event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_completed', + sessionId: currentSession.sessionId, + parentSessionId: currentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: currentSession, reason: 'aborted' } + }, isBackground); // Clear todo list on abort await AgentRunner.clearTodoList(agentName, tools); @@ -836,17 +833,15 @@ export class AgentRunner { agentSession.endTime = new Date(); agentSession.terminationReason = 'error'; - // Emit session completed event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_completed', - sessionId: agentSession.sessionId, - parentSessionId: agentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: agentSession, reason: 'error' } - }); - } + // Emit session completed event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_completed', + sessionId: agentSession.sessionId, + parentSessionId: agentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: agentSession, reason: 'error' } + }, isBackground); // Clear todo list on error await AgentRunner.clearTodoList(agentName, tools); @@ -1017,17 +1012,15 @@ export class AgentRunner { agentSession.endTime = new Date(); agentSession.terminationReason = 'handed_off'; - // Emit session completed event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_completed', - sessionId: agentSession.sessionId, - parentSessionId: agentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: agentSession, reason: 'handed_off' } - }); - } + // Emit session completed event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_completed', + sessionId: agentSession.sessionId, + parentSessionId: agentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: agentSession, reason: 'handed_off' } + }, isBackground); return { ...handoffResult, agentSession }; @@ -1104,21 +1097,19 @@ export class AgentRunner { } }); - // Emit child agent starting - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'child_agent_started', - sessionId: currentSession.sessionId, - parentSessionId: currentSession.parentSessionId, - agentName: currentSession.agentName, - timestamp: new Date(), - data: { - parentSession: currentSession, - childAgentName: toolName, - childSessionId: preallocatedChildId - } - }); - } + // Emit child agent starting (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'child_agent_started', + sessionId: currentSession.sessionId, + parentSessionId: currentSession.parentSessionId, + agentName: currentSession.agentName, + timestamp: new Date(), + data: { + parentSession: currentSession, + childAgentName: toolName, + childSessionId: preallocatedChildId + } + }, isBackground); } try { @@ -1326,17 +1317,15 @@ export class AgentRunner { agentSession.endTime = new Date(); agentSession.terminationReason = 'final_answer'; - // Emit session completed event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_completed', - sessionId: agentSession.sessionId, - parentSessionId: agentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: agentSession, reason: 'final_answer' } - }); - } + // Emit session completed event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_completed', + sessionId: agentSession.sessionId, + parentSessionId: agentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: agentSession, reason: 'final_answer' } + }, isBackground); // Exit loop and return success with final answer (summary appended if configured) const result = createSuccessResult(finalAnswer, messages, 'final_answer'); @@ -1388,17 +1377,15 @@ export class AgentRunner { agentSession.endTime = new Date(); agentSession.terminationReason = 'error'; - // Emit session completed event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_completed', - sessionId: agentSession.sessionId, - parentSessionId: agentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: agentSession, reason: 'error' } - }); - } + // Emit session completed event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_completed', + sessionId: agentSession.sessionId, + parentSessionId: agentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: agentSession, reason: 'error' } + }, isBackground); // Clear todo list on error await AgentRunner.clearTodoList(agentName, tools); @@ -1461,17 +1448,15 @@ export class AgentRunner { agentSession.endTime = new Date(); agentSession.terminationReason = 'handed_off'; - // Emit session completed event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_completed', - sessionId: agentSession.sessionId, - parentSessionId: agentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: agentSession, reason: 'handed_off' } - }); - } + // Emit session completed event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_completed', + sessionId: agentSession.sessionId, + parentSessionId: agentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: agentSession, reason: 'handed_off' } + }, isBackground); return { ...actualResult, agentSession }; // Return the result from the handoff target } @@ -1485,17 +1470,15 @@ export class AgentRunner { agentSession.endTime = new Date(); agentSession.terminationReason = 'max_iterations'; - // Emit session completed event - if (AgentRunner.eventBus) { - AgentRunner.eventBus.emitProgress({ - type: 'session_completed', - sessionId: agentSession.sessionId, - parentSessionId: agentSession.parentSessionId, - agentName, - timestamp: new Date(), - data: { session: agentSession, reason: 'max_iterations' } - }); - } + // Emit session completed event (skip for background agents) + AgentRunner.eventBus?.emitProgress({ + type: 'session_completed', + sessionId: agentSession.sessionId, + parentSessionId: agentSession.parentSessionId, + agentName, + timestamp: new Date(), + data: { session: agentSession, reason: 'max_iterations' } + }, isBackground); // Generate summary of agent progress instead of generic error message const progressSummary = await this.summarizeAgentProgress(messages, maxIterations, agentName, modelName, 'max_iterations', config.provider, config.getVisionCapability); diff --git a/front_end/panels/ai_chat/agent_framework/AgentRunnerEventBus.ts b/front_end/panels/ai_chat/agent_framework/AgentRunnerEventBus.ts index 3094835e71..1b58a6a93f 100644 --- a/front_end/panels/ai_chat/agent_framework/AgentRunnerEventBus.ts +++ b/front_end/panels/ai_chat/agent_framework/AgentRunnerEventBus.ts @@ -25,7 +25,10 @@ export class AgentRunnerEventBus extends Common.ObjectWrapper.ObjectWrapper<{ return this.instance; } - emitProgress(event: AgentRunnerProgressEvent): void { + emitProgress(event: AgentRunnerProgressEvent, isBackground?: boolean): void { + if (isBackground) { + return; + } this.dispatchEventToListeners('agent-progress', event); } } diff --git a/front_end/panels/ai_chat/agent_framework/ConfigurableAgentTool.ts b/front_end/panels/ai_chat/agent_framework/ConfigurableAgentTool.ts index a908677c3a..2a48355b9d 100644 --- a/front_end/panels/ai_chat/agent_framework/ConfigurableAgentTool.ts +++ b/front_end/panels/ai_chat/agent_framework/ConfigurableAgentTool.ts @@ -30,6 +30,8 @@ export interface CallCtx { overrideTraceId?: string, abortSignal?: AbortSignal, agentDescriptor?: AgentDescriptor, + /** If true, don't emit UI progress events (for background agents) */ + background?: boolean, } /** @@ -595,6 +597,7 @@ export class ConfigurableAgentTool implements Tool new BookmarkStoreTool()); ToolRegistry.registerToolFactory('document_search', () => new DocumentSearchTool()); + + // Register memory tools + ToolRegistry.registerToolFactory('search_memory', () => new SearchMemoryTool()); + ToolRegistry.registerToolFactory('update_memory', () => new UpdateMemoryTool()); + ToolRegistry.registerToolFactory('list_memory_blocks', () => new ListMemoryBlocksTool()); // Create and register Direct URL Navigator Agent const directURLNavigatorAgentConfig = createDirectURLNavigatorAgentConfig(); @@ -131,4 +137,14 @@ export function initializeConfiguredAgents(): void { const ecommerceProductInfoAgent = new ConfigurableAgentTool(ecommerceProductInfoAgentConfig); ToolRegistry.registerToolFactory('ecommerce_product_info_fetcher_tool', () => ecommerceProductInfoAgent); + // Create and register Memory Agent (background memory consolidation) + const memoryAgentConfig = createMemoryAgentConfig('extraction'); + const memoryAgent = new ConfigurableAgentTool(memoryAgentConfig); + ToolRegistry.registerToolFactory('memory_agent', () => memoryAgent); + + // Create and register Search Memory Agent (read-only memory search for orchestrators) + const searchMemoryAgentConfig = createMemoryAgentConfig('search'); + const searchMemoryAgent = new ConfigurableAgentTool(searchMemoryAgentConfig); + ToolRegistry.registerToolFactory('search_memory_agent', () => searchMemoryAgent); + } diff --git a/front_end/panels/ai_chat/agent_framework/implementation/agents/MemoryAgent.ts b/front_end/panels/ai_chat/agent_framework/implementation/agents/MemoryAgent.ts new file mode 100644 index 0000000000..acd65e5167 --- /dev/null +++ b/front_end/panels/ai_chat/agent_framework/implementation/agents/MemoryAgent.ts @@ -0,0 +1,176 @@ +// 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 { AgentToolConfig } from '../../ConfigurableAgentTool.js'; +import { ChatMessageEntity } from '../../../models/ChatTypes.js'; +import type { ChatMessage } from '../../../models/ChatTypes.js'; +import type { ConfigurableAgentArgs } from '../../ConfigurableAgentTool.js'; +import { MODEL_SENTINELS } from '../../../core/Constants.js'; +import { AGENT_VERSION } from './AgentVersion.js'; + +const MEMORY_AGENT_PROMPT = `You are a Memory Consolidation Agent that runs in the background after conversations end. + +## Your Purpose +Extract and organize important information from completed conversations into persistent memory blocks that will help the assistant in future conversations. + +## Memory Block Types + +| Block | Purpose | Max Size | +|-------|---------|----------| +| user | User identity, preferences, communication style | 20000 chars | +| facts | Factual information learned from conversations | 20000 chars | +| project_ | Project-specific context (up to 4 projects) | 20000 chars each | + +## Workflow + +1. **List current memory** using list_memory_blocks +2. **Analyze** the conversation for extractable information +3. **Check for duplicates** before adding new facts +4. **Update blocks** with consolidated, organized content +5. **Verify** changes are correct and within limits + +## What to Extract + +### High Priority (Always Extract) +- User's name, role, job title +- Explicit preferences ("I prefer...", "I like...", "Always use...") +- Project names, tech stacks, goals +- Recurring patterns in requests + +### Medium Priority (Extract if Relevant) +- Problem-solving approaches that worked +- Tools/libraries the user uses frequently +- Team members or collaborators mentioned + +### Skip (Do Not Extract) +- One-time troubleshooting details +- Temporary debugging information +- Generic conversation pleasantries +- Information already in memory + +## Writing Guidelines + +### Be Specific with Dates +❌ "Recently discussed migration" +✅ "2025-01-15: Discussed database migration to PostgreSQL" + +### Be Concise +❌ "The user mentioned that they have a strong preference for using TypeScript in their projects because they find it helps catch errors" +✅ "Prefers TypeScript for type safety" + +### Use Bullet Points +\`\`\` +- Name: Alex Chen +- Role: Senior Frontend Engineer +- Prefers: TypeScript, React, Tailwind CSS +- Dislikes: Inline styles, any types +\`\`\` + +### Consolidate Related Info +If user block has: +\`\`\` +- Likes dark mode +- Uses VS Code +- Prefers dark themes +\`\`\` + +Consolidate to: +\`\`\` +- Prefers dark mode/themes +- Uses VS Code +\`\`\` + +## Examples + +### Example 1: User Preferences +**Conversation excerpt:** +> User: "Hey, I'm Sarah. Can you help me debug this React component? I always use functional components with hooks, never class components." + +**Memory update (user block):** +\`\`\` +- Name: Sarah +- React: Functional components + hooks only, no class components +\`\`\` + +### Example 2: Project Context +**Conversation excerpt:** +> User: "Working on our e-commerce platform. We're using Next.js 14 with App Router, Prisma for the database, and Stripe for payments." + +**Memory update (project_ecommerce block):** +\`\`\` +Project: E-commerce Platform +Stack: Next.js 14 (App Router), Prisma, Stripe +\`\`\` + +### Example 3: Skip Extraction +**Conversation excerpt:** +> User: "Getting a 404 error on /api/users endpoint" +> Assistant: "The route file is missing, create app/api/users/route.ts" +> User: "Fixed, thanks!" + +**Action:** No extraction needed - one-time debugging, no lasting value. + +## Output +After processing, briefly state what was updated or why nothing was updated. +`; + +/** + * Create the configuration for the Memory Agent + */ +export function createMemoryAgentConfig(): AgentToolConfig { + return { + name: 'memory_agent', + version: AGENT_VERSION, + description: 'Background memory consolidation agent that extracts facts from conversations and maintains organized memory blocks.', + + ui: { + displayName: 'Memory Agent', + avatar: '🧠', + color: '#8b5cf6', + backgroundColor: '#f5f3ff' + }, + + systemPrompt: MEMORY_AGENT_PROMPT, + + tools: [ + 'search_memory', + 'update_memory', + 'list_memory_blocks', + ], + + schema: { + type: 'object', + properties: { + conversation_summary: { + type: 'string', + description: 'Summary of the conversation to analyze for memory extraction' + }, + reasoning: { + type: 'string', + description: 'Why this extraction is being run' + } + }, + required: ['conversation_summary', 'reasoning'] + }, + + prepareMessages: (args: ConfigurableAgentArgs): ChatMessage[] => { + return [{ + entity: ChatMessageEntity.USER, + text: `## Conversation to Analyze + +${args.conversation_summary || ''} + +## Reason for Extraction +${args.reasoning || 'Automatic extraction after session completion'} + +Please analyze this conversation and update memory blocks as appropriate.`, + }]; + }, + + maxIterations: 5, + modelName: MODEL_SENTINELS.USE_MINI, // Cost-effective for background task + temperature: 0.1, + handoffs: [], + }; +} diff --git a/front_end/panels/ai_chat/agent_framework/implementation/agents/SearchMemoryAgent.ts b/front_end/panels/ai_chat/agent_framework/implementation/agents/SearchMemoryAgent.ts new file mode 100644 index 0000000000..a4ba71a0f0 --- /dev/null +++ b/front_end/panels/ai_chat/agent_framework/implementation/agents/SearchMemoryAgent.ts @@ -0,0 +1,111 @@ +// 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 { AgentToolConfig, ConfigurableAgentArgs } from '../../ConfigurableAgentTool.js'; +import { ChatMessageEntity } from '../../../models/ChatTypes.js'; +import type { ChatMessage } from '../../../models/ChatTypes.js'; +import { MODEL_SENTINELS } from '../../../core/Constants.js'; +import { AGENT_VERSION } from './AgentVersion.js'; + +const SEARCH_MEMORY_AGENT_PROMPT = `You are a Memory Retrieval Agent. Your job is to find and summarize relevant information from stored memory to help the assistant respond to the user. + +## Memory Structure + +| Block | Contains | +|-------|----------| +| user | User identity, preferences, communication style | +| facts | Factual information from past conversations | +| project_* | Project-specific context (tech stack, goals, current work) | + +## Workflow + +1. Use list_memory_blocks to retrieve all stored memory +2. Scan each block for information relevant to the query +3. Return a concise summary of relevant findings + +## Response Format + +### When Memory Exists +Return relevant information organized by category: + +\`\`\` +**User Context:** +- Name: Sarah, Senior Frontend Engineer +- Prefers TypeScript, functional React components + +**Relevant Project:** +- E-commerce Platform: Next.js 14, Prisma, Stripe + +**Related Facts:** +- 2025-01-10: Migrated auth to NextAuth.js +\`\`\` + +### When No Memory Exists +Simply respond: "No relevant memory found." + +### When Memory is Empty +Simply respond: "No memory stored yet." + +## Guidelines + +- Only include information relevant to the query +- Don't dump entire blocks - summarize what's useful +- Prioritize recent information over old +- If query is vague, return user preferences + active project context +`; + +/** + * Create the configuration for the Search Memory Agent. + * This agent provides read-only memory search capability to orchestrator agents. + */ +export function createSearchMemoryAgentConfig(): AgentToolConfig { + return { + name: 'search_memory_agent', + version: AGENT_VERSION, + description: 'Search user memory for relevant information. Use when you need to recall user preferences, past facts, or project context.', + + ui: { + displayName: 'Search Memory', + avatar: '🔍', + color: '#10b981', + backgroundColor: '#ecfdf5' + }, + + systemPrompt: SEARCH_MEMORY_AGENT_PROMPT, + + tools: [ + 'list_memory_blocks', // Returns all memory block contents directly + ], + + schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'What to search for in memory (user preferences, facts, project info)' + }, + context: { + type: 'string', + description: 'Why this search is needed (helps with relevance)' + } + }, + required: ['query'] + }, + + prepareMessages: (args: ConfigurableAgentArgs): ChatMessage[] => { + return [{ + entity: ChatMessageEntity.USER, + text: `Search memory for: ${args.query || ''} +${args.context ? `\nContext: ${args.context}` : ''} + +Please search memory and return any relevant information.`, + }]; + }, + + maxIterations: 2, // Just need to list and respond + modelName: MODEL_SENTINELS.USE_NANO, // Fast, cheap model for simple searches + temperature: 0, + handoffs: [], + }; +} diff --git a/front_end/panels/ai_chat/core/AgentNodes.ts b/front_end/panels/ai_chat/core/AgentNodes.ts index aba0b7af4a..7253289bc4 100644 --- a/front_end/panels/ai_chat/core/AgentNodes.ts +++ b/front_end/panels/ai_chat/core/AgentNodes.ts @@ -1067,3 +1067,4 @@ export function createFinalNode(): Runnable { }(); return finalNode; } + diff --git a/front_end/panels/ai_chat/core/AgentService.ts b/front_end/panels/ai_chat/core/AgentService.ts index 0106071fbe..5b62167933 100644 --- a/front_end/panels/ai_chat/core/AgentService.ts +++ b/front_end/panels/ai_chat/core/AgentService.ts @@ -29,6 +29,8 @@ 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'; +import { ToolRegistry } from '../agent_framework/ConfigurableAgentTool.js'; +import { MemoryModule } from '../memory/index.js'; // Cache break: 2025-09-17T17:54:00Z - Force rebuild with AUTOMATED_MODE bypass const logger = createLogger('AgentService'); @@ -165,6 +167,12 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ // Subscribe to configuration changes this.#configManager.addChangeListener(this.#handleConfigurationChange.bind(this)); + + // Process any old conversations that missed memory extraction + // Delay to avoid blocking startup and ensure tools are registered + setTimeout(() => { + this.processUnprocessedConversations(); + }, 5000); } /** @@ -889,6 +897,9 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ * Starts a new conversation */ async newConversation(): Promise { + // Capture conversation ID BEFORE clearing (for async memory extraction) + const endingConversationId = this.#currentConversationId; + // Abort any running execution this.cancelRun(); @@ -914,6 +925,126 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ this.dispatchEventToListeners(Events.CONVERSATION_CHANGED, null); logger.info('Started new conversation'); + + // Fire off memory extraction in background (non-blocking) + if (endingConversationId) { + this.#processConversationMemory(endingConversationId); + } + } + + /** + * Processes memory for a conversation. Uses claim mechanism to prevent + * concurrent processing of the same conversation. + */ + async #processConversationMemory(conversationId: string): Promise { + logger.info('[Memory] Starting processing for conversation', {conversationId}); + // Check if memory is enabled in settings + if (!MemoryModule.getInstance().isEnabled()) { + logger.info('[Memory] Skipping - memory disabled in settings'); + return; + } + + // Try to claim - if another instance is processing, skip + const claimed = await this.#conversationManager.tryClaimForMemoryProcessing(conversationId); + if (!claimed) { + logger.info('[Memory] Skipping - already processing or completed', {conversationId}); + return; + } + + try { + // Load the conversation to get messages + const loaded = await this.#conversationManager.loadConversation(conversationId); + if (!loaded || loaded.state.messages.length < 4) { + // Mark as completed (nothing to extract) + await this.#conversationManager.markMemoryCompleted(conversationId); + logger.info('[Memory] Skipping - conversation too short', {conversationId, messageCount: loaded?.state.messages.length || 0}); + return; + } + + // Format conversation summary + const conversationSummary = loaded.state.messages + .filter(m => m.entity === ChatMessageEntity.USER || m.entity === ChatMessageEntity.MODEL) + .slice(-20) + .map(m => { + const role = m.entity === ChatMessageEntity.USER ? 'User' : 'Assistant'; + const text = m.entity === ChatMessageEntity.USER + ? (m as {text: string}).text + : ((m as ModelChatMessage).answer || ''); + return `${role}: ${text}`; + }) + .join('\n'); + + const memoryAgent = ToolRegistry.getToolInstance('memory_agent'); + if (!memoryAgent) { + await this.#conversationManager.markMemoryFailed(conversationId); + logger.warn('[Memory] memory_agent not found in registry'); + return; + } + + const config = this.#configManager.getConfiguration(); + logger.info('[Memory] Processing conversation', { + conversationId, + provider: config.provider, + model: config.mainModel, + miniModel: config.miniModel, + summaryLength: conversationSummary.length + }); + + const result = await memoryAgent.execute({ + conversation_summary: conversationSummary, + reasoning: 'Extracting facts from conversation', + }, { + apiKey: config.apiKey, + provider: config.provider, + model: config.mainModel, + miniModel: config.miniModel, + nanoModel: config.nanoModel, + background: true, // Don't show in UI + }); + + logger.info('[Memory] Agent execution result', { + conversationId, + success: result.success, + outputLength: result.output?.length || 0, + outputPreview: result.output?.substring(0, 500), + error: result.error, + terminationReason: result.terminationReason, + toolCallsCount: result.toolCalls?.length || 0, + toolCalls: result.toolCalls?.map((tc: any) => ({ name: tc.name, args: tc.args })) || [], + }); + + await this.#conversationManager.markMemoryCompleted(conversationId); + logger.info('[Memory] Completed', {conversationId}); + + } catch (err) { + logger.error('[Memory] Failed:', err); + await this.#conversationManager.markMemoryFailed(conversationId); + } + } + + /** + * Processes any old conversations that never had memory extracted. + * Call this on initialization or periodically. + */ + async processUnprocessedConversations(): Promise { + const pending = await this.#conversationManager.getConversationsNeedingMemoryProcessing(); + + // Skip the currently active conversation and limit to avoid overload + const toProcess = pending + .filter(conv => conv.id !== this.#currentConversationId) + .slice(0, 3); + + for (const conv of toProcess) { + // Don't await - process in parallel + this.#processConversationMemory(conv.id); + } + + if (pending.length > 0) { + logger.info('[Memory] Processing unprocessed conversations', { + total: pending.length, + processing: toProcess.length, + }); + } } /** @@ -1216,6 +1347,7 @@ export class AgentService extends Common.ObjectWrapper.ObjectWrapper<{ }, 5000); } } + } // Define UI strings object to manage i18n strings diff --git a/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts b/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts index 008e7aeeef..0c0ccb3847 100644 --- a/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts +++ b/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts @@ -35,7 +35,7 @@ import { ListFilesTool, type Tool } from '../tools/Tools.js'; -// Imports from their own files +import { MemoryModule } from '../memory/index.js'; // Initialize configured agents initializeConfiguredAgents(); @@ -50,7 +50,7 @@ export enum BaseOrchestratorAgentType { SHOPPING = 'shopping' } -// System prompts for each agent type +// System prompts for each agent type (WITHOUT memory instructions - added dynamically) export const SYSTEM_PROMPTS = { [BaseOrchestratorAgentType.SEARCH]: `You are an search browser agent specialized in pinpoint web fact-finding. Always delegate investigative work to the 'search_agent' tool so it can gather verified, structured results (emails, team rosters, niche professionals, etc.). @@ -323,6 +323,7 @@ export const AGENT_CONFIGS: {[key: string]: AgentConfig} = { new DeleteFileTool(), new ReadFileTool(), new ListFilesTool(), + ToolRegistry.getToolInstance('search_memory_agent') || (() => { throw new Error('search_memory_agent tool not found'); })(), ] }, [BaseOrchestratorAgentType.DEEP_RESEARCH]: { @@ -347,6 +348,7 @@ export const AGENT_CONFIGS: {[key: string]: AgentConfig} = { new DeleteFileTool(), new ReadFileTool(), new ListFilesTool(), + ToolRegistry.getToolInstance('search_memory_agent') || (() => { throw new Error('search_memory_agent tool not found'); })(), ] }, // [BaseOrchestratorAgentType.SHOPPING]: { @@ -389,14 +391,17 @@ AgentDescriptorRegistry.registerSource({ /** * Get the system prompt for a specific agent type + * Memory instructions are dynamically prepended if memory is enabled */ export function getSystemPrompt(agentType: string): string { + const memoryPrefix = MemoryModule.getInstance().getInstructions(); + // Check if there's a custom prompt for this agent type if (hasCustomPrompt(agentType)) { - return getAgentPrompt(agentType); + return memoryPrefix + getAgentPrompt(agentType); } - - return AGENT_CONFIGS[agentType]?.systemPrompt || + + return memoryPrefix + (AGENT_CONFIGS[agentType]?.systemPrompt || // Default system prompt if agent type not found `You are a browser agent for helping users with tasks. And, you are an expert task orchestrator agent focused on high-level task strategy, planning, efficient delegation to specialized web agents, and final result synthesis. Your core goal is to provide maximally helpful task completion by orchestrating an effective execution process. @@ -517,14 +522,18 @@ After specialized agents complete their tasks: 2. Identify patterns, best options, and key insights 3. Note any remaining gaps or follow-up needs 4. Create a comprehensive response following the appropriate format -`; +`); } /** * Get available tools for a specific agent type + * Conditionally includes search_memory_agent if memory is enabled */ export function getAgentTools(agentType: string): Array> { - return AGENT_CONFIGS[agentType]?.availableTools || [ + const memoryModule = MemoryModule.getInstance(); + + // Get base tools from config or use default list + const baseTools = AGENT_CONFIGS[agentType]?.availableTools || [ ToolRegistry.getToolInstance('search_agent') || (() => { throw new Error('search_agent tool not found'); })(), ToolRegistry.getToolInstance('web_task_agent') || (() => { throw new Error('web_task_agent tool not found'); })(), ToolRegistry.getToolInstance('document_search') || (() => { throw new Error('document_search tool not found'); })(), @@ -541,6 +550,22 @@ export function getAgentTools(agentType: string): Array> { new ReadFileTool(), new ListFilesTool(), ]; + + // Filter out search_memory_agent if memory is disabled, or add it if enabled and not present + if (memoryModule.shouldIncludeMemoryTool()) { + // Check if search_memory_agent is already in the list + const hasMemoryAgent = baseTools.some(tool => tool.name === 'search_memory_agent'); + if (!hasMemoryAgent) { + const memoryAgent = ToolRegistry.getToolInstance('search_memory_agent'); + if (memoryAgent) { + return [...baseTools, memoryAgent]; + } + } + return baseTools; + } + + // Memory disabled - filter out search_memory_agent + return baseTools.filter(tool => tool.name !== 'search_memory_agent'); } // Custom event for agent type selection diff --git a/front_end/panels/ai_chat/core/CustomProviderManager.ts b/front_end/panels/ai_chat/core/CustomProviderManager.ts index d922e7b73b..4f101f3e97 100644 --- a/front_end/panels/ai_chat/core/CustomProviderManager.ts +++ b/front_end/panels/ai_chat/core/CustomProviderManager.ts @@ -14,6 +14,7 @@ export interface CustomProviderConfig { 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 + modelsManuallyAdded: boolean; // True if user manually configured models, false if fetched from API enabled: boolean; // Whether the provider is enabled createdAt: number; // Timestamp when created updatedAt: number; // Timestamp when last updated @@ -62,8 +63,10 @@ export class CustomProviderManager { } } - if (!config.models || config.models.length === 0) { - errors.push('At least one model is required'); + // Models are optional - they can be fetched from the API if not manually specified + // Only validate if modelsManuallyAdded is true and models are empty + if (config.modelsManuallyAdded && (!config.models || config.models.length === 0)) { + errors.push('At least one model is required when manually adding models'); } return { @@ -145,10 +148,19 @@ export class CustomProviderManager { /** * Add a new custom provider + * @param config Provider configuration (modelsManuallyAdded defaults to true if models are provided) */ - static addProvider(config: Omit): CustomProviderConfig { + static addProvider(config: Omit & { modelsManuallyAdded?: boolean }): CustomProviderConfig { + // Determine if models were manually added (default to true if models are provided) + const modelsManuallyAdded = config.modelsManuallyAdded ?? (config.models && config.models.length > 0); + + const fullConfig = { + ...config, + modelsManuallyAdded, + }; + // Validate config - const validation = CustomProviderManager.validateConfig(config); + const validation = CustomProviderManager.validateConfig(fullConfig); if (!validation.valid) { throw new Error(`Invalid provider configuration: ${validation.errors.join(', ')}`); } @@ -163,7 +175,7 @@ export class CustomProviderManager { const now = Date.now(); const newProvider: CustomProviderConfig = { - ...config, + ...fullConfig, id, createdAt: now, updatedAt: now, diff --git a/front_end/panels/ai_chat/core/GraphConfigs.ts b/front_end/panels/ai_chat/core/GraphConfigs.ts index effcbb4d74..328063310b 100644 --- a/front_end/panels/ai_chat/core/GraphConfigs.ts +++ b/front_end/panels/ai_chat/core/GraphConfigs.ts @@ -8,10 +8,11 @@ import { NodeType } from './Types.js'; /** * Defines the default agent graph configuration. + * Flow: AGENT → TOOL_EXECUTOR → AGENT → ... → FINAL → __end__ + * Memory is accessed on-demand via search_memory_agent tool. */ export const defaultAgentGraphConfig: GraphConfig = { name: 'defaultAgentGraph', - // Revert to using NodeType enum members entryPoint: NodeType.AGENT.toString(), nodes: [ { name: NodeType.AGENT.toString(), type: 'agent' }, diff --git a/front_end/panels/ai_chat/core/GraphHelpers.ts b/front_end/panels/ai_chat/core/GraphHelpers.ts index b6e02aa420..bc3ebb96a7 100644 --- a/front_end/panels/ai_chat/core/GraphHelpers.ts +++ b/front_end/panels/ai_chat/core/GraphHelpers.ts @@ -43,7 +43,9 @@ export async function createSystemPromptAsync(state: AgentState): Promise = { + openai: { + main: 'gpt-4.1-2025-04-14', + mini: 'gpt-4.1-mini-2025-04-14', + nano: 'gpt-4.1-nano-2025-04-14' + }, + litellm: { + main: '', // Will use first available model + mini: '', + nano: '' + }, + groq: { + main: 'openai/gpt-oss-120b', + mini: 'openai/gpt-oss-120b', + nano: 'openai/gpt-oss-120b' + }, + openrouter: { + main: 'anthropic/claude-sonnet-4.5', + mini: 'google/gemini-2.5-flash', + nano: 'openai/gpt-oss-120b:exacto' + }, + browseroperator: { + main: 'main', + mini: 'mini', + nano: 'nano' + }, + cerebras: { + main: 'gpt-oss-120b', + mini: 'gpt-oss-120b', + nano: 'llama3.1-8b' + }, + anthropic: { + main: 'claude-sonnet-4-5', + mini: 'claude-haiku-4-5', + nano: 'claude-haiku-4-5' + }, + googleai: { + main: 'gemini-2.5-pro', + mini: 'gemini-2.5-flash', + nano: 'gemini-2.5-flash' + } +}; + +/** + * Default OpenAI models (static list for providers without fetch capability) + */ +export const DEFAULT_OPENAI_MODELS: ModelOption[] = [ + {value: 'o4-mini-2025-04-16', label: 'O4 Mini', type: 'openai'}, + {value: 'o3-mini-2025-01-31', label: 'O3 Mini', type: 'openai'}, + {value: 'gpt-5-2025-08-07', label: 'GPT-5', type: 'openai'}, + {value: 'gpt-5-mini-2025-08-07', label: 'GPT-5 Mini', type: 'openai'}, + {value: 'gpt-5-nano-2025-08-07', label: 'GPT-5 Nano', type: 'openai'}, + {value: 'gpt-4.1-2025-04-14', label: 'GPT-4.1', type: 'openai'}, + {value: 'gpt-4.1-mini-2025-04-14', label: 'GPT-4.1 Mini', type: 'openai'}, + {value: 'gpt-4.1-nano-2025-04-14', label: 'GPT-4.1 Nano', type: 'openai'}, +]; + +/** + * Placeholder constants for model options + */ +export const MODEL_PLACEHOLDERS = { + ADD_CUSTOM: 'add_custom_model', + NO_MODELS: 'no_models_available', +}; + /** * Configuration interface for LLM settings */ @@ -38,6 +116,10 @@ const STORAGE_KEYS = { GROQ_API_KEY: 'ai_chat_groq_api_key', OPENROUTER_API_KEY: 'ai_chat_openrouter_api_key', BROWSEROPERATOR_API_KEY: 'ai_chat_browseroperator_api_key', + // Model options storage + ALL_MODEL_OPTIONS: 'ai_chat_all_model_options', + MODEL_OPTIONS: 'ai_chat_model_options', // Legacy, for backward compatibility + CUSTOM_MODELS: 'ai_chat_custom_models', // For LiteLLM custom models } as const; /** @@ -49,9 +131,15 @@ export class LLMConfigurationManager { private overrideConfig?: Partial; // Override for automated mode private changeListeners: Array<() => void> = []; + // Model options state - organized by provider + private modelOptionsByProvider: Map = new Map(); + private modelOptionsInitialized = false; + private constructor() { // Listen for localStorage changes from other tabs (manual mode) window.addEventListener('storage', this.handleStorageChange.bind(this)); + // Initialize model options from localStorage + this.loadModelOptionsFromStorage(); } /** @@ -77,6 +165,7 @@ export class LLMConfigurationManager { /** * Get the main model with override fallback + * Note: For default fallback, ensure models are fetched and selected in the UI */ getMainModel(): string { if (this.overrideConfig?.mainModel) { @@ -154,6 +243,303 @@ export class LLMConfigurationManager { }; } + // ============================================================================ + // Model Options Management + // ============================================================================ + + /** + * Load model options from localStorage into memory + */ + private loadModelOptionsFromStorage(): void { + try { + // Load from comprehensive storage + const allOptionsJson = localStorage.getItem(STORAGE_KEYS.ALL_MODEL_OPTIONS); + if (allOptionsJson) { + const allOptions: ModelOption[] = JSON.parse(allOptionsJson); + // Group by provider + this.modelOptionsByProvider.clear(); + for (const option of allOptions) { + const providerOptions = this.modelOptionsByProvider.get(option.type) || []; + providerOptions.push(option); + this.modelOptionsByProvider.set(option.type, providerOptions); + } + logger.debug('Loaded model options from storage', { + providers: Array.from(this.modelOptionsByProvider.keys()), + totalModels: allOptions.length + }); + } else { + // Initialize with defaults + this.modelOptionsByProvider.set('openai', [...DEFAULT_OPENAI_MODELS]); + logger.debug('Initialized with default OpenAI models'); + } + this.modelOptionsInitialized = true; + } catch (error) { + logger.error('Failed to load model options from storage:', error); + // Initialize with defaults on error + this.modelOptionsByProvider.set('openai', [...DEFAULT_OPENAI_MODELS]); + this.modelOptionsInitialized = true; + } + } + + /** + * Get all model options across all providers + */ + getAllModelOptions(): ModelOption[] { + const allOptions: ModelOption[] = []; + for (const options of this.modelOptionsByProvider.values()) { + allOptions.push(...options); + } + return allOptions; + } + + /** + * Get model options for a specific provider + * @param provider Provider ID (e.g., 'openai', 'groq'). If not provided, uses current provider. + */ + getModelOptions(provider?: string): ModelOption[] { + const targetProvider = provider || this.getProvider(); + return this.modelOptionsByProvider.get(targetProvider) || []; + } + + /** + * Get model options for the currently selected provider + */ + getModelOptionsForCurrentProvider(): ModelOption[] { + return this.getModelOptions(this.getProvider()); + } + + /** + * Set model options for a provider + * @param provider Provider ID + * @param models Array of model options + */ + setModelOptions(provider: string, models: ModelOption[]): void { + logger.info(`Setting ${models.length} models for provider ${provider}`); + this.modelOptionsByProvider.set(provider, models); + this.persistModelOptionsToStorage(); + this.notifyListeners(); + } + + /** + * Clear model options for a provider, or all providers if not specified + * @param provider Optional provider ID to clear + */ + clearModelOptions(provider?: string): void { + if (provider) { + this.modelOptionsByProvider.delete(provider); + logger.debug(`Cleared model options for provider ${provider}`); + } else { + this.modelOptionsByProvider.clear(); + logger.debug('Cleared all model options'); + } + this.persistModelOptionsToStorage(); + this.notifyListeners(); + } + + /** + * Add a custom model option (primarily for LiteLLM) + * @param modelName The model name to add + * @param provider The provider type (defaults to current provider) + */ + addCustomModelOption(modelName: string, provider?: string): void { + const targetProvider = provider || this.getProvider(); + const providerOptions = this.modelOptionsByProvider.get(targetProvider) || []; + + // Check if model already exists + if (providerOptions.some(m => m.value === modelName)) { + logger.debug(`Model ${modelName} already exists for provider ${targetProvider}`); + return; + } + + // Create label - just use the model name (consumers can format as needed) + const newOption: ModelOption = { + value: modelName, + label: modelName, + type: targetProvider + }; + + providerOptions.push(newOption); + this.modelOptionsByProvider.set(targetProvider, providerOptions); + + // Also save to custom models list for LiteLLM + if (targetProvider === 'litellm') { + this.saveCustomModelToStorage(modelName); + } + + this.persistModelOptionsToStorage(); + this.notifyListeners(); + + logger.info(`Added custom model ${modelName} for provider ${targetProvider}`); + } + + /** + * Remove a custom model option + * @param modelName The model name to remove + * @param provider The provider type (defaults to current provider) + */ + removeCustomModelOption(modelName: string, provider?: string): void { + const targetProvider = provider || this.getProvider(); + const providerOptions = this.modelOptionsByProvider.get(targetProvider) || []; + + const filteredOptions = providerOptions.filter(m => m.value !== modelName); + if (filteredOptions.length === providerOptions.length) { + logger.debug(`Model ${modelName} not found for provider ${targetProvider}`); + return; + } + + this.modelOptionsByProvider.set(targetProvider, filteredOptions); + + // Also remove from custom models list for LiteLLM + if (targetProvider === 'litellm') { + this.removeCustomModelFromStorage(modelName); + } + + this.persistModelOptionsToStorage(); + this.notifyListeners(); + + logger.info(`Removed custom model ${modelName} from provider ${targetProvider}`); + } + + /** + * Validate a model selection against available options + * @param model The model value to validate + * @param provider Optional provider to validate against (defaults to current) + */ + validateModelSelection(model: string, provider?: string): boolean { + if (!model) return false; + const options = this.getModelOptions(provider); + return options.some(opt => opt.value === model); + } + + /** + * Validate and fix model selections for the current provider + * Returns the corrected selections + */ + validateAndFixModelSelections(): { main: string; mini: string; nano: string } { + const provider = this.getProvider(); + const available = this.getModelOptionsForCurrentProvider(); + const defaults = DEFAULT_PROVIDER_MODELS[provider] || {}; + + const availableValues = available.filter(m => m.type === provider).map(m => m.value); + + const validateModel = (stored: string, defaultValue: string | undefined): string => { + // 1. Check exact match for stored model + if (stored && available.some(m => m.value === stored && m.type === provider)) { + return stored; + } + + // 2. Try fuzzy match for stored model + if (stored) { + const fuzzyMatch = findClosestModel(stored, availableValues); + if (fuzzyMatch) { + logger.info(`Fuzzy matched model '${stored}' to '${fuzzyMatch}'`); + return fuzzyMatch; + } + } + + // 3. Check exact match for provider default + if (defaultValue && available.some(m => m.value === defaultValue)) { + return defaultValue; + } + + // 4. Try fuzzy match for provider default + if (defaultValue) { + const fuzzyDefault = findClosestModel(defaultValue, availableValues); + if (fuzzyDefault) { + logger.info(`Fuzzy matched default '${defaultValue}' to '${fuzzyDefault}'`); + return fuzzyDefault; + } + } + + // 5. Fall back to first available + return available.length > 0 ? available[0].value : ''; + }; + + const currentMain = localStorage.getItem(STORAGE_KEYS.MODEL_SELECTION) || ''; + const currentMini = localStorage.getItem(STORAGE_KEYS.MINI_MODEL) || ''; + const currentNano = localStorage.getItem(STORAGE_KEYS.NANO_MODEL) || ''; + + const main = validateModel(currentMain, defaults.main); + const mini = validateModel(currentMini, defaults.mini); + const nano = validateModel(currentNano, defaults.nano); + + // Persist corrections if needed + if (main !== currentMain) { + localStorage.setItem(STORAGE_KEYS.MODEL_SELECTION, main); + logger.info(`Corrected main model from '${currentMain}' to '${main}'`); + } + if (mini !== currentMini) { + if (mini) { + localStorage.setItem(STORAGE_KEYS.MINI_MODEL, mini); + } else { + localStorage.removeItem(STORAGE_KEYS.MINI_MODEL); + } + logger.info(`Corrected mini model from '${currentMini}' to '${mini}'`); + } + if (nano !== currentNano) { + if (nano) { + localStorage.setItem(STORAGE_KEYS.NANO_MODEL, nano); + } else { + localStorage.removeItem(STORAGE_KEYS.NANO_MODEL); + } + logger.info(`Corrected nano model from '${currentNano}' to '${nano}'`); + } + + return { main, mini, nano }; + } + + /** + * Persist model options to localStorage + */ + private persistModelOptionsToStorage(): void { + try { + const allOptions = this.getAllModelOptions(); + localStorage.setItem(STORAGE_KEYS.ALL_MODEL_OPTIONS, JSON.stringify(allOptions)); + + // Also update legacy storage for backward compatibility + const currentProviderOptions = this.getModelOptionsForCurrentProvider(); + localStorage.setItem(STORAGE_KEYS.MODEL_OPTIONS, JSON.stringify(currentProviderOptions)); + } catch (error) { + logger.error('Failed to persist model options to storage:', error); + } + } + + /** + * Save a custom model name to the LiteLLM custom models list + */ + private saveCustomModelToStorage(modelName: string): void { + try { + const customModelsJson = localStorage.getItem(STORAGE_KEYS.CUSTOM_MODELS); + const customModels: string[] = customModelsJson ? JSON.parse(customModelsJson) : []; + if (!customModels.includes(modelName)) { + customModels.push(modelName); + localStorage.setItem(STORAGE_KEYS.CUSTOM_MODELS, JSON.stringify(customModels)); + } + } catch (error) { + logger.error('Failed to save custom model to storage:', error); + } + } + + /** + * Remove a custom model name from the LiteLLM custom models list + */ + private removeCustomModelFromStorage(modelName: string): void { + try { + const customModelsJson = localStorage.getItem(STORAGE_KEYS.CUSTOM_MODELS); + if (customModelsJson) { + const customModels: string[] = JSON.parse(customModelsJson); + const filtered = customModels.filter(m => m !== modelName); + localStorage.setItem(STORAGE_KEYS.CUSTOM_MODELS, JSON.stringify(filtered)); + } + } catch (error) { + logger.error('Failed to remove custom model from storage:', error); + } + } + + // ============================================================================ + // Override Configuration + // ============================================================================ + /** * Set override configuration (for automated mode per-request overrides) */ diff --git a/front_end/panels/ai_chat/core/PageInfoManager.ts b/front_end/panels/ai_chat/core/PageInfoManager.ts index 7d1d58be14..96c1c52ef3 100644 --- a/front_end/panels/ai_chat/core/PageInfoManager.ts +++ b/front_end/panels/ai_chat/core/PageInfoManager.ts @@ -6,6 +6,7 @@ import * as SDK from '../../../core/sdk/sdk.js'; import * as Utils from '../common/utils.js'; // Path relative to core/ assuming utils.ts will be in common/ later, this will be common/utils.js import { VisitHistoryManager } from '../tools/VisitHistoryManager.js'; // Path relative to core/ assuming VisitHistoryManager.ts will be in core/ import { FileStorageManager } from '../tools/FileStorageManager.js'; +import { MemoryBlockManager } from '../memory/index.js'; import { createLogger } from './Logger.js'; const logger = createLogger('PageInfoManager'); @@ -199,6 +200,15 @@ export async function enhancePromptWithPageContext(basePrompt: string): Promise< logger.warn('Failed to fetch files for context:', error); } + // Get memory context (global across sessions) + let memoryContext = ''; + try { + const memoryManager = new MemoryBlockManager(); + memoryContext = await memoryManager.compileMemoryContext(); + } catch (error) { + logger.warn('Failed to fetch memory context:', error); + } + // If no page info is available, return the original prompt if (!pageInfo) { return basePrompt; @@ -213,6 +223,7 @@ export async function enhancePromptWithPageContext(basePrompt: string): Promise< ${new Date().toLocaleDateString()} + ${memoryContext} ${pageInfo.title} diff --git a/front_end/panels/ai_chat/memory/ListMemoryBlocksTool.ts b/front_end/panels/ai_chat/memory/ListMemoryBlocksTool.ts new file mode 100644 index 0000000000..f018ab7b38 --- /dev/null +++ b/front_end/panels/ai_chat/memory/ListMemoryBlocksTool.ts @@ -0,0 +1,93 @@ +// 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 { Tool, LLMContext } from '../tools/Tools.js'; +import { MemoryBlockManager } from './MemoryBlockManager.js'; + +const logger = createLogger('Tool:ListMemoryBlocks'); + +export interface ListMemoryBlocksArgs { + // No arguments needed +} + +export interface ListMemoryBlocksResult { + success: boolean; + blocks: Array<{ + type: string; + label: string; + content: string; + charCount: number; + charLimit: number; + updatedAt: string; + }>; + summary: { + totalBlocks: number; + totalChars: number; + maxChars: number; + }; + error?: string; +} + +/** + * Tool for listing all memory blocks with their content and metadata. + * Useful for the MemoryAgent to see current memory state before making updates. + */ +export class ListMemoryBlocksTool implements Tool { + name = 'list_memory_blocks'; + description = 'List all memory blocks with their current content and metadata (size, limits, last updated). Use this to see the current state of memory before making updates.'; + + schema = { + type: 'object', + properties: {}, + required: [] + }; + + async execute(_args: ListMemoryBlocksArgs, _ctx?: LLMContext): Promise { + logger.info('Executing list memory blocks'); + + // Calculate max capacity: 20000 (user) + 20000 (facts) + 4*20000 (projects) + const maxChars = 120000; + + try { + const manager = new MemoryBlockManager(); + const blocks = await manager.getAllBlocks(); + + const formattedBlocks = blocks.map(b => ({ + type: b.type, + label: b.label, + content: b.content, + charCount: b.content.length, + charLimit: b.charLimit, + updatedAt: new Date(b.updatedAt).toISOString() + })); + + const summary = { + totalBlocks: blocks.length, + totalChars: blocks.reduce((sum, b) => sum + b.content.length, 0), + maxChars + }; + + logger.info('Listed memory blocks', { blockCount: blocks.length, totalChars: summary.totalChars }); + + return { + success: true, + blocks: formattedBlocks, + summary + }; + } catch (error: any) { + logger.error('Failed to list memory blocks', { error: error?.message }); + return { + success: false, + blocks: [], + summary: { + totalBlocks: 0, + totalChars: 0, + maxChars, + }, + error: error?.message || 'Failed to list memory blocks.' + }; + } + } +} diff --git a/front_end/panels/ai_chat/memory/MemoryAgentConfig.ts b/front_end/panels/ai_chat/memory/MemoryAgentConfig.ts new file mode 100644 index 0000000000..e449545c38 --- /dev/null +++ b/front_end/panels/ai_chat/memory/MemoryAgentConfig.ts @@ -0,0 +1,284 @@ +// 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 { AgentToolConfig, ConfigurableAgentArgs } from '../agent_framework/ConfigurableAgentTool.js'; +import { ChatMessageEntity } from '../models/ChatTypes.js'; +import type { ChatMessage } from '../models/ChatTypes.js'; +import { MODEL_SENTINELS } from '../core/Constants.js'; +import { AGENT_VERSION } from '../agent_framework/implementation/agents/AgentVersion.js'; + +/** + * Memory agent mode determines behavior and configuration. + */ +export type MemoryAgentMode = 'extraction' | 'search'; + +// Extraction mode prompt - runs after conversations to consolidate facts +const EXTRACTION_PROMPT = `You are a Memory Consolidation Agent that runs in the background after conversations end. + +## Your Purpose +Extract and organize important information from completed conversations into persistent memory blocks that will help the assistant in future conversations. + +## Memory Block Types + +| Block | Purpose | Max Size | +|-------|---------|----------| +| user | User identity, preferences, communication style | 20000 chars | +| facts | Factual information learned from conversations | 20000 chars | +| project_ | Project-specific context (up to 4 projects) | 20000 chars each | + +## Workflow + +1. **List current memory** using list_memory_blocks +2. **Analyze** the conversation for extractable information +3. **Check for duplicates** before adding new facts +4. **Update blocks** with consolidated, organized content +5. **Verify** changes are correct and within limits + +## What to Extract + +### High Priority (Always Extract) +- User's name, role, job title +- Explicit preferences ("I prefer...", "I like...", "Always use...") +- Project names, tech stacks, goals +- Recurring patterns in requests + +### Medium Priority (Extract if Relevant) +- Problem-solving approaches that worked +- Tools/libraries the user uses frequently +- Team members or collaborators mentioned + +### Skip (Do Not Extract) +- One-time troubleshooting details +- Temporary debugging information +- Generic conversation pleasantries +- Information already in memory + +## Writing Guidelines + +### Be Specific with Dates +❌ "Recently discussed migration" +✅ "2025-01-15: Discussed database migration to PostgreSQL" + +### Be Concise +❌ "The user mentioned that they have a strong preference for using TypeScript in their projects because they find it helps catch errors" +✅ "Prefers TypeScript for type safety" + +### Use Bullet Points +\`\`\` +- Name: Alex Chen +- Role: Senior Frontend Engineer +- Prefers: TypeScript, React, Tailwind CSS +- Dislikes: Inline styles, any types +\`\`\` + +### Consolidate Related Info +If user block has: +\`\`\` +- Likes dark mode +- Uses VS Code +- Prefers dark themes +\`\`\` + +Consolidate to: +\`\`\` +- Prefers dark mode/themes +- Uses VS Code +\`\`\` + +## Examples + +### Example 1: User Preferences +**Conversation excerpt:** +> User: "Hey, I'm Sarah. Can you help me debug this React component? I always use functional components with hooks, never class components." + +**Memory update (user block):** +\`\`\` +- Name: Sarah +- React: Functional components + hooks only, no class components +\`\`\` + +### Example 2: Project Context +**Conversation excerpt:** +> User: "Working on our e-commerce platform. We're using Next.js 14 with App Router, Prisma for the database, and Stripe for payments." + +**Memory update (project_ecommerce block):** +\`\`\` +Project: E-commerce Platform +Stack: Next.js 14 (App Router), Prisma, Stripe +\`\`\` + +### Example 3: Skip Extraction +**Conversation excerpt:** +> User: "Getting a 404 error on /api/users endpoint" +> Assistant: "The route file is missing, create app/api/users/route.ts" +> User: "Fixed, thanks!" + +**Action:** No extraction needed - one-time debugging, no lasting value. + +## Output +After processing, briefly state what was updated or why nothing was updated. +`; + +// Search mode prompt - read-only queries for orchestrators +const SEARCH_PROMPT = `You are a Memory Retrieval Agent. Your job is to find and summarize relevant information from stored memory to help the assistant respond to the user. + +## Memory Structure + +| Block | Contains | +|-------|----------| +| user | User identity, preferences, communication style | +| facts | Factual information from past conversations | +| project_* | Project-specific context (tech stack, goals, current work) | + +## Workflow + +1. Use list_memory_blocks to retrieve all stored memory +2. Scan each block for information relevant to the query +3. Return a concise summary of relevant findings + +## Response Format + +### When Memory Exists +Return relevant information organized by category: + +\`\`\` +**User Context:** +- Name: Sarah, Senior Frontend Engineer +- Prefers TypeScript, functional React components + +**Relevant Project:** +- E-commerce Platform: Next.js 14, Prisma, Stripe + +**Related Facts:** +- 2025-01-10: Migrated auth to NextAuth.js +\`\`\` + +### When No Memory Exists +Simply respond: "No relevant memory found." + +### When Memory is Empty +Simply respond: "No memory stored yet." + +## Guidelines + +- Only include information relevant to the query +- Don't dump entire blocks - summarize what's useful +- Prioritize recent information over old +- If query is vague, return user preferences + active project context +`; + +/** + * Create a memory agent configuration with the specified mode. + * + * @param mode - 'extraction' for background consolidation, 'search' for read-only queries + * @returns AgentToolConfig for the memory agent + */ +export function createMemoryAgentConfig(mode: MemoryAgentMode): AgentToolConfig { + if (mode === 'extraction') { + return createExtractionConfig(); + } + return createSearchConfig(); +} + +function createExtractionConfig(): AgentToolConfig { + return { + name: 'memory_agent', + version: AGENT_VERSION, + description: 'Background memory consolidation agent that extracts facts from conversations and maintains organized memory blocks.', + + ui: { + displayName: 'Memory Agent', + avatar: '🧠', + color: '#8b5cf6', + backgroundColor: '#f5f3ff' + }, + + systemPrompt: EXTRACTION_PROMPT, + + tools: ['search_memory', 'update_memory', 'list_memory_blocks'], + + schema: { + type: 'object', + properties: { + conversation_summary: { + type: 'string', + description: 'Summary of the conversation to analyze for memory extraction' + }, + reasoning: { + type: 'string', + description: 'Why this extraction is being run' + } + }, + required: ['conversation_summary', 'reasoning'] + }, + + prepareMessages: (args: ConfigurableAgentArgs): ChatMessage[] => { + return [{ + entity: ChatMessageEntity.USER, + text: `## Conversation to Analyze + +${args.conversation_summary || ''} + +## Reason for Extraction +${args.reasoning || 'Automatic extraction after session completion'} + +Please analyze this conversation and update memory blocks as appropriate.`, + }]; + }, + + maxIterations: 5, + modelName: MODEL_SENTINELS.USE_MINI, // Cost-effective for background task + temperature: 0.1, + handoffs: [], + }; +} + +function createSearchConfig(): AgentToolConfig { + return { + name: 'search_memory_agent', + version: AGENT_VERSION, + description: 'Search user memory for relevant information. Use when you need to recall user preferences, past facts, or project context.', + + ui: { + displayName: 'Search Memory', + avatar: '🔍', + color: '#10b981', + backgroundColor: '#ecfdf5' + }, + + systemPrompt: SEARCH_PROMPT, + + tools: ['list_memory_blocks'], + + schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'What to search for in memory (user preferences, facts, project info)' + }, + context: { + type: 'string', + description: 'Why this search is needed (helps with relevance)' + } + }, + required: ['query'] + }, + + prepareMessages: (args: ConfigurableAgentArgs): ChatMessage[] => { + return [{ + entity: ChatMessageEntity.USER, + text: `Search memory for: ${args.query || ''} +${args.context ? `\nContext: ${args.context}` : ''} + +Please search memory and return any relevant information.`, + }]; + }, + + maxIterations: 2, // Just need to list and respond + modelName: MODEL_SENTINELS.USE_NANO, // Fast, cheap model for simple searches + temperature: 0, + handoffs: [], + }; +} diff --git a/front_end/panels/ai_chat/memory/MemoryBlockManager.ts b/front_end/panels/ai_chat/memory/MemoryBlockManager.ts new file mode 100644 index 0000000000..3a5737b118 --- /dev/null +++ b/front_end/panels/ai_chat/memory/MemoryBlockManager.ts @@ -0,0 +1,289 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { FileStorageManager } from '../tools/FileStorageManager.js'; +import { createLogger } from '../core/Logger.js'; +import { MemoryModule } from './MemoryModule.js'; +import type { BlockType, MemoryBlock, MemorySearchResult } from './types.js'; + +const logger = createLogger('MemoryBlockManager'); + +/** + * Manages memory blocks stored as files via FileStorageManager. + * Memory is global (shared across all conversations) using a reserved session ID. + * + * Block types: + * - user: User preferences, name, coding style (20000 chars) + * - facts: Recent extracted facts (20000 chars) + * - project: Project-specific context (20000 chars each, max 4) + */ +export class MemoryBlockManager { + private fileManager: FileStorageManager; + private memoryModule: MemoryModule; + + constructor() { + this.fileManager = FileStorageManager.getInstance(); + this.memoryModule = MemoryModule.getInstance(); + } + + /** + * Execute a function with the global memory session, restoring the previous session after. + */ + private async withGlobalSession(fn: () => Promise): Promise { + const prevSession = this.fileManager.getSessionId(); + this.fileManager.setSessionId(this.memoryModule.getSessionId()); + try { + return await fn(); + } finally { + this.fileManager.setSessionId(prevSession); + } + } + + // --- Block CRUD --- + + /** + * Get a memory block by type and optional project name. + */ + async getBlock(type: BlockType, projectName?: string): Promise { + return this.withGlobalSession(async () => { + const filename = this.getFilename(type, projectName); + const file = await this.fileManager.readFile(filename); + if (!file) { + return null; + } + + return { + filename, + type, + label: this.getLabel(type, projectName), + description: this.getDescription(type), + content: file.content, + charLimit: this.memoryModule.getBlockLimit(type), + updatedAt: file.updatedAt, + }; + }); + } + + /** + * Update or create a memory block. + */ + async updateBlock(type: BlockType, content: string, projectName?: string): Promise { + if (type === 'project' && (!projectName || !projectName.trim())) { + throw new Error('projectName is required for project blocks'); + } + + const limit = this.memoryModule.getBlockLimit(type); + if (content.length > limit) { + throw new Error(`Content exceeds ${limit} char limit (got ${content.length})`); + } + + return this.withGlobalSession(async () => { + const filename = this.getFilename(type, projectName); + const exists = await this.fileManager.readFile(filename); + + if (exists) { + await this.fileManager.updateFile(filename, content, false); + logger.info('Updated memory block', { type, filename }); + } else { + // Check project limit before creating new project block + if (type === 'project') { + const projects = await this.listProjectBlocks(); + if (projects.length >= this.memoryModule.getMaxProjectBlocks()) { + throw new Error(`Max ${this.memoryModule.getMaxProjectBlocks()} project blocks allowed`); + } + } + await this.fileManager.createFile(filename, content, 'text/markdown'); + logger.info('Created memory block', { type, filename }); + } + }); + } + + /** + * Delete a memory block. + */ + async deleteBlock(type: BlockType, projectName?: string): Promise { + return this.withGlobalSession(async () => { + const filename = this.getFilename(type, projectName); + try { + await this.fileManager.deleteFile(filename); + logger.info('Deleted memory block', { type, filename }); + } catch (error) { + // Ignore if file doesn't exist + logger.debug('Block not found for deletion', { type, filename }); + } + }); + } + + // --- Queries --- + + /** + * Get all memory blocks. + */ + async getAllBlocks(): Promise { + return this.withGlobalSession(async () => { + const files = await this.fileManager.listFiles(); + const blocks: MemoryBlock[] = []; + + for (const file of files) { + if (!file.fileName.startsWith('memory_')) { + continue; + } + + const fullFile = await this.fileManager.readFile(file.fileName); + if (!fullFile) { + continue; + } + + const { type, projectName } = this.parseFilename(file.fileName); + blocks.push({ + filename: file.fileName, + type, + label: this.getLabel(type, projectName), + description: this.getDescription(type), + content: fullFile.content, + charLimit: this.memoryModule.getBlockLimit(type), + updatedAt: file.updatedAt, + }); + } + + return blocks; + }); + } + + /** + * List only project blocks. + */ + async listProjectBlocks(): Promise { + const all = await this.getAllBlocks(); + return all.filter(b => b.type === 'project'); + } + + /** + * Search across all blocks for matching lines. + */ + async searchBlocks(query: string): Promise { + const blocks = await this.getAllBlocks(); + const results: MemorySearchResult[] = []; + const queryLower = query.toLowerCase(); + + for (const block of blocks) { + const lines = block.content.split('\n'); + const matches = lines.filter(line => + line.toLowerCase().includes(queryLower) + ); + if (matches.length > 0) { + results.push({ block, matches }); + } + } + + return results; + } + + // --- Helpers --- + + private getFilename(type: BlockType, projectName?: string): string { + if (type === 'project' && projectName) { + const safeName = projectName.toLowerCase().replace(/[^a-z0-9]/g, '_'); + return `memory_project_${safeName}.md`; + } + return `memory_${type}.md`; + } + + private parseFilename(filename: string): { type: BlockType; projectName?: string } { + if (filename === 'memory_user.md') { + return { type: 'user' }; + } + if (filename === 'memory_facts.md') { + return { type: 'facts' }; + } + if (filename.startsWith('memory_project_')) { + const projectName = filename.replace('memory_project_', '').replace('.md', ''); + return { type: 'project', projectName }; + } + return { type: 'facts' }; // fallback + } + + private getLabel(type: BlockType, projectName?: string): string { + if (type === 'project') { + return `project:${projectName}`; + } + return type; + } + + private getDescription(type: BlockType): string { + switch (type) { + case 'user': + return 'User preferences, name, coding style, and personal context'; + case 'facts': + return 'Recent facts extracted from conversations'; + case 'project': + return 'Project-specific context, tech stack, and goals'; + } + } + + // --- Memory Compilation (for prompt injection) --- + + /** + * Compile all memory blocks into XML context for prompt injection. + */ + async compileMemoryContext(): Promise { + const blocks = await this.getAllBlocks(); + if (blocks.length === 0) { + return ''; + } + + let context = '\n'; + + for (const block of blocks) { + if (!block.content.trim()) { + continue; + } + + context += `<${block.label}>\n`; + context += `${block.description}\n`; + context += `\n${block.content}\n\n`; + context += `\n`; + } + + context += ''; + return context; + } + + /** + * Get user memory and project list compiled as XML context. + * Used for direct prompt injection (facts and project content require search_memory_agent). + */ + async getUserMemoryContext(): Promise { + const userBlock = await this.getBlock('user'); + const projectBlocks = await this.listProjectBlocks(); + + // If no user block and no projects, return empty + if ((!userBlock || !userBlock.content.trim()) && projectBlocks.length === 0) { + return ''; + } + + let context = '\n'; + + // Add user block if exists + if (userBlock && userBlock.content.trim()) { + context += ` +${userBlock.description} + +${userBlock.content} + +\n`; + } + + // Add project list (names only, not content) + if (projectBlocks.length > 0) { + context += ` + +${projectBlocks.map(p => ``).join('\n')} +\n`; + } + + context += ''; + return context; + } +} diff --git a/front_end/panels/ai_chat/memory/MemoryModule.ts b/front_end/panels/ai_chat/memory/MemoryModule.ts new file mode 100644 index 0000000000..b653844763 --- /dev/null +++ b/front_end/panels/ai_chat/memory/MemoryModule.ts @@ -0,0 +1,144 @@ +// 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 { MemoryConfig } from './types.js'; +import { MemoryBlockManager } from './MemoryBlockManager.js'; + +/** + * Memory Module - Central facade for the memory system. + * + * Provides: + * - Configuration constants + * - Settings management (enable/disable) + * - Memory instructions for prompts + * - Tool availability checks + */ + +// Memory instructions prepended to orchestrator prompts when memory is enabled +const MEMORY_INSTRUCTIONS_TEXT = ` +You have a persistent memory system that remembers information across conversations. + +**Already in your context** (in the block): +- User's name, identity, preferences, and working style +- List of projects the user is working on (names only) + +**For detailed memory, use 'search_memory_agent':** +- **facts**: Important facts learned from past conversations +- **project content**: Full project context (tech stack, goals, current work) + +Call search_memory_agent when: +- The user asks about something you might have discussed before +- You need full context for a specific project +- You need facts from past interactions + +Memory is updated automatically after conversations end. + + +`; + +// Default configuration +const DEFAULT_CONFIG: MemoryConfig = { + blockLimits: { + user: 20000, + facts: 20000, + project: 20000, + }, + maxProjectBlocks: 4, + sessionId: '__global_memory__', + enabledKey: 'ai_chat_memory_enabled', +}; + +/** + * Singleton class for memory system configuration and settings. + */ +export class MemoryModule { + private static instance: MemoryModule | null = null; + private config: MemoryConfig; + + private constructor() { + this.config = { ...DEFAULT_CONFIG }; + } + + /** + * Get the singleton instance. + */ + static getInstance(): MemoryModule { + if (!MemoryModule.instance) { + MemoryModule.instance = new MemoryModule(); + } + return MemoryModule.instance; + } + + /** + * Get the memory configuration. + */ + getConfig(): MemoryConfig { + return this.config; + } + + /** + * Check if memory is enabled in settings. + * Memory is enabled by default (returns true if not explicitly set to 'false'). + */ + isEnabled(): boolean { + return localStorage.getItem(this.config.enabledKey) !== 'false'; + } + + /** + * Enable or disable memory. + */ + setEnabled(enabled: boolean): void { + localStorage.setItem(this.config.enabledKey, enabled.toString()); + } + + /** + * Get memory instructions for prompt injection. + * Returns empty string if memory is disabled. + */ + getInstructions(): string { + return this.isEnabled() ? MEMORY_INSTRUCTIONS_TEXT : ''; + } + + /** + * Get user memory context for prompt injection. + * Only includes user block - other memory requires search_memory_agent. + * Returns empty string if memory is disabled. + */ + async getMemoryContext(): Promise { + if (!this.isEnabled()) { + return ''; + } + const blockManager = new MemoryBlockManager(); + return blockManager.getUserMemoryContext(); + } + + /** + * Check if memory tool should be included in agent tools. + * Shorthand for isEnabled() - useful for tool filtering. + */ + shouldIncludeMemoryTool(): boolean { + return this.isEnabled(); + } + + /** + * Get the character limit for a specific block type. + */ + getBlockLimit(type: 'user' | 'facts' | 'project'): number { + return this.config.blockLimits[type]; + } + + /** + * Get the maximum number of project blocks allowed. + */ + getMaxProjectBlocks(): number { + return this.config.maxProjectBlocks; + } + + /** + * Get the session ID used for global memory storage. + */ + getSessionId(): string { + return this.config.sessionId; + } +} diff --git a/front_end/panels/ai_chat/memory/SearchMemoryTool.ts b/front_end/panels/ai_chat/memory/SearchMemoryTool.ts new file mode 100644 index 0000000000..c293087207 --- /dev/null +++ b/front_end/panels/ai_chat/memory/SearchMemoryTool.ts @@ -0,0 +1,72 @@ +// 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 { Tool, LLMContext } from '../tools/Tools.js'; +import { MemoryBlockManager } from './MemoryBlockManager.js'; + +const logger = createLogger('Tool:SearchMemory'); + +export interface SearchMemoryArgs { + query: string; +} + +export interface SearchMemoryResult { + success: boolean; + results: Array<{ + block: string; + matches: string[]; + }>; + count: number; + error?: string; +} + +/** + * Tool for searching across all memory blocks. + */ +export class SearchMemoryTool implements Tool { + name = 'search_memory'; + description = 'Search across all memory blocks (user preferences, facts, projects) for relevant information. Returns matching lines from each block.'; + + schema = { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query to find in memory blocks' + } + }, + required: ['query'] + }; + + async execute(args: SearchMemoryArgs, _ctx?: LLMContext): Promise { + logger.info('Executing search memory', { query: args.query }); + + try { + const manager = new MemoryBlockManager(); + const searchResults = await manager.searchBlocks(args.query); + + const results = searchResults.map(r => ({ + block: r.block.label, + matches: r.matches.slice(0, 5) // Limit to 5 matches per block + })); + + logger.info('Search completed', { resultCount: results.length }); + + return { + success: true, + results, + count: results.length + }; + } catch (error: any) { + logger.error('Failed to search memory', { error: error?.message }); + return { + success: false, + results: [], + count: 0, + error: error?.message || 'Failed to search memory.' + }; + } + } +} diff --git a/front_end/panels/ai_chat/memory/UpdateMemoryTool.ts b/front_end/panels/ai_chat/memory/UpdateMemoryTool.ts new file mode 100644 index 0000000000..8b029724f5 --- /dev/null +++ b/front_end/panels/ai_chat/memory/UpdateMemoryTool.ts @@ -0,0 +1,95 @@ +// 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 { Tool, LLMContext } from '../tools/Tools.js'; +import { MemoryBlockManager } from './MemoryBlockManager.js'; +import type { BlockType } from './types.js'; + +const logger = createLogger('Tool:UpdateMemory'); + +export interface UpdateMemoryArgs { + blockType: BlockType; + content: string; + projectName?: string; +} + +export interface UpdateMemoryResult { + success: boolean; + message: string; + error?: string; +} + +/** + * Tool for updating memory blocks. + */ +export class UpdateMemoryTool implements Tool { + name = 'update_memory'; + description = `Update a memory block with new content. Block types: +- "user": User preferences, name, coding style (max 20000 chars) +- "facts": Recent facts extracted from conversations (max 20000 chars) +- "project": Project-specific context (max 20000 chars each, max 4 projects) + +For project blocks, you must also provide projectName.`; + + schema = { + type: 'object', + properties: { + blockType: { + type: 'string', + enum: ['user', 'facts', 'project'], + description: 'Type of memory block to update' + }, + content: { + type: 'string', + description: 'New content for the block (replaces existing content)' + }, + projectName: { + type: 'string', + description: 'Project name (required when blockType is "project")' + } + }, + required: ['blockType', 'content'] + }; + + async execute(args: UpdateMemoryArgs, _ctx?: LLMContext): Promise { + logger.info('Executing update memory', { + blockType: args.blockType, + contentLength: args.content.length, + projectName: args.projectName + }); + + try { + // Validate project name for project blocks + if (args.blockType === 'project' && !args.projectName) { + return { + success: false, + message: 'projectName is required for project blocks', + error: 'projectName is required for project blocks' + }; + } + + const manager = new MemoryBlockManager(); + await manager.updateBlock(args.blockType, args.content, args.projectName); + + const label = args.blockType === 'project' + ? `project:${args.projectName}` + : args.blockType; + + logger.info('Memory block updated', { label }); + + return { + success: true, + message: `Updated ${label} block (${args.content.length} chars)` + }; + } catch (error: any) { + logger.error('Failed to update memory block', { error: error?.message }); + return { + success: false, + message: error?.message || 'Failed to update memory block', + error: error?.message || 'Failed to update memory block' + }; + } + } +} diff --git a/front_end/panels/ai_chat/memory/index.ts b/front_end/panels/ai_chat/memory/index.ts new file mode 100644 index 0000000000..e275cd312e --- /dev/null +++ b/front_end/panels/ai_chat/memory/index.ts @@ -0,0 +1,28 @@ +// 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. + +/** + * Memory Module - Public API + * + * This module provides a consolidated memory system for the AI Chat panel. + * All memory-related functionality should be accessed through this index. + */ + +// Core exports +export { MemoryModule } from './MemoryModule.js'; +export { MemoryBlockManager } from './MemoryBlockManager.js'; +export { createMemoryAgentConfig } from './MemoryAgentConfig.js'; + +// Tool exports +export { SearchMemoryTool } from './SearchMemoryTool.js'; +export { UpdateMemoryTool } from './UpdateMemoryTool.js'; +export { ListMemoryBlocksTool } from './ListMemoryBlocksTool.js'; + +// Type exports +export type { + BlockType, + MemoryBlock, + MemorySearchResult, + MemoryConfig, +} from './types.js'; diff --git a/front_end/panels/ai_chat/memory/types.ts b/front_end/panels/ai_chat/memory/types.ts new file mode 100644 index 0000000000..337c2bdce1 --- /dev/null +++ b/front_end/panels/ai_chat/memory/types.ts @@ -0,0 +1,87 @@ +// 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. + +/** + * Memory System Types + * + * Shared type definitions for the memory module. + */ + +/** + * Block types for memory storage. + * - user: User preferences, name, coding style + * - facts: Recent extracted facts from conversations + * - project: Project-specific context (max 4 blocks) + */ +export type BlockType = 'user' | 'facts' | 'project'; + +/** + * A memory block stored in the system. + */ +export interface MemoryBlock { + filename: string; + type: BlockType; + label: string; + description: string; + content: string; + charLimit: number; + updatedAt: number; +} + +/** + * Result from searching memory blocks. + */ +export interface MemorySearchResult { + block: MemoryBlock; + matches: string[]; +} + +/** + * Configuration constants for the memory system. + */ +export interface MemoryConfig { + /** Character limit per block type */ + blockLimits: { + user: number; + facts: number; + project: number; + }; + /** Maximum number of project blocks allowed */ + maxProjectBlocks: number; + /** Session ID used for global memory storage */ + sessionId: string; + /** LocalStorage key for memory enabled setting */ + enabledKey: string; +} + +/** + * Operations supported by the unified memory tool. + */ +export type MemoryOperation = 'search' | 'update' | 'list' | 'delete'; + +/** + * Arguments for the unified memory tool. + */ +export interface MemoryToolArgs { + /** The operation to perform */ + operation: MemoryOperation; + /** Search query (for search operation) */ + query?: string; + /** Block type (for update/delete operations) */ + blockType?: BlockType; + /** Content to write (for update operation) */ + content?: string; + /** Project name (required for project block operations) */ + projectName?: string; +} + +/** + * Result from the unified memory tool. + */ +export interface MemoryToolResult { + success: boolean; + operation: MemoryOperation; + data?: unknown; + error?: string; +} diff --git a/front_end/panels/ai_chat/persistence/ConversationManager.ts b/front_end/panels/ai_chat/persistence/ConversationManager.ts index 50ff286f1a..4a81903a27 100644 --- a/front_end/panels/ai_chat/persistence/ConversationManager.ts +++ b/front_end/panels/ai_chat/persistence/ConversationManager.ts @@ -27,6 +27,9 @@ export class ConversationManager { private static instance: ConversationManager|null = null; private storageManager: ConversationStorageManager; + // 30 minutes timeout for stale 'processing' status + private static readonly PROCESSING_TIMEOUT_MS = 30 * 60 * 1000; + private constructor() { this.storageManager = ConversationStorageManager.getInstance(); logger.info('Initialized ConversationManager'); @@ -181,4 +184,112 @@ export class ConversationManager { await this.storageManager.clearAllConversations(); logger.info('Cleared all conversations'); } + + // ==================== Memory Processing Methods ==================== + + /** + * Attempts to claim a conversation for memory processing. + * Returns true if claimed successfully, false if already processing. + * Uses 'processing' status as a lock to prevent concurrent processing. + * + * Will re-claim if: + * - Status is 'failed' (retry) + * - Status is 'processing' but started > 30 min ago (stale/crashed) + */ + async tryClaimForMemoryProcessing(conversationId: string): Promise { + const conversation = await this.storageManager.loadConversation(conversationId); + if (!conversation) { + logger.info('[Memory] Claim failed - conversation not found', { conversationId }); + return false; + } + + logger.info('[Memory] Current memory status', { + conversationId, + memoryStatus: conversation.memoryStatus, + memoryProcessedAt: conversation.memoryProcessedAt, + memoryProcessingStartedAt: conversation.memoryProcessingStartedAt, + }); + + // Already completed - don't reprocess + if (conversation.memoryStatus === 'completed') { + logger.info('[Memory] Claim failed - already completed', { conversationId }); + return false; + } + + // Currently processing - check if stale (> 30 min) + if (conversation.memoryStatus === 'processing') { + const startedAt = conversation.memoryProcessingStartedAt || 0; + const elapsed = Date.now() - startedAt; + if (elapsed < ConversationManager.PROCESSING_TIMEOUT_MS) { + // Still within timeout, don't re-claim + return false; + } + // Stale processing - allow re-claim + logger.warn('Re-claiming stale processing conversation', { + conversationId, + elapsedMs: elapsed, + }); + } + + // Claim it by setting to 'processing' with timestamp + conversation.memoryStatus = 'processing'; + conversation.memoryProcessingStartedAt = Date.now(); + await this.storageManager.saveConversation(conversation); + logger.info('Claimed conversation for memory processing', {conversationId}); + return true; + } + + /** + * Marks memory processing as completed. + */ + async markMemoryCompleted(conversationId: string): Promise { + const conversation = await this.storageManager.loadConversation(conversationId); + if (conversation) { + conversation.memoryStatus = 'completed'; + conversation.memoryProcessedAt = Date.now(); + await this.storageManager.saveConversation(conversation); + logger.info('Marked memory as completed', {conversationId}); + } + } + + /** + * Marks memory processing as failed (can be retried later). + */ + async markMemoryFailed(conversationId: string): Promise { + const conversation = await this.storageManager.loadConversation(conversationId); + if (conversation) { + conversation.memoryStatus = 'failed'; + await this.storageManager.saveConversation(conversation); + logger.warn('Marked memory as failed', {conversationId}); + } + } + + /** + * Returns conversations that need memory processing. + * Includes: + * - pending, failed, or undefined status (old conversations) + * - 'processing' that started > 30 min ago (stale/crashed) + */ + async getConversationsNeedingMemoryProcessing(): Promise { + const all = await this.listConversations(); + const now = Date.now(); + + return all.filter(c => { + // Not started, pending, or failed - needs processing + if (!c.memoryStatus || + c.memoryStatus === 'pending' || + c.memoryStatus === 'failed') { + return true; + } + + // Stale processing (> 30 min) - needs retry + if (c.memoryStatus === 'processing') { + const startedAt = c.memoryProcessingStartedAt || 0; + const elapsed = now - startedAt; + return elapsed >= ConversationManager.PROCESSING_TIMEOUT_MS; + } + + return false; + }); + } } diff --git a/front_end/panels/ai_chat/persistence/ConversationTypes.ts b/front_end/panels/ai_chat/persistence/ConversationTypes.ts index 71092ef63c..c3f556ac3a 100644 --- a/front_end/panels/ai_chat/persistence/ConversationTypes.ts +++ b/front_end/panels/ai_chat/persistence/ConversationTypes.ts @@ -7,6 +7,15 @@ import type {ChatMessage} from '../models/ChatTypes.js'; import {ChatMessageEntity} from '../models/ChatTypes.js'; import type {AgentSession} from '../agent_framework/AgentSessionTypes.js'; +/** + * Memory processing status for conversation + */ +export type MemoryProcessingStatus = + | 'pending' // Not yet processed + | 'processing' // Currently being processed (prevents concurrent runs) + | 'completed' // Successfully processed + | 'failed'; // Failed (can retry) + /** * Represents a fully stored conversation with all state and metadata */ @@ -32,6 +41,11 @@ export interface StoredConversation { // Total number of messages in the conversation messageCount: number; + + // Memory extraction status + memoryStatus?: MemoryProcessingStatus; + memoryProcessedAt?: number; // Unix timestamp when completed + memoryProcessingStartedAt?: number; // Unix timestamp when processing started (for timeout detection) } /** @@ -44,6 +58,8 @@ export interface ConversationMetadata { updatedAt: number; preview?: string; messageCount: number; + memoryStatus?: MemoryProcessingStatus; + memoryProcessingStartedAt?: number; // Needed to detect stale processing } /** @@ -284,5 +300,7 @@ export function extractMetadata(conversation: StoredConversation): ConversationM updatedAt: conversation.updatedAt, preview: conversation.preview, messageCount: conversation.messageCount, + memoryStatus: conversation.memoryStatus, + memoryProcessingStartedAt: conversation.memoryProcessingStartedAt, }; } diff --git a/front_end/panels/ai_chat/tools/FileStorageManager.ts b/front_end/panels/ai_chat/tools/FileStorageManager.ts index 3e431cbb20..fea32ff4ac 100644 --- a/front_end/panels/ai_chat/tools/FileStorageManager.ts +++ b/front_end/panels/ai_chat/tools/FileStorageManager.ts @@ -49,8 +49,8 @@ export class FileStorageManager { private dbInitializationPromise: Promise | null = null; private constructor() { - this.sessionId = 'default'; // Will be set to conversation ID when conversation is created/loaded - logger.info('Initialized FileStorageManager with default session'); + this.sessionId = `temp-${this.generateUUID()}`; // Unique per session, will be set to conversation ID when conversation is created/loaded + logger.info('Initialized FileStorageManager with session', { sessionId: this.sessionId }); } static getInstance(): FileStorageManager { diff --git a/front_end/panels/ai_chat/tools/Tools.ts b/front_end/panels/ai_chat/tools/Tools.ts index e282b068a8..8b97e70a8d 100644 --- a/front_end/panels/ai_chat/tools/Tools.ts +++ b/front_end/panels/ai_chat/tools/Tools.ts @@ -62,6 +62,8 @@ export interface LLMContext { miniModel?: string; nanoModel?: string; abortSignal?: AbortSignal; + /** If true, don't emit UI progress events (for background tools/agents) */ + background?: boolean; } /** diff --git a/front_end/panels/ai_chat/ui/AIChatPanel.ts b/front_end/panels/ai_chat/ui/AIChatPanel.ts index c2311e360b..23c0f5d99f 100644 --- a/front_end/panels/ai_chat/ui/AIChatPanel.ts +++ b/front_end/panels/ai_chat/ui/AIChatPanel.ts @@ -9,7 +9,13 @@ import * as SDK from '../../../core/sdk/sdk.js'; import * as UI from '../../../ui/legacy/legacy.js'; import {AgentService, Events as AgentEvents} from '../core/AgentService.js'; import { LLMClient } from '../LLM/LLMClient.js'; -import { LLMConfigurationManager } from '../core/LLMConfigurationManager.js'; +import { + LLMConfigurationManager, + type ModelOption, + DEFAULT_PROVIDER_MODELS, + DEFAULT_OPENAI_MODELS, + MODEL_PLACEHOLDERS as CONFIG_MODEL_PLACEHOLDERS, +} from '../core/LLMConfigurationManager.js'; import { LLMProviderRegistry } from '../LLM/LLMProviderRegistry.js'; import { createLogger } from '../core/Logger.js'; import { CustomProviderManager } from '../core/CustomProviderManager.js'; @@ -18,6 +24,7 @@ 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'; +import { OnboardingDialog, createSetupRequiredBanner } from './OnboardingDialog.js'; // Import of LiveAgentSessionComponent is not required here; the element is // registered by ChatView where it is used. @@ -92,73 +99,12 @@ import { MCPConnectorsCatalogDialog } from './mcp/MCPConnectorsCatalogDialog.js' import { ConversationHistoryList } from './ConversationHistoryList.js'; -// Model type definition -export interface ModelOption { - value: string; - label: string; - type: string; // Supports standard providers and custom providers (e.g., 'custom:my-provider') -} - -// Add model options constant - these are the default OpenAI models -const DEFAULT_OPENAI_MODELS: ModelOption[] = [ - {value: 'o4-mini-2025-04-16', label: 'O4 Mini', type: 'openai'}, - {value: 'o3-mini-2025-01-31', label: 'O3 Mini', type: 'openai'}, - {value: 'gpt-5-2025-08-07', label: 'GPT-5', type: 'openai'}, - {value: 'gpt-5-mini-2025-08-07', label: 'GPT-5 Mini', type: 'openai'}, - {value: 'gpt-5-nano-2025-08-07', label: 'GPT-5 Nano', type: 'openai'}, - {value: 'gpt-4.1-2025-04-14', label: 'GPT-4.1', type: 'openai'}, - {value: 'gpt-4.1-mini-2025-04-14', label: 'GPT-4.1 Mini', type: 'openai'}, - {value: 'gpt-4.1-nano-2025-04-14', label: 'GPT-4.1 Nano', type: 'openai'}, -]; - -// Default model selections for each provider -export const DEFAULT_PROVIDER_MODELS: Record = { - openai: { - main: 'gpt-4.1-2025-04-14', - mini: 'gpt-4.1-mini-2025-04-14', - nano: 'gpt-4.1-nano-2025-04-14' - }, - litellm: { - main: '', // Will use first available model - mini: '', - nano: '' - }, - groq: { - main: 'meta-llama/llama-4-scout-17b-16e-instruct', - mini: 'qwen/qwen3-32b', - nano: 'llama-3.1-8b-instant' - }, - openrouter: { - main: 'anthropic/claude-sonnet-4', - mini: 'google/gemini-2.5-flash', - nano: 'google/gemini-2.5-flash-lite-preview-06-17' - }, - browseroperator: { - main: 'main', - mini: 'mini', - nano: 'nano' - }, - cerebras: { - main: 'llama-3.3-70b', - mini: 'llama-3.3-8b', - nano: 'llama-3.3-8b' - }, - anthropic: { - main: 'claude-sonnet-4-20250514', - mini: 'claude-haiku-3-5-20241022', - nano: 'claude-haiku-3-5-20241022' - }, - googleai: { - main: 'gemini-2.0-flash-exp', - mini: 'gemini-2.0-flash-thinking-exp-01-21', - nano: 'gemini-2.0-flash-thinking-exp-01-21' - } -}; - -// This will hold the current active model options -let MODEL_OPTIONS: ModelOption[] = [...DEFAULT_OPENAI_MODELS]; +// Re-export ModelOption type for backward compatibility +export type { ModelOption }; +// Re-export DEFAULT_PROVIDER_MODELS for backward compatibility +export { DEFAULT_PROVIDER_MODELS }; -// Model selector localStorage keys +// Model selector localStorage keys (kept for local usage) const MODEL_SELECTION_KEY = 'ai_chat_model_selection'; const MINI_MODEL_STORAGE_KEY = 'ai_chat_mini_model'; const NANO_MODEL_STORAGE_KEY = 'ai_chat_nano_model'; @@ -168,6 +114,25 @@ const PROVIDER_SELECTION_KEY = 'ai_chat_provider'; const LITELLM_ENDPOINT_KEY = 'ai_chat_litellm_endpoint'; const LITELLM_API_KEY_STORAGE_KEY = 'ai_chat_litellm_api_key'; +// Local MODEL_OPTIONS reference that syncs with LLMConfigurationManager +// This maintains backward compatibility while delegating to the centralized manager +let MODEL_OPTIONS: ModelOption[] = [...DEFAULT_OPENAI_MODELS]; + +// Helper to get MODEL_OPTIONS from LLMConfigurationManager +function getModelOptions(): ModelOption[] { + return LLMConfigurationManager.getInstance().getModelOptionsForCurrentProvider(); +} + +// Helper to get all model options across all providers +function getAllModelOptions(): ModelOption[] { + return LLMConfigurationManager.getInstance().getAllModelOptions(); +} + +// Sync local MODEL_OPTIONS with LLMConfigurationManager +function syncModelOptions(): void { + MODEL_OPTIONS = LLMConfigurationManager.getInstance().getModelOptionsForCurrentProvider(); +} + const UIStrings = { /** *@description Text for the AI welcome message @@ -377,263 +342,82 @@ export class AIChatPanel extends UI.Panel.Panel { * @returns Array of model options */ 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) { - try { - const allModelOptions = JSON.parse(allModelOptionsStr); - // If provider is specified, filter by it - return provider ? allModelOptions.filter((opt: ModelOption) => opt.type === provider) : allModelOptions; - } catch (error) { - console.warn('Failed to parse ai_chat_all_model_options from localStorage, removing corrupted data:', error); - localStorage.removeItem('ai_chat_all_model_options'); - } - } - - // Fallback to legacy model_options if all_model_options doesn't exist - const modelOptionsStr = localStorage.getItem('ai_chat_model_options'); - if (modelOptionsStr) { - try { - const modelOptions = JSON.parse(modelOptionsStr); - // If we got legacy options, migrate them to all_model_options for future use - localStorage.setItem('ai_chat_all_model_options', modelOptionsStr); - // Apply provider filter if needed - return provider ? modelOptions.filter((opt: ModelOption) => opt.type === provider) : modelOptions; - } catch (error) { - console.warn('Failed to parse ai_chat_model_options from localStorage, removing corrupted data:', error); - localStorage.removeItem('ai_chat_model_options'); - } + const configManager = LLMConfigurationManager.getInstance(); + if (provider) { + return configManager.getModelOptions(provider); } - - // If nothing is found, return default OpenAI models - return provider === 'litellm' ? [] : DEFAULT_OPENAI_MODELS; + return configManager.getAllModelOptions(); } /** * Updates model options with new provider models + * Delegates to centralized LLMConfigurationManager * @param providerModels Models fetched from any provider (LiteLLM, Groq, etc.) - * @param hadWildcard Whether LiteLLM returned a wildcard model + * @param _hadWildcard Whether LiteLLM returned a wildcard model (kept for backward compatibility) * @returns Updated model options */ - static updateModelOptions(providerModels: ModelOption[] = [], hadWildcard = false): ModelOption[] { - // Get the selected provider (for context, but we store all models regardless) - const selectedProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - - // Get existing models from localStorage - let existingAllModels: ModelOption[] = []; - try { - existingAllModels = JSON.parse(localStorage.getItem('ai_chat_all_model_options') || '[]'); - } catch (error) { - console.warn('Failed to parse ai_chat_all_model_options from localStorage, using empty array:', error); - localStorage.removeItem('ai_chat_all_model_options'); - } - - // Get existing custom models (if any) - these are for LiteLLM only - let savedCustomModels: string[] = []; - try { - savedCustomModels = JSON.parse(localStorage.getItem('ai_chat_custom_models') || '[]'); - } catch (error) { - console.warn('Failed to parse ai_chat_custom_models from localStorage, using empty array:', error); - localStorage.removeItem('ai_chat_custom_models'); - } - const customModels = savedCustomModels.map((model: string) => ({ - value: model, - label: `LiteLLM: ${model}`, - type: 'litellm' as const - })); - - // 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); - } - } + static updateModelOptions(providerModels: ModelOption[] = [], _hadWildcard = false): ModelOption[] { + const configManager = LLMConfigurationManager.getInstance(); - // Update models for the provider type we're adding (if any) + // Determine provider from the models if (providerModels.length > 0) { - const firstModelType = providerModels[0].type; - - if (firstModelType === 'litellm') { - // 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 comprehensive model list from all providers - const allModels: ModelOption[] = []; - for (const providerType of ALL_PROVIDER_TYPES) { - const models = modelsByProvider.get(providerType) || []; - allModels.push(...models); - } - - // Save comprehensive list to localStorage - localStorage.setItem('ai_chat_all_model_options', JSON.stringify(allModels)); - - // 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 - }); - } + const provider = providerModels[0].type; + configManager.setModelOptions(provider, providerModels); } - // Save MODEL_OPTIONS to localStorage for backwards compatibility - localStorage.setItem('ai_chat_model_options', JSON.stringify(MODEL_OPTIONS)); + // Sync local MODEL_OPTIONS + syncModelOptions(); - // Build log info dynamically for all providers - const logInfo: Record = { - provider: selectedProvider, - 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 via configManager:', { + provider: configManager.getProvider(), + modelCount: MODEL_OPTIONS.length + }); - logger.info('Updated model options:', logInfo); - - return allModels; + return configManager.getAllModelOptions(); } /** * Adds a custom model to the options + * Delegates to centralized LLMConfigurationManager * @param modelName Name of the model to add * @param modelType Type of the model ('openai' or 'litellm') * @returns Updated model options */ static addCustomModelOption(modelName: string, modelType?: ProviderType): ModelOption[] { - // Default to litellm if not specified - const finalModelType = modelType || 'litellm'; + const configManager = LLMConfigurationManager.getInstance(); + configManager.addCustomModelOption(modelName, modelType); - // Get existing custom models - const savedCustomModels = JSON.parse(localStorage.getItem('ai_chat_custom_models') || '[]'); - - // Check if the model already exists - if (savedCustomModels.includes(modelName)) { - logger.info(`Custom model ${modelName} already exists, not adding again`); - return AIChatPanel.getModelOptions(); - } - - // Add the new model to custom models - savedCustomModels.push(modelName); - localStorage.setItem('ai_chat_custom_models', JSON.stringify(savedCustomModels)); - - // Create the model option object - const newOption: ModelOption = { - value: modelName, - label: finalModelType === 'litellm' ? `LiteLLM: ${modelName}` : - finalModelType === 'groq' ? `Groq: ${modelName}` : - finalModelType === 'openrouter' ? `OpenRouter: ${modelName}` : - `OpenAI: ${modelName}`, - type: finalModelType - }; - - // Get all existing model options - const allModelOptions = AIChatPanel.getModelOptions(); - - // Add the new option - const updatedOptions = [...allModelOptions, newOption]; - localStorage.setItem('ai_chat_all_model_options', JSON.stringify(updatedOptions)); - - // Update MODEL_OPTIONS for backwards compatibility if provider matches - const currentProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - if ((currentProvider === 'openai' && modelType === 'openai') || - (currentProvider === 'litellm' && modelType === 'litellm') || - (currentProvider === 'groq' && modelType === 'groq')) { - MODEL_OPTIONS = [...MODEL_OPTIONS, newOption]; - localStorage.setItem('ai_chat_model_options', JSON.stringify(MODEL_OPTIONS)); - } - - return updatedOptions; + // Sync local MODEL_OPTIONS + syncModelOptions(); + + return configManager.getAllModelOptions(); } /** * Clears cached model data to force refresh from defaults + * Delegates to centralized LLMConfigurationManager */ static clearModelCache(): void { - localStorage.removeItem('ai_chat_all_model_options'); - localStorage.removeItem('ai_chat_model_options'); - logger.info('Cleared model cache - will use DEFAULT_OPENAI_MODELS on next refresh'); + const configManager = LLMConfigurationManager.getInstance(); + configManager.clearModelOptions(); + syncModelOptions(); + logger.info('Cleared model cache via configManager'); } /** * Removes a custom model from the options + * Delegates to centralized LLMConfigurationManager * @param modelName Name of the model to remove * @returns Updated model options */ static removeCustomModelOption(modelName: string): ModelOption[] { - // Get existing custom models - const savedCustomModels = JSON.parse(localStorage.getItem('ai_chat_custom_models') || '[]'); - - // Check if the model exists - if (!savedCustomModels.includes(modelName)) { - logger.info(`Custom model ${modelName} not found, nothing to remove`); - return AIChatPanel.getModelOptions(); - } - - // Remove the model from custom models - const updatedCustomModels = savedCustomModels.filter((model: string) => model !== modelName); - localStorage.setItem('ai_chat_custom_models', JSON.stringify(updatedCustomModels)); - - // Get all existing model options and remove the specified one - const allModelOptions = AIChatPanel.getModelOptions(); - const updatedOptions = allModelOptions.filter(option => option.value !== modelName); - localStorage.setItem('ai_chat_all_model_options', JSON.stringify(updatedOptions)); - - // Update MODEL_OPTIONS for backwards compatibility - MODEL_OPTIONS = MODEL_OPTIONS.filter(option => option.value !== modelName); - localStorage.setItem('ai_chat_model_options', JSON.stringify(MODEL_OPTIONS)); - - return updatedOptions; + const configManager = LLMConfigurationManager.getInstance(); + configManager.removeCustomModelOption(modelName); + + // Sync local MODEL_OPTIONS + syncModelOptions(); + + return configManager.getAllModelOptions(); } static readonly panelName = 'ai-chat'; @@ -847,94 +631,21 @@ export class AIChatPanel extends UI.Panel.Panel { * Sets up model options based on provider and stored preferences */ #setupModelOptions(): void { - // Get the selected provider - const selectedProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - - // Initialize MODEL_OPTIONS based on the selected provider - this.#updateModelOptions([], false); - - // Load custom models - const savedCustomModels = JSON.parse(localStorage.getItem('ai_chat_custom_models') || '[]'); - - // If we have custom models and using LiteLLM, add them - if (savedCustomModels.length > 0 && selectedProvider === 'litellm') { - // Add custom models to MODEL_OPTIONS - const customOptions = savedCustomModels.map((model: string) => ({ - value: model, - label: `LiteLLM: ${model}`, - type: 'litellm' as const - })); - MODEL_OPTIONS = [...MODEL_OPTIONS, ...customOptions]; + const configManager = LLMConfigurationManager.getInstance(); - // Save MODEL_OPTIONS to localStorage - localStorage.setItem('ai_chat_model_options', JSON.stringify(MODEL_OPTIONS)); - } - - this.#loadModelSelections(); - - // Validate models after loading - this.#validateAndFixModelSelections(); - } + // Sync local MODEL_OPTIONS from the centralized manager + syncModelOptions(); - /** - * Loads model selections from localStorage - */ - #loadModelSelections(): void { - // Get the current provider - const currentProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - const providerDefaults = DEFAULT_PROVIDER_MODELS[currentProvider] || DEFAULT_PROVIDER_MODELS.openai; - - // Load the selected model - const storedModel = localStorage.getItem(MODEL_SELECTION_KEY); - - if (MODEL_OPTIONS.length === 0) { - logger.warn('No model options available when loading model selections'); - return; - } - - if (storedModel && MODEL_OPTIONS.some(option => option.value === storedModel)) { - this.#selectedModel = storedModel; - } else if (MODEL_OPTIONS.length > 0) { - // Check if provider default main model is available - if (providerDefaults.main && MODEL_OPTIONS.some(option => option.value === providerDefaults.main)) { - this.#selectedModel = providerDefaults.main; - } else { - // Otherwise, use the first available model - this.#selectedModel = MODEL_OPTIONS[0].value; - } - localStorage.setItem(MODEL_SELECTION_KEY, this.#selectedModel); - } - - // Load mini model - check that it belongs to current provider - const storedMiniModel = localStorage.getItem(MINI_MODEL_STORAGE_KEY); - const storedMiniModelOption = storedMiniModel ? MODEL_OPTIONS.find(option => option.value === storedMiniModel) : null; - if (storedMiniModelOption && storedMiniModelOption.type === currentProvider && storedMiniModel) { - this.#miniModel = storedMiniModel; - } else if (providerDefaults.mini && MODEL_OPTIONS.some(option => option.value === providerDefaults.mini)) { - // Use provider default mini model if available - this.#miniModel = providerDefaults.mini; - localStorage.setItem(MINI_MODEL_STORAGE_KEY, this.#miniModel); - } else { - this.#miniModel = ''; - localStorage.removeItem(MINI_MODEL_STORAGE_KEY); - } + // Validate and fix model selections using centralized manager + const corrected = configManager.validateAndFixModelSelections(); - // Load nano model - check that it belongs to current provider - const storedNanoModel = localStorage.getItem(NANO_MODEL_STORAGE_KEY); - const storedNanoModelOption = storedNanoModel ? MODEL_OPTIONS.find(option => option.value === storedNanoModel) : null; - if (storedNanoModelOption && storedNanoModelOption.type === currentProvider && storedNanoModel) { - this.#nanoModel = storedNanoModel; - } else if (providerDefaults.nano && MODEL_OPTIONS.some(option => option.value === providerDefaults.nano)) { - // Use provider default nano model if available - this.#nanoModel = providerDefaults.nano; - localStorage.setItem(NANO_MODEL_STORAGE_KEY, this.#nanoModel); - } else { - this.#nanoModel = ''; - localStorage.removeItem(NANO_MODEL_STORAGE_KEY); - } - - logger.info('Loaded model selections:', { - provider: currentProvider, + // Apply the corrected values to instance state + this.#selectedModel = corrected.main; + this.#miniModel = corrected.mini; + this.#nanoModel = corrected.nano; + + logger.info('Setup model options:', { + provider: configManager.getProvider(), selectedModel: this.#selectedModel, miniModel: this.#miniModel, nanoModel: this.#nanoModel @@ -952,99 +663,37 @@ export class AIChatPanel extends UI.Panel.Panel { /** * Validates and fixes model selections to ensure they exist in the current provider * Returns true if all models are valid, false if any needed to be fixed + * Delegates to centralized LLMConfigurationManager */ #validateAndFixModelSelections(): boolean { - logger.info('=== VALIDATING MODEL SELECTIONS ==='); - - const currentProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - const providerDefaults = DEFAULT_PROVIDER_MODELS[currentProvider] || DEFAULT_PROVIDER_MODELS.openai; - const availableModels = AIChatPanel.getModelOptions(currentProvider as 'openai' | 'litellm' | 'groq' | 'openrouter' | 'browseroperator'); - - let allValid = true; - - // Log current state - logger.info('Current state:', { - provider: currentProvider, - selectedModel: this.#selectedModel, - miniModel: this.#miniModel, - nanoModel: this.#nanoModel, - availableModelsCount: availableModels.length - }); - - // If no models available for provider, we have a problem - if (availableModels.length === 0) { - logger.error(`No models available for provider ${currentProvider}`); - return false; - } - - // Validate main model - const mainModelValid = availableModels.some(m => m.value === this.#selectedModel); - if (!mainModelValid) { - logger.warn(`Main model ${this.#selectedModel} not valid for ${currentProvider}, resetting...`); - allValid = false; - - // Try provider default first - if (providerDefaults.main && availableModels.some(m => m.value === providerDefaults.main)) { - this.#selectedModel = providerDefaults.main; - logger.info(`Reset main model to provider default: ${providerDefaults.main}`); - } else { - // Fall back to first available model - this.#selectedModel = availableModels[0].value; - logger.info(`Reset main model to first available: ${this.#selectedModel}`); - } - localStorage.setItem(MODEL_SELECTION_KEY, this.#selectedModel); - } - - // Validate mini model - if (this.#miniModel) { - const miniModelValid = availableModels.some(m => m.value === this.#miniModel); - if (!miniModelValid) { - logger.warn(`Mini model ${this.#miniModel} not valid for ${currentProvider}, resetting...`); - allValid = false; - - // Try provider default first - if (providerDefaults.mini && availableModels.some(m => m.value === providerDefaults.mini)) { - this.#miniModel = providerDefaults.mini; - logger.info(`Reset mini model to provider default: ${providerDefaults.mini}`); - localStorage.setItem(MINI_MODEL_STORAGE_KEY, this.#miniModel); - } else { - // Clear mini model to fall back to main model - this.#miniModel = ''; - logger.info('Cleared mini model to fall back to main model'); - localStorage.removeItem(MINI_MODEL_STORAGE_KEY); - } - } - } - - // Validate nano model - if (this.#nanoModel) { - const nanoModelValid = availableModels.some(m => m.value === this.#nanoModel); - if (!nanoModelValid) { - logger.warn(`Nano model ${this.#nanoModel} not valid for ${currentProvider}, resetting...`); - allValid = false; - - // Try provider default first - if (providerDefaults.nano && availableModels.some(m => m.value === providerDefaults.nano)) { - this.#nanoModel = providerDefaults.nano; - logger.info(`Reset nano model to provider default: ${providerDefaults.nano}`); - localStorage.setItem(NANO_MODEL_STORAGE_KEY, this.#nanoModel); - } else { - // Clear nano model to fall back to mini/main model - this.#nanoModel = ''; - logger.info('Cleared nano model to fall back to mini/main model'); - localStorage.removeItem(NANO_MODEL_STORAGE_KEY); - } - } + const configManager = LLMConfigurationManager.getInstance(); + + // Track previous values to determine if changes were made + const prevMain = this.#selectedModel; + const prevMini = this.#miniModel; + const prevNano = this.#nanoModel; + + // Delegate to centralized validation + const corrected = configManager.validateAndFixModelSelections(); + + // Apply corrected values to instance state + this.#selectedModel = corrected.main; + this.#miniModel = corrected.mini; + this.#nanoModel = corrected.nano; + + // Return true if no changes were needed + const allValid = prevMain === corrected.main && + prevMini === corrected.mini && + prevNano === corrected.nano; + + if (!allValid) { + logger.info('Model selections were fixed:', { + main: { from: prevMain, to: corrected.main }, + mini: { from: prevMini, to: corrected.mini }, + nano: { from: prevNano, to: corrected.nano } + }); } - - // Log final state - logger.info('Validation complete:', { - allValid, - finalSelectedModel: this.#selectedModel, - finalMiniModel: this.#miniModel, - finalNanoModel: this.#nanoModel - }); - + return allValid; } @@ -1230,57 +879,6 @@ export class AIChatPanel extends UI.Panel.Panel { AIChatPanel.updateModelOptions(litellmModels, hadWildcard); } - /** - * Refreshes Groq models from the API - */ - async #refreshGroqModels(): Promise { - try { - const groqApiKey = localStorage.getItem('ai_chat_groq_api_key'); - - if (!groqApiKey) { - logger.info('No Groq API key configured, skipping model refresh'); - return; - } - - const { models: groqModels } = await this.#fetchGroqModels(groqApiKey); - this.#updateModelOptions(groqModels, false); - - // Update MODEL_OPTIONS to reflect the fetched models - this.performUpdate(); - } catch (error) { - logger.error('Failed to refresh Groq models:', error); - // Clear Groq models on error - AIChatPanel.updateModelOptions([], false); - this.performUpdate(); - } - } - - /** - * Fetches Groq models from the API - * @param apiKey API key to use for the request - * @returns Object containing models - */ - async #fetchGroqModels(apiKey: string): Promise<{models: ModelOption[]}> { - try { - // Fetch models from Groq - const models = await LLMClient.fetchGroqModels(apiKey); - - // Transform the models to the format we need - const groqModels = models.map(model => ({ - value: model.id, - label: `Groq: ${model.id}`, - type: 'groq' as const - })); - - logger.info(`Fetched ${groqModels.length} Groq models`); - return { models: groqModels }; - } catch (error) { - logger.error('Failed to fetch Groq models:', error); - // Return empty array on error - return { models: [] }; - } - } - /** * Determines the status of the selected model * @param modelValue The model value to check @@ -1958,6 +1556,57 @@ export class AIChatPanel extends UI.Panel.Panel { override wasShown(): void { this.performUpdate(); this.#chatView?.focus(); + + // Show onboarding for first-time users + if (OnboardingDialog.shouldShowOnboarding()) { + OnboardingDialog.show(async () => { + // Fetch models for the newly selected provider + await this.#refreshModelsForCurrentProvider(); + // Sync MODEL_OPTIONS and validate model selections + this.#setupModelOptions(); + // Re-initialize agent service with newly selected provider + this.#initializeAgentService(); + // Refresh UI after onboarding completes + this.performUpdate(); + }); + return; + } + + // Refresh models when panel is shown to ensure we have the latest available models + void this.#refreshModelsForCurrentProvider(); + } + + /** + * Fetches and caches models for the current provider + * Uses LLMProviderRegistry directly (doesn't require LLMClient initialization) + */ + async #refreshModelsForCurrentProvider(): Promise { + const configManager = LLMConfigurationManager.getInstance(); + const provider = configManager.getProvider(); + + try { + const apiKey = LLMProviderRegistry.getProviderApiKey(provider as LLMProvider); + if (!apiKey) { + logger.debug(`No API key for provider ${provider}, skipping model refresh`); + return; + } + + const models = await LLMProviderRegistry.fetchProviderModels(provider as LLMProvider, apiKey); + + // Convert ModelInfo[] to ModelOption[] for UI caching + const modelOptions = models.map(m => ({ + value: m.id, + label: m.name || m.id, + type: provider + })); + + // Store in the configuration manager's cache + configManager.setModelOptions(provider, modelOptions); + logger.info(`Fetched and cached ${modelOptions.length} models for provider ${provider}`); + } catch (error) { + logger.error(`Failed to refresh models for provider ${provider}:`, error); + // Don't clear cache on error - keep existing cached models if available + } } /** @@ -2188,10 +1837,11 @@ export class AIChatPanel extends UI.Panel.Panel { this.#isProcessing = false; this.#selectedAgentType = null; // Reset selected agent type - // Reset file storage session ID to default for new chat + // Reset file storage session ID to a new unique ID 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'); + const newSessionId = `temp-${crypto.randomUUID()}`; + FileStorageManager.getInstance().setSessionId(newSessionId); + logger.info('Set file storage sessionId for new chat', { sessionId: newSessionId }); // Create new EvaluationAgent for new chat session this.#createEvaluationAgentIfNeeded(); @@ -2503,43 +2153,34 @@ export class AIChatPanel extends UI.Panel.Panel { * Handles changes made in the settings dialog */ async #handleSettingsChanged(): Promise { - // Get the selected provider - const prevProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - const newProvider = localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai'; - - logger.info(`Provider changing from ${prevProvider} to ${newProvider}`); - - // Load saved settings + const configManager = LLMConfigurationManager.getInstance(); + const newProvider = configManager.getProvider(); + + logger.info(`Settings changed, current provider: ${newProvider}`); + + // Load saved settings (for instance properties) this.#apiKey = localStorage.getItem('ai_chat_api_key'); this.#liteLLMApiKey = localStorage.getItem(LITELLM_API_KEY_STORAGE_KEY); this.#liteLLMEndpoint = localStorage.getItem(LITELLM_ENDPOINT_KEY); - - // Reset model options based on the new provider - if (newProvider === 'litellm') { - // First update model options with empty models - this.#updateModelOptions([], false); - - // Then refresh LiteLLM models - await this.#refreshLiteLLMModels(); - } else if (newProvider === 'groq') { - // For Groq, update model options and refresh models if API key exists - this.#updateModelOptions([], false); - - const groqApiKey = localStorage.getItem('ai_chat_groq_api_key'); - if (groqApiKey) { - await this.#refreshGroqModels(); - } - } else { - // For OpenAI, just update model options with empty LiteLLM models - this.#updateModelOptions([], false); - } - - this.#updateModelSelections(); - - // Validate models after updating selections - this.#validateAndFixModelSelections(); - + + // Fetch models for the current provider + await this.#refreshModelsForCurrentProvider(); + + // Sync local MODEL_OPTIONS with the centralized manager + syncModelOptions(); + + // Use the centralized validation method (single source of truth) + const corrected = configManager.validateAndFixModelSelections(); + + // Update instance properties with corrected values + this.#selectedModel = corrected.main; + this.#miniModel = corrected.mini; + this.#nanoModel = corrected.nano; + + logger.info('Model selections after validation:', corrected); + this.#initializeAgentService(); + // Re-initialize MCP based on latest settings try { await MCPRegistry.init(); @@ -2588,15 +2229,21 @@ export class AIChatPanel extends UI.Panel.Panel { // Check if the current selected model is valid for the new provider const selectedModelOption = MODEL_OPTIONS.find(opt => opt.value === this.#selectedModel); - if (!selectedModelOption || selectedModelOption.type !== currentProvider) { - logger.info(`Selected model ${this.#selectedModel} is not valid for provider ${currentProvider}`); - + if (!this.#selectedModel || !selectedModelOption || selectedModelOption.type !== currentProvider) { + logger.info(`Selected model ${this.#selectedModel} is not valid for provider ${currentProvider}, selecting default`); + // Try to use provider default main model first if (providerDefaults.main && MODEL_OPTIONS.some(option => option.value === providerDefaults.main)) { this.#selectedModel = providerDefaults.main; + logger.info(`Set main model to provider default: ${providerDefaults.main}`); } else if (MODEL_OPTIONS.length > 0) { // Otherwise, use the first available model this.#selectedModel = MODEL_OPTIONS[0].value; + logger.info(`Set main model to first available: ${this.#selectedModel}`); + } else { + // No models available + this.#selectedModel = ''; + logger.warn(`No models available for provider ${currentProvider}`); } localStorage.setItem(MODEL_SELECTION_KEY, this.#selectedModel); } diff --git a/front_end/panels/ai_chat/ui/ConversationHistoryList.ts b/front_end/panels/ai_chat/ui/ConversationHistoryList.ts index e50098ab0b..1b95475dd4 100644 --- a/front_end/panels/ai_chat/ui/ConversationHistoryList.ts +++ b/front_end/panels/ai_chat/ui/ConversationHistoryList.ts @@ -10,11 +10,42 @@ import {getConversationHistoryStyles} from './conversationHistoryStyles.js'; const logger = createLogger('ConversationHistoryList'); -const {html, nothing, Directives} = Lit; +const {html, Directives} = Lit; const {unsafeHTML} = Directives; +// SVG icons as template literals +const chatBubbleIcon = html` + + + +`; + +const trashIcon = html` + + + + +`; + +const emptyStateIcon = html` + + + + +`; + +type DateGroup = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'earlier'; + +interface GroupedConversations { + today: ConversationMetadata[]; + yesterday: ConversationMetadata[]; + thisWeek: ConversationMetadata[]; + thisMonth: ConversationMetadata[]; + earlier: ConversationMetadata[]; +} + /** - * Component that displays conversation history + * Component that displays conversation history with modern UI */ export class ConversationHistoryList extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`ai-conversation-history-list`; @@ -88,12 +119,71 @@ export class ConversationHistoryList extends HTMLElement { #handleDeleteConversation(event: Event, conversation: ConversationMetadata): void { event.stopPropagation(); + logger.info('Delete button clicked', {conversationId: conversation.id, title: conversation.title}); if (this.#onDeleteConversation) { + logger.info('Calling onDeleteConversation callback'); this.#onDeleteConversation(conversation.id); + } else { + logger.warn('onDeleteConversation callback is not set!'); } this.#handleClose(); } + #getDateGroup(timestamp: number): DateGroup { + const date = new Date(timestamp); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + const monthAgo = new Date(today); + monthAgo.setMonth(monthAgo.getMonth() - 1); + + if (date >= today) { + return 'today'; + } else if (date >= yesterday) { + return 'yesterday'; + } else if (date >= weekAgo) { + return 'thisWeek'; + } else if (date >= monthAgo) { + return 'thisMonth'; + } + return 'earlier'; + } + + #groupConversations(): GroupedConversations { + const groups: GroupedConversations = { + today: [], + yesterday: [], + thisWeek: [], + thisMonth: [], + earlier: [], + }; + + for (const conversation of this.#conversations) { + const group = this.#getDateGroup(conversation.updatedAt); + groups[group].push(conversation); + } + + return groups; + } + + #getGroupLabel(group: DateGroup): string { + switch (group) { + case 'today': + return 'Today'; + case 'yesterday': + return 'Yesterday'; + case 'thisWeek': + return 'This Week'; + case 'thisMonth': + return 'This Month'; + case 'earlier': + return 'Earlier'; + } + } + #formatDate(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); @@ -115,7 +205,54 @@ export class ConversationHistoryList extends HTMLElement { } } + #renderConversationItem(conversation: ConversationMetadata): Lit.TemplateResult { + return html` +
this.#handleConversationSelected(conversation.id)} + > +
+ ${chatBubbleIcon} +
+
+
${conversation.title}
+ +
+ +
+ `; + } + + #renderDateGroup(group: DateGroup, conversations: ConversationMetadata[]): Lit.TemplateResult { + if (conversations.length === 0) { + return html``; + } + + return html` +
+
+ ${this.#getGroupLabel(group)} + +
+ ${conversations.map(conv => this.#renderConversationItem(conv))} +
+ `; + } + #render(): void { + const grouped = this.#groupConversations(); + const hasConversations = this.#conversations.length > 0; + Lit.render( html`