Notes/apps/server/src/services/llm/ai_service_manager.ts

688 lines
23 KiB
TypeScript

import options from '../options.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 {
getProviderPrecedence,
getPreferredProvider,
getEmbeddingProviderPrecedence,
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: Record<ServiceProviders, AIService> = {
openai: new OpenAIService(),
anthropic: new AnthropicService(),
ollama: new OllamaService()
};
private providerOrder: ServiceProviders[] = []; // Will be populated from configuration
private initialized = false;
constructor() {
// Initialize provider order immediately
this.updateProviderOrder();
// Initialize tools immediately
this.initializeTools().catch(error => {
log.error(`Error initializing LLM tools during AIServiceManager construction: ${error.message || String(error)}`);
});
}
/**
* Initialize all LLM tools in one place
*/
private async initializeTools(): Promise<void> {
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
}
}
/**
* Update the provider precedence order using the new configuration system
*/
async updateProviderOrderAsync(): Promise<void> {
try {
const providers = await getProviderPrecedence();
this.providerOrder = providers as ServiceProviders[];
this.initialized = true;
log.info(`Updated provider order: ${providers.join(', ')}`);
} catch (error) {
log.error(`Failed to get provider precedence: ${error}`);
// Keep empty order, will be handled gracefully by other methods
this.providerOrder = [];
this.initialized = true;
}
}
/**
* Update the provider precedence order (legacy sync version)
* Returns true if successful, false if options not available yet
*/
updateProviderOrder(): boolean {
if (this.initialized) {
return true;
}
// Use async version but don't wait
this.updateProviderOrderAsync().catch(error => {
log.error(`Error in async provider order update: ${error}`);
});
return true;
}
/**
* Validate AI configuration using the new configuration system
*/
async validateConfiguration(): Promise<string | null> {
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}`;
}
}
/**
* @deprecated Use validateConfiguration() instead
*/
async validateEmbeddingProviders(): Promise<string | null> {
log.info('validateEmbeddingProviders is deprecated, use validateConfiguration instead');
return this.validateConfiguration();
}
/**
* 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<ChatResponse> {
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');
}
// 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(':')) {
// Use the new configuration system to parse model identifier
const modelIdentifier = parseModelIdentifier(options.model);
if (modelIdentifier.provider && availableProviders.includes(modelIdentifier.provider as ServiceProviders)) {
try {
const modifiedOptions = { ...options, model: modelIdentifier.modelId };
log.info(`[AIServiceManager] Using provider ${modelIdentifier.provider} from model prefix with modifiedOptions.stream: ${modifiedOptions.stream}`);
return await this.services[modelIdentifier.provider as ServiceProviders].generateChatCompletion(messages, modifiedOptions);
} catch (error) {
log.error(`Error with specified provider ${modelIdentifier.provider}: ${error}`);
// If the specified provider fails, continue with the fallback providers
}
}
// If not a provider prefix, treat the entire string as a model name and continue with normal provider selection
}
// Try each provider in order until one succeeds
let lastError: Error | null = null;
for (const provider of sortedProviders) {
try {
log.info(`[AIServiceManager] Trying provider ${provider} with options.stream: ${options.stream}`);
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 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<void> {
// 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<boolean> {
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');
}
/**
* Set up embeddings provider using the new configuration system
*/
async setupEmbeddingsProvider(): Promise<void> {
try {
const aiEnabled = await isAIEnabled();
if (!aiEnabled) {
log.info('AI features are disabled');
return;
}
// Use the new configuration system - no string parsing!
const enabledProviders = await getEnabledEmbeddingProviders();
if (enabledProviders.length === 0) {
log.info('No embedding providers are enabled');
return;
}
// Initialize embedding providers
log.info('Embedding providers initialized successfully');
} catch (error: any) {
log.error(`Error setting up embedding providers: ${error.message}`);
throw error;
}
}
/**
* Initialize the AI Service using the new configuration system
*/
async initialize(): Promise<void> {
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;
}
// Update provider order from configuration
await this.updateProviderOrderAsync();
// Set up embeddings provider if AI is enabled
await this.setupEmbeddingsProvider();
// 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<string> {
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<string> {
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.getPreferredProvider();
const llmService = 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
*/
getService(provider?: string): AIService {
this.ensureInitialized();
// If provider is specified, try to use it
if (provider && this.services[provider as ServiceProviders]?.isAvailable()) {
return this.services[provider as ServiceProviders];
}
// Otherwise, use the first available provider in the configured order
for (const providerName of this.providerOrder) {
const service = this.services[providerName];
if (service.isAvailable()) {
return service;
}
}
// If no provider is available, use first one anyway (it will throw an error)
// This allows us to show a proper error message rather than "provider not found"
return this.services[this.providerOrder[0]];
}
/**
* Get the preferred provider based on configuration using the new system
*/
async getPreferredProviderAsync(): Promise<string> {
try {
const preferredProvider = await getPreferredProvider();
if (preferredProvider === null) {
// No providers configured, fallback to first available
log.info('No providers configured in precedence, using first available provider');
return this.providerOrder[0];
}
return preferredProvider;
} catch (error) {
log.error(`Error getting preferred provider: ${error}`);
return this.providerOrder[0];
}
}
/**
* Get the preferred provider based on configuration (sync version for compatibility)
*/
getPreferredProvider(): string {
this.ensureInitialized();
// Return the first available provider in the order
for (const providerName of this.providerOrder) {
if (this.services[providerName].isAvailable()) {
return providerName;
}
}
// Return the first provider as fallback
return this.providerOrder[0];
}
/**
* 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);
}
}
// 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<ChatResponse> {
return getInstance().generateChatCompletion(messages, options);
},
// Add validateEmbeddingProviders method
async validateEmbeddingProviders(): Promise<string | null> {
return getInstance().validateEmbeddingProviders();
},
// 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<string> {
return getInstance().getAgentToolsContext(
noteId,
query,
showThinking,
relevantNotes
);
},
// New methods
getService(provider?: string): AIService {
return getInstance().getService(provider);
},
getPreferredProvider(): string {
return getInstance().getPreferredProvider();
},
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();