diff --git a/apps/server/src/routes/api/embeddings.ts b/apps/server/src/routes/api/embeddings.ts
index 012a9c82f..226231ea2 100644
--- a/apps/server/src/routes/api/embeddings.ts
+++ b/apps/server/src/routes/api/embeddings.ts
@@ -408,7 +408,7 @@ async function reprocessAllNotes(req: Request, res: Response) {
try {
// Wrap the operation in cls.init to ensure proper context
cls.init(async () => {
- await vectorStore.reprocessAllNotes();
+ await indexService.reprocessAllNotes();
log.info("Embedding reprocessing completed successfully");
});
} catch (error: any) {
@@ -782,6 +782,49 @@ async function getIndexRebuildStatus(req: Request, res: Response) {
};
}
+/**
+ * Start embedding generation when AI is enabled
+ */
+async function startEmbeddings(req: Request, res: Response) {
+ try {
+ log.info("Starting embedding generation system");
+
+ // Initialize the index service if not already initialized
+ await indexService.initialize();
+
+ // Start automatic indexing
+ await indexService.startEmbeddingGeneration();
+
+ return {
+ success: true,
+ message: "Embedding generation started"
+ };
+ } catch (error: any) {
+ log.error(`Error starting embeddings: ${error.message || 'Unknown error'}`);
+ throw new Error(`Failed to start embeddings: ${error.message || 'Unknown error'}`);
+ }
+}
+
+/**
+ * Stop embedding generation when AI is disabled
+ */
+async function stopEmbeddings(req: Request, res: Response) {
+ try {
+ log.info("Stopping embedding generation system");
+
+ // Stop automatic indexing
+ await indexService.stopEmbeddingGeneration();
+
+ return {
+ success: true,
+ message: "Embedding generation stopped"
+ };
+ } catch (error: any) {
+ log.error(`Error stopping embeddings: ${error.message || 'Unknown error'}`);
+ throw new Error(`Failed to stop embeddings: ${error.message || 'Unknown error'}`);
+ }
+}
+
export default {
findSimilarNotes,
searchByText,
@@ -794,5 +837,7 @@ export default {
retryFailedNote,
retryAllFailedNotes,
rebuildIndex,
- getIndexRebuildStatus
+ getIndexRebuildStatus,
+ startEmbeddings,
+ stopEmbeddings
};
diff --git a/apps/server/src/routes/api/llm.ts b/apps/server/src/routes/api/llm.ts
index c21426a66..f586b85d6 100644
--- a/apps/server/src/routes/api/llm.ts
+++ b/apps/server/src/routes/api/llm.ts
@@ -825,7 +825,10 @@ async function streamMessage(req: Request, res: Response) {
success: true,
message: 'Streaming initiated successfully'
});
- log.info(`Sent immediate success response for streaming setup`);
+
+ // Mark response as handled to prevent apiResultHandler from processing it again
+ (res as any).triliumResponseHandled = true;
+
// Create a new response object for streaming through WebSocket only
// We won't use HTTP streaming since we've already sent the HTTP response
@@ -889,78 +892,33 @@ async function streamMessage(req: Request, res: Response) {
thinking: showThinking ? 'Initializing streaming LLM response...' : undefined
});
- // Instead of trying to reimplement the streaming logic ourselves,
- // delegate to restChatService but set up the correct protocol:
- // 1. We've already sent a success response to the initial POST
- // 2. Now we'll have restChatService process the actual streaming through WebSocket
+ // Process the LLM request using the existing service but with streaming setup
+ // Since we've already sent the initial HTTP response, we'll use the WebSocket for streaming
try {
- // Import the WebSocket service for sending messages
- const wsService = (await import('../../services/ws.js')).default;
-
- // Create a simple pass-through response object that won't write to the HTTP response
- // but will allow restChatService to send WebSocket messages
- const dummyResponse = {
- writableEnded: false,
- // Implement methods that would normally be used by restChatService
- write: (_chunk: string) => {
- // Silent no-op - we're only using WebSocket
- return true;
+ // Call restChatService with streaming mode enabled
+ // The important part is setting method to GET to indicate streaming mode
+ await restChatService.handleSendMessage({
+ ...req,
+ method: 'GET', // Indicate streaming mode
+ query: {
+ ...req.query,
+ stream: 'true' // Add the required stream parameter
},
- end: (_chunk?: string) => {
- // Log when streaming is complete via WebSocket
- log.info(`[${chatNoteId}] Completed HTTP response handling during WebSocket streaming`);
- return dummyResponse;
+ body: {
+ content: enhancedContent,
+ useAdvancedContext: useAdvancedContext === true,
+ showThinking: showThinking === true
},
- setHeader: (name: string, _value: string) => {
- // Only log for content-type to reduce noise
- if (name.toLowerCase() === 'content-type') {
- log.info(`[${chatNoteId}] Setting up streaming for WebSocket only`);
- }
- return dummyResponse;
- }
- };
+ params: { chatNoteId }
+ } as unknown as Request, res);
+ } catch (streamError) {
+ log.error(`Error during WebSocket streaming: ${streamError}`);
- // Process the streaming now through WebSocket only
- try {
- log.info(`[${chatNoteId}] Processing LLM streaming through WebSocket after successful initiation at ${new Date().toISOString()}`);
-
- // Call restChatService with our enhanced request and dummy response
- // The important part is setting method to GET to indicate streaming mode
- await restChatService.handleSendMessage({
- ...req,
- method: 'GET', // Indicate streaming mode
- query: {
- ...req.query,
- stream: 'true' // Add the required stream parameter
- },
- body: {
- content: enhancedContent,
- useAdvancedContext: useAdvancedContext === true,
- showThinking: showThinking === true
- },
- params: { chatNoteId }
- } as unknown as Request, dummyResponse as unknown as Response);
-
- log.info(`[${chatNoteId}] WebSocket streaming completed at ${new Date().toISOString()}`);
- } catch (streamError) {
- log.error(`[${chatNoteId}] Error during WebSocket streaming: ${streamError}`);
-
- // Send error message through WebSocket
- wsService.sendMessageToAllClients({
- type: 'llm-stream',
- chatNoteId: chatNoteId,
- error: `Error during streaming: ${streamError}`,
- done: true
- });
- }
- } catch (error) {
- log.error(`Error during streaming: ${error}`);
-
- // Send error to client via WebSocket
+ // Send error message through WebSocket
wsService.sendMessageToAllClients({
type: 'llm-stream',
chatNoteId: chatNoteId,
- error: `Error processing message: ${error}`,
+ error: `Error during streaming: ${streamError}`,
done: true
});
}
diff --git a/apps/server/src/routes/api/openai.ts b/apps/server/src/routes/api/openai.ts
index ced03ce04..84efad2ca 100644
--- a/apps/server/src/routes/api/openai.ts
+++ b/apps/server/src/routes/api/openai.ts
@@ -66,12 +66,13 @@ async function listModels(req: Request, res: Response) {
const apiKey = await options.getOption('openaiApiKey');
if (!apiKey) {
- throw new Error('OpenAI API key is not configured');
+ // Log warning but don't throw - some OpenAI-compatible endpoints don't require API keys
+ log.info('OpenAI API key is not configured when listing models. This may cause issues with official OpenAI endpoints.');
}
- // Initialize OpenAI client with the API key and base URL
+ // Initialize OpenAI client with the API key (or empty string) and base URL
const openai = new OpenAI({
- apiKey,
+ apiKey: apiKey || '', // Default to empty string if no API key
baseURL: openaiBaseUrl
});
@@ -84,9 +85,9 @@ async function listModels(req: Request, res: Response) {
// Include all models as chat models, without filtering by specific model names
// This allows models from providers like OpenRouter to be displayed
const chatModels = allModels
- .filter((model) =>
+ .filter((model) =>
// Exclude models that are explicitly for embeddings
- !model.id.includes('embedding') &&
+ !model.id.includes('embedding') &&
!model.id.includes('embed')
)
.map((model) => ({
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index 42d4fb110..022b4514e 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -96,22 +96,26 @@ const ALLOWED_OPTIONS = new Set
([
"aiEnabled",
"aiTemperature",
"aiSystemPrompt",
- "aiProviderPrecedence",
+ "aiSelectedProvider",
"openaiApiKey",
"openaiBaseUrl",
"openaiDefaultModel",
"openaiEmbeddingModel",
+ "openaiEmbeddingApiKey",
+ "openaiEmbeddingBaseUrl",
"anthropicApiKey",
"anthropicBaseUrl",
"anthropicDefaultModel",
"voyageApiKey",
"voyageEmbeddingModel",
+ "voyageEmbeddingBaseUrl",
"ollamaBaseUrl",
"ollamaDefaultModel",
"ollamaEmbeddingModel",
+ "ollamaEmbeddingBaseUrl",
"embeddingAutoUpdateEnabled",
"embeddingDimensionStrategy",
- "embeddingProviderPrecedence",
+ "embeddingSelectedProvider",
"embeddingSimilarityThreshold",
"embeddingBatchSize",
"embeddingUpdateInterval",
diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts
index d9ac2c2f8..73beb28e2 100644
--- a/apps/server/src/routes/routes.ts
+++ b/apps/server/src/routes/routes.ts
@@ -400,6 +400,8 @@ function register(app: express.Application) {
asyncApiRoute(PST, "/api/llm/embeddings/retry-all-failed", embeddingsRoute.retryAllFailedNotes);
asyncApiRoute(PST, "/api/llm/embeddings/rebuild-index", embeddingsRoute.rebuildIndex);
asyncApiRoute(GET, "/api/llm/embeddings/index-rebuild-status", embeddingsRoute.getIndexRebuildStatus);
+ asyncApiRoute(PST, "/api/llm/embeddings/start", embeddingsRoute.startEmbeddings);
+ asyncApiRoute(PST, "/api/llm/embeddings/stop", embeddingsRoute.stopEmbeddings);
// LLM provider endpoints - moved under /api/llm/providers hierarchy
asyncApiRoute(GET, "/api/llm/providers/ollama/models", ollamaRoute.listModels);
diff --git a/apps/server/src/services/llm/ai_service_manager.ts b/apps/server/src/services/llm/ai_service_manager.ts
index d7bbf4cf7..394523d8f 100644
--- a/apps/server/src/services/llm/ai_service_manager.ts
+++ b/apps/server/src/services/llm/ai_service_manager.ts
@@ -1,4 +1,5 @@
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';
@@ -20,9 +21,8 @@ import type { NoteSearchResult } from './interfaces/context_interfaces.js';
// Import new configuration system
import {
- getProviderPrecedence,
- getPreferredProvider,
- getEmbeddingProviderPrecedence,
+ getSelectedProvider,
+ getSelectedEmbeddingProvider,
parseModelIdentifier,
isAIEnabled,
getDefaultModelForProvider,
@@ -43,23 +43,20 @@ interface NoteContext {
}
export class AIServiceManager implements IAIServiceManager {
- private services: Record = {
- openai: new OpenAIService(),
- anthropic: new AnthropicService(),
- ollama: new OllamaService()
- };
+ private services: Partial> = {};
- 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)}`);
});
+
+ // Set up event listener for provider changes
+ this.setupProviderChangeListener();
+
+ this.initialized = true;
}
/**
@@ -84,39 +81,18 @@ export class AIServiceManager implements IAIServiceManager {
}
/**
- * Update the provider precedence order using the new configuration system
+ * Get the currently selected provider using the new configuration system
*/
- async updateProviderOrderAsync(): Promise {
+ async getSelectedProviderAsync(): Promise {
try {
- const providers = await getProviderPrecedence();
- this.providerOrder = providers as ServiceProviders[];
- this.initialized = true;
- log.info(`Updated provider order: ${providers.join(', ')}`);
+ const selectedProvider = await getSelectedProvider();
+ return selectedProvider as ServiceProviders || null;
} 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;
+ log.error(`Failed to get selected provider: ${error}`);
+ return null;
}
}
- /**
- * 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
*/
@@ -158,16 +134,44 @@ export class AIServiceManager implements IAIServiceManager {
* Ensure manager is initialized before using
*/
private ensureInitialized() {
- if (!this.initialized) {
- this.updateProviderOrder();
+ // 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
+ * Check if any AI service is available (legacy method for backward compatibility)
*/
isAnyServiceAvailable(): boolean {
- return Object.values(this.services).some(service => service.isAvailable());
+ this.ensureInitialized();
+
+ // Check if we have the selected provider available
+ return this.getAvailableProviders().length > 0;
}
/**
@@ -175,9 +179,42 @@ export class AIServiceManager implements IAIServiceManager {
*/
getAvailableProviders(): ServiceProviders[] {
this.ensureInitialized();
- return Object.entries(this.services)
- .filter(([_, service]) => service.isAvailable())
- .map(([key, _]) => key as ServiceProviders);
+
+ 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;
}
/**
@@ -198,51 +235,54 @@ export class AIServiceManager implements IAIServiceManager {
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.');
+ // 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.`);
}
-
- // 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)) {
+ if (modelIdentifier.provider && modelIdentifier.provider === selectedProvider) {
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);
+ 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}`);
- // If the specified provider fails, continue with the fallback providers
+ 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
}
- // 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
+ // 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}`);
}
-
- // If we get here, all providers failed
- throw new Error(`All AI providers failed: ${lastError?.message || 'Unknown error'}`);
}
setupEventListeners() {
@@ -340,30 +380,64 @@ export class AIServiceManager implements IAIServiceManager {
}
/**
- * Set up embeddings provider using the new configuration system
+ * Get or create a chat provider on-demand with inline validation
*/
- async setupEmbeddingsProvider(): Promise {
- 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;
+ 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;
}
/**
@@ -381,12 +455,6 @@ export class AIServiceManager implements IAIServiceManager {
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();
@@ -453,8 +521,8 @@ export class AIServiceManager implements IAIServiceManager {
if (!contextNotes || contextNotes.length === 0) {
try {
// Get the default LLM service for context enhancement
- const provider = this.getPreferredProvider();
- const llmService = this.getService(provider);
+ const provider = this.getSelectedProvider();
+ const llmService = await this.getService(provider);
// Find relevant notes
contextNotes = await contextService.findRelevantNotes(
@@ -495,25 +563,31 @@ export class AIServiceManager implements IAIServiceManager {
/**
* Get AI service for the given provider
*/
- getService(provider?: string): AIService {
+ async getService(provider?: string): Promise {
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()) {
+ // 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`);
}
- // 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]];
+ // 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.`);
}
/**
@@ -521,34 +595,37 @@ export class AIServiceManager implements IAIServiceManager {
*/
async getPreferredProviderAsync(): Promise {
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];
+ const selectedProvider = await getSelectedProvider();
+ if (selectedProvider === null) {
+ // No provider selected, fallback to default
+ log.info('No provider selected, using default provider');
+ return 'openai';
}
- return preferredProvider;
+ return selectedProvider;
} catch (error) {
log.error(`Error getting preferred provider: ${error}`);
- return this.providerOrder[0];
+ return 'openai';
}
}
/**
- * Get the preferred provider based on configuration (sync version for compatibility)
+ * Get the selected provider based on configuration (sync version for compatibility)
*/
- getPreferredProvider(): string {
+ getSelectedProvider(): string {
this.ensureInitialized();
- // Return the first available provider in the order
- for (const providerName of this.providerOrder) {
- if (this.services[providerName].isAvailable()) {
- return providerName;
+ // 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 the first provider as fallback
- return this.providerOrder[0];
+ // Return a default if nothing is selected (for backward compatibility)
+ return 'openai';
}
/**
@@ -580,6 +657,7 @@ export class AIServiceManager implements IAIServiceManager {
};
}
+
/**
* Error handler that properly types the error object
*/
@@ -589,6 +667,79 @@ export class AIServiceManager implements IAIServiceManager {
}
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
@@ -610,6 +761,9 @@ export default {
isAnyServiceAvailable(): boolean {
return getInstance().isAnyServiceAvailable();
},
+ async getOrCreateAnyService(): Promise {
+ return getInstance().getOrCreateAnyService();
+ },
getAvailableProviders() {
return getInstance().getAvailableProviders();
},
@@ -661,11 +815,11 @@ export default {
);
},
// New methods
- getService(provider?: string): AIService {
+ async getService(provider?: string): Promise {
return getInstance().getService(provider);
},
- getPreferredProvider(): string {
- return getInstance().getPreferredProvider();
+ getSelectedProvider(): string {
+ return getInstance().getSelectedProvider();
},
isProviderAvailable(provider: string): boolean {
return getInstance().isProviderAvailable(provider);
diff --git a/apps/server/src/services/llm/chat/rest_chat_service.ts b/apps/server/src/services/llm/chat/rest_chat_service.ts
index 1ad3d7a22..53ea457a1 100644
--- a/apps/server/src/services/llm/chat/rest_chat_service.ts
+++ b/apps/server/src/services/llm/chat/rest_chat_service.ts
@@ -5,7 +5,7 @@
import log from "../../log.js";
import type { Request, Response } from "express";
import type { Message, ChatCompletionOptions } from "../ai_interface.js";
-import { AIServiceManager } from "../ai_service_manager.js";
+import aiServiceManager from "../ai_service_manager.js";
import { ChatPipeline } from "../pipeline/chat_pipeline.js";
import type { ChatPipelineInput } from "../pipeline/interfaces.js";
import options from "../../options.js";
@@ -14,7 +14,7 @@ import type { LLMStreamMessage } from "../interfaces/chat_ws_messages.js";
import chatStorageService from '../chat_storage_service.js';
import {
isAIEnabled,
- getFirstValidModelConfig,
+ getSelectedModelConfig,
} from '../config/configuration_helpers.js';
/**
@@ -33,25 +33,6 @@ class RestChatService {
}
}
- /**
- * Check if AI services are available
- */
- safelyUseAIManager(): boolean {
- if (!this.isDatabaseInitialized()) {
- log.info("AI check failed: Database is not initialized");
- return false;
- }
-
- try {
- const aiManager = new AIServiceManager();
- const isAvailable = aiManager.isAnyServiceAvailable();
- log.info(`AI service availability check result: ${isAvailable}`);
- return isAvailable;
- } catch (error) {
- log.error(`Error accessing AI service manager: ${error}`);
- return false;
- }
- }
/**
* Handle a message sent to an LLM and get a response
@@ -93,10 +74,14 @@ class RestChatService {
return { error: "AI features are disabled. Please enable them in the settings." };
}
- if (!this.safelyUseAIManager()) {
- return { error: "AI services are currently unavailable. Please check your configuration." };
+ // Check database initialization first
+ if (!this.isDatabaseInitialized()) {
+ throw new Error("Database is not initialized");
}
+ // Get or create AI service - will throw meaningful error if not possible
+ await aiServiceManager.getOrCreateAnyService();
+
// Load or create chat directly from storage
let chat = await chatStorageService.getChat(chatNoteId);
@@ -252,14 +237,6 @@ class RestChatService {
// Send WebSocket message
wsService.sendMessageToAllClients(message);
-
- // Send SSE response for compatibility
- const responseData: any = { content: data, done };
- if (rawChunk?.toolExecution) {
- responseData.toolExecution = rawChunk.toolExecution;
- }
-
- res.write(`data: ${JSON.stringify(responseData)}\n\n`);
// When streaming is complete, save the accumulated content to the chat note
if (done) {
@@ -281,8 +258,8 @@ class RestChatService {
log.error(`Error saving streaming response: ${error}`);
}
- // End the response
- res.end();
+ // Note: For WebSocket-only streaming, we don't end the HTTP response here
+ // since it was already handled by the calling endpoint
}
}
@@ -419,7 +396,7 @@ class RestChatService {
*/
async getPreferredModel(): Promise {
try {
- const validConfig = await getFirstValidModelConfig();
+ const validConfig = await getSelectedModelConfig();
if (!validConfig) {
log.error('No valid AI model configuration found');
return undefined;
diff --git a/apps/server/src/services/llm/config/configuration_helpers.ts b/apps/server/src/services/llm/config/configuration_helpers.ts
index 88d2cf1da..2635cc35f 100644
--- a/apps/server/src/services/llm/config/configuration_helpers.ts
+++ b/apps/server/src/services/llm/config/configuration_helpers.ts
@@ -1,10 +1,9 @@
import configurationManager from './configuration_manager.js';
+import optionService from '../../options.js';
import type {
ProviderType,
ModelIdentifier,
ModelConfig,
- ProviderPrecedenceConfig,
- EmbeddingProviderPrecedenceConfig
} from '../interfaces/configuration_interfaces.js';
/**
@@ -13,41 +12,19 @@ import type {
*/
/**
- * Get the ordered list of AI providers
+ * Get the selected AI provider
*/
-export async function getProviderPrecedence(): Promise {
- const config = await configurationManager.getProviderPrecedence();
- return config.providers;
+export async function getSelectedProvider(): Promise {
+ const providerOption = optionService.getOption('aiSelectedProvider');
+ return providerOption as ProviderType || null;
}
/**
- * Get the default/preferred AI provider
+ * Get the selected embedding provider
*/
-export async function getPreferredProvider(): Promise {
- const config = await configurationManager.getProviderPrecedence();
- if (config.providers.length === 0) {
- return null; // No providers configured
- }
- return config.defaultProvider || config.providers[0];
-}
-
-/**
- * Get the ordered list of embedding providers
- */
-export async function getEmbeddingProviderPrecedence(): Promise {
- const config = await configurationManager.getEmbeddingProviderPrecedence();
- return config.providers;
-}
-
-/**
- * Get the default embedding provider
- */
-export async function getPreferredEmbeddingProvider(): Promise {
- const config = await configurationManager.getEmbeddingProviderPrecedence();
- if (config.providers.length === 0) {
- return null; // No providers configured
- }
- return config.defaultProvider || config.providers[0];
+export async function getSelectedEmbeddingProvider(): Promise {
+ const providerOption = optionService.getOption('embeddingSelectedProvider');
+ return providerOption || null;
}
/**
@@ -107,22 +84,20 @@ export async function isProviderConfigured(provider: ProviderType): Promise {
- const providers = await getProviderPrecedence();
-
- if (providers.length === 0) {
- return null; // No providers configured
+export async function getAvailableSelectedProvider(): Promise {
+ const selectedProvider = await getSelectedProvider();
+
+ if (!selectedProvider) {
+ return null; // No provider selected
}
- for (const provider of providers) {
- if (await isProviderConfigured(provider)) {
- return provider;
- }
+ if (await isProviderConfigured(selectedProvider)) {
+ return selectedProvider;
}
- return null; // No providers are properly configured
+ return null; // Selected provider is not properly configured
}
/**
@@ -163,17 +138,15 @@ export async function getValidModelConfig(provider: ProviderType): Promise<{ mod
}
/**
- * Get the first valid model configuration from the provider precedence list
+ * Get the model configuration for the currently selected provider
*/
-export async function getFirstValidModelConfig(): Promise<{ model: string; provider: ProviderType } | null> {
- const providers = await getProviderPrecedence();
-
- for (const provider of providers) {
- const config = await getValidModelConfig(provider);
- if (config) {
- return config;
- }
+export async function getSelectedModelConfig(): Promise<{ model: string; provider: ProviderType } | null> {
+ const selectedProvider = await getSelectedProvider();
+
+ if (!selectedProvider) {
+ return null; // No provider selected
}
- return null; // No valid model configuration found
+ return await getValidModelConfig(selectedProvider);
}
+
diff --git a/apps/server/src/services/llm/config/configuration_manager.ts b/apps/server/src/services/llm/config/configuration_manager.ts
index 5bc9611b8..1e082db41 100644
--- a/apps/server/src/services/llm/config/configuration_manager.ts
+++ b/apps/server/src/services/llm/config/configuration_manager.ts
@@ -50,8 +50,8 @@ export class ConfigurationManager {
try {
const config: AIConfig = {
enabled: await this.getAIEnabled(),
- providerPrecedence: await this.getProviderPrecedence(),
- embeddingProviderPrecedence: await this.getEmbeddingProviderPrecedence(),
+ selectedProvider: await this.getSelectedProvider(),
+ selectedEmbeddingProvider: await this.getSelectedEmbeddingProvider(),
defaultModels: await this.getDefaultModels(),
providerSettings: await this.getProviderSettings()
};
@@ -66,46 +66,28 @@ export class ConfigurationManager {
}
/**
- * Parse provider precedence from string option
+ * Get the selected AI provider
*/
- public async getProviderPrecedence(): Promise {
+ public async getSelectedProvider(): Promise {
try {
- const precedenceOption = await options.getOption('aiProviderPrecedence');
- const providers = this.parseProviderList(precedenceOption);
-
- return {
- providers: providers as ProviderType[],
- defaultProvider: providers.length > 0 ? providers[0] as ProviderType : undefined
- };
+ const selectedProvider = options.getOption('aiSelectedProvider');
+ return selectedProvider as ProviderType || null;
} catch (error) {
- log.error(`Error parsing provider precedence: ${error}`);
- // Only return known providers if they exist, don't assume defaults
- return {
- providers: [],
- defaultProvider: undefined
- };
+ log.error(`Error getting selected provider: ${error}`);
+ return null;
}
}
/**
- * Parse embedding provider precedence from string option
+ * Get the selected embedding provider
*/
- public async getEmbeddingProviderPrecedence(): Promise {
+ public async getSelectedEmbeddingProvider(): Promise {
try {
- const precedenceOption = await options.getOption('embeddingProviderPrecedence');
- const providers = this.parseProviderList(precedenceOption);
-
- return {
- providers: providers as EmbeddingProviderType[],
- defaultProvider: providers.length > 0 ? providers[0] as EmbeddingProviderType : undefined
- };
+ const selectedProvider = options.getOption('embeddingSelectedProvider');
+ return selectedProvider as EmbeddingProviderType || null;
} catch (error) {
- log.error(`Error parsing embedding provider precedence: ${error}`);
- // Don't assume defaults, return empty configuration
- return {
- providers: [],
- defaultProvider: undefined
- };
+ log.error(`Error getting selected embedding provider: ${error}`);
+ return null;
}
}
@@ -173,11 +155,9 @@ export class ConfigurationManager {
*/
public async getDefaultModels(): Promise> {
try {
- const [openaiModel, anthropicModel, ollamaModel] = await Promise.all([
- options.getOption('openaiDefaultModel'),
- options.getOption('anthropicDefaultModel'),
- options.getOption('ollamaDefaultModel')
- ]);
+ const openaiModel = options.getOption('openaiDefaultModel');
+ const anthropicModel = options.getOption('anthropicDefaultModel');
+ const ollamaModel = options.getOption('ollamaDefaultModel');
return {
openai: openaiModel || undefined,
@@ -200,20 +180,14 @@ export class ConfigurationManager {
*/
public async getProviderSettings(): Promise {
try {
- const [
- openaiApiKey, openaiBaseUrl, openaiDefaultModel,
- anthropicApiKey, anthropicBaseUrl, anthropicDefaultModel,
- ollamaBaseUrl, ollamaDefaultModel
- ] = await Promise.all([
- options.getOption('openaiApiKey'),
- options.getOption('openaiBaseUrl'),
- options.getOption('openaiDefaultModel'),
- options.getOption('anthropicApiKey'),
- options.getOption('anthropicBaseUrl'),
- options.getOption('anthropicDefaultModel'),
- options.getOption('ollamaBaseUrl'),
- options.getOption('ollamaDefaultModel')
- ]);
+ const openaiApiKey = options.getOption('openaiApiKey');
+ const openaiBaseUrl = options.getOption('openaiBaseUrl');
+ const openaiDefaultModel = options.getOption('openaiDefaultModel');
+ const anthropicApiKey = options.getOption('anthropicApiKey');
+ const anthropicBaseUrl = options.getOption('anthropicBaseUrl');
+ const anthropicDefaultModel = options.getOption('anthropicDefaultModel');
+ const ollamaBaseUrl = options.getOption('ollamaBaseUrl');
+ const ollamaDefaultModel = options.getOption('ollamaDefaultModel');
const settings: ProviderSettings = {};
@@ -265,31 +239,29 @@ export class ConfigurationManager {
return result;
}
- // Validate provider precedence
- if (config.providerPrecedence.providers.length === 0) {
- result.errors.push('No providers configured in precedence list');
+ // Validate selected provider
+ if (!config.selectedProvider) {
+ result.errors.push('No AI provider selected');
result.isValid = false;
- }
+ } else {
+ // Validate selected provider settings
+ const providerConfig = config.providerSettings[config.selectedProvider];
- // Validate provider settings
- for (const provider of config.providerPrecedence.providers) {
- const providerConfig = config.providerSettings[provider];
-
- if (provider === 'openai') {
+ if (config.selectedProvider === 'openai') {
const openaiConfig = providerConfig as OpenAISettings | undefined;
if (!openaiConfig?.apiKey) {
result.warnings.push('OpenAI API key is not configured');
}
}
- if (provider === 'anthropic') {
+ if (config.selectedProvider === 'anthropic') {
const anthropicConfig = providerConfig as AnthropicSettings | undefined;
if (!anthropicConfig?.apiKey) {
result.warnings.push('Anthropic API key is not configured');
}
}
- if (provider === 'ollama') {
+ if (config.selectedProvider === 'ollama') {
const ollamaConfig = providerConfig as OllamaSettings | undefined;
if (!ollamaConfig?.baseUrl) {
result.warnings.push('Ollama base URL is not configured');
@@ -297,6 +269,11 @@ export class ConfigurationManager {
}
}
+ // Validate selected embedding provider
+ if (!config.selectedEmbeddingProvider) {
+ result.warnings.push('No embedding provider selected');
+ }
+
} catch (error) {
result.errors.push(`Configuration validation error: ${error}`);
result.isValid = false;
@@ -317,7 +294,7 @@ export class ConfigurationManager {
private async getAIEnabled(): Promise {
try {
- return await options.getOptionBool('aiEnabled');
+ return options.getOptionBool('aiEnabled');
} catch {
return false;
}
@@ -356,14 +333,8 @@ export class ConfigurationManager {
private getDefaultConfig(): AIConfig {
return {
enabled: false,
- providerPrecedence: {
- providers: [],
- defaultProvider: undefined
- },
- embeddingProviderPrecedence: {
- providers: [],
- defaultProvider: undefined
- },
+ selectedProvider: null,
+ selectedEmbeddingProvider: null,
defaultModels: {
openai: undefined,
anthropic: undefined,
diff --git a/apps/server/src/services/llm/context/index.ts b/apps/server/src/services/llm/context/index.ts
index 258428705..c44eea9cb 100644
--- a/apps/server/src/services/llm/context/index.ts
+++ b/apps/server/src/services/llm/context/index.ts
@@ -33,7 +33,7 @@ async function getSemanticContext(
}
// Get an LLM service
- const llmService = aiServiceManager.getInstance().getService();
+ const llmService = await aiServiceManager.getInstance().getService();
const result = await contextService.processQuery("", llmService, {
maxResults: options.maxSimilarNotes || 5,
@@ -543,7 +543,7 @@ export class ContextExtractor {
try {
const { default: aiServiceManager } = await import('../ai_service_manager.js');
const contextService = aiServiceManager.getInstance().getContextService();
- const llmService = aiServiceManager.getInstance().getService();
+ const llmService = await aiServiceManager.getInstance().getService();
if (!contextService) {
return "Context service not available.";
diff --git a/apps/server/src/services/llm/context/modules/provider_manager.ts b/apps/server/src/services/llm/context/modules/provider_manager.ts
index 8030e3592..56c6437c0 100644
--- a/apps/server/src/services/llm/context/modules/provider_manager.ts
+++ b/apps/server/src/services/llm/context/modules/provider_manager.ts
@@ -1,51 +1,32 @@
-import options from '../../../options.js';
import log from '../../../log.js';
import { getEmbeddingProvider, getEnabledEmbeddingProviders } from '../../providers/providers.js';
+import { getSelectedEmbeddingProvider as getSelectedEmbeddingProviderName } from '../../config/configuration_helpers.js';
/**
* Manages embedding providers for context services
*/
export class ProviderManager {
/**
- * Get the preferred embedding provider based on user settings
- * Tries to use the most appropriate provider in this order:
- * 1. User's configured default provider
- * 2. OpenAI if API key is set
- * 3. Anthropic if API key is set
- * 4. Ollama if configured
- * 5. Any available provider
- * 6. Local provider as fallback
+ * Get the selected embedding provider based on user settings
+ * Uses the single provider selection approach
*
- * @returns The preferred embedding provider or null if none available
+ * @returns The selected embedding provider or null if none available
*/
- async getPreferredEmbeddingProvider(): Promise {
+ async getSelectedEmbeddingProvider(): Promise {
try {
- // Try to get providers based on precedence list
- const precedenceOption = await options.getOption('embeddingProviderPrecedence');
- let precedenceList: string[] = [];
-
- 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];
- }
- }
- }
-
- // Try each provider in the precedence list
- for (const providerId of precedenceList) {
- const provider = await getEmbeddingProvider(providerId);
+ // Get the selected embedding provider
+ const selectedProvider = await getSelectedEmbeddingProviderName();
+
+ if (selectedProvider) {
+ const provider = await getEmbeddingProvider(selectedProvider);
if (provider) {
- log.info(`Using embedding provider from precedence list: ${providerId}`);
+ log.info(`Using selected embedding provider: ${selectedProvider}`);
return provider;
}
+ log.info(`Selected embedding provider ${selectedProvider} is not available`);
}
- // If no provider from precedence list is available, try any enabled provider
+ // If no provider is selected or available, try any enabled provider
const providers = await getEnabledEmbeddingProviders();
if (providers.length > 0) {
log.info(`Using available embedding provider: ${providers[0].name}`);
@@ -70,7 +51,7 @@ export class ProviderManager {
async generateQueryEmbedding(query: string): Promise {
try {
// Get the preferred embedding provider
- const provider = await this.getPreferredEmbeddingProvider();
+ const provider = await this.getSelectedEmbeddingProvider();
if (!provider) {
log.error('No embedding provider available');
return null;
diff --git a/apps/server/src/services/llm/context/services/context_service.ts b/apps/server/src/services/llm/context/services/context_service.ts
index a227c3936..b4d4fd613 100644
--- a/apps/server/src/services/llm/context/services/context_service.ts
+++ b/apps/server/src/services/llm/context/services/context_service.ts
@@ -58,7 +58,7 @@ export class ContextService {
this.initPromise = (async () => {
try {
// Initialize provider
- const provider = await providerManager.getPreferredEmbeddingProvider();
+ const provider = await providerManager.getSelectedEmbeddingProvider();
if (!provider) {
throw new Error(`No embedding provider available. Could not initialize context service.`);
}
@@ -224,7 +224,7 @@ export class ContextService {
log.info(`Final combined results: ${relevantNotes.length} relevant notes`);
// Step 4: Build context from the notes
- const provider = await providerManager.getPreferredEmbeddingProvider();
+ const provider = await providerManager.getSelectedEmbeddingProvider();
const providerId = provider?.name || 'default';
const context = await contextFormatter.buildContextFromNotes(
diff --git a/apps/server/src/services/llm/context/services/vector_search_service.ts b/apps/server/src/services/llm/context/services/vector_search_service.ts
index 480ba05bd..98c7b993c 100644
--- a/apps/server/src/services/llm/context/services/vector_search_service.ts
+++ b/apps/server/src/services/llm/context/services/vector_search_service.ts
@@ -79,7 +79,7 @@ export class VectorSearchService {
}
// Get provider information
- const provider = await providerManager.getPreferredEmbeddingProvider();
+ const provider = await providerManager.getSelectedEmbeddingProvider();
if (!provider) {
log.error('No embedding provider available');
return [];
@@ -280,7 +280,7 @@ export class VectorSearchService {
}
// Get provider information
- const provider = await providerManager.getPreferredEmbeddingProvider();
+ const provider = await providerManager.getSelectedEmbeddingProvider();
if (!provider) {
log.error('No embedding provider available');
return [];
diff --git a/apps/server/src/services/llm/embeddings/events.ts b/apps/server/src/services/llm/embeddings/events.ts
index a078b2c32..2b8eac7d9 100644
--- a/apps/server/src/services/llm/embeddings/events.ts
+++ b/apps/server/src/services/llm/embeddings/events.ts
@@ -9,6 +9,9 @@ import becca from "../../../becca/becca.js";
// Add mutex to prevent concurrent processing
let isProcessingEmbeddings = false;
+// Store interval reference for cleanup
+let backgroundProcessingInterval: NodeJS.Timeout | null = null;
+
/**
* Setup event listeners for embedding-related events
*/
@@ -53,9 +56,15 @@ export function setupEmbeddingEventListeners() {
* Setup background processing of the embedding queue
*/
export async function setupEmbeddingBackgroundProcessing() {
+ // Clear any existing interval
+ if (backgroundProcessingInterval) {
+ clearInterval(backgroundProcessingInterval);
+ backgroundProcessingInterval = null;
+ }
+
const interval = parseInt(await options.getOption('embeddingUpdateInterval') || '200', 10);
- setInterval(async () => {
+ backgroundProcessingInterval = setInterval(async () => {
try {
// Skip if already processing
if (isProcessingEmbeddings) {
@@ -78,6 +87,17 @@ export async function setupEmbeddingBackgroundProcessing() {
}, interval);
}
+/**
+ * Stop background processing of the embedding queue
+ */
+export function stopEmbeddingBackgroundProcessing() {
+ if (backgroundProcessingInterval) {
+ clearInterval(backgroundProcessingInterval);
+ backgroundProcessingInterval = null;
+ log.info("Embedding background processing stopped");
+ }
+}
+
/**
* Initialize embeddings system
*/
diff --git a/apps/server/src/services/llm/embeddings/index.ts b/apps/server/src/services/llm/embeddings/index.ts
index 89d0f711e..c4be44a2e 100644
--- a/apps/server/src/services/llm/embeddings/index.ts
+++ b/apps/server/src/services/llm/embeddings/index.ts
@@ -58,12 +58,12 @@ export const processNoteWithChunking = async (
export const {
setupEmbeddingEventListeners,
setupEmbeddingBackgroundProcessing,
+ stopEmbeddingBackgroundProcessing,
initEmbeddings
} = events;
export const {
getEmbeddingStats,
- reprocessAllNotes,
cleanupEmbeddings
} = stats;
@@ -100,11 +100,11 @@ export default {
// Event handling
setupEmbeddingEventListeners: events.setupEmbeddingEventListeners,
setupEmbeddingBackgroundProcessing: events.setupEmbeddingBackgroundProcessing,
+ stopEmbeddingBackgroundProcessing: events.stopEmbeddingBackgroundProcessing,
initEmbeddings: events.initEmbeddings,
// Stats and maintenance
getEmbeddingStats: stats.getEmbeddingStats,
- reprocessAllNotes: stats.reprocessAllNotes,
cleanupEmbeddings: stats.cleanupEmbeddings,
// Index operations
diff --git a/apps/server/src/services/llm/embeddings/init.ts b/apps/server/src/services/llm/embeddings/init.ts
index 50724c05e..75d664e43 100644
--- a/apps/server/src/services/llm/embeddings/init.ts
+++ b/apps/server/src/services/llm/embeddings/init.ts
@@ -4,6 +4,7 @@ import { initEmbeddings } from "./index.js";
import providerManager from "../providers/providers.js";
import sqlInit from "../../sql_init.js";
import sql from "../../sql.js";
+import { validateProviders, logValidationResults, hasWorkingEmbeddingProviders } from "../provider_validation.js";
/**
* Reset any stuck embedding queue items that were left in processing state
@@ -43,13 +44,20 @@ export async function initializeEmbeddings() {
// Reset any stuck embedding queue items from previous server shutdown
await resetStuckEmbeddingQueue();
- // Initialize default embedding providers
- await providerManager.initializeDefaultProviders();
-
// Start the embedding system if AI is enabled
if (await options.getOptionBool('aiEnabled')) {
- await initEmbeddings();
- log.info("Embedding system initialized successfully.");
+ // Validate providers before starting the embedding system
+ log.info("Validating AI providers before starting embedding system...");
+ const validation = await validateProviders();
+ logValidationResults(validation);
+
+ if (await hasWorkingEmbeddingProviders()) {
+ // Embedding providers will be created on-demand when needed
+ await initEmbeddings();
+ log.info("Embedding system initialized successfully.");
+ } else {
+ log.info("Embedding system not started: No working embedding providers found. Please configure at least one AI provider (OpenAI, Ollama, or Voyage) to use embedding features.");
+ }
} else {
log.info("Embedding system disabled (AI features are turned off).");
}
diff --git a/apps/server/src/services/llm/embeddings/queue.ts b/apps/server/src/services/llm/embeddings/queue.ts
index 12f915c81..b002513c5 100644
--- a/apps/server/src/services/llm/embeddings/queue.ts
+++ b/apps/server/src/services/llm/embeddings/queue.ts
@@ -282,8 +282,6 @@ export async function processEmbeddingQueue() {
continue;
}
- // Log that we're starting to process this note
- log.info(`Starting embedding generation for note ${noteId}`);
// Get note context for embedding
const context = await getNoteEmbeddingContext(noteId);
@@ -334,7 +332,6 @@ export async function processEmbeddingQueue() {
"DELETE FROM embedding_queue WHERE noteId = ?",
[noteId]
);
- log.info(`Successfully completed embedding processing for note ${noteId}`);
// Count as successfully processed
processedCount++;
diff --git a/apps/server/src/services/llm/embeddings/stats.ts b/apps/server/src/services/llm/embeddings/stats.ts
index 6154da368..a8b594723 100644
--- a/apps/server/src/services/llm/embeddings/stats.ts
+++ b/apps/server/src/services/llm/embeddings/stats.ts
@@ -1,29 +1,5 @@
import sql from "../../../services/sql.js";
import log from "../../../services/log.js";
-import cls from "../../../services/cls.js";
-import { queueNoteForEmbedding } from "./queue.js";
-
-/**
- * Reprocess all notes to update embeddings
- */
-export async function reprocessAllNotes() {
- log.info("Queueing all notes for embedding updates");
-
- // Get all non-deleted note IDs
- const noteIds = await sql.getColumn(
- "SELECT noteId FROM notes WHERE isDeleted = 0"
- );
-
- log.info(`Adding ${noteIds.length} notes to embedding queue`);
-
- // Process each note ID within a cls context
- for (const noteId of noteIds) {
- // Use cls.init to ensure proper context for each operation
- await cls.init(async () => {
- await queueNoteForEmbedding(noteId as string, 'UPDATE');
- });
- }
-}
/**
* Get current embedding statistics
diff --git a/apps/server/src/services/llm/embeddings/storage.ts b/apps/server/src/services/llm/embeddings/storage.ts
index ac096071f..bbd1eba9c 100644
--- a/apps/server/src/services/llm/embeddings/storage.ts
+++ b/apps/server/src/services/llm/embeddings/storage.ts
@@ -11,7 +11,7 @@ import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
import type { NoteEmbeddingContext } from "./embeddings_interface.js";
import becca from "../../../becca/becca.js";
import { isNoteExcludedFromAIById } from "../utils/ai_exclusion_utils.js";
-import { getEmbeddingProviderPrecedence } from '../config/configuration_helpers.js';
+import { getSelectedEmbeddingProvider } from '../config/configuration_helpers.js';
interface Similarity {
noteId: string;
@@ -277,9 +277,10 @@ export async function findSimilarNotes(
log.info('No embeddings found for specified provider, trying fallback providers...');
// Use the new configuration system - no string parsing!
- const preferredProviders = await getEmbeddingProviderPrecedence();
+ const selectedProvider = await getSelectedEmbeddingProvider();
+ const preferredProviders = selectedProvider ? [selectedProvider] : [];
- log.info(`Using provider precedence: ${preferredProviders.join(', ')}`);
+ log.info(`Using selected provider: ${selectedProvider || 'none'}`);
// Try providers in precedence order
for (const provider of preferredProviders) {
diff --git a/apps/server/src/services/llm/index_service.ts b/apps/server/src/services/llm/index_service.ts
index f1182495d..6887fbc36 100644
--- a/apps/server/src/services/llm/index_service.ts
+++ b/apps/server/src/services/llm/index_service.ts
@@ -12,6 +12,7 @@
import log from "../log.js";
import options from "../options.js";
import becca from "../../becca/becca.js";
+import beccaLoader from "../../becca/becca_loader.js";
import vectorStore from "./embeddings/index.js";
import providerManager from "./providers/providers.js";
import { ContextExtractor } from "./context/index.js";
@@ -21,6 +22,7 @@ import sqlInit from "../sql_init.js";
import { CONTEXT_PROMPTS } from './constants/llm_prompt_constants.js';
import { SEARCH_CONSTANTS } from './constants/search_constants.js';
import { isNoteExcludedFromAI } from "./utils/ai_exclusion_utils.js";
+import { hasWorkingEmbeddingProviders } from "./provider_validation.js";
export class IndexService {
private initialized = false;
@@ -46,47 +48,16 @@ export class IndexService {
async initialize() {
if (this.initialized) return;
- try {
- // Check if database is initialized before proceeding
- if (!sqlInit.isDbInitialized()) {
- log.info("Index service: Database not initialized yet, skipping initialization");
- return;
- }
+ // Setup event listeners for note changes
+ this.setupEventListeners();
- const aiEnabled = options.getOptionOrNull('aiEnabled') === "true";
- if (!aiEnabled) {
- log.info("Index service: AI features disabled, skipping initialization");
- return;
- }
-
- // Check if embedding system is ready
- const providers = await providerManager.getEnabledEmbeddingProviders();
- if (!providers || providers.length === 0) {
- throw new Error("No embedding providers available");
- }
-
- // Check if this instance should process embeddings
- const embeddingLocation = await options.getOption('embeddingGenerationLocation') || 'client';
- const isSyncServer = await this.isSyncServerForEmbeddings();
- const shouldProcessEmbeddings = embeddingLocation === 'client' || isSyncServer;
-
- // Setup automatic indexing if enabled and this instance should process embeddings
- if (await options.getOptionBool('embeddingAutoUpdateEnabled') && shouldProcessEmbeddings) {
- this.setupAutomaticIndexing();
- log.info(`Index service: Automatic indexing enabled, processing embeddings ${isSyncServer ? 'as sync server' : 'as client'}`);
- } else if (await options.getOptionBool('embeddingAutoUpdateEnabled')) {
- log.info("Index service: Automatic indexing enabled, but this instance is not configured to process embeddings");
- }
-
- // Listen for note changes to update index
- this.setupEventListeners();
-
- this.initialized = true;
- log.info("Index service initialized successfully");
- } catch (error: any) {
- log.error(`Error initializing index service: ${error.message || "Unknown error"}`);
- throw error;
+ // Setup automatic indexing if enabled
+ if (await options.getOptionBool('embeddingAutoUpdateEnabled')) {
+ this.setupAutomaticIndexing();
}
+
+ this.initialized = true;
+ log.info("Index service initialized");
}
/**
@@ -139,23 +110,7 @@ export class IndexService {
this.automaticIndexingInterval = setInterval(async () => {
try {
if (!this.indexingInProgress) {
- // Check if this instance should process embeddings
- const embeddingLocation = await options.getOption('embeddingGenerationLocation') || 'client';
- const isSyncServer = await this.isSyncServerForEmbeddings();
- const shouldProcessEmbeddings = embeddingLocation === 'client' || isSyncServer;
-
- if (!shouldProcessEmbeddings) {
- // This instance is not configured to process embeddings
- return;
- }
-
- const stats = await vectorStore.getEmbeddingStats();
-
- // Only run automatic indexing if we're below 95% completion
- if (stats.percentComplete < 95) {
- log.info(`Starting automatic indexing (current completion: ${stats.percentComplete}%)`);
- await this.runBatchIndexing(50); // Process 50 notes at a time
- }
+ await this.runBatchIndexing(50); // Processing logic handles sync server checks
}
} catch (error: any) {
log.error(`Error in automatic indexing: ${error.message || "Unknown error"}`);
@@ -265,7 +220,7 @@ export class IndexService {
this.indexRebuildTotal = totalNotes;
log.info("No embeddings found, starting full embedding generation first");
- await vectorStore.reprocessAllNotes();
+ await this.reprocessAllNotes();
log.info("Full embedding generation initiated");
} else {
// For index rebuild, use the number of embeddings as the total
@@ -292,7 +247,7 @@ export class IndexService {
// Only start indexing if we're below 90% completion or if embeddings exist but need optimization
if (stats.percentComplete < 90) {
log.info("Embedding coverage below 90%, starting full embedding generation");
- await vectorStore.reprocessAllNotes();
+ await this.reprocessAllNotes();
log.info("Full embedding generation initiated");
} else {
log.info(`Embedding coverage at ${stats.percentComplete}%, starting index optimization`);
@@ -378,11 +333,10 @@ export class IndexService {
if (!shouldProcessEmbeddings) {
// This instance is not configured to process embeddings
- log.info("Skipping batch indexing as this instance is not configured to process embeddings");
return false;
}
- // Process the embedding queue
+ // Process the embedding queue (batch size is controlled by embeddingBatchSize option)
await vectorStore.processEmbeddingQueue();
return true;
@@ -491,51 +445,14 @@ export class IndexService {
}
try {
- // Get all enabled embedding providers
- const providers = await providerManager.getEnabledEmbeddingProviders();
- if (!providers || providers.length === 0) {
- throw new Error("No embedding providers available");
- }
-
- // Get the embedding provider precedence
- const options = (await import('../options.js')).default;
- let preferredProviders: string[] = [];
-
- const embeddingPrecedence = await options.getOption('embeddingProviderPrecedence');
- let provider;
-
- if (embeddingPrecedence) {
- // Parse the precedence string
- if (embeddingPrecedence.startsWith('[') && embeddingPrecedence.endsWith(']')) {
- preferredProviders = JSON.parse(embeddingPrecedence);
- } else if (typeof embeddingPrecedence === 'string') {
- if (embeddingPrecedence.includes(',')) {
- preferredProviders = embeddingPrecedence.split(',').map(p => p.trim());
- } else {
- preferredProviders = [embeddingPrecedence];
- }
- }
-
- // Find first enabled provider by precedence order
- for (const providerName of preferredProviders) {
- const matchedProvider = providers.find(p => p.name === providerName);
- if (matchedProvider) {
- provider = matchedProvider;
- break;
- }
- }
-
- // If no match found, use first available
- if (!provider && providers.length > 0) {
- provider = providers[0];
- }
- } else {
- // Default to first available provider
- provider = providers[0];
- }
+ // Get the selected embedding provider on-demand
+ const selectedEmbeddingProvider = await options.getOption('embeddingSelectedProvider');
+ const provider = selectedEmbeddingProvider
+ ? await providerManager.getOrCreateEmbeddingProvider(selectedEmbeddingProvider)
+ : (await providerManager.getEnabledEmbeddingProviders())[0];
if (!provider) {
- throw new Error("No suitable embedding provider found");
+ throw new Error("No embedding provider available");
}
log.info(`Searching with embedding provider: ${provider.name}, model: ${provider.getConfig().model}`);
@@ -693,6 +610,12 @@ export class IndexService {
}
try {
+ // Get embedding providers on-demand
+ const providers = await providerManager.getEnabledEmbeddingProviders();
+ if (providers.length === 0) {
+ return "I don't have access to your note embeddings. Please configure an embedding provider in your AI settings.";
+ }
+
// Find similar notes to the query
const similarNotes = await this.findSimilarNotes(
query,
@@ -828,9 +751,13 @@ export class IndexService {
// Get complete note context for indexing
const context = await vectorStore.getNoteEmbeddingContext(noteId);
- // Queue note for embedding with all available providers
- const providers = await providerManager.getEnabledEmbeddingProviders();
- for (const provider of providers) {
+ // Generate embedding with the selected provider
+ const selectedEmbeddingProvider = await options.getOption('embeddingSelectedProvider');
+ const provider = selectedEmbeddingProvider
+ ? await providerManager.getOrCreateEmbeddingProvider(selectedEmbeddingProvider)
+ : (await providerManager.getEnabledEmbeddingProviders())[0];
+
+ if (provider) {
try {
const embedding = await provider.generateNoteEmbeddings(context);
if (embedding) {
@@ -853,6 +780,189 @@ export class IndexService {
return false;
}
}
+
+ /**
+ * Start embedding generation (called when AI is enabled)
+ */
+ async startEmbeddingGeneration() {
+ try {
+ log.info("Starting embedding generation system");
+
+ const aiEnabled = options.getOptionOrNull('aiEnabled') === "true";
+ if (!aiEnabled) {
+ log.error("Cannot start embedding generation - AI features are disabled");
+ throw new Error("AI features must be enabled first");
+ }
+
+ // Re-initialize if needed
+ if (!this.initialized) {
+ await this.initialize();
+ }
+
+ // Check if this instance should process embeddings
+ const embeddingLocation = await options.getOption('embeddingGenerationLocation') || 'client';
+ const isSyncServer = await this.isSyncServerForEmbeddings();
+ const shouldProcessEmbeddings = embeddingLocation === 'client' || isSyncServer;
+
+ if (!shouldProcessEmbeddings) {
+ log.info("This instance is not configured to process embeddings");
+ return;
+ }
+
+ // Get embedding providers (will be created on-demand when needed)
+ const providers = await providerManager.getEnabledEmbeddingProviders();
+ if (providers.length === 0) {
+ log.info("No embedding providers configured, but continuing initialization");
+ } else {
+ log.info(`Found ${providers.length} embedding providers: ${providers.map(p => p.name).join(', ')}`);
+ }
+
+ // Setup automatic indexing if enabled
+ if (await options.getOptionBool('embeddingAutoUpdateEnabled')) {
+ this.setupAutomaticIndexing();
+ log.info(`Automatic embedding indexing started ${isSyncServer ? 'as sync server' : 'as client'}`);
+ }
+
+ // Start background processing of the embedding queue
+ const { setupEmbeddingBackgroundProcessing } = await import('./embeddings/events.js');
+ await setupEmbeddingBackgroundProcessing();
+
+ // Re-initialize event listeners
+ this.setupEventListeners();
+
+ // Queue notes that don't have embeddings for current providers
+ await this.queueNotesForMissingEmbeddings();
+
+ // Start processing the queue immediately
+ await this.runBatchIndexing(20);
+
+ log.info("Embedding generation started successfully");
+ } catch (error: any) {
+ log.error(`Error starting embedding generation: ${error.message || "Unknown error"}`);
+ throw error;
+ }
+ }
+
+
+
+ /**
+ * Queue notes that don't have embeddings for current provider settings
+ */
+ async queueNotesForMissingEmbeddings() {
+ try {
+ // Wait for becca to be fully loaded before accessing notes
+ await beccaLoader.beccaLoaded;
+
+ // Get all non-deleted notes
+ const allNotes = Object.values(becca.notes).filter(note => !note.isDeleted);
+
+ // Get enabled providers
+ const providers = await providerManager.getEnabledEmbeddingProviders();
+ if (providers.length === 0) {
+ return;
+ }
+
+ let queuedCount = 0;
+ let excludedCount = 0;
+
+ // Process notes in batches to avoid overwhelming the system
+ const batchSize = 100;
+ for (let i = 0; i < allNotes.length; i += batchSize) {
+ const batch = allNotes.slice(i, i + batchSize);
+
+ for (const note of batch) {
+ try {
+ // Skip notes excluded from AI
+ if (isNoteExcludedFromAI(note)) {
+ excludedCount++;
+ continue;
+ }
+
+ // Check if note needs embeddings for any enabled provider
+ let needsEmbedding = false;
+
+ for (const provider of providers) {
+ const config = provider.getConfig();
+ const existingEmbedding = await vectorStore.getEmbeddingForNote(
+ note.noteId,
+ provider.name,
+ config.model
+ );
+
+ if (!existingEmbedding) {
+ needsEmbedding = true;
+ break;
+ }
+ }
+
+ if (needsEmbedding) {
+ await vectorStore.queueNoteForEmbedding(note.noteId, 'UPDATE');
+ queuedCount++;
+ }
+ } catch (error: any) {
+ log.error(`Error checking embeddings for note ${note.noteId}: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ }
+ } catch (error: any) {
+ log.error(`Error queuing notes for missing embeddings: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Reprocess all notes to update embeddings
+ */
+ async reprocessAllNotes() {
+ if (!this.initialized) {
+ await this.initialize();
+ }
+
+ try {
+ // Get all non-deleted note IDs
+ const noteIds = await sql.getColumn("SELECT noteId FROM notes WHERE isDeleted = 0");
+
+ // Process each note ID
+ for (const noteId of noteIds) {
+ await vectorStore.queueNoteForEmbedding(noteId as string, 'UPDATE');
+ }
+ } catch (error: any) {
+ log.error(`Error reprocessing all notes: ${error.message || 'Unknown error'}`);
+ throw error;
+ }
+ }
+
+ /**
+ * Stop embedding generation (called when AI is disabled)
+ */
+ async stopEmbeddingGeneration() {
+ try {
+ log.info("Stopping embedding generation system");
+
+ // Clear automatic indexing interval
+ if (this.automaticIndexingInterval) {
+ clearInterval(this.automaticIndexingInterval);
+ this.automaticIndexingInterval = undefined;
+ log.info("Automatic indexing stopped");
+ }
+
+ // Stop the background processing from embeddings/events.ts
+ const { stopEmbeddingBackgroundProcessing } = await import('./embeddings/events.js');
+ stopEmbeddingBackgroundProcessing();
+
+ // Clear all embedding providers to clean up resources
+ providerManager.clearAllEmbeddingProviders();
+
+ // Mark as not indexing
+ this.indexingInProgress = false;
+ this.indexRebuildInProgress = false;
+
+ log.info("Embedding generation stopped successfully");
+ } catch (error: any) {
+ log.error(`Error stopping embedding generation: ${error.message || "Unknown error"}`);
+ throw error;
+ }
+ }
}
// Create singleton instance
diff --git a/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts b/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts
index 3126691a4..52736cbd5 100644
--- a/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts
+++ b/apps/server/src/services/llm/interfaces/ai_service_interfaces.ts
@@ -28,9 +28,9 @@ export interface AIServiceManagerConfig {
* Interface for managing AI service providers
*/
export interface IAIServiceManager {
- getService(provider?: string): AIService;
+ getService(provider?: string): Promise;
getAvailableProviders(): string[];
- getPreferredProvider(): string;
+ getSelectedProvider(): string;
isProviderAvailable(provider: string): boolean;
getProviderMetadata(provider: string): ProviderMetadata | null;
getAIEnabled(): boolean;
diff --git a/apps/server/src/services/llm/interfaces/configuration_interfaces.ts b/apps/server/src/services/llm/interfaces/configuration_interfaces.ts
index 5a03dc4f1..6adcac977 100644
--- a/apps/server/src/services/llm/interfaces/configuration_interfaces.ts
+++ b/apps/server/src/services/llm/interfaces/configuration_interfaces.ts
@@ -46,8 +46,8 @@ export interface ModelCapabilities {
*/
export interface AIConfig {
enabled: boolean;
- providerPrecedence: ProviderPrecedenceConfig;
- embeddingProviderPrecedence: EmbeddingProviderPrecedenceConfig;
+ selectedProvider: ProviderType | null;
+ selectedEmbeddingProvider: EmbeddingProviderType | null;
defaultModels: Record;
providerSettings: ProviderSettings;
}
@@ -87,7 +87,7 @@ export type ProviderType = 'openai' | 'anthropic' | 'ollama';
/**
* Valid embedding provider types
*/
-export type EmbeddingProviderType = 'openai' | 'ollama' | 'local';
+export type EmbeddingProviderType = 'openai' | 'voyage' | 'ollama' | 'local';
/**
* Model identifier with provider prefix (e.g., "openai:gpt-4" or "ollama:llama2")
diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline.ts b/apps/server/src/services/llm/pipeline/chat_pipeline.ts
index 947a562e6..50671a809 100644
--- a/apps/server/src/services/llm/pipeline/chat_pipeline.ts
+++ b/apps/server/src/services/llm/pipeline/chat_pipeline.ts
@@ -298,6 +298,9 @@ export class ChatPipeline {
this.updateStageMetrics('llmCompletion', llmStartTime);
log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
+ // Track whether content has been streamed to prevent duplication
+ let hasStreamedContent = false;
+
// Handle streaming if enabled and available
// Use shouldEnableStream variable which contains our streaming decision
if (shouldEnableStream && completion.response.stream && streamCallback) {
@@ -311,6 +314,9 @@ export class ChatPipeline {
// Forward to callback with original chunk data in case it contains additional information
streamCallback(processedChunk.text, processedChunk.done, chunk);
+
+ // Mark that we have streamed content to prevent duplication
+ hasStreamedContent = true;
});
}
@@ -767,11 +773,15 @@ export class ChatPipeline {
const responseText = currentResponse.text || "";
log.info(`Resuming streaming with final response: ${responseText.length} chars`);
- if (responseText.length > 0) {
- // Resume streaming with the final response text
+ if (responseText.length > 0 && !hasStreamedContent) {
+ // Resume streaming with the final response text only if we haven't already streamed content
// This is where we send the definitive done:true signal with the complete content
streamCallback(responseText, true);
log.info(`Sent final response with done=true signal and text content`);
+ } else if (hasStreamedContent) {
+ log.info(`Content already streamed, sending done=true signal only after tool execution`);
+ // Just send the done signal without duplicating content
+ streamCallback('', true);
} else {
// For Anthropic, sometimes text is empty but response is in stream
if ((currentResponse.provider === 'Anthropic' || currentResponse.provider === 'OpenAI') && currentResponse.stream) {
@@ -803,13 +813,17 @@ export class ChatPipeline {
log.info(`LLM response did not contain any tool calls, skipping tool execution`);
// Handle streaming for responses without tool calls
- if (shouldEnableStream && streamCallback) {
+ if (shouldEnableStream && streamCallback && !hasStreamedContent) {
log.info(`Sending final streaming response without tool calls: ${currentResponse.text.length} chars`);
// Send the final response with done=true to complete the streaming
streamCallback(currentResponse.text, true);
log.info(`Sent final non-tool response with done=true signal`);
+ } else if (shouldEnableStream && streamCallback && hasStreamedContent) {
+ log.info(`Content already streamed, sending done=true signal only`);
+ // Just send the done signal without duplicating content
+ streamCallback('', true);
}
}
diff --git a/apps/server/src/services/llm/pipeline/stages/context_extraction_stage.ts b/apps/server/src/services/llm/pipeline/stages/context_extraction_stage.ts
index 95d7620e2..b1eaa69f9 100644
--- a/apps/server/src/services/llm/pipeline/stages/context_extraction_stage.ts
+++ b/apps/server/src/services/llm/pipeline/stages/context_extraction_stage.ts
@@ -43,7 +43,7 @@ export class ContextExtractionStage {
// Get enhanced context from the context service
const contextService = aiServiceManager.getContextService();
- const llmService = aiServiceManager.getService();
+ const llmService = await aiServiceManager.getService();
if (contextService) {
// Use unified context service to get smart context
diff --git a/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts b/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts
index 7dd6984c8..6354e4c59 100644
--- a/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts
+++ b/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts
@@ -104,7 +104,7 @@ export class LLMCompletionStage extends BasePipelineStage SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.HIGH_THRESHOLD ? 'high' : 'medium';
}
- // Set the model and add provider metadata
- updatedOptions.model = modelName;
- this.addProviderMetadata(updatedOptions, preferredProvider as ServiceProviders, modelName);
+ // Add provider metadata (model is already set above)
+ this.addProviderMetadata(updatedOptions, selectedProvider as ServiceProviders, updatedOptions.model);
- log.info(`Selected model: ${modelName} from provider: ${preferredProvider} for query complexity: ${queryComplexity}`);
+ log.info(`Selected model: ${updatedOptions.model} from provider: ${selectedProvider} for query complexity: ${queryComplexity}`);
log.info(`[ModelSelectionStage] Final options: ${JSON.stringify({
model: updatedOptions.model,
stream: updatedOptions.stream,
- provider: preferredProvider,
+ provider: selectedProvider,
enableTools: updatedOptions.enableTools
})}`);
@@ -210,39 +219,41 @@ export class ModelSelectionStage extends BasePipelineStage {
try {
- // Use the new configuration system
- const providers = await getProviderPrecedence();
+ // Use the same logic as the main process method
+ const { getValidModelConfig, getSelectedProvider } = await import('../../config/configuration_helpers.js');
+ const selectedProvider = await getSelectedProvider();
- // Use only providers that are available
- const availableProviders = providers.filter(provider =>
- aiServiceManager.isProviderAvailable(provider));
-
- if (availableProviders.length === 0) {
- throw new Error('No AI providers are available');
+ if (!selectedProvider) {
+ throw new Error('No AI provider is selected. Please select a provider in your AI settings.');
}
- // Get the first available provider and its default model
- const defaultProvider = availableProviders[0];
- const defaultModel = await getDefaultModelForProvider(defaultProvider);
+ // Check if the provider is available through the service manager
+ if (!aiServiceManager.isProviderAvailable(selectedProvider)) {
+ throw new Error(`Selected provider ${selectedProvider} is not available`);
+ }
- if (!defaultModel) {
- throw new Error(`No default model configured for provider ${defaultProvider}. Please configure a default model in your AI settings.`);
+ // Try to get a valid model config
+ const modelConfig = await getValidModelConfig(selectedProvider);
+
+ if (!modelConfig) {
+ throw new Error(`No default model configured for provider ${selectedProvider}. Please configure a default model in your AI settings.`);
}
// Set provider metadata
if (!input.options.providerMetadata) {
input.options.providerMetadata = {
- provider: defaultProvider as 'openai' | 'anthropic' | 'ollama' | 'local',
- modelId: defaultModel
+ provider: selectedProvider as 'openai' | 'anthropic' | 'ollama' | 'local',
+ modelId: modelConfig.model
};
}
- log.info(`Selected default model ${defaultModel} from provider ${defaultProvider}`);
- return defaultModel;
+ log.info(`Selected default model ${modelConfig.model} from provider ${selectedProvider}`);
+ return modelConfig.model;
} catch (error) {
log.error(`Error determining default model: ${error}`);
throw error; // Don't provide fallback defaults, let the error propagate
@@ -271,4 +282,49 @@ export class ModelSelectionStage extends BasePipelineStage {
+ try {
+ log.info(`Getting default model for provider ${provider} using AI service manager`);
+
+ // Use the existing AI service manager instead of duplicating API calls
+ const service = await aiServiceManager.getInstance().getService(provider);
+
+ if (!service || !service.isAvailable()) {
+ log.info(`Provider ${provider} service is not available`);
+ return null;
+ }
+
+ // Check if the service has a method to get available models
+ if (typeof (service as any).getAvailableModels === 'function') {
+ try {
+ const models = await (service as any).getAvailableModels();
+ if (models && models.length > 0) {
+ // Use the first available model - no hardcoded preferences
+ const selectedModel = models[0];
+
+ // Import server-side options to update the default model
+ const optionService = (await import('../../../options.js')).default;
+ const optionKey = `${provider}DefaultModel` as const;
+
+ await optionService.setOption(optionKey, selectedModel);
+ log.info(`Set default ${provider} model to: ${selectedModel}`);
+ return selectedModel;
+ }
+ } catch (modelError) {
+ log.error(`Error fetching models from ${provider} service: ${modelError}`);
+ }
+ }
+
+ log.info(`Provider ${provider} does not support dynamic model fetching`);
+ return null;
+ } catch (error) {
+ log.error(`Error getting default model for provider ${provider}: ${error}`);
+ return null;
+ }
+ }
}
diff --git a/apps/server/src/services/llm/pipeline/stages/semantic_context_extraction_stage.ts b/apps/server/src/services/llm/pipeline/stages/semantic_context_extraction_stage.ts
index bf2cc8fd7..139510663 100644
--- a/apps/server/src/services/llm/pipeline/stages/semantic_context_extraction_stage.ts
+++ b/apps/server/src/services/llm/pipeline/stages/semantic_context_extraction_stage.ts
@@ -50,7 +50,7 @@ export class SemanticContextExtractionStage extends BasePipelineStage {
+ const result: ProviderValidationResult = {
+ hasValidProviders: false,
+ validEmbeddingProviders: [],
+ validChatProviders: [],
+ errors: [],
+ warnings: []
+ };
+
+ try {
+ // Check if AI is enabled
+ const aiEnabled = await options.getOptionBool('aiEnabled');
+ if (!aiEnabled) {
+ result.warnings.push("AI features are disabled");
+ return result;
+ }
+
+ // Check configuration only - don't create providers
+ await checkEmbeddingProviderConfigs(result);
+ await checkChatProviderConfigs(result);
+
+ // Determine if we have any valid providers based on configuration
+ result.hasValidProviders = result.validChatProviders.length > 0;
+
+ if (!result.hasValidProviders) {
+ result.errors.push("No valid AI providers are configured");
+ }
+
+ } catch (error: any) {
+ result.errors.push(`Error during provider validation: ${error.message || 'Unknown error'}`);
+ }
+
+ return result;
+}
+
+/**
+ * Check embedding provider configurations without creating providers
+ */
+async function checkEmbeddingProviderConfigs(result: ProviderValidationResult): Promise {
+ try {
+ // Check OpenAI embedding configuration
+ const openaiApiKey = await options.getOption('openaiApiKey');
+ const openaiBaseUrl = await options.getOption('openaiBaseUrl');
+ if (openaiApiKey || openaiBaseUrl) {
+ if (!openaiApiKey) {
+ result.warnings.push("OpenAI embedding: No API key (may work with compatible endpoints)");
+ }
+ log.info("OpenAI embedding provider configuration available");
+ }
+
+ // Check Ollama embedding configuration
+ const ollamaEmbeddingBaseUrl = await options.getOption('ollamaEmbeddingBaseUrl');
+ if (ollamaEmbeddingBaseUrl) {
+ log.info("Ollama embedding provider configuration available");
+ }
+
+ // Check Voyage embedding configuration
+ const voyageApiKey = await options.getOption('voyageApiKey' as any);
+ if (voyageApiKey) {
+ log.info("Voyage embedding provider configuration available");
+ }
+
+ // Local provider is always available
+ log.info("Local embedding provider available as fallback");
+
+ } catch (error: any) {
+ result.errors.push(`Error checking embedding provider configs: ${error.message || 'Unknown error'}`);
+ }
+}
+
+/**
+ * Check chat provider configurations without creating providers
+ */
+async function checkChatProviderConfigs(result: ProviderValidationResult): Promise {
+ try {
+ // Check OpenAI chat provider
+ const openaiApiKey = await options.getOption('openaiApiKey');
+ const openaiBaseUrl = await options.getOption('openaiBaseUrl');
+
+ if (openaiApiKey || openaiBaseUrl) {
+ if (!openaiApiKey) {
+ result.warnings.push("OpenAI chat: No API key (may work with compatible endpoints)");
+ }
+ result.validChatProviders.push('openai');
+ }
+
+ // Check Anthropic chat provider
+ const anthropicApiKey = await options.getOption('anthropicApiKey');
+ if (anthropicApiKey) {
+ result.validChatProviders.push('anthropic');
+ }
+
+ // Check Ollama chat provider
+ const ollamaBaseUrl = await options.getOption('ollamaBaseUrl');
+ if (ollamaBaseUrl) {
+ result.validChatProviders.push('ollama');
+ }
+
+ if (result.validChatProviders.length === 0) {
+ result.warnings.push("No chat providers configured. Please configure at least one provider.");
+ }
+
+ } catch (error: any) {
+ result.errors.push(`Error checking chat provider configs: ${error.message || 'Unknown error'}`);
+ }
+}
+
+
+/**
+ * Check if any chat providers are configured
+ */
+export async function hasWorkingChatProviders(): Promise {
+ const validation = await validateProviders();
+ return validation.validChatProviders.length > 0;
+}
+
+/**
+ * Check if any embedding providers are configured (simplified)
+ */
+export async function hasWorkingEmbeddingProviders(): Promise {
+ if (!(await options.getOptionBool('aiEnabled'))) {
+ return false;
+ }
+
+ // Check if any embedding provider is configured
+ const openaiKey = await options.getOption('openaiApiKey');
+ const openaiBaseUrl = await options.getOption('openaiBaseUrl');
+ const ollamaUrl = await options.getOption('ollamaEmbeddingBaseUrl');
+ const voyageKey = await options.getOption('voyageApiKey' as any);
+
+ // Local provider is always available as fallback
+ return !!(openaiKey || openaiBaseUrl || ollamaUrl || voyageKey) || true;
+}
+
+/**
+ * Log validation results in a user-friendly way
+ */
+export function logValidationResults(validation: ProviderValidationResult): void {
+ if (validation.hasValidProviders) {
+ log.info(`AI provider validation passed: ${validation.validEmbeddingProviders.length} embedding providers, ${validation.validChatProviders.length} chat providers`);
+
+ if (validation.validEmbeddingProviders.length > 0) {
+ log.info(`Working embedding providers: ${validation.validEmbeddingProviders.map(p => p.name).join(', ')}`);
+ }
+
+ if (validation.validChatProviders.length > 0) {
+ log.info(`Working chat providers: ${validation.validChatProviders.join(', ')}`);
+ }
+ } else {
+ log.info("AI provider validation failed: No working providers found");
+ }
+
+ validation.warnings.forEach(warning => log.info(`Provider validation: ${warning}`));
+ validation.errors.forEach(error => log.error(`Provider validation: ${error}`));
+}
\ No newline at end of file
diff --git a/apps/server/src/services/llm/providers/anthropic_service.ts b/apps/server/src/services/llm/providers/anthropic_service.ts
index a533acf4a..ed034bdfd 100644
--- a/apps/server/src/services/llm/providers/anthropic_service.ts
+++ b/apps/server/src/services/llm/providers/anthropic_service.ts
@@ -606,4 +606,12 @@ export class AnthropicService extends BaseAIService {
return convertedTools;
}
+
+ /**
+ * Clear cached Anthropic client to force recreation with new settings
+ */
+ clearCache(): void {
+ this.client = null;
+ log.info('Anthropic client cache cleared');
+ }
}
diff --git a/apps/server/src/services/llm/providers/ollama_service.ts b/apps/server/src/services/llm/providers/ollama_service.ts
index 750118027..4ebbbaa4b 100644
--- a/apps/server/src/services/llm/providers/ollama_service.ts
+++ b/apps/server/src/services/llm/providers/ollama_service.ts
@@ -526,4 +526,13 @@ export class OllamaService extends BaseAIService {
log.info(`Added tool execution feedback: ${toolExecutionStatus.length} statuses`);
return updatedMessages;
}
+
+ /**
+ * Clear cached Ollama client to force recreation with new settings
+ */
+ clearCache(): void {
+ // Ollama service doesn't maintain a persistent client like OpenAI/Anthropic
+ // but we can clear any future cached state here if needed
+ log.info('Ollama client cache cleared (no persistent client to clear)');
+ }
}
diff --git a/apps/server/src/services/llm/providers/openai_service.ts b/apps/server/src/services/llm/providers/openai_service.ts
index e0633ca1f..411f512f7 100644
--- a/apps/server/src/services/llm/providers/openai_service.ts
+++ b/apps/server/src/services/llm/providers/openai_service.ts
@@ -14,7 +14,9 @@ export class OpenAIService extends BaseAIService {
}
override isAvailable(): boolean {
- return super.isAvailable() && !!options.getOption('openaiApiKey');
+ // Make API key optional to support OpenAI-compatible endpoints that don't require authentication
+ // The provider is considered available as long as the parent checks pass
+ return super.isAvailable();
}
private getClient(apiKey: string, baseUrl?: string): OpenAI {
@@ -29,7 +31,7 @@ export class OpenAIService extends BaseAIService {
async generateChatCompletion(messages: Message[], opts: ChatCompletionOptions = {}): Promise {
if (!this.isAvailable()) {
- throw new Error('OpenAI service is not available. Check API key and AI settings.');
+ throw new Error('OpenAI service is not available. Check AI settings.');
}
// Get provider-specific options from the central provider manager
@@ -257,4 +259,12 @@ export class OpenAIService extends BaseAIService {
throw error;
}
}
+
+ /**
+ * Clear cached OpenAI client to force recreation with new settings
+ */
+ clearCache(): void {
+ this.openai = null;
+ log.info('OpenAI client cache cleared');
+ }
}
diff --git a/apps/server/src/services/llm/providers/providers.ts b/apps/server/src/services/llm/providers/providers.ts
index f4d69801c..dae8b34a0 100644
--- a/apps/server/src/services/llm/providers/providers.ts
+++ b/apps/server/src/services/llm/providers/providers.ts
@@ -86,6 +86,29 @@ export function registerEmbeddingProvider(provider: EmbeddingProvider) {
log.info(`Registered embedding provider: ${provider.name}`);
}
+/**
+ * Unregister an embedding provider
+ */
+export function unregisterEmbeddingProvider(name: string): boolean {
+ const existed = providers.has(name);
+ if (existed) {
+ providers.delete(name);
+ log.info(`Unregistered embedding provider: ${name}`);
+ }
+ return existed;
+}
+
+/**
+ * Clear all embedding providers
+ */
+export function clearAllEmbeddingProviders(): void {
+ const providerNames = Array.from(providers.keys());
+ providers.clear();
+ if (providerNames.length > 0) {
+ log.info(`Cleared all embedding providers: ${providerNames.join(', ')}`);
+ }
+}
+
/**
* Get all registered embedding providers
*/
@@ -101,35 +124,126 @@ export function getEmbeddingProvider(name: string): EmbeddingProvider | undefine
}
/**
- * Get all enabled embedding providers
+ * Get or create a specific embedding provider with inline validation
*/
-export async function getEnabledEmbeddingProviders(): Promise {
+export async function getOrCreateEmbeddingProvider(providerName: string): Promise {
+ // Return existing provider if already created and valid
+ const existing = providers.get(providerName);
+ if (existing) {
+ return existing;
+ }
+
+ // Create and validate provider on-demand
+ try {
+ let provider: EmbeddingProvider | null = null;
+
+ switch (providerName) {
+ case 'ollama': {
+ const baseUrl = await options.getOption('ollamaEmbeddingBaseUrl');
+ if (!baseUrl) return null;
+
+ const model = await options.getOption('ollamaEmbeddingModel');
+ provider = new OllamaEmbeddingProvider({
+ model,
+ dimension: 768,
+ type: 'float32',
+ baseUrl
+ });
+
+ // Validate by initializing (if provider supports it)
+ if ('initialize' in provider && typeof provider.initialize === 'function') {
+ await provider.initialize();
+ }
+ break;
+ }
+
+ case 'openai': {
+ const apiKey = await options.getOption('openaiApiKey');
+ const baseUrl = await options.getOption('openaiBaseUrl');
+ if (!apiKey && !baseUrl) return null;
+
+ const model = await options.getOption('openaiEmbeddingModel');
+ provider = new OpenAIEmbeddingProvider({
+ model,
+ dimension: 1536,
+ type: 'float32',
+ apiKey: apiKey || '',
+ baseUrl: baseUrl || 'https://api.openai.com/v1'
+ });
+
+ if (!apiKey) {
+ log.info('OpenAI embedding provider created without API key for compatible endpoints');
+ }
+ break;
+ }
+
+ case 'voyage': {
+ const apiKey = await options.getOption('voyageApiKey' as any);
+ if (!apiKey) return null;
+
+ const model = await options.getOption('voyageEmbeddingModel') || 'voyage-2';
+ provider = new VoyageEmbeddingProvider({
+ model,
+ dimension: 1024,
+ type: 'float32',
+ apiKey,
+ baseUrl: 'https://api.voyageai.com/v1'
+ });
+ break;
+ }
+
+ case 'local': {
+ provider = new SimpleLocalEmbeddingProvider({
+ model: 'local',
+ dimension: 384,
+ type: 'float32'
+ });
+ break;
+ }
+
+ default:
+ return null;
+ }
+
+ if (provider) {
+ registerEmbeddingProvider(provider);
+ log.info(`Created and validated ${providerName} embedding provider`);
+ return provider;
+ }
+ } catch (error: any) {
+ log.error(`Failed to create ${providerName} embedding provider: ${error.message || 'Unknown error'}`);
+ }
+
+ return null;
+}
+
+/**
+ * Get all enabled embedding providers for the specified feature
+ */
+export async function getEnabledEmbeddingProviders(feature: 'embeddings' | 'chat' = 'embeddings'): Promise {
if (!(await options.getOptionBool('aiEnabled'))) {
return [];
}
- // Get providers from database ordered by priority
- const dbProviders = await sql.getRows(`
- SELECT providerId, name, config
- FROM embedding_providers
- ORDER BY priority DESC`
- );
-
const result: EmbeddingProvider[] = [];
- for (const row of dbProviders) {
- const rowData = row as any;
- const provider = providers.get(rowData.name);
+ // Get the selected provider for the feature
+ const selectedProvider = feature === 'embeddings'
+ ? await options.getOption('embeddingSelectedProvider')
+ : await options.getOption('aiSelectedProvider');
- if (provider) {
- result.push(provider);
- } else {
- // Only log error if we haven't logged it before for this provider
- if (!loggedProviderErrors.has(rowData.name)) {
- log.error(`Enabled embedding provider ${rowData.name} not found in registered providers`);
- loggedProviderErrors.add(rowData.name);
- }
+ // Try to get or create the specific selected provider
+ const provider = await getOrCreateEmbeddingProvider(selectedProvider);
+ if (!provider) {
+ throw new Error(`Failed to create selected embedding provider: ${selectedProvider}. Please check your configuration.`);
}
+ result.push(provider);
+
+
+ // Always ensure local provider as fallback
+ const localProvider = await getOrCreateEmbeddingProvider('local');
+ if (localProvider && !result.some(p => p.name === 'local')) {
+ result.push(localProvider);
}
return result;
@@ -232,144 +346,18 @@ export async function getEmbeddingProviderConfigs() {
return await sql.getRows("SELECT * FROM embedding_providers ORDER BY priority DESC");
}
-/**
- * Initialize the default embedding providers
- */
-export async function initializeDefaultProviders() {
- // Register built-in providers
- try {
- // Register OpenAI provider if API key is configured
- const openaiApiKey = await options.getOption('openaiApiKey');
- if (openaiApiKey) {
- const openaiModel = await options.getOption('openaiEmbeddingModel') || 'text-embedding-3-small';
- const openaiBaseUrl = await options.getOption('openaiBaseUrl') || 'https://api.openai.com/v1';
-
- registerEmbeddingProvider(new OpenAIEmbeddingProvider({
- model: openaiModel,
- dimension: 1536, // OpenAI's typical dimension
- type: 'float32',
- apiKey: openaiApiKey,
- baseUrl: openaiBaseUrl
- }));
-
- // Create OpenAI provider config if it doesn't exist
- const existingOpenAI = await sql.getRow(
- "SELECT * FROM embedding_providers WHERE name = ?",
- ['openai']
- );
-
- if (!existingOpenAI) {
- await createEmbeddingProviderConfig('openai', {
- model: openaiModel,
- dimension: 1536,
- type: 'float32'
- }, 100);
- }
- }
-
- // Register Voyage provider if API key is configured
- const voyageApiKey = await options.getOption('voyageApiKey' as any);
- if (voyageApiKey) {
- const voyageModel = await options.getOption('voyageEmbeddingModel') || 'voyage-2';
- const voyageBaseUrl = 'https://api.voyageai.com/v1';
-
- registerEmbeddingProvider(new VoyageEmbeddingProvider({
- model: voyageModel,
- dimension: 1024, // Voyage's embedding dimension
- type: 'float32',
- apiKey: voyageApiKey,
- baseUrl: voyageBaseUrl
- }));
-
- // Create Voyage provider config if it doesn't exist
- const existingVoyage = await sql.getRow(
- "SELECT * FROM embedding_providers WHERE name = ?",
- ['voyage']
- );
-
- if (!existingVoyage) {
- await createEmbeddingProviderConfig('voyage', {
- model: voyageModel,
- dimension: 1024,
- type: 'float32'
- }, 75);
- }
- }
-
- // Register Ollama provider if base URL is configured
- const ollamaBaseUrl = await options.getOption('ollamaBaseUrl');
- if (ollamaBaseUrl) {
- // Use specific embedding models if available
- const embeddingModel = await options.getOption('ollamaEmbeddingModel');
-
- try {
- // Create provider with initial dimension to be updated during initialization
- const ollamaProvider = new OllamaEmbeddingProvider({
- model: embeddingModel,
- dimension: 768, // Initial value, will be updated during initialization
- type: 'float32',
- baseUrl: ollamaBaseUrl
- });
-
- // Register the provider
- registerEmbeddingProvider(ollamaProvider);
-
- // Initialize the provider to detect model capabilities
- await ollamaProvider.initialize();
-
- // Create Ollama provider config if it doesn't exist
- const existingOllama = await sql.getRow(
- "SELECT * FROM embedding_providers WHERE name = ?",
- ['ollama']
- );
-
- if (!existingOllama) {
- await createEmbeddingProviderConfig('ollama', {
- model: embeddingModel,
- dimension: ollamaProvider.getDimension(),
- type: 'float32'
- }, 50);
- }
- } catch (error: any) {
- log.error(`Error initializing Ollama embedding provider: ${error.message || 'Unknown error'}`);
- }
- }
-
- // Always register local provider as fallback
- registerEmbeddingProvider(new SimpleLocalEmbeddingProvider({
- model: 'local',
- dimension: 384,
- type: 'float32'
- }));
-
- // Create local provider config if it doesn't exist
- const existingLocal = await sql.getRow(
- "SELECT * FROM embedding_providers WHERE name = ?",
- ['local']
- );
-
- if (!existingLocal) {
- await createEmbeddingProviderConfig('local', {
- model: 'local',
- dimension: 384,
- type: 'float32'
- }, 10);
- }
- } catch (error: any) {
- log.error(`Error initializing default embedding providers: ${error.message || 'Unknown error'}`);
- }
-}
-
export default {
registerEmbeddingProvider,
+ unregisterEmbeddingProvider,
+ clearAllEmbeddingProviders,
getEmbeddingProviders,
getEmbeddingProvider,
getEnabledEmbeddingProviders,
+ getOrCreateEmbeddingProvider,
createEmbeddingProviderConfig,
updateEmbeddingProviderConfig,
deleteEmbeddingProviderConfig,
- getEmbeddingProviderConfigs,
- initializeDefaultProviders
+ getEmbeddingProviderConfigs
};
/**
@@ -382,7 +370,8 @@ export function getOpenAIOptions(
try {
const apiKey = options.getOption('openaiApiKey');
if (!apiKey) {
- throw new Error('OpenAI API key is not configured');
+ // Log warning but don't throw - some OpenAI-compatible endpoints don't require API keys
+ log.info('OpenAI API key is not configured. This may cause issues with official OpenAI endpoints.');
}
const baseUrl = options.getOption('openaiBaseUrl') || PROVIDER_CONSTANTS.OPENAI.BASE_URL;
@@ -407,7 +396,7 @@ export function getOpenAIOptions(
return {
// Connection settings
- apiKey,
+ apiKey: apiKey || '', // Default to empty string if no API key
baseUrl,
// Provider metadata
diff --git a/apps/server/src/services/llm/tools/note_summarization_tool.ts b/apps/server/src/services/llm/tools/note_summarization_tool.ts
index bc5999e0c..8fa5d39d8 100644
--- a/apps/server/src/services/llm/tools/note_summarization_tool.ts
+++ b/apps/server/src/services/llm/tools/note_summarization_tool.ts
@@ -102,12 +102,7 @@ export class NoteSummarizationTool implements ToolHandler {
const cleanContent = this.cleanHtml(content);
// Generate the summary using the AI service
- const aiService = aiServiceManager.getService();
-
- if (!aiService) {
- log.error('No AI service available for summarization');
- return `Error: No AI service is available for summarization`;
- }
+ const aiService = await aiServiceManager.getService();
log.info(`Using ${aiService.getName()} to generate summary`);
diff --git a/apps/server/src/services/llm/tools/relationship_tool.ts b/apps/server/src/services/llm/tools/relationship_tool.ts
index a7023981d..d0a91c98d 100644
--- a/apps/server/src/services/llm/tools/relationship_tool.ts
+++ b/apps/server/src/services/llm/tools/relationship_tool.ts
@@ -312,16 +312,7 @@ export class RelationshipTool implements ToolHandler {
}
// Get the AI service for relationship suggestion
- const aiService = aiServiceManager.getService();
-
- if (!aiService) {
- log.error('No AI service available for relationship suggestions');
- return {
- success: false,
- message: 'AI service not available for relationship suggestions',
- relatedNotes: relatedResult.relatedNotes
- };
- }
+ const aiService = await aiServiceManager.getService();
log.info(`Using ${aiService.getName()} to suggest relationships for ${relatedResult.relatedNotes.length} related notes`);
diff --git a/apps/server/src/services/llm/tools/search_notes_tool.ts b/apps/server/src/services/llm/tools/search_notes_tool.ts
index 09b3fc645..152187dec 100644
--- a/apps/server/src/services/llm/tools/search_notes_tool.ts
+++ b/apps/server/src/services/llm/tools/search_notes_tool.ts
@@ -122,10 +122,10 @@ export class SearchNotesTool implements ToolHandler {
// If summarization is requested
if (summarize) {
// Try to get an LLM service for summarization
- const llmService = aiServiceManager.getService();
- if (llmService) {
- try {
- const messages = [
+ try {
+ const llmService = await aiServiceManager.getService();
+
+ const messages = [
{
role: "system" as const,
content: "Summarize the following note content concisely while preserving key information. Keep your summary to about 3-4 sentences."
@@ -147,13 +147,12 @@ export class SearchNotesTool implements ToolHandler {
} as Record))
});
- if (result && result.text) {
- return result.text;
- }
- } catch (error) {
- log.error(`Error summarizing content: ${error}`);
- // Fall through to smart truncation if summarization fails
+ if (result && result.text) {
+ return result.text;
}
+ } catch (error) {
+ log.error(`Error summarizing content: ${error}`);
+ // Fall through to smart truncation if summarization fails
}
}
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index 212f47366..5311a67f0 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -195,26 +195,32 @@ const defaultOptions: DefaultOption[] = [
// AI Options
{ name: "aiEnabled", value: "false", isSynced: true },
{ name: "openaiApiKey", value: "", isSynced: false },
- { name: "openaiDefaultModel", value: "gpt-4o", isSynced: true },
- { name: "openaiEmbeddingModel", value: "text-embedding-3-small", isSynced: true },
+ { name: "openaiDefaultModel", value: "", isSynced: true },
+ { name: "openaiEmbeddingModel", value: "", isSynced: true },
{ name: "openaiBaseUrl", value: "https://api.openai.com/v1", isSynced: true },
{ name: "anthropicApiKey", value: "", isSynced: false },
- { name: "anthropicDefaultModel", value: "claude-3-opus-20240229", isSynced: true },
- { name: "voyageEmbeddingModel", value: "voyage-2", isSynced: true },
+ { name: "anthropicDefaultModel", value: "", isSynced: true },
+ { name: "voyageEmbeddingModel", value: "", isSynced: true },
{ name: "voyageApiKey", value: "", isSynced: false },
{ name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true },
{ name: "ollamaEnabled", value: "false", isSynced: true },
- { name: "ollamaDefaultModel", value: "llama3", isSynced: true },
+ { name: "ollamaDefaultModel", value: "", isSynced: true },
{ name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true },
- { name: "ollamaEmbeddingModel", value: "nomic-embed-text", isSynced: true },
+ { name: "ollamaEmbeddingModel", value: "", isSynced: true },
{ name: "embeddingAutoUpdateEnabled", value: "true", isSynced: true },
+ // Embedding-specific provider options
+ { name: "openaiEmbeddingApiKey", value: "", isSynced: false },
+ { name: "openaiEmbeddingBaseUrl", value: "https://api.openai.com/v1", isSynced: true },
+ { name: "voyageEmbeddingBaseUrl", value: "https://api.voyageai.com/v1", isSynced: true },
+ { name: "ollamaEmbeddingBaseUrl", value: "http://localhost:11434", isSynced: true },
+
// Adding missing AI options
{ name: "aiTemperature", value: "0.7", isSynced: true },
{ name: "aiSystemPrompt", value: "", isSynced: true },
- { name: "aiProviderPrecedence", value: "openai,anthropic,ollama", isSynced: true },
+ { name: "aiSelectedProvider", value: "openai", isSynced: true },
{ name: "embeddingDimensionStrategy", value: "auto", isSynced: true },
- { name: "embeddingProviderPrecedence", value: "openai,voyage,ollama,local", isSynced: true },
+ { name: "embeddingSelectedProvider", value: "openai", isSynced: true },
{ name: "embeddingSimilarityThreshold", value: "0.75", isSynced: true },
{ name: "enableAutomaticIndexing", value: "true", isSynced: true },
{ name: "maxNotesPerLlmQuery", value: "3", isSynced: true },
diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts
index 32f731936..77c3b2a68 100644
--- a/packages/commons/src/lib/options_interface.ts
+++ b/packages/commons/src/lib/options_interface.ts
@@ -132,26 +132,29 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions