import options from '../options.js'; import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js'; import { OpenAIService } from './providers/openai_service.js'; import { AnthropicService } from './providers/anthropic_service.js'; import { OllamaService } from './providers/ollama_service.js'; import log from '../log.js'; import { ContextExtractor } from './context/index.js'; import semanticContextService from './semantic_context_service.js'; import indexService from './index_service.js'; import { getEmbeddingProvider, getEnabledEmbeddingProviders } from './embeddings/providers.js'; type ServiceProviders = 'openai' | 'anthropic' | 'ollama'; export class AIServiceManager { private services: Record = { openai: new OpenAIService(), anthropic: new AnthropicService(), ollama: new OllamaService() }; private providerOrder: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; // Default order private initialized = false; constructor() { // Don't call updateProviderOrder here // Wait until a method is called to initialize } /** * Update the provider precedence order from saved options * Returns true if successful, false if options not available yet */ updateProviderOrder(): boolean { if (this.initialized) { return true; } try { // Default precedence: openai, anthropic, ollama const defaultOrder: ServiceProviders[] = ['openai', 'anthropic', 'ollama']; // Get custom order from options const customOrder = options.getOption('aiProviderPrecedence'); if (customOrder) { try { // Try to parse as JSON first let parsed; // Handle both array in JSON format and simple string format if (customOrder.startsWith('[') && customOrder.endsWith(']')) { parsed = JSON.parse(customOrder); } else if (typeof customOrder === 'string') { // If it's a string with commas, split it if (customOrder.includes(',')) { parsed = customOrder.split(',').map(p => p.trim()); } else { // If it's a simple string (like "ollama"), convert to single-item array parsed = [customOrder]; } } else { // Fallback to default parsed = defaultOrder; } // Validate that all providers are valid if (Array.isArray(parsed) && parsed.every(p => Object.keys(this.services).includes(p))) { this.providerOrder = parsed as ServiceProviders[]; } else { log.info('Invalid AI provider precedence format, using defaults'); this.providerOrder = defaultOrder; } } catch (e) { log.error(`Failed to parse AI provider precedence: ${e}`); this.providerOrder = defaultOrder; } } else { this.providerOrder = defaultOrder; } this.initialized = true; // Remove the validateEmbeddingProviders call since we now do validation on the client // this.validateEmbeddingProviders(); return true; } catch (error) { // If options table doesn't exist yet, use defaults // This happens during initial database creation this.providerOrder = ['openai', 'anthropic', 'ollama']; return false; } } /** * Validate embedding providers configuration * - Check if embedding default provider is in provider precedence list * - Check if all providers in precedence list and default provider are enabled * * @returns A warning message if there are issues, or null if everything is fine */ async validateEmbeddingProviders(): Promise { try { // Check if AI is enabled, if not, skip validation const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return null; } // Get default embedding provider const defaultProviderName = await options.getOption('embeddingsDefaultProvider') || 'openai'; // Parse provider precedence list (similar to updateProviderOrder) let precedenceList: string[] = []; const precedenceOption = await options.getOption('aiProviderPrecedence'); if (precedenceOption) { if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) { precedenceList = JSON.parse(precedenceOption); } else if (typeof precedenceOption === 'string') { if (precedenceOption.includes(',')) { precedenceList = precedenceOption.split(',').map(p => p.trim()); } else { precedenceList = [precedenceOption]; } } } // Get enabled providers const enabledProviders = await getEnabledEmbeddingProviders(); const enabledProviderNames = enabledProviders.map(p => p.name); // Check if default provider is in precedence list const defaultInPrecedence = precedenceList.includes(defaultProviderName); // Check if default provider is enabled const defaultIsEnabled = enabledProviderNames.includes(defaultProviderName); // Check if all providers in precedence list are enabled const allPrecedenceEnabled = precedenceList.every(p => enabledProviderNames.includes(p) || p === 'local'); // Return warning message if there are issues if (!defaultInPrecedence || !defaultIsEnabled || !allPrecedenceEnabled) { let message = 'There are issues with your AI provider configuration:'; if (!defaultInPrecedence) { message += `\n• The default embedding provider "${defaultProviderName}" is not in your provider precedence list.`; } if (!defaultIsEnabled) { message += `\n• The default embedding provider "${defaultProviderName}" is not enabled.`; } if (!allPrecedenceEnabled) { const disabledProviders = precedenceList.filter(p => !enabledProviderNames.includes(p) && p !== 'local'); message += `\n• The following providers in your precedence list are not enabled: ${disabledProviders.join(', ')}.`; } message += '\n\nPlease check your AI settings.'; // Log warning to console log.error('AI Provider Configuration Warning: ' + message); return message; } return null; } catch (error) { log.error(`Error validating embedding providers: ${error}`); return null; } } /** * Ensure manager is initialized before using */ private ensureInitialized() { if (!this.initialized) { this.updateProviderOrder(); } } /** * Check if any AI service is available */ isAnyServiceAvailable(): boolean { return Object.values(this.services).some(service => service.isAvailable()); } /** * Get list of available providers */ getAvailableProviders(): ServiceProviders[] { this.ensureInitialized(); return Object.entries(this.services) .filter(([_, service]) => service.isAvailable()) .map(([key, _]) => key as ServiceProviders); } /** * 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(); if (!messages || messages.length === 0) { throw new Error('No messages provided for chat completion'); } // Try providers in order of preference const availableProviders = this.getAvailableProviders(); if (availableProviders.length === 0) { throw new Error('No AI providers are available. Please check your AI settings.'); } // Sort available providers by precedence const sortedProviders = this.providerOrder .filter(provider => availableProviders.includes(provider)); // If a specific provider is requested and available, use it if (options.model && options.model.includes(':')) { const [providerName, modelName] = options.model.split(':'); if (availableProviders.includes(providerName as ServiceProviders)) { try { const modifiedOptions = { ...options, model: modelName }; return await this.services[providerName as ServiceProviders].generateChatCompletion(messages, modifiedOptions); } catch (error) { log.error(`Error with specified provider ${providerName}: ${error}`); // If the specified provider fails, continue with the fallback providers } } } // Try each provider in order until one succeeds let lastError: Error | null = null; for (const provider of sortedProviders) { try { return await this.services[provider].generateChatCompletion(messages, options); } catch (error) { log.error(`Error with provider ${provider}: ${error}`); lastError = error as Error; // Continue to the next provider } } // If we get here, all providers failed throw new Error(`All AI providers failed: ${lastError?.message || 'Unknown error'}`); } setupEventListeners() { // Setup event listeners for AI services } /** * Get the context extractor service * @returns The context extractor instance */ getContextExtractor() { return contextExtractor; } /** * Get the semantic context service for advanced context handling * @returns The semantic context service instance */ getSemanticContextService() { return semanticContextService; } /** * Get the index service for managing knowledge base indexing * @returns The index service instance */ getIndexService() { return indexService; } } // 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(); }, getAvailableProviders() { return getInstance().getAvailableProviders(); }, async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise { return getInstance().generateChatCompletion(messages, options); }, // Add validateEmbeddingProviders method async validateEmbeddingProviders(): Promise { return getInstance().validateEmbeddingProviders(); }, // Context and index related methods getContextExtractor() { return getInstance().getContextExtractor(); }, getSemanticContextService() { return getInstance().getSemanticContextService(); }, getIndexService() { return getInstance().getIndexService(); } }; // Create an instance of ContextExtractor for backward compatibility const contextExtractor = new ContextExtractor();