From d3013c925ef766d8543d44a8ff9e313bf6db86ff Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sat, 8 Mar 2025 22:23:50 +0000 Subject: [PATCH] add additional options for ollama embeddings --- .../type_widgets/options/ai_settings.ts | 82 ++++++++++- src/public/translations/en/translation.json | 6 + src/routes/api/ollama.ts | 40 ++++++ src/routes/routes.ts | 4 + src/services/llm/embeddings/providers.ts | 98 ++++++++++--- .../llm/embeddings/providers/ollama.ts | 129 +++++++++++++++++- src/services/options_init.ts | 16 ++- src/services/options_interface.ts | 2 + 8 files changed, 351 insertions(+), 26 deletions(-) create mode 100644 src/routes/api/ollama.ts diff --git a/src/public/app/widgets/type_widgets/options/ai_settings.ts b/src/public/app/widgets/type_widgets/options/ai_settings.ts index e0bf6259a..8eaf95e17 100644 --- a/src/public/app/widgets/type_widgets/options/ai_settings.ts +++ b/src/public/app/widgets/type_widgets/options/ai_settings.ts @@ -4,6 +4,19 @@ import type { FilterOptionsByType, OptionMap } from "../../../../../services/opt import server from "../../../services/server.js"; import toastService from "../../../services/toast.js"; +// Interface for the Ollama model response +interface OllamaModelResponse { + success: boolean; + models: Array<{ + name: string; + model: string; + details?: { + family?: string; + parameter_size?: string; + } + }>; +} + export default class AiSettingsWidget extends OptionsWidget { doRender() { this.$widget = $(` @@ -102,16 +115,27 @@ export default class AiSettingsWidget extends OptionsWidget {
- +
${t("ai_llm.ollama_url_description")}
- +
${t("ai_llm.ollama_model_description")}
+ +
+ + +
${t("ai_llm.ollama_embedding_model_description")}
+ +

