import options from '../options.js'; import eventService from '../events.js'; import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js'; import { AnthropicService } from './providers/anthropic_service.js'; import { ContextExtractor } from './context/index.js'; import agentTools from './context_extractors/index.js'; import contextService from './context/services/context_service.js'; import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './providers/providers.js'; import indexService from './index_service.js'; import log from '../log.js'; import { OllamaService } from './providers/ollama_service.js'; import { OpenAIService } from './providers/openai_service.js'; // Import interfaces import type { ServiceProviders, IAIServiceManager, ProviderMetadata } from './interfaces/ai_service_interfaces.js'; import type { NoteSearchResult } from './interfaces/context_interfaces.js'; // Import new configuration system import { getSelectedProvider, getSelectedEmbeddingProvider, parseModelIdentifier, isAIEnabled, getDefaultModelForProvider, clearConfigurationCache, validateConfiguration } from './config/configuration_helpers.js'; import type { ProviderType } from './interfaces/configuration_interfaces.js'; /** * Interface representing relevant note context */ interface NoteContext { title: string; content?: string; noteId?: string; summary?: string; score?: number; } export class AIServiceManager implements IAIServiceManager { private services: Partial> = {}; private initialized = false; constructor() { // Initialize tools immediately this.initializeTools().catch(error => { log.error(`Error initializing LLM tools during AIServiceManager construction: ${error.message || String(error)}`); }); // Set up event listener for provider changes this.setupProviderChangeListener(); this.initialized = true; } /** * Initialize all LLM tools in one place */ private async initializeTools(): Promise { try { log.info('Initializing LLM tools during AIServiceManager construction...'); // Initialize agent tools await this.initializeAgentTools(); log.info("Agent tools initialized successfully"); // Initialize LLM tools const toolInitializer = await import('./tools/tool_initializer.js'); await toolInitializer.default.initializeTools(); log.info("LLM tools initialized successfully"); } catch (error: unknown) { log.error(`Error initializing tools: ${this.handleError(error)}`); // Don't throw, just log the error to prevent breaking construction } } /** * Get the currently selected provider using the new configuration system */ async getSelectedProviderAsync(): Promise { try { const selectedProvider = await getSelectedProvider(); return selectedProvider as ServiceProviders || null; } catch (error) { log.error(`Failed to get selected provider: ${error}`); return null; } } /** * Validate AI configuration using the new configuration system */ async validateConfiguration(): Promise { try { const result = await validateConfiguration(); if (!result.isValid) { let message = 'There are issues with your AI configuration:'; for (const error of result.errors) { message += `\n• ${error}`; } if (result.warnings.length > 0) { message += '\n\nWarnings:'; for (const warning of result.warnings) { message += `\n• ${warning}`; } } message += '\n\nPlease check your AI settings.'; return message; } if (result.warnings.length > 0) { let message = 'AI configuration warnings:'; for (const warning of result.warnings) { message += `\n• ${warning}`; } log.info(message); } return null; } catch (error) { log.error(`Error validating AI configuration: ${error}`); return `Configuration validation failed: ${error}`; } } /** * Ensure manager is initialized before using */ private ensureInitialized() { // No longer needed with simplified approach } /** * Get or create any available AI service following the simplified pattern * Returns a service or throws a meaningful error */ async getOrCreateAnyService(): Promise { this.ensureInitialized(); // Get the selected provider using the new configuration system const selectedProvider = await this.getSelectedProviderAsync(); if (!selectedProvider) { throw new Error('No AI provider is selected. Please select a provider (OpenAI, Anthropic, or Ollama) in your AI settings.'); } try { const service = await this.getOrCreateChatProvider(selectedProvider); if (service) { return service; } throw new Error(`Failed to create ${selectedProvider} service`); } catch (error) { log.error(`Provider ${selectedProvider} not available: ${error}`); throw new Error(`Selected AI provider (${selectedProvider}) is not available. Please check your configuration: ${error}`); } } /** * Check if any AI service is available (legacy method for backward compatibility) */ isAnyServiceAvailable(): boolean { this.ensureInitialized(); // Check if we have the selected provider available return this.getAvailableProviders().length > 0; } /** * Get list of available providers */ getAvailableProviders(): ServiceProviders[] { this.ensureInitialized(); const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; const availableProviders: ServiceProviders[] = []; for (const providerName of allProviders) { // Use a sync approach - check if we can create the provider const service = this.services[providerName]; if (service && service.isAvailable()) { availableProviders.push(providerName); } else { // For providers not yet created, check configuration to see if they would be available try { switch (providerName) { case 'openai': if (options.getOption('openaiApiKey')) { availableProviders.push(providerName); } break; case 'anthropic': if (options.getOption('anthropicApiKey')) { availableProviders.push(providerName); } break; case 'ollama': if (options.getOption('ollamaBaseUrl')) { availableProviders.push(providerName); } break; } } catch (error) { // Ignore configuration errors, provider just won't be available } } } return availableProviders; } /** * Generate a chat completion response using the first available AI service * based on the configured precedence order */ async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise { this.ensureInitialized(); log.info(`[AIServiceManager] generateChatCompletion called with options: ${JSON.stringify({ model: options.model, stream: options.stream, enableTools: options.enableTools })}`); log.info(`[AIServiceManager] Stream option type: ${typeof options.stream}`); if (!messages || messages.length === 0) { throw new Error('No messages provided for chat completion'); } // Get the selected provider const selectedProvider = await this.getSelectedProviderAsync(); if (!selectedProvider) { throw new Error('No AI provider is selected. Please select a provider in your AI settings.'); } // Check if the selected provider is available const availableProviders = this.getAvailableProviders(); if (!availableProviders.includes(selectedProvider)) { throw new Error(`Selected AI provider (${selectedProvider}) is not available. Please check your configuration.`); } // If a specific provider is requested and available, use it if (options.model && options.model.includes(':')) { // Use the new configuration system to parse model identifier const modelIdentifier = parseModelIdentifier(options.model); if (modelIdentifier.provider && modelIdentifier.provider === selectedProvider) { try { const service = await this.getOrCreateChatProvider(modelIdentifier.provider as ServiceProviders); if (service) { const modifiedOptions = { ...options, model: modelIdentifier.modelId }; log.info(`[AIServiceManager] Using provider ${modelIdentifier.provider} from model prefix with modifiedOptions.stream: ${modifiedOptions.stream}`); return await service.generateChatCompletion(messages, modifiedOptions); } } catch (error) { log.error(`Error with specified provider ${modelIdentifier.provider}: ${error}`); throw new Error(`Failed to use specified provider ${modelIdentifier.provider}: ${error}`); } } else if (modelIdentifier.provider && modelIdentifier.provider !== selectedProvider) { throw new Error(`Model specifies provider '${modelIdentifier.provider}' but selected provider is '${selectedProvider}'. Please select the correct provider or use a model without provider prefix.`); } // If not a provider prefix, treat the entire string as a model name and continue with normal provider selection } // Use the selected provider try { const service = await this.getOrCreateChatProvider(selectedProvider); if (!service) { throw new Error(`Failed to create selected chat provider: ${selectedProvider}. Please check your configuration.`); } log.info(`[AIServiceManager] Using selected provider ${selectedProvider} with options.stream: ${options.stream}`); return await service.generateChatCompletion(messages, options); } catch (error) { log.error(`Error with selected provider ${selectedProvider}: ${error}`); throw new Error(`Selected AI provider (${selectedProvider}) failed: ${error}`); } } setupEventListeners() { // Setup event listeners for AI services } /** * Get the context extractor service * @returns The context extractor instance */ getContextExtractor() { return contextExtractor; } /** * Get the context service for advanced context management * @returns The context service instance */ getContextService() { return contextService; } /** * Get the index service for managing knowledge base indexing * @returns The index service instance */ getIndexService() { return indexService; } /** * Ensure agent tools are initialized (no-op as they're initialized in constructor) * Kept for backward compatibility with existing API */ async initializeAgentTools(): Promise { // Agent tools are already initialized in the constructor // This method is kept for backward compatibility log.info("initializeAgentTools called, but tools are already initialized in constructor"); } /** * Get the agent tools manager * This provides access to all agent tools */ getAgentTools() { return agentTools; } /** * Get the vector search tool for semantic similarity search */ getVectorSearchTool() { const tools = agentTools.getTools(); return tools.vectorSearch; } /** * Get the note navigator tool for hierarchical exploration */ getNoteNavigatorTool() { const tools = agentTools.getTools(); return tools.noteNavigator; } /** * Get the query decomposition tool for complex queries */ getQueryDecompositionTool() { const tools = agentTools.getTools(); return tools.queryDecomposition; } /** * Get the contextual thinking tool for transparent reasoning */ getContextualThinkingTool() { const tools = agentTools.getTools(); return tools.contextualThinking; } /** * Get whether AI features are enabled using the new configuration system */ async getAIEnabledAsync(): Promise { return isAIEnabled(); } /** * Get whether AI features are enabled (sync version for compatibility) */ getAIEnabled(): boolean { // For synchronous compatibility, use the old method // In a full refactor, this should be async return options.getOptionBool('aiEnabled'); } /** * Get or create a chat provider on-demand with inline validation */ private async getOrCreateChatProvider(providerName: ServiceProviders): Promise { // Return existing provider if already created if (this.services[providerName]) { return this.services[providerName]; } // Create and validate provider on-demand try { let service: AIService | null = null; switch (providerName) { case 'openai': { const apiKey = options.getOption('openaiApiKey'); const baseUrl = options.getOption('openaiBaseUrl'); if (!apiKey && !baseUrl) return null; service = new OpenAIService(); // Validate by checking if it's available if (!service.isAvailable()) { throw new Error('OpenAI service not available'); } break; } case 'anthropic': { const apiKey = options.getOption('anthropicApiKey'); if (!apiKey) return null; service = new AnthropicService(); if (!service.isAvailable()) { throw new Error('Anthropic service not available'); } break; } case 'ollama': { const baseUrl = options.getOption('ollamaBaseUrl'); if (!baseUrl) return null; service = new OllamaService(); if (!service.isAvailable()) { throw new Error('Ollama service not available'); } break; } } if (service) { this.services[providerName] = service; return service; } } catch (error: any) { log.error(`Failed to create ${providerName} chat provider: ${error.message || 'Unknown error'}`); } return null; } /** * Initialize the AI Service using the new configuration system */ async initialize(): Promise { try { log.info("Initializing AI service..."); // Check if AI is enabled using the new helper const aiEnabled = await isAIEnabled(); if (!aiEnabled) { log.info("AI features are disabled in options"); return; } // Initialize index service await this.getIndexService().initialize(); // Tools are already initialized in the constructor // No need to initialize them again this.initialized = true; log.info("AI service initialized successfully"); } catch (error: any) { log.error(`Error initializing AI service: ${error.message}`); throw error; } } /** * Get description of available agent tools */ async getAgentToolsDescription(): Promise { try { // Get all available tools const tools = agentTools.getAllTools(); if (!tools || tools.length === 0) { return ""; } // Format tool descriptions const toolDescriptions = tools.map(tool => `- ${tool.name}: ${tool.description}` ).join('\n'); return `Available tools:\n${toolDescriptions}`; } catch (error) { log.error(`Error getting agent tools description: ${error}`); return ""; } } /** * Get enhanced context with available agent tools * @param noteId - The ID of the note * @param query - The user's query * @param showThinking - Whether to show LLM's thinking process * @param relevantNotes - Optional notes already found to be relevant * @returns Enhanced context with agent tools information */ async getAgentToolsContext( noteId: string, query: string, showThinking: boolean = false, relevantNotes: NoteSearchResult[] = [] ): Promise { try { // Create agent tools message const toolsMessage = await this.getAgentToolsDescription(); // Agent tools are already initialized in the constructor // No need to initialize them again // If we have notes that were already found to be relevant, use them directly let contextNotes = relevantNotes; // If no notes provided, find relevant ones if (!contextNotes || contextNotes.length === 0) { try { // Get the default LLM service for context enhancement const provider = this.getSelectedProvider(); const llmService = await this.getService(provider); // Find relevant notes contextNotes = await contextService.findRelevantNotes( query, noteId, { maxResults: 5, summarize: true, llmService } ); log.info(`Found ${contextNotes.length} relevant notes for context`); } catch (error) { log.error(`Failed to find relevant notes: ${this.handleError(error)}`); // Continue without context notes contextNotes = []; } } // Format notes into context string if we have any let contextStr = ""; if (contextNotes && contextNotes.length > 0) { contextStr = "\n\nRelevant context:\n"; contextNotes.forEach((note, index) => { contextStr += `[${index + 1}] "${note.title}"\n${note.content || 'No content available'}\n\n`; }); } // Combine tool message with context return toolsMessage + contextStr; } catch (error) { log.error(`Error getting agent tools context: ${this.handleError(error)}`); return ""; } } /** * Get AI service for the given provider */ async getService(provider?: string): Promise { this.ensureInitialized(); // If provider is specified, try to get or create it if (provider) { const service = await this.getOrCreateChatProvider(provider as ServiceProviders); if (service && service.isAvailable()) { return service; } throw new Error(`Specified provider ${provider} is not available`); } // Otherwise, use the selected provider const selectedProvider = await this.getSelectedProviderAsync(); if (!selectedProvider) { throw new Error('No AI provider is selected. Please select a provider in your AI settings.'); } const service = await this.getOrCreateChatProvider(selectedProvider); if (service && service.isAvailable()) { return service; } // If no provider is available, throw a clear error throw new Error(`Selected AI provider (${selectedProvider}) is not available. Please check your AI settings.`); } /** * Get the preferred provider based on configuration using the new system */ async getPreferredProviderAsync(): Promise { try { const selectedProvider = await getSelectedProvider(); if (selectedProvider === null) { // No provider selected, fallback to default log.info('No provider selected, using default provider'); return 'openai'; } return selectedProvider; } catch (error) { log.error(`Error getting preferred provider: ${error}`); return 'openai'; } } /** * Get the selected provider based on configuration (sync version for compatibility) */ getSelectedProvider(): string { this.ensureInitialized(); // Try to get the selected provider synchronously try { const selectedProvider = options.getOption('aiSelectedProvider'); if (selectedProvider) { return selectedProvider; } } catch (error) { log.error(`Error getting selected provider: ${error}`); } // Return a default if nothing is selected (for backward compatibility) return 'openai'; } /** * Check if a specific provider is available */ isProviderAvailable(provider: string): boolean { return this.services[provider as ServiceProviders]?.isAvailable() ?? false; } /** * Get metadata about a provider */ getProviderMetadata(provider: string): ProviderMetadata | null { const service = this.services[provider as ServiceProviders]; if (!service) { return null; } return { name: provider, capabilities: { chat: true, embeddings: provider !== 'anthropic', // Anthropic doesn't have embeddings streaming: true, functionCalling: provider === 'openai' // Only OpenAI has function calling }, models: ['default'], // Placeholder, could be populated from the service defaultModel: 'default' }; } /** * Error handler that properly types the error object */ private handleError(error: unknown): string { if (error instanceof Error) { return error.message || String(error); } return String(error); } /** * Set up event listener for provider changes */ private setupProviderChangeListener(): void { // List of AI-related options that should trigger service recreation const aiRelatedOptions = [ 'aiEnabled', 'aiSelectedProvider', 'embeddingSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel', 'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel', 'ollamaBaseUrl', 'ollamaDefaultModel', 'voyageApiKey' ]; eventService.subscribe(['entityChanged'], async ({ entityName, entity }) => { if (entityName === 'options' && entity && aiRelatedOptions.includes(entity.name)) { log.info(`AI-related option '${entity.name}' changed, recreating LLM services`); // Special handling for aiEnabled toggle if (entity.name === 'aiEnabled') { const isEnabled = entity.value === 'true'; if (isEnabled) { log.info('AI features enabled, initializing AI service and embeddings'); // Initialize the AI service await this.initialize(); // Initialize embeddings through index service await indexService.startEmbeddingGeneration(); } else { log.info('AI features disabled, stopping embeddings and clearing providers'); // Stop embeddings through index service await indexService.stopEmbeddingGeneration(); // Clear chat providers this.services = {}; } } else { // For other AI-related options, recreate services on-demand await this.recreateServices(); } } }); } /** * Recreate LLM services when provider settings change */ private async recreateServices(): Promise { try { log.info('Recreating LLM services due to configuration change'); // Clear configuration cache first clearConfigurationCache(); // Clear existing chat providers (they will be recreated on-demand) this.services = {}; // Clear embedding providers (they will be recreated on-demand when needed) const providerManager = await import('./providers/providers.js'); providerManager.clearAllEmbeddingProviders(); log.info('LLM services recreated successfully'); } catch (error) { log.error(`Error recreating LLM services: ${this.handleError(error)}`); } } } // Don't create singleton immediately, use a lazy-loading pattern let instance: AIServiceManager | null = null; /** * Get the AIServiceManager instance (creates it if not already created) */ function getInstance(): AIServiceManager { if (!instance) { instance = new AIServiceManager(); } return instance; } export default { getInstance, // Also export methods directly for convenience isAnyServiceAvailable(): boolean { return getInstance().isAnyServiceAvailable(); }, async getOrCreateAnyService(): Promise { return getInstance().getOrCreateAnyService(); }, getAvailableProviders() { return getInstance().getAvailableProviders(); }, async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise { return getInstance().generateChatCompletion(messages, options); }, // Add validateEmbeddingProviders method async validateEmbeddingProviders(): Promise { return getInstance().validateConfiguration(); }, // Context and index related methods getContextExtractor() { return getInstance().getContextExtractor(); }, getContextService() { return getInstance().getContextService(); }, getIndexService() { return getInstance().getIndexService(); }, // Agent tools related methods // Tools are now initialized in the constructor getAgentTools() { return getInstance().getAgentTools(); }, getVectorSearchTool() { return getInstance().getVectorSearchTool(); }, getNoteNavigatorTool() { return getInstance().getNoteNavigatorTool(); }, getQueryDecompositionTool() { return getInstance().getQueryDecompositionTool(); }, getContextualThinkingTool() { return getInstance().getContextualThinkingTool(); }, async getAgentToolsContext( noteId: string, query: string, showThinking: boolean = false, relevantNotes: NoteSearchResult[] = [] ): Promise { return getInstance().getAgentToolsContext( noteId, query, showThinking, relevantNotes ); }, // New methods async getService(provider?: string): Promise { return getInstance().getService(provider); }, getSelectedProvider(): string { return getInstance().getSelectedProvider(); }, isProviderAvailable(provider: string): boolean { return getInstance().isProviderAvailable(provider); }, getProviderMetadata(provider: string): ProviderMetadata | null { return getInstance().getProviderMetadata(provider); } }; // Create an instance of ContextExtractor for backward compatibility const contextExtractor = new ContextExtractor();