@@ -220,6 +244,59 @@ export default class AiSettingsWidget extends OptionsWidget { await this.updateOption('ollamaDefaultModel', $ollamaDefaultModel.val() as string); }); + const $ollamaEmbeddingModel = this.$widget.find('.ollama-embedding-model'); + $ollamaEmbeddingModel.on('change', async () => { + await this.updateOption('ollamaEmbeddingModel', $ollamaEmbeddingModel.val() as string); + }); + + const $refreshModels = this.$widget.find('.refresh-models'); + $refreshModels.on('click', async () => { + $refreshModels.prop('disabled', true); + $refreshModels.text(t("ai_llm.refresh_models")); + + try { + const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val() as string; + const response = await server.post('ollama/list-models', { baseUrl: ollamaBaseUrl }); + + if (response && response.models) { + const $embedModelSelect = this.$widget.find('.ollama-embedding-model'); + const currentValue = $embedModelSelect.val(); + + // Clear existing options + $embedModelSelect.empty(); + + // Add embedding-specific models first + const embeddingModels = response.models.filter(model => + model.name.includes('embed') || model.name.includes('bert')); + + embeddingModels.forEach(model => { + $embedModelSelect.append(``); + }); + + // Add separator + $embedModelSelect.append(``); + + // Add other models (LLMs can also generate embeddings) + const otherModels = response.models.filter(model => + !model.name.includes('embed') && !model.name.includes('bert')); + + otherModels.forEach(model => { + $embedModelSelect.append(``); + }); + + // Restore previous selection if possible + if (currentValue) { + $embedModelSelect.val(currentValue); + } + } + } catch (error) { + console.error("Error refreshing Ollama models:", error); + } finally { + $refreshModels.prop('disabled', false); + $refreshModels.text(t("ai_llm.refresh_models")); + } + }); + // Embedding options event handlers const $embeddingAutoUpdateEnabled = this.$widget.find('.embedding-auto-update-enabled'); $embeddingAutoUpdateEnabled.on('change', async () => { @@ -290,6 +367,7 @@ export default class AiSettingsWidget extends OptionsWidget { this.$widget.find('.ollama-base-url').val(options.ollamaBaseUrl); this.$widget.find('.ollama-default-model').val(options.ollamaDefaultModel); + this.$widget.find('.ollama-embedding-model').val(options.ollamaEmbeddingModel || 'nomic-embed-text'); // Load embedding options this.setCheckboxState(this.$widget.find('.embedding-auto-update-enabled'), options.embeddingAutoUpdateEnabled); diff --git a/src/public/translations/en/translation.json b/src/public/translations/en/translation.json index 03512d06b..d277e12c8 100644 --- a/src/public/translations/en/translation.json +++ b/src/public/translations/en/translation.json @@ -1144,8 +1144,14 @@ "ollama_configuration": "Ollama Configuration", "enable_ollama": "Enable Ollama", "enable_ollama_description": "Enable Ollama for local AI model usage", + "ollama_url": "Ollama URL", "ollama_url_description": "Default: http://localhost:11434", + "ollama_model": "Ollama Model", "ollama_model_description": "Examples: llama3, mistral, phi3", + "ollama_embedding_model": "Embedding Model", + "ollama_embedding_model_description": "Specialized model for generating embeddings (vector representations)", + "refresh_models": "Refresh Models", + "refreshing_models": "Refreshing...", "embedding_configuration": "Embeddings Configuration", "enable_auto_update_embeddings": "Auto-update Embeddings", "enable_auto_update_embeddings_description": "Automatically update embeddings when notes are modified", diff --git a/src/routes/api/ollama.ts b/src/routes/api/ollama.ts new file mode 100644 index 000000000..a945a993f --- /dev/null +++ b/src/routes/api/ollama.ts @@ -0,0 +1,40 @@ +import axios from 'axios'; +import options from "../../services/options.js"; +import log from "../../services/log.js"; +import type { Request, Response } from "express"; + +/** + * List available models from Ollama + */ +async function listModels(req: Request, res: Response) { + try { + const { baseUrl } = req.body; + + // Use provided base URL or default from options + const ollamaBaseUrl = baseUrl || await options.getOption('ollamaBaseUrl') || 'http://localhost:11434'; + + // Call Ollama API to get models + const response = await axios.get(`${ollamaBaseUrl}/api/tags`, { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000 + }); + + // Return the models list + return res.send({ + success: true, + models: response.data.models || [] + }); + } catch (error: any) { + log.error(`Error listing Ollama models: ${error.message || 'Unknown error'}`); + + return res.status(500).send({ + success: false, + message: error.message || 'Failed to list Ollama models', + error: error.toString() + }); + } +} + +export default { + listModels +}; diff --git a/src/routes/routes.ts b/src/routes/routes.ts index b5a35a5f4..bf5c6b9de 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -61,6 +61,7 @@ import relationMapApiRoute from "./api/relation-map.js"; import otherRoute from "./api/other.js"; import shareRoutes from "../share/routes.js"; import embeddingsRoute from "./api/embeddings.js"; +import ollamaRoute from "./api/ollama.js"; import etapiAuthRoutes from "../etapi/auth.js"; import etapiAppInfoRoutes from "../etapi/app_info.js"; @@ -378,6 +379,9 @@ function register(app: express.Application) { route(PST, "/api/embeddings/reprocess", [auth.checkApiAuth, csrfMiddleware], embeddingsRoute.reprocessAllNotes, apiResultHandler); route(GET, "/api/embeddings/queue-status", [auth.checkApiAuth], embeddingsRoute.getQueueStatus, apiResultHandler); + // Ollama API endpoints + route(PST, "/api/ollama/list-models", [auth.checkApiAuth, csrfMiddleware], ollamaRoute.listModels, apiResultHandler); + // API Documentation apiDocsRoute.register(app); diff --git a/src/services/llm/embeddings/providers.ts b/src/services/llm/embeddings/providers.ts index 34d8c8754..b0a9b609e 100644 --- a/src/services/llm/embeddings/providers.ts +++ b/src/services/llm/embeddings/providers.ts @@ -7,7 +7,52 @@ import type { EmbeddingProvider, EmbeddingConfig } from "./embeddings_interface. import { OpenAIEmbeddingProvider } from "./providers/openai.js"; import { OllamaEmbeddingProvider } from "./providers/ollama.js"; import { AnthropicEmbeddingProvider } from "./providers/anthropic.js"; -import { LocalEmbeddingProvider } from "./providers/local.js"; + +/** + * Simple local embedding provider implementation + * This avoids the need to import a separate file which might not exist + */ +class SimpleLocalEmbeddingProvider implements EmbeddingProvider { + name = "local"; + config: EmbeddingConfig; + + constructor(config: EmbeddingConfig) { + this.config = config; + } + + getConfig(): EmbeddingConfig { + return this.config; + } + + async generateEmbeddings(text: string): Promise { + // Create deterministic embeddings based on text content + const result = new Float32Array(this.config.dimension || 384); + + // Simple hash-based approach + for (let i = 0; i < result.length; i++) { + // Use character codes and position to generate values between -1 and 1 + const charSum = Array.from(text).reduce((sum, char, idx) => + sum + char.charCodeAt(0) * Math.sin(idx * 0.1), 0); + result[i] = Math.sin(i * 0.1 + charSum * 0.01); + } + + return result; + } + + async generateBatchEmbeddings(texts: string[]): Promise { + return Promise.all(texts.map(text => this.generateEmbeddings(text))); + } + + async generateNoteEmbeddings(context: any): Promise { + // Combine text from context + const text = (context.title || "") + " " + (context.content || ""); + return this.generateEmbeddings(text); + } + + async generateBatchNoteEmbeddings(contexts: any[]): Promise { + return Promise.all(contexts.map(context => this.generateNoteEmbeddings(context))); + } +} const providers = new Map(); @@ -236,33 +281,46 @@ export async function initializeDefaultProviders() { // Register Ollama provider if enabled if (await options.getOptionBool('ollamaEnabled')) { - const ollamaModel = await options.getOption('ollamaDefaultModel') || 'llama3'; const ollamaBaseUrl = await options.getOption('ollamaBaseUrl') || 'http://localhost:11434'; - registerEmbeddingProvider(new OllamaEmbeddingProvider({ - model: ollamaModel, - dimension: 4096, // Typical for Ollama models - type: 'float32', - baseUrl: ollamaBaseUrl - })); + // Use specific embedding models if available + const embeddingModel = await options.getOption('ollamaEmbeddingModel') || 'nomic-embed-text'; - // Create Ollama provider config if it doesn't exist - const existingOllama = await sql.getRow( - "SELECT * FROM embedding_providers WHERE name = ?", - ['ollama'] - ); + 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 + }); - if (!existingOllama) { - await createEmbeddingProviderConfig('ollama', { - model: ollamaModel, - dimension: 4096, - type: 'float32' - }, true, 50); + // 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' + }, true, 50); + } + } catch (error: any) { + log.error(`Error initializing Ollama embedding provider: ${error.message || 'Unknown error'}`); } } // Always register local provider as fallback - registerEmbeddingProvider(new LocalEmbeddingProvider({ + registerEmbeddingProvider(new SimpleLocalEmbeddingProvider({ model: 'local', dimension: 384, type: 'float32' diff --git a/src/services/llm/embeddings/providers/ollama.ts b/src/services/llm/embeddings/providers/ollama.ts index 41a583a5d..765cc1b14 100644 --- a/src/services/llm/embeddings/providers/ollama.ts +++ b/src/services/llm/embeddings/providers/ollama.ts @@ -7,33 +7,156 @@ interface OllamaEmbeddingConfig extends EmbeddingConfig { baseUrl: string; } +// Model-specific embedding dimensions +interface EmbeddingModelInfo { + dimension: number; + contextWindow: number; +} + /** * Ollama embedding provider implementation */ export class OllamaEmbeddingProvider extends BaseEmbeddingProvider { name = "ollama"; private baseUrl: string; + // Cache for model dimensions to avoid repeated API calls + private modelInfoCache = new Map(); constructor(config: OllamaEmbeddingConfig) { super(config); this.baseUrl = config.baseUrl; } + /** + * Initialize the provider by detecting model capabilities + */ + async initialize(): Promise { + const modelName = this.config.model || "llama3"; + try { + await this.getModelInfo(modelName); + log.info(`Ollama embedding provider initialized with model ${modelName}`); + } catch (error: any) { + log.error(`Failed to initialize Ollama embedding provider: ${error.message}`); + // Still continue with default dimensions + } + } + + /** + * Get model information including embedding dimensions + */ + async getModelInfo(modelName: string): Promise { + // Check cache first + if (this.modelInfoCache.has(modelName)) { + return this.modelInfoCache.get(modelName)!; + } + + // Default dimensions for common embedding models + const defaultDimensions: Record = { + "nomic-embed-text": 768, + "mxbai-embed-large": 1024, + "llama3": 4096, + "all-minilm": 384, + "default": 4096 + }; + + // Default context windows + const defaultContextWindows: Record = { + "nomic-embed-text": 8192, + "mxbai-embed-large": 8192, + "llama3": 8192, + "all-minilm": 4096, + "default": 4096 + }; + + try { + // Try to detect if this is an embedding model + const testResponse = await axios.post( + `${this.baseUrl}/api/embeddings`, + { + model: modelName, + prompt: "Test" + }, + { + headers: { "Content-Type": "application/json" }, + timeout: 10000 + } + ); + + let dimension = 0; + let contextWindow = 0; + + if (testResponse.data && Array.isArray(testResponse.data.embedding)) { + dimension = testResponse.data.embedding.length; + + // Set context window based on model name if we have it + const baseModelName = modelName.split(':')[0]; + contextWindow = defaultContextWindows[baseModelName] || defaultContextWindows.default; + + log.info(`Detected Ollama model ${modelName} with dimension ${dimension}`); + } else { + throw new Error("Could not detect embedding dimensions"); + } + + const modelInfo: EmbeddingModelInfo = { dimension, contextWindow }; + this.modelInfoCache.set(modelName, modelInfo); + + // Update the provider config dimension + this.config.dimension = dimension; + + return modelInfo; + } catch (error: any) { + log.error(`Error detecting Ollama model capabilities: ${error.message}`); + + // If detection fails, use defaults based on model name + const baseModelName = modelName.split(':')[0]; + const dimension = defaultDimensions[baseModelName] || defaultDimensions.default; + const contextWindow = defaultContextWindows[baseModelName] || defaultContextWindows.default; + + log.info(`Using default dimension ${dimension} for model ${modelName}`); + + const modelInfo: EmbeddingModelInfo = { dimension, contextWindow }; + this.modelInfoCache.set(modelName, modelInfo); + + // Update the provider config dimension + this.config.dimension = dimension; + + return modelInfo; + } + } + + /** + * Get the current embedding dimension + */ + getDimension(): number { + return this.config.dimension; + } + /** * Generate embeddings for a single text */ async generateEmbeddings(text: string): Promise { try { + const modelName = this.config.model || "llama3"; + + // Ensure we have model info + const modelInfo = await this.getModelInfo(modelName); + + // Trim text if it might exceed context window (rough character estimate) + // This is a simplistic approach - ideally we'd count tokens properly + const charLimit = modelInfo.contextWindow * 4; // Rough estimate: avg 4 chars per token + const trimmedText = text.length > charLimit ? text.substring(0, charLimit) : text; + const response = await axios.post( `${this.baseUrl}/api/embeddings`, { - model: this.config.model || "llama3", - prompt: text + model: modelName, + prompt: trimmedText }, { headers: { "Content-Type": "application/json" - } + }, + timeout: 30000 // Longer timeout for larger texts } ); diff --git a/src/services/options_init.ts b/src/services/options_init.ts index 979459eec..686bfe4ec 100644 --- a/src/services/options_init.ts +++ b/src/services/options_init.ts @@ -260,7 +260,21 @@ const defaultOptions: DefaultOption[] = [ // Share settings { name: "redirectBareDomain", value: "false", isSynced: true }, - { name: "showLoginInShareTheme", value: "false", isSynced: true } + { name: "showLoginInShareTheme", value: "false", isSynced: true }, + + // AI Options + { name: "aiEnabled", value: "false", isSynced: true }, + { name: "openaiApiKey", value: "", isSynced: false }, + { name: "openaiDefaultModel", value: "gpt-3.5-turbo", isSynced: true }, + { name: "openaiBaseUrl", value: "https://api.openai.com/v1", isSynced: true }, + { name: "anthropicApiKey", value: "", isSynced: false }, + { name: "anthropicDefaultModel", value: "claude-3-haiku-20240307", isSynced: true }, + { name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true }, + { name: "ollamaEnabled", value: "false", isSynced: true }, + { name: "ollamaDefaultModel", value: "llama3", isSynced: true }, + { name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true }, + { name: "ollamaEmbeddingModel", value: "nomic-embed-text", isSynced: true }, + { name: "embeddingAutoUpdate", value: "true", isSynced: true }, ]; /** diff --git a/src/services/options_interface.ts b/src/services/options_interface.ts index b920764b0..71c7c6c8f 100644 --- a/src/services/options_interface.ts +++ b/src/services/options_interface.ts @@ -57,6 +57,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions