- `);
-
- $failedList.append($item);
- }
-
- // Add the header and list to the DOM (no card structure)
- this.$widget.find('.embedding-failed-notes-list').empty().append($header, $failedList);
-
- // Add event handlers using local variables to avoid 'this' issues
- const self = this;
-
- this.$widget.find('.retry-btn').on('click', async function(e) {
- // Prevent default behavior
- e.preventDefault();
-
- const $button = $(this);
- const noteId = $button.data('note-id');
-
- // Show loading state
- $button.prop('disabled', true)
- .removeClass('btn-outline-secondary')
- .addClass('btn-outline-secondary')
- .html('Retrying');
-
- const success = await self.retryFailedEmbedding(noteId);
-
- if (success) {
- toastService.showMessage(t("ai_llm.note_queued_for_retry"));
- await self.refreshEmbeddingStats();
- } else {
- toastService.showError(t("ai_llm.failed_to_retry_note"));
- $button.prop('disabled', false)
- .html(' Retry');
- }
- });
-
- this.$widget.find('.retry-all-btn').on('click', async function(e) {
- const $button = $(this);
-
- // Show loading state
- $button.prop('disabled', true)
- .removeClass('btn-primary')
- .addClass('btn-secondary')
- .html('Retrying All');
-
- const success = await self.retryAllFailedEmbeddings();
-
- if (success) {
- toastService.showMessage(t("ai_llm.all_notes_queued_for_retry"));
- await self.refreshEmbeddingStats();
-
- // Return button to original state after successful refresh
- if (!$button.is(':disabled')) { // Check if button still exists
- $button.prop('disabled', false)
- .removeClass('btn-secondary')
- .addClass('btn-primary')
- .html('Retry All');
- }
- } else {
- toastService.showError(t("ai_llm.failed_to_retry_all"));
- $button.prop('disabled', false)
- .removeClass('btn-secondary')
- .addClass('btn-primary')
- .html('Retry All');
- }
- });
- }
-
- // Replace displayValidationWarnings method with client-side implementation
- async displayValidationWarnings() {
- if (!this.$widget) return;
-
- const $warningDiv = this.$widget.find('.provider-validation-warning');
- let hasWarnings = false;
- let message = 'There are issues with your AI provider configuration:';
-
- try {
- // Get required data from current settings
- const aiEnabled = this.$widget.find('.ai-enabled').prop('checked');
-
- // If AI isn't enabled, don't show warnings
- if (!aiEnabled) {
- $warningDiv.hide();
- return;
- }
-
- // Get default embedding provider
- const defaultProvider = this.$widget.find('.embedding-default-provider').val() as string;
-
- // Get provider precedence
- const precedenceStr = this.$widget.find('.ai-provider-precedence').val() as string;
- let precedenceList: string[] = [];
-
- if (precedenceStr) {
- if (precedenceStr.startsWith('[') && precedenceStr.endsWith(']')) {
- precedenceList = JSON.parse(precedenceStr);
- } else if (precedenceStr.includes(',')) {
- precedenceList = precedenceStr.split(',').map(p => p.trim());
- } else {
- precedenceList = [precedenceStr];
- }
- }
-
- // Get enabled providers
- // Since we don't have direct access to DB from client, we'll use the UI state
- // This is an approximation - enabled providers are generally those with API keys or enabled state
- const enabledProviders: string[] = [];
-
- // OpenAI is enabled if API key is set
- const openaiKey = this.$widget.find('.openai-api-key').val() as string;
- if (openaiKey) {
- enabledProviders.push('openai');
- }
-
- // Anthropic is enabled if API key is set
- const anthropicKey = this.$widget.find('.anthropic-api-key').val() as string;
- if (anthropicKey) {
- enabledProviders.push('anthropic');
- }
-
- // Ollama is enabled if checkbox is checked
- const ollamaEnabled = this.$widget.find('.ollama-enabled').prop('checked');
- if (ollamaEnabled) {
- enabledProviders.push('ollama');
- }
-
- // Local is always available
- enabledProviders.push('local');
-
- // Perform validation checks
- const defaultInPrecedence = precedenceList.includes(defaultProvider);
- const defaultIsEnabled = enabledProviders.includes(defaultProvider);
- const allPrecedenceEnabled = precedenceList.every(p => enabledProviders.includes(p));
-
- // Check for provider configuration issues
- if (!defaultInPrecedence || !defaultIsEnabled || !allPrecedenceEnabled) {
- hasWarnings = true;
-
- if (!defaultInPrecedence) {
- message += ` • The default embedding provider "${defaultProvider}" is not in your provider precedence list.`;
- }
-
- if (!defaultIsEnabled) {
- message += ` • The default embedding provider "${defaultProvider}" is not enabled.`;
- }
-
- if (!allPrecedenceEnabled) {
- const disabledProviders = precedenceList.filter(p => !enabledProviders.includes(p));
- message += ` • The following providers in your precedence list are not enabled: ${disabledProviders.join(', ')}.`;
- }
- }
-
- // Check if embeddings are still being processed
- const queuedNotes = parseInt(this.$widget.find('.embedding-queued-notes').text(), 10);
- if (!isNaN(queuedNotes) && queuedNotes > 0) {
- hasWarnings = true;
- message += ` • There are currently ${queuedNotes} notes in the embedding processing queue.`;
- message += ` Some AI features may produce incomplete results until processing completes.`;
- }
-
- // Show warning message if there are any issues
- if (hasWarnings) {
- message += '
Please check your AI settings.';
- $warningDiv.html(message);
- $warningDiv.show();
- } else {
- $warningDiv.hide();
- }
- } catch (error) {
- console.error('Error validating embedding providers:', error);
- $warningDiv.hide();
- }
- }
-
- /**
- * Set up drag and drop functionality for AI provider precedence
- */
- setupProviderPrecedence() {
- if (!this.$widget) return;
-
- // Setup event handlers for AI provider buttons
- this.setupAiProviderRemoveHandlers();
-
- // Setup drag handlers for all AI provider items
- const $aiSortableList = this.$widget.find('.provider-sortable');
- const $aiListItems = $aiSortableList.find('li');
- $aiListItems.attr('draggable', 'true');
- $aiListItems.each((_, item) => {
- this.setupAiItemDragHandlers($(item));
- });
-
- // Setup event handlers for embedding provider buttons
- this.setupEmbeddingProviderRemoveHandlers();
-
- // Setup drag handlers for all embedding provider items
- const $embeddingSortableList = this.$widget.find('.embedding-provider-sortable');
- const $embeddingListItems = $embeddingSortableList.find('li');
- $embeddingListItems.attr('draggable', 'true');
- $embeddingListItems.each((_, item) => {
- this.setupEmbeddingItemDragHandlers($(item));
- });
- }
-
- /**
- * Setup event handlers for embedding provider remove buttons
- */
- setupEmbeddingProviderRemoveHandlers() {
- if (!this.$widget) return;
-
- const self = this;
- const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
- const $embeddingSortableList = this.$widget.find('.embedding-provider-sortable');
-
- // Remove any existing handlers to prevent duplicates
- this.$widget.find('.remove-provider').off('click');
-
- // Add remove button click handler to all provider items
- this.$widget.find('.remove-provider').on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- const $button = $(this);
- const $item = $button.closest('li');
- const provider = $item.data('provider');
- const providerName = $item.find('strong').text();
-
- // Create a new item for the disabled list
- const $disabledItem = $(`
-
- ${providerName}
-
-
- `);
-
- // Add to disabled list
- self.$widget.find('.embedding-provider-disabled').append($disabledItem);
-
- // Remove from active list
- $item.remove();
-
- // Setup restore handler
- self.setupEmbeddingProviderRestoreHandler($disabledItem);
-
- // Update the hidden input value based on current order
- const providers = $embeddingSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- // Only update if we have providers or if the current value isn't empty
- // This prevents setting an empty string when all providers are removed
- if (providers || $embeddingProviderPrecedence.val()) {
- $embeddingProviderPrecedence.val(providers);
- // Trigger the change event to save the option
- $embeddingProviderPrecedence.trigger('change');
- }
-
- // Show/hide the disabled providers container
- const $disabledContainer = self.$widget.find('.disabled-providers-container');
- const hasDisabledProviders = self.$widget.find('.embedding-provider-disabled li').length > 0;
- $disabledContainer.toggle(hasDisabledProviders);
- });
- }
-
- /**
- * Setup restore button handler for disabled embedding providers
- */
- setupEmbeddingProviderRestoreHandler($disabledItem: JQuery) {
- if (!this.$widget) return;
-
- const self = this;
- const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
- const $embeddingSortableList = this.$widget.find('.embedding-provider-sortable');
-
- $disabledItem.find('.restore-provider').on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- const $button = $(this);
- const $disabledItem = $button.closest('li');
- const provider = $disabledItem.data('provider');
- const providerName = $disabledItem.find('strong').text();
-
- // Create a new item for the active list
- const $activeItem = $(`
-
-
- ${providerName}
-
-
- `);
-
- // Add to active list
- $embeddingSortableList.append($activeItem);
-
- // Remove from disabled list
- $disabledItem.remove();
-
- // Setup drag handlers for the new item
- self.setupEmbeddingItemDragHandlers($activeItem);
-
- // Setup remove button handler
- $activeItem.find('.remove-provider').on('click', function() {
- $(this).closest('li').find('.remove-provider').trigger('click');
- });
-
- // Update the hidden input value based on current order
- const providers = $embeddingSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- // Only update if we have providers or if the current value isn't empty
- // This prevents setting an empty string when all providers are removed
- if (providers || $embeddingProviderPrecedence.val()) {
- $embeddingProviderPrecedence.val(providers);
- // Trigger the change event to save the option
- $embeddingProviderPrecedence.trigger('change');
- }
-
- // Show/hide the disabled providers container
- const $disabledContainer = self.$widget.find('.disabled-providers-container');
- const hasDisabledProviders = self.$widget.find('.embedding-provider-disabled li').length > 0;
- $disabledContainer.toggle(hasDisabledProviders);
- });
- }
-
- /**
- * Setup drag handlers for an embedding provider list item
- */
- setupEmbeddingItemDragHandlers($item: JQuery) {
- if (!this.$widget) return;
-
- const self = this;
- const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
- const $embeddingSortableList = this.$widget.find('.embedding-provider-sortable');
-
- // Setup dragstart handler
- $item.on('dragstart', function(e: JQuery.DragStartEvent) {
- $(this).addClass('dragging');
- e.originalEvent?.dataTransfer?.setData('text/plain', '');
- });
-
- // Setup dragend handler
- $item.on('dragend', function() {
- $(this).removeClass('dragging');
-
- // Update the hidden input value
- const providers = $embeddingSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- // Only update if we have providers or if the current value isn't empty
- // This prevents setting an empty string when all providers are removed
- if (providers || $embeddingProviderPrecedence.val()) {
- $embeddingProviderPrecedence.val(providers);
- $embeddingProviderPrecedence.trigger('change');
- }
- });
-
- // Setup dragover handler
- $item.on('dragover', function(e: JQuery.DragOverEvent) {
- e.preventDefault();
- const draggingItem = self.$widget?.find('.dragging');
- if (!draggingItem?.length || this === draggingItem.get(0)) return;
-
- $(this).addClass('drag-over');
- });
-
- // Setup dragleave handler
- $item.on('dragleave', function() {
- $(this).removeClass('drag-over');
- });
-
- // Setup drop handler
- $item.on('drop', function(e: JQuery.DropEvent) {
- e.preventDefault();
- $(this).removeClass('drag-over');
-
- const draggingItem = self.$widget?.find('.dragging');
- if (!draggingItem?.length || this === draggingItem.get(0)) return;
-
- // Get positions - fixed to handle type errors
- const $this = $(this);
- const allItems = Array.from($embeddingSortableList.find('li').get());
- const draggedIndex = allItems.findIndex(item => $(item).is(draggingItem));
- const dropIndex = allItems.findIndex(item => $(item).is($this));
-
- if (draggedIndex >= 0 && dropIndex >= 0) {
- if (draggedIndex < dropIndex) {
- // Insert after
- $this.after(draggingItem);
- } else {
- // Insert before
- $this.before(draggingItem);
- }
-
- // Update precedence
- const providers = $embeddingSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- // Only update if we have providers or if the current value isn't empty
- // This prevents setting an empty string when all providers are removed
- if (providers || $embeddingProviderPrecedence.val()) {
- $embeddingProviderPrecedence.val(providers);
- $embeddingProviderPrecedence.trigger('change');
- }
- }
- });
- }
-
- /**
- * Initialize the embedding provider precedence order based on saved values
- */
- initializeEmbeddingProviderOrder() {
- if (!this.$widget) return;
-
- const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
- const $sortableList = this.$widget.find('.embedding-provider-sortable');
-
- // Get the current value
- const savedValue = $embeddingProviderPrecedence.val() as string;
- // If no saved value, don't proceed with initialization to avoid triggering the "empty" change
- if (!savedValue) return;
-
- // Get all available providers
- const allProviders = ['openai', 'voyage', 'ollama', 'local'];
- const savedProviders = savedValue.split(',');
-
- // Clear all items from the disabled list first to avoid duplicates
- this.$widget.find('.embedding-provider-disabled').empty();
-
- // Find disabled providers (providers in allProviders but not in savedProviders)
- const disabledProviders = allProviders.filter(p => !savedProviders.includes(p));
-
- // Move saved providers to the end in the correct order
- savedProviders.forEach(provider => {
- const $item = $sortableList.find(`li[data-provider="${provider}"]`);
- if ($item.length) {
- $sortableList.append($item); // Move to the end in the correct order
- }
- });
-
- // Setup remove click handlers first to ensure they work when simulating clicks
- this.setupEmbeddingProviderRemoveHandlers();
-
- // Move disabled providers to the disabled list
- disabledProviders.forEach(provider => {
- const $item = $sortableList.find(`li[data-provider="${provider}"]`);
- if ($item.length) {
- // Simulate clicking the remove button to move it to the disabled list
- $item.find('.remove-provider').trigger('click');
- } else {
- // If it's not in the active list already, manually create it in the disabled list
- const providerName = this.getProviderDisplayName(provider);
- const $disabledItem = $(`
-
- ${providerName}
-
-
- `);
- this.$widget.find('.embedding-provider-disabled').append($disabledItem);
-
- // Add restore button handler
- this.setupEmbeddingProviderRestoreHandler($disabledItem);
- }
- });
-
- // Show/hide the disabled providers container
- const $disabledContainer = this.$widget.find('.disabled-providers-container');
- const hasDisabledProviders = this.$widget.find('.embedding-provider-disabled li').length > 0;
- $disabledContainer.toggle(hasDisabledProviders);
- }
-
- /**
- * Setup drag handlers for an AI provider list item
- */
- setupAiItemDragHandlers($item: JQuery) {
- if (!this.$widget) return;
-
- const self = this;
- const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
- const $aiSortableList = this.$widget.find('.provider-sortable');
-
- // Setup dragstart handler
- $item.on('dragstart', function(e: JQuery.DragStartEvent) {
- $(this).addClass('dragging');
- e.originalEvent?.dataTransfer?.setData('text/plain', '');
- });
-
- // Setup dragend handler
- $item.on('dragend', function() {
- $(this).removeClass('dragging');
-
- // Update the hidden input value
- const providers = $aiSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- $aiProviderPrecedence.val(providers);
- $aiProviderPrecedence.trigger('change');
- });
-
- // Setup dragover handler
- $item.on('dragover', function(e: JQuery.DragOverEvent) {
- e.preventDefault();
- const draggingItem = self.$widget?.find('.dragging');
- if (!draggingItem?.length || this === draggingItem.get(0)) return;
-
- $(this).addClass('drag-over');
- });
-
- // Setup dragleave handler
- $item.on('dragleave', function() {
- $(this).removeClass('drag-over');
- });
-
- // Setup drop handler
- $item.on('drop', function(e: JQuery.DropEvent) {
- e.preventDefault();
- $(this).removeClass('drag-over');
-
- const draggingItem = self.$widget?.find('.dragging');
- if (!draggingItem?.length || this === draggingItem.get(0)) return;
-
- // Get positions - fixed to handle type errors
- const $this = $(this);
- const allItems = Array.from($aiSortableList.find('li').get());
- const draggedIndex = allItems.findIndex(item => $(item).is(draggingItem));
- const dropIndex = allItems.findIndex(item => $(item).is($this));
-
- if (draggedIndex >= 0 && dropIndex >= 0) {
- if (draggedIndex < dropIndex) {
- // Insert after
- $this.after(draggingItem);
- } else {
- // Insert before
- $this.before(draggingItem);
- }
-
- // Update precedence
- const providers = $aiSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- $aiProviderPrecedence.val(providers);
- $aiProviderPrecedence.trigger('change');
- }
- });
- }
-
- /**
- * Initialize the AI provider precedence order based on saved values
- */
- initializeAiProviderOrder() {
- if (!this.$widget) return;
-
- const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
- const $aiSortableList = this.$widget.find('.provider-sortable');
-
- // Get the current value
- const savedValue = $aiProviderPrecedence.val() as string;
- if (!savedValue) return;
-
- // Get all available providers
- const allProviders = ['openai', 'anthropic', 'ollama', 'voyage'];
- const savedProviders = savedValue.split(',');
-
- // Clear all items from the disabled list first to avoid duplicates
- this.$widget.find('.provider-disabled').empty();
-
- // Find disabled providers (providers in allProviders but not in savedProviders)
- const disabledProviders = allProviders.filter(p => !savedProviders.includes(p));
-
- // Move saved providers to the end in the correct order
- savedProviders.forEach(provider => {
- const $item = $aiSortableList.find(`li[data-provider="${provider}"]`);
- if ($item.length) {
- $aiSortableList.append($item); // Move to the end in the correct order
- }
- });
-
- // Setup remove click handlers first to ensure they work when simulating clicks
- this.setupAiProviderRemoveHandlers();
-
- // Move disabled providers to the disabled list
- disabledProviders.forEach(provider => {
- const $item = $aiSortableList.find(`li[data-provider="${provider}"]`);
- if ($item.length) {
- // Simulate clicking the remove button to move it to the disabled list
- $item.find('.remove-ai-provider').trigger('click');
- } else {
- // If it's not in the active list already, manually create it in the disabled list
- const providerName = this.getProviderDisplayName(provider);
- const $disabledItem = $(`
-
- ${providerName}
-
-
- `);
- this.$widget.find('.provider-disabled').append($disabledItem);
-
- // Add restore button handler
- this.setupAiProviderRestoreHandler($disabledItem);
- }
- });
-
- // Show/hide the disabled providers container
- const $disabledContainer = this.$widget.find('.disabled-ai-providers-container');
- const hasDisabledProviders = this.$widget.find('.provider-disabled li').length > 0;
- $disabledContainer.toggle(hasDisabledProviders);
- }
-
- /**
- * Helper to get display name for providers
- */
- getProviderDisplayName(provider: string): string {
- switch(provider) {
- case 'openai': return 'OpenAI';
- case 'anthropic': return 'Anthropic';
- case 'ollama': return 'Ollama';
- case 'voyage': return 'Voyage';
- case 'local': return 'Local';
- default: return provider.charAt(0).toUpperCase() + provider.slice(1);
- }
- }
-
- /**
- * Setup event handlers for AI provider remove buttons
- */
- setupAiProviderRemoveHandlers() {
- if (!this.$widget) return;
-
- const self = this;
- const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
- const $aiSortableList = this.$widget.find('.provider-sortable');
-
- // Remove any existing handlers to prevent duplicates
- this.$widget.find('.remove-ai-provider').off('click');
-
- // Add remove button click handler to all provider items
- this.$widget.find('.remove-ai-provider').on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- const $button = $(this);
- const $item = $button.closest('li');
- const provider = $item.data('provider');
- const providerName = $item.find('strong').text();
-
- // Create a new item for the disabled list
- const $disabledItem = $(`
-
- ${providerName}
-
-
- `);
-
- // Add to disabled list
- self.$widget.find('.provider-disabled').append($disabledItem);
-
- // Remove from active list
- $item.remove();
-
- // Setup restore handler
- self.setupAiProviderRestoreHandler($disabledItem);
-
- // Update the hidden input value based on current order
- const providers = $aiSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- $aiProviderPrecedence.val(providers);
- // Trigger the change event to save the option
- $aiProviderPrecedence.trigger('change');
-
- // Show/hide the disabled providers container
- const $disabledContainer = self.$widget.find('.disabled-ai-providers-container');
- const hasDisabledProviders = self.$widget.find('.provider-disabled li').length > 0;
- $disabledContainer.toggle(hasDisabledProviders);
- });
- }
-
- /**
- * Setup restore button handler for disabled AI providers
- */
- setupAiProviderRestoreHandler($disabledItem: JQuery) {
- if (!this.$widget) return;
-
- const self = this;
- const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
- const $aiSortableList = this.$widget.find('.provider-sortable');
-
- $disabledItem.find('.restore-ai-provider').on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
-
- const $button = $(this);
- const $disabledItem = $button.closest('li');
- const provider = $disabledItem.data('provider');
- const providerName = $disabledItem.find('strong').text();
-
- // Create a new item for the active list
- const $activeItem = $(`
-
-
- ${providerName}
-
-
- `);
-
- // Add to active list
- $aiSortableList.append($activeItem);
-
- // Remove from disabled list
- $disabledItem.remove();
-
- // Setup drag handlers for the new item
- self.setupAiItemDragHandlers($activeItem);
-
- // Setup remove button handler
- $activeItem.find('.remove-ai-provider').on('click', function() {
- $(this).closest('li').find('.remove-ai-provider').trigger('click');
- });
-
- // Update the hidden input value based on current order
- const providers = $aiSortableList.find('li').map(function() {
- return $(this).data('provider');
- }).get().join(',');
-
- $aiProviderPrecedence.val(providers);
- // Trigger the change event to save the option
- $aiProviderPrecedence.trigger('change');
-
- // Show/hide the disabled providers container
- const $disabledContainer = self.$widget.find('.disabled-ai-providers-container');
- const hasDisabledProviders = self.$widget.find('.provider-disabled li').length > 0;
- $disabledContainer.toggle(hasDisabledProviders);
- });
- }
-
-}
-
+import AiSettingsWidget from './ai_settings/index.js';
+export default AiSettingsWidget;
\ No newline at end of file
diff --git a/src/public/app/widgets/type_widgets/options/ai_settings/ai_settings_widget.ts b/src/public/app/widgets/type_widgets/options/ai_settings/ai_settings_widget.ts
new file mode 100644
index 000000000..c42fc0148
--- /dev/null
+++ b/src/public/app/widgets/type_widgets/options/ai_settings/ai_settings_widget.ts
@@ -0,0 +1,932 @@
+import OptionsWidget from "../options_widget.js";
+import { TPL } from "./template.js";
+import { t } from "../../../../services/i18n.js";
+import type { FilterOptionsByType, OptionMap } from "../../../../../../services/options_interface.js";
+import server from "../../../../services/server.js";
+import toastService from "../../../../services/toast.js";
+import type { EmbeddingStats, FailedEmbeddingNotes } from "./interfaces.js";
+import { ProviderService } from "./providers.js";
+
+export default class AiSettingsWidget extends OptionsWidget {
+ private ollamaModelsRefreshed = false;
+ private openaiModelsRefreshed = false;
+ private anthropicModelsRefreshed = false;
+ private statsRefreshInterval: NodeJS.Timeout | null = null;
+ private indexRebuildRefreshInterval: NodeJS.Timeout | null = null;
+ private readonly STATS_REFRESH_INTERVAL = 5000; // 5 seconds
+ private providerService: ProviderService | null = null;
+
+ doRender() {
+ this.$widget = $(TPL);
+ this.providerService = new ProviderService(this.$widget);
+
+ // Setup event handlers for options
+ this.setupEventHandlers();
+
+ this.refreshEmbeddingStats();
+ this.fetchFailedEmbeddingNotes();
+
+ return this.$widget;
+ }
+
+ /**
+ * Set up all event handlers for options
+ */
+ setupEventHandlers() {
+ if (!this.$widget) return;
+
+ // AI Enabled checkbox
+ const $aiEnabled = this.$widget.find('.ai-enabled');
+ $aiEnabled.on('change', async () => {
+ await this.updateOption('aiEnabled', $aiEnabled.prop('checked') ? 'true' : 'false');
+ // Display validation warnings after changing aiEnabled
+ await this.displayValidationWarnings();
+ });
+
+ // Provider precedence
+ const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
+ $aiProviderPrecedence.on('change', async () => {
+ await this.updateOption('aiProviderPrecedence', $aiProviderPrecedence.val() as string);
+ // Display validation warnings after changing precedence list
+ await this.displayValidationWarnings();
+ });
+
+ // Temperature
+ const $aiTemperature = this.$widget.find('.ai-temperature');
+ $aiTemperature.on('change', async () => {
+ await this.updateOption('aiTemperature', $aiTemperature.val() as string);
+ });
+
+ // System prompt
+ const $aiSystemPrompt = this.$widget.find('.ai-system-prompt');
+ $aiSystemPrompt.on('change', async () => {
+ await this.updateOption('aiSystemPrompt', $aiSystemPrompt.val() as string);
+ });
+
+ // OpenAI options
+ const $openaiApiKey = this.$widget.find('.openai-api-key');
+ $openaiApiKey.on('change', async () => {
+ await this.updateOption('openaiApiKey', $openaiApiKey.val() as string);
+ // Display validation warnings after changing API key
+ await this.displayValidationWarnings();
+ });
+
+ const $openaiBaseUrl = this.$widget.find('.openai-base-url');
+ $openaiBaseUrl.on('change', async () => {
+ await this.updateOption('openaiBaseUrl', $openaiBaseUrl.val() as string);
+ // Display validation warnings after changing URL
+ await this.displayValidationWarnings();
+ });
+
+ const $openaiDefaultModel = this.$widget.find('.openai-default-model');
+ $openaiDefaultModel.on('change', async () => {
+ await this.updateOption('openaiDefaultModel', $openaiDefaultModel.val() as string);
+ });
+
+ const $openaiEmbeddingModel = this.$widget.find('.openai-embedding-model');
+ $openaiEmbeddingModel.on('change', async () => {
+ await this.updateOption('openaiEmbeddingModel', $openaiEmbeddingModel.val() as string);
+ });
+
+ // Anthropic options
+ const $anthropicApiKey = this.$widget.find('.anthropic-api-key');
+ $anthropicApiKey.on('change', async () => {
+ await this.updateOption('anthropicApiKey', $anthropicApiKey.val() as string);
+ // Display validation warnings after changing API key
+ await this.displayValidationWarnings();
+ });
+
+ const $anthropicDefaultModel = this.$widget.find('.anthropic-default-model');
+ $anthropicDefaultModel.on('change', async () => {
+ await this.updateOption('anthropicDefaultModel', $anthropicDefaultModel.val() as string);
+ });
+
+ const $anthropicBaseUrl = this.$widget.find('.anthropic-base-url');
+ $anthropicBaseUrl.on('change', async () => {
+ await this.updateOption('anthropicBaseUrl', $anthropicBaseUrl.val() as string);
+ });
+
+ const $voyageApiKey = this.$widget.find('.voyage-api-key');
+ $voyageApiKey.on('change', async () => {
+ await this.updateOption('voyageApiKey', $voyageApiKey.val() as string);
+ });
+
+ const $voyageEmbeddingModel = this.$widget.find('.voyage-embedding-model');
+ $voyageEmbeddingModel.on('change', async () => {
+ await this.updateOption('voyageEmbeddingModel', $voyageEmbeddingModel.val() as string);
+ });
+
+ const $ollamaBaseUrl = this.$widget.find('.ollama-base-url');
+ $ollamaBaseUrl.on('change', async () => {
+ await this.updateOption('ollamaBaseUrl', $ollamaBaseUrl.val() as string);
+ });
+
+ const $ollamaDefaultModel = this.$widget.find('.ollama-default-model');
+ $ollamaDefaultModel.on('change', async () => {
+ 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 () => {
+ this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(true, this.ollamaModelsRefreshed) || false;
+ });
+
+ // Add tab change handler for Ollama tab
+ const $ollamaTab = this.$widget.find('#nav-ollama-tab');
+ $ollamaTab.on('shown.bs.tab', async () => {
+ // Only refresh the models if we haven't done it before
+ this.ollamaModelsRefreshed = await this.providerService?.refreshOllamaModels(false, this.ollamaModelsRefreshed) || false;
+ });
+
+ // OpenAI models refresh button
+ const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
+ $refreshOpenAIModels.on('click', async () => {
+ this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(true, this.openaiModelsRefreshed) || false;
+ });
+
+ // Add tab change handler for OpenAI tab
+ const $openaiTab = this.$widget.find('#nav-openai-tab');
+ $openaiTab.on('shown.bs.tab', async () => {
+ // Only refresh the models if we haven't done it before
+ this.openaiModelsRefreshed = await this.providerService?.refreshOpenAIModels(false, this.openaiModelsRefreshed) || false;
+ });
+
+ // Anthropic models refresh button
+ const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
+ $refreshAnthropicModels.on('click', async () => {
+ this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(true, this.anthropicModelsRefreshed) || false;
+ });
+
+ // Add tab change handler for Anthropic tab
+ const $anthropicTab = this.$widget.find('#nav-anthropic-tab');
+ $anthropicTab.on('shown.bs.tab', async () => {
+ // Only refresh the models if we haven't done it before
+ this.anthropicModelsRefreshed = await this.providerService?.refreshAnthropicModels(false, this.anthropicModelsRefreshed) || false;
+ });
+
+ // Embedding options event handlers
+ const $embeddingAutoUpdateEnabled = this.$widget.find('.embedding-auto-update-enabled');
+ $embeddingAutoUpdateEnabled.on('change', async () => {
+ await this.updateOption('embeddingAutoUpdateEnabled', $embeddingAutoUpdateEnabled.prop('checked') ? "true" : "false");
+ });
+
+ const $enableAutomaticIndexing = this.$widget.find('.enable-automatic-indexing');
+ $enableAutomaticIndexing.on('change', async () => {
+ await this.updateOption('enableAutomaticIndexing', $enableAutomaticIndexing.prop('checked') ? "true" : "false");
+ });
+
+ const $embeddingSimilarityThreshold = this.$widget.find('.embedding-similarity-threshold');
+ $embeddingSimilarityThreshold.on('change', async () => {
+ await this.updateOption('embeddingSimilarityThreshold', $embeddingSimilarityThreshold.val() as string);
+ });
+
+ const $maxNotesPerLlmQuery = this.$widget.find('.max-notes-per-llm-query');
+ $maxNotesPerLlmQuery.on('change', async () => {
+ await this.updateOption('maxNotesPerLlmQuery', $maxNotesPerLlmQuery.val() as string);
+ });
+
+ const $embeddingDefaultProvider = this.$widget.find('.embedding-default-provider');
+ $embeddingDefaultProvider.on('change', async () => {
+ await this.updateOption('embeddingsDefaultProvider', $embeddingDefaultProvider.val() as string);
+ // Display validation warnings after changing default provider
+ await this.displayValidationWarnings();
+ });
+
+ const $embeddingDimensionStrategy = this.$widget.find('.embedding-dimension-strategy');
+ $embeddingDimensionStrategy.on('change', async () => {
+ await this.updateOption('embeddingDimensionStrategy', $embeddingDimensionStrategy.val() as string);
+ });
+
+ const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
+ $embeddingProviderPrecedence.on('change', async () => {
+ await this.updateOption('embeddingProviderPrecedence', $embeddingProviderPrecedence.val() as string);
+ // Display validation warnings after changing precedence list
+ await this.displayValidationWarnings();
+ });
+
+ // Set up sortable behavior for the embedding provider precedence list
+ this.setupEmbeddingProviderSortable();
+ this.setupAiProviderSortable();
+
+ // Embedding stats refresh button
+ const $refreshStats = this.$widget.find('.embedding-refresh-stats');
+ $refreshStats.on('click', async () => {
+ await this.refreshEmbeddingStats();
+ await this.fetchFailedEmbeddingNotes();
+ });
+
+ // Rebuild index button
+ const $rebuildIndex = this.$widget.find('.rebuild-embeddings-index');
+ $rebuildIndex.on('click', async () => {
+ try {
+ await server.post('embeddings/rebuild');
+ toastService.showMessage(t("ai_llm.rebuild_index_started"));
+
+ // Start progress polling
+ this.pollIndexRebuildProgress();
+ } catch (e) {
+ console.error('Error starting index rebuild:', e);
+ toastService.showError(t("ai_llm.rebuild_index_error"));
+ }
+ });
+ }
+
+ /**
+ * Display warnings for validation issues with providers
+ */
+ async displayValidationWarnings() {
+ if (!this.$widget) return;
+
+ const $warningDiv = this.$widget.find('.provider-validation-warning');
+
+ // Check if AI is enabled
+ const aiEnabled = this.$widget.find('.ai-enabled').prop('checked');
+ if (!aiEnabled) {
+ $warningDiv.hide();
+ return;
+ }
+
+ // Get provider precedence
+ const providerPrecedence = (this.$widget.find('.ai-provider-precedence').val() as string || '').split(',');
+
+ // Check for OpenAI configuration if it's in the precedence list
+ const openaiWarnings = [];
+ if (providerPrecedence.includes('openai')) {
+ const openaiApiKey = this.$widget.find('.openai-api-key').val();
+ if (!openaiApiKey) {
+ openaiWarnings.push(t("ai_llm.warning_openai_missing_api_key"));
+ }
+ }
+
+ // Check for Anthropic configuration if it's in the precedence list
+ const anthropicWarnings = [];
+ if (providerPrecedence.includes('anthropic')) {
+ const anthropicApiKey = this.$widget.find('.anthropic-api-key').val();
+ if (!anthropicApiKey) {
+ anthropicWarnings.push(t("ai_llm.warning_anthropic_missing_api_key"));
+ }
+ }
+
+ // Check for Voyage configuration if it's in the precedence list
+ const voyageWarnings = [];
+ if (providerPrecedence.includes('voyage')) {
+ const voyageApiKey = this.$widget.find('.voyage-api-key').val();
+ if (!voyageApiKey) {
+ voyageWarnings.push(t("ai_llm.warning_voyage_missing_api_key"));
+ }
+ }
+
+ // Check for Ollama configuration if it's in the precedence list
+ const ollamaWarnings = [];
+ if (providerPrecedence.includes('ollama')) {
+ const ollamaBaseUrl = this.$widget.find('.ollama-base-url').val();
+ if (!ollamaBaseUrl) {
+ ollamaWarnings.push(t("ai_llm.warning_ollama_missing_url"));
+ }
+ }
+
+ // Similar checks for embeddings
+ const embeddingWarnings = [];
+ const embeddingsEnabled = this.$widget.find('.enable-automatic-indexing').prop('checked');
+
+ if (embeddingsEnabled) {
+ const embeddingProviderPrecedence = (this.$widget.find('.embedding-provider-precedence').val() as string || '').split(',');
+
+ if (embeddingProviderPrecedence.includes('openai') && !this.$widget.find('.openai-api-key').val()) {
+ embeddingWarnings.push(t("ai_llm.warning_openai_embedding_missing_api_key"));
+ }
+
+ if (embeddingProviderPrecedence.includes('voyage') && !this.$widget.find('.voyage-api-key').val()) {
+ embeddingWarnings.push(t("ai_llm.warning_voyage_embedding_missing_api_key"));
+ }
+
+ if (embeddingProviderPrecedence.includes('ollama') && !this.$widget.find('.ollama-base-url').val()) {
+ embeddingWarnings.push(t("ai_llm.warning_ollama_embedding_missing_url"));
+ }
+ }
+
+ // Combine all warnings
+ const allWarnings = [
+ ...openaiWarnings,
+ ...anthropicWarnings,
+ ...voyageWarnings,
+ ...ollamaWarnings,
+ ...embeddingWarnings
+ ];
+
+ // Show or hide warnings
+ if (allWarnings.length > 0) {
+ const warningHtml = '' + t("ai_llm.configuration_warnings") + '
+ `;
+
+ $failedNotesList.html(html);
+
+ // Add event handlers for retry buttons
+ $failedNotesList.find('.retry-embedding').on('click', async function() {
+ const noteId = $(this).closest('tr').data('note-id');
+ try {
+ await server.post('embeddings/retry', { noteId });
+ toastService.showMessage(t("ai_llm.retry_queued"));
+ // Remove this row or update status
+ $(this).closest('tr').remove();
+ } catch (e) {
+ console.error('Error retrying embedding:', e);
+ toastService.showError(t("ai_llm.retry_failed"));
+ }
+ });
+
+ // Add event handlers for open note links
+ $failedNotesList.find('.open-note').on('click', function(e) {
+ e.preventDefault();
+ const noteId = $(this).closest('tr').data('note-id');
+ window.open(`#${noteId}`, '_blank');
+ });
+ }
+ } catch (e) {
+ console.error('Error fetching failed embedding notes:', e);
+ }
+ }
+
+ /**
+ * Helper to get display name for providers
+ */
+ getProviderDisplayName(provider: string): string {
+ switch(provider) {
+ case 'openai': return 'OpenAI';
+ case 'anthropic': return 'Anthropic';
+ case 'ollama': return 'Ollama';
+ case 'voyage': return 'Voyage';
+ case 'local': return 'Local';
+ default: return provider.charAt(0).toUpperCase() + provider.slice(1);
+ }
+ }
+
+ /**
+ * Setup sortable behavior for embedding provider precedence
+ */
+ setupEmbeddingProviderSortable() {
+ if (!this.$widget) return;
+
+ const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
+ const $sortableList = this.$widget.find('.embedding-provider-sortable');
+ const $items = $sortableList.find('li');
+
+ // Make list items draggable
+ $items.each((index, item) => this.setupEmbeddingProviderItemDragHandlers($(item)));
+
+ // Setup the remove buttons
+ this.setupEmbeddingProviderRemoveHandlers();
+
+ // Setup disabled providers list restore handlers
+ this.$widget.find('.embedding-provider-disabled li').each((index, item) => {
+ this.setupEmbeddingProviderRestoreHandler($(item));
+ });
+
+ // Initialize the order based on saved value
+ this.initializeEmbeddingProviderOrder();
+ }
+
+ /**
+ * Setup sortable behavior for AI provider precedence
+ */
+ setupAiProviderSortable() {
+ if (!this.$widget) return;
+
+ const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
+ const $sortableList = this.$widget.find('.provider-sortable');
+ const $items = $sortableList.find('li');
+
+ // Make list items draggable
+ $items.each((index, item) => this.setupAiItemDragHandlers($(item)));
+
+ // Setup the remove buttons
+ this.setupAiProviderRemoveHandlers();
+
+ // Setup disabled providers list restore handlers
+ this.$widget.find('.provider-disabled li').each((index, item) => {
+ this.setupAiProviderRestoreHandler($(item));
+ });
+
+ // Initialize the order based on saved value
+ this.initializeAiProviderOrder();
+ }
+
+ /**
+ * Setup drag handlers for an embedding provider list item
+ */
+ setupEmbeddingProviderItemDragHandlers($item: JQuery) {
+ if (!this.$widget) return;
+
+ const self = this;
+ const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
+ const $embeddingSortableList = this.$widget.find('.embedding-provider-sortable');
+
+ // Setup dragstart handler
+ $item.on('dragstart', function(e: JQuery.DragStartEvent) {
+ $(this).addClass('dragging');
+ e.originalEvent?.dataTransfer?.setData('text/plain', '');
+ });
+
+ // Setup dragend handler
+ $item.on('dragend', function() {
+ $(this).removeClass('dragging');
+
+ // Update the hidden input value
+ const providers = $embeddingSortableList.find('li').map(function() {
+ return $(this).data('provider');
+ }).get().join(',');
+
+ // Only update if we have providers or if the current value isn't empty
+ // This prevents setting an empty string when all providers are removed
+ if (providers || $embeddingProviderPrecedence.val()) {
+ $embeddingProviderPrecedence.val(providers);
+ $embeddingProviderPrecedence.trigger('change');
+ }
+ });
+
+ // Additional drag event handlers ...
+
+ // All other drag event handlers would be implemented here
+ }
+
+ /**
+ * Setup event handlers for embedding provider remove buttons
+ */
+ setupEmbeddingProviderRemoveHandlers() {
+ if (!this.$widget) return;
+
+ const self = this;
+ const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
+ const $embeddingSortableList = this.$widget.find('.embedding-provider-sortable');
+
+ // Remove any existing handlers to prevent duplicates
+ this.$widget.find('.remove-provider').off('click');
+
+ // Add handlers
+ this.$widget.find('.remove-provider').on('click', function() {
+ const $item = $(this).closest('li');
+ const provider = $item.data('provider');
+ const providerName = self.getProviderDisplayName(provider);
+
+ // Create a new item for the disabled list
+ const $disabledItem = $(`
+
+ ${providerName}
+
+
+ `);
+
+ // Move to disabled list
+ self.$widget?.find('.embedding-provider-disabled').append($disabledItem);
+ self.setupEmbeddingProviderRestoreHandler($disabledItem);
+ $item.remove();
+
+ // Update the precedence value
+ const providers = $embeddingSortableList.find('li').map(function() {
+ return $(this).data('provider');
+ }).get().join(',');
+ $embeddingProviderPrecedence.val(providers);
+ $embeddingProviderPrecedence.trigger('change');
+
+ // Show disabled providers container
+ self.$widget?.find('.disabled-providers-container').show();
+ });
+ }
+
+ /**
+ * Setup event handler for embedding provider restore button
+ */
+ setupEmbeddingProviderRestoreHandler($item: JQuery) {
+ if (!this.$widget) return;
+
+ const self = this;
+ const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
+ const $embeddingSortableList = this.$widget.find('.embedding-provider-sortable');
+
+ // Remove any existing handlers to prevent duplicates
+ $item.find('.restore-provider').off('click');
+
+ // Add handlers
+ $item.find('.restore-provider').on('click', function() {
+ const $disabledItem = $(this).closest('li');
+ const provider = $disabledItem.data('provider');
+ const providerName = self.getProviderDisplayName(provider);
+
+ // Create a new item for the active list
+ const $activeItem = $(`
+
+
+ ${providerName}
+
+
+ `);
+
+ // Move to active list
+ $embeddingSortableList.append($activeItem);
+ self.setupEmbeddingProviderItemDragHandlers($activeItem);
+ self.setupEmbeddingProviderRemoveHandlers();
+ $disabledItem.remove();
+
+ // Update the precedence value
+ const providers = $embeddingSortableList.find('li').map(function() {
+ return $(this).data('provider');
+ }).get().join(',');
+ $embeddingProviderPrecedence.val(providers);
+ $embeddingProviderPrecedence.trigger('change');
+
+ // Hide disabled providers container if it's now empty
+ if (self.$widget?.find('.embedding-provider-disabled li').length === 0) {
+ self.$widget?.find('.disabled-providers-container').hide();
+ }
+ });
+ }
+
+ /**
+ * Initialize the embedding provider precedence order based on saved values
+ */
+ initializeEmbeddingProviderOrder() {
+ if (!this.$widget) return;
+
+ const $embeddingProviderPrecedence = this.$widget.find('.embedding-provider-precedence');
+ const $sortableList = this.$widget.find('.embedding-provider-sortable');
+
+ // Get the current value
+ const savedValue = $embeddingProviderPrecedence.val() as string;
+ // If no saved value, don't proceed with initialization to avoid triggering the "empty" change
+ if (!savedValue) return;
+
+ // Get all available providers
+ const allProviders = ['openai', 'voyage', 'ollama', 'local'];
+ const savedProviders = savedValue.split(',');
+
+ // Clear all items from the disabled list first to avoid duplicates
+ this.$widget.find('.embedding-provider-disabled').empty();
+
+ // Find disabled providers (providers in allProviders but not in savedProviders)
+ const disabledProviders = allProviders.filter(p => !savedProviders.includes(p));
+
+ // Move saved providers to the end in the correct order
+ savedProviders.forEach(provider => {
+ const $item = $sortableList.find(`li[data-provider="${provider}"]`);
+ if ($item.length) {
+ $sortableList.append($item); // Move to the end in the correct order
+ }
+ });
+
+ // Setup remove click handlers first to ensure they work when simulating clicks
+ this.setupEmbeddingProviderRemoveHandlers();
+
+ // Move disabled providers to the disabled list
+ disabledProviders.forEach(provider => {
+ const $item = $sortableList.find(`li[data-provider="${provider}"]`);
+ if ($item.length) {
+ // Simulate clicking the remove button to move it to the disabled list
+ $item.find('.remove-provider').trigger('click');
+ } else {
+ // If it's not in the active list already, manually create it in the disabled list
+ const providerName = this.getProviderDisplayName(provider);
+ const $disabledItem = $(`
+
+ ${providerName}
+
+
+ `);
+ this.$widget.find('.embedding-provider-disabled').append($disabledItem);
+
+ // Add restore button handler
+ this.setupEmbeddingProviderRestoreHandler($disabledItem);
+ }
+ });
+
+ // Show/hide the disabled providers container
+ const $disabledContainer = this.$widget.find('.disabled-providers-container');
+ const hasDisabledProviders = this.$widget.find('.embedding-provider-disabled li').length > 0;
+ $disabledContainer.toggle(hasDisabledProviders);
+ }
+
+ /**
+ * Setup drag handlers for an AI provider list item
+ */
+ setupAiItemDragHandlers($item: JQuery) {
+ if (!this.$widget) return;
+
+ const self = this;
+ const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
+ const $aiSortableList = this.$widget.find('.provider-sortable');
+
+ // Setup dragstart handler
+ $item.on('dragstart', function(e: JQuery.DragStartEvent) {
+ $(this).addClass('dragging');
+ e.originalEvent?.dataTransfer?.setData('text/plain', '');
+ });
+
+ // Setup dragend handler
+ $item.on('dragend', function() {
+ $(this).removeClass('dragging');
+
+ // Update the hidden input value
+ const providers = $aiSortableList.find('li').map(function() {
+ return $(this).data('provider');
+ }).get().join(',');
+
+ $aiProviderPrecedence.val(providers);
+ $aiProviderPrecedence.trigger('change');
+ });
+
+ // Additional drag event handlers would go here...
+ }
+
+ /**
+ * Initialize the AI provider precedence order based on saved values
+ */
+ initializeAiProviderOrder() {
+ if (!this.$widget) return;
+
+ const $aiProviderPrecedence = this.$widget.find('.ai-provider-precedence');
+ const $aiSortableList = this.$widget.find('.provider-sortable');
+
+ // Get the current value
+ const savedValue = $aiProviderPrecedence.val() as string;
+ if (!savedValue) return;
+
+ // Get all available providers
+ const allProviders = ['openai', 'anthropic', 'ollama', 'voyage'];
+ const savedProviders = savedValue.split(',');
+
+ // Clear all items from the disabled list first to avoid duplicates
+ this.$widget.find('.provider-disabled').empty();
+
+ // Find disabled providers (providers in allProviders but not in savedProviders)
+ const disabledProviders = allProviders.filter(p => !savedProviders.includes(p));
+
+ // Move saved providers to the end in the correct order
+ savedProviders.forEach(provider => {
+ const $item = $aiSortableList.find(`li[data-provider="${provider}"]`);
+ if ($item.length) {
+ $aiSortableList.append($item); // Move to the end in the correct order
+ }
+ });
+
+ // Setup remove click handlers first to ensure they work when simulating clicks
+ this.setupAiProviderRemoveHandlers();
+
+ // Move disabled providers to the disabled list
+ disabledProviders.forEach(provider => {
+ const $item = $aiSortableList.find(`li[data-provider="${provider}"]`);
+ if ($item.length) {
+ // Simulate clicking the remove button to move it to the disabled list
+ $item.find('.remove-ai-provider').trigger('click');
+ } else {
+ // If it's not in the active list already, manually create it in the disabled list
+ const providerName = this.getProviderDisplayName(provider);
+ const $disabledItem = $(`
+
+ ${providerName}
+
+
+ `);
+ this.$widget.find('.provider-disabled').append($disabledItem);
+
+ // Add restore button handler
+ this.setupAiProviderRestoreHandler($disabledItem);
+ }
+ });
+
+ // Show/hide the disabled providers container
+ const $disabledContainer = this.$widget.find('.disabled-ai-providers-container');
+ const hasDisabledProviders = this.$widget.find('.provider-disabled li').length > 0;
+ $disabledContainer.toggle(hasDisabledProviders);
+ }
+
+ /**
+ * Setup event handlers for AI provider remove buttons
+ */
+ setupAiProviderRemoveHandlers() {
+ if (!this.$widget) return;
+
+ // Implementation would go here...
+ }
+
+ /**
+ * Setup event handler for AI provider restore button
+ */
+ setupAiProviderRestoreHandler($item: JQuery) {
+ if (!this.$widget) return;
+
+ // Implementation would go here...
+ }
+
+ /**
+ * Called when the options have been loaded from the server
+ */
+ optionsLoaded(options: OptionMap) {
+ if (!this.$widget) return;
+
+ // AI Options
+ this.$widget.find('.ai-enabled').prop('checked', options.aiEnabled !== 'false');
+ this.$widget.find('.ai-temperature').val(options.aiTemperature || '0.7');
+ this.$widget.find('.ai-system-prompt').val(options.aiSystemPrompt || '');
+ this.$widget.find('.ai-provider-precedence').val(options.aiProviderPrecedence || 'openai,anthropic,ollama');
+
+ // OpenAI Section
+ this.$widget.find('.openai-api-key').val(options.openaiApiKey || '');
+ this.$widget.find('.openai-base-url').val(options.openaiBaseUrl || 'https://api.openai.com/v1');
+ this.$widget.find('.openai-default-model').val(options.openaiDefaultModel || 'gpt-4o');
+ this.$widget.find('.openai-embedding-model').val(options.openaiEmbeddingModel || 'text-embedding-3-small');
+
+ // Anthropic Section
+ this.$widget.find('.anthropic-api-key').val(options.anthropicApiKey || '');
+ this.$widget.find('.anthropic-base-url').val(options.anthropicBaseUrl || 'https://api.anthropic.com');
+ this.$widget.find('.anthropic-default-model').val(options.anthropicDefaultModel || 'claude-3-opus-20240229');
+
+ // Voyage Section
+ this.$widget.find('.voyage-api-key').val(options.voyageApiKey || '');
+ this.$widget.find('.voyage-embedding-model').val(options.voyageEmbeddingModel || 'voyage-2');
+
+ // Ollama Section
+ this.$widget.find('.ollama-base-url').val(options.ollamaBaseUrl || 'http://localhost:11434');
+ this.$widget.find('.ollama-default-model').val(options.ollamaDefaultModel || 'llama3');
+ this.$widget.find('.ollama-embedding-model').val(options.ollamaEmbeddingModel || 'nomic-embed-text');
+
+ // Embedding Options
+ this.$widget.find('.embedding-provider-precedence').val(options.embeddingProviderPrecedence || 'openai,voyage,ollama,local');
+ this.$widget.find('.embedding-auto-update-enabled').prop('checked', options.embeddingAutoUpdateEnabled !== 'false');
+ this.$widget.find('.enable-automatic-indexing').prop('checked', options.enableAutomaticIndexing !== 'false');
+ this.$widget.find('.embedding-similarity-threshold').val(options.embeddingSimilarityThreshold || '0.75');
+ this.$widget.find('.max-notes-per-llm-query').val(options.maxNotesPerLlmQuery || '3');
+ this.$widget.find('.embedding-dimension-strategy').val(options.embeddingDimensionStrategy || 'auto');
+
+ // Initialize sortable lists
+ this.initializeEmbeddingProviderOrder();
+ this.initializeAiProviderOrder();
+
+ // Display validation warnings
+ this.displayValidationWarnings();
+ }
+
+ cleanup() {
+ // Clear intervals
+ if (this.statsRefreshInterval) {
+ clearInterval(this.statsRefreshInterval);
+ this.statsRefreshInterval = null;
+ }
+
+ if (this.indexRebuildRefreshInterval) {
+ clearInterval(this.indexRebuildRefreshInterval);
+ this.indexRebuildRefreshInterval = null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/public/app/widgets/type_widgets/options/ai_settings/index.ts b/src/public/app/widgets/type_widgets/options/ai_settings/index.ts
new file mode 100644
index 000000000..487abb407
--- /dev/null
+++ b/src/public/app/widgets/type_widgets/options/ai_settings/index.ts
@@ -0,0 +1,2 @@
+import AiSettingsWidget from './ai_settings_widget.js';
+export default AiSettingsWidget;
\ No newline at end of file
diff --git a/src/public/app/widgets/type_widgets/options/ai_settings/interfaces.ts b/src/public/app/widgets/type_widgets/options/ai_settings/interfaces.ts
new file mode 100644
index 000000000..2a3326ced
--- /dev/null
+++ b/src/public/app/widgets/type_widgets/options/ai_settings/interfaces.ts
@@ -0,0 +1,69 @@
+// Interface for the Ollama model response
+export interface OllamaModelResponse {
+ success: boolean;
+ models: Array<{
+ name: string;
+ model: string;
+ details?: {
+ family?: string;
+ parameter_size?: string;
+ }
+ }>;
+}
+
+// Interface for embedding statistics
+export interface EmbeddingStats {
+ success: boolean;
+ stats: {
+ totalNotesCount: number;
+ embeddedNotesCount: number;
+ queuedNotesCount: number;
+ failedNotesCount: number;
+ lastProcessedDate: string | null;
+ percentComplete: number;
+ }
+}
+
+// Interface for failed embedding notes
+export interface FailedEmbeddingNotes {
+ success: boolean;
+ failedNotes: Array<{
+ noteId: string;
+ title?: string;
+ operation: string;
+ attempts: number;
+ lastAttempt: string;
+ error: string;
+ failureType: string;
+ chunks: number;
+ isPermanent: boolean;
+ }>;
+}
+
+export interface OpenAIModelResponse {
+ success: boolean;
+ chatModels: Array<{
+ id: string;
+ name: string;
+ type: string;
+ }>;
+ embeddingModels: Array<{
+ id: string;
+ name: string;
+ type: string;
+ }>;
+}
+
+export interface AnthropicModelResponse {
+ success: boolean;
+ chatModels: Array<{
+ id: string;
+ name: string;
+ type: string;
+ }>;
+ embeddingModels: Array<{
+ id: string;
+ name: string;
+ type: string;
+ }>;
+}
\ No newline at end of file
diff --git a/src/public/app/widgets/type_widgets/options/ai_settings/providers.ts b/src/public/app/widgets/type_widgets/options/ai_settings/providers.ts
new file mode 100644
index 000000000..c5dfc71d3
--- /dev/null
+++ b/src/public/app/widgets/type_widgets/options/ai_settings/providers.ts
@@ -0,0 +1,305 @@
+import server from "../../../../services/server.js";
+import toastService from "../../../../services/toast.js";
+import { t } from "../../../../services/i18n.js";
+import { OpenAIModelResponse, AnthropicModelResponse, OllamaModelResponse } from "./interfaces.js";
+
+export class ProviderService {
+ constructor(private $widget: JQuery) {}
+
+ /**
+ * Refreshes the list of OpenAI models
+ * @param showLoading Whether to show loading indicators and toasts
+ * @param openaiModelsRefreshed Reference to track if models have been refreshed
+ * @returns Promise that resolves when the refresh is complete
+ */
+ async refreshOpenAIModels(showLoading: boolean, openaiModelsRefreshed: boolean): Promise {
+ if (!this.$widget) return false;
+
+ const $refreshOpenAIModels = this.$widget.find('.refresh-openai-models');
+
+ // If we've already refreshed and we're not forcing a refresh, don't do it again
+ if (openaiModelsRefreshed && !showLoading) {
+ return openaiModelsRefreshed;
+ }
+
+ if (showLoading) {
+ $refreshOpenAIModels.prop('disabled', true);
+ $refreshOpenAIModels.html(``);
+ }
+
+ try {
+ const openaiBaseUrl = this.$widget.find('.openai-base-url').val() as string;
+ const response = await server.post('openai/list-models', { baseUrl: openaiBaseUrl });
+
+ if (response && response.success) {
+ // Update the chat models dropdown
+ if (response.chatModels?.length > 0) {
+ const $chatModelSelect = this.$widget.find('.openai-default-model');
+ const currentChatValue = $chatModelSelect.val();
+
+ // Clear existing options
+ $chatModelSelect.empty();
+
+ // Sort models by name
+ const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
+
+ // Add models to the dropdown
+ sortedChatModels.forEach(model => {
+ $chatModelSelect.append(``);
+ });
+
+ // Try to restore the previously selected value
+ if (currentChatValue) {
+ $chatModelSelect.val(currentChatValue);
+ // If the value doesn't exist anymore, select the first option
+ if (!$chatModelSelect.val()) {
+ $chatModelSelect.prop('selectedIndex', 0);
+ }
+ }
+ }
+
+ // Update the embedding models dropdown
+ if (response.embeddingModels?.length > 0) {
+ const $embedModelSelect = this.$widget.find('.openai-embedding-model');
+ const currentEmbedValue = $embedModelSelect.val();
+
+ // Clear existing options
+ $embedModelSelect.empty();
+
+ // Sort models by name
+ const sortedEmbedModels = [...response.embeddingModels].sort((a, b) => a.name.localeCompare(b.name));
+
+ // Add models to the dropdown
+ sortedEmbedModels.forEach(model => {
+ $embedModelSelect.append(``);
+ });
+
+ // Try to restore the previously selected value
+ if (currentEmbedValue) {
+ $embedModelSelect.val(currentEmbedValue);
+ // If the value doesn't exist anymore, select the first option
+ if (!$embedModelSelect.val()) {
+ $embedModelSelect.prop('selectedIndex', 0);
+ }
+ }
+ }
+
+ if (showLoading) {
+ // Show success message
+ const totalModels = (response.chatModels?.length || 0) + (response.embeddingModels?.length || 0);
+ toastService.showMessage(`${totalModels} OpenAI models found.`);
+ }
+
+ return true;
+ } else if (showLoading) {
+ toastService.showError(`No OpenAI models found. Please check your API key and settings.`);
+ }
+
+ return openaiModelsRefreshed;
+ } catch (e) {
+ console.error(`Error fetching OpenAI models:`, e);
+ if (showLoading) {
+ toastService.showError(`Error fetching OpenAI models: ${e}`);
+ }
+ return openaiModelsRefreshed;
+ } finally {
+ if (showLoading) {
+ $refreshOpenAIModels.prop('disabled', false);
+ $refreshOpenAIModels.html(``);
+ }
+ }
+ }
+
+ /**
+ * Refreshes the list of Anthropic models
+ * @param showLoading Whether to show loading indicators and toasts
+ * @param anthropicModelsRefreshed Reference to track if models have been refreshed
+ * @returns Promise that resolves when the refresh is complete
+ */
+ async refreshAnthropicModels(showLoading: boolean, anthropicModelsRefreshed: boolean): Promise {
+ if (!this.$widget) return false;
+
+ const $refreshAnthropicModels = this.$widget.find('.refresh-anthropic-models');
+
+ // If we've already refreshed and we're not forcing a refresh, don't do it again
+ if (anthropicModelsRefreshed && !showLoading) {
+ return anthropicModelsRefreshed;
+ }
+
+ if (showLoading) {
+ $refreshAnthropicModels.prop('disabled', true);
+ $refreshAnthropicModels.html(``);
+ }
+
+ try {
+ const anthropicBaseUrl = this.$widget.find('.anthropic-base-url').val() as string;
+ const response = await server.post('anthropic/list-models', { baseUrl: anthropicBaseUrl });
+
+ if (response && response.success) {
+ // Update the chat models dropdown
+ if (response.chatModels?.length > 0) {
+ const $chatModelSelect = this.$widget.find('.anthropic-default-model');
+ const currentChatValue = $chatModelSelect.val();
+
+ // Clear existing options
+ $chatModelSelect.empty();
+
+ // Sort models by name
+ const sortedChatModels = [...response.chatModels].sort((a, b) => a.name.localeCompare(b.name));
+
+ // Add models to the dropdown
+ sortedChatModels.forEach(model => {
+ $chatModelSelect.append(``);
+ });
+
+ // Try to restore the previously selected value
+ if (currentChatValue) {
+ $chatModelSelect.val(currentChatValue);
+ // If the value doesn't exist anymore, select the first option
+ if (!$chatModelSelect.val()) {
+ $chatModelSelect.prop('selectedIndex', 0);
+ }
+ }
+ }
+
+ // Handle embedding models if they exist
+ if (response.embeddingModels?.length > 0 && showLoading) {
+ toastService.showMessage(`Found ${response.embeddingModels.length} Anthropic embedding models.`);
+ }
+
+ if (showLoading) {
+ // Show success message
+ const totalModels = (response.chatModels?.length || 0) + (response.embeddingModels?.length || 0);
+ toastService.showMessage(`${totalModels} Anthropic models found.`);
+ }
+
+ return true;
+ } else if (showLoading) {
+ toastService.showError(`No Anthropic models found. Please check your API key and settings.`);
+ }
+
+ return anthropicModelsRefreshed;
+ } catch (e) {
+ console.error(`Error fetching Anthropic models:`, e);
+ if (showLoading) {
+ toastService.showError(`Error fetching Anthropic models: ${e}`);
+ }
+ return anthropicModelsRefreshed;
+ } finally {
+ if (showLoading) {
+ $refreshAnthropicModels.prop('disabled', false);
+ $refreshAnthropicModels.html(``);
+ }
+ }
+ }
+
+ /**
+ * Refreshes the list of Ollama models
+ * @param showLoading Whether to show loading indicators and toasts
+ * @param ollamaModelsRefreshed Reference to track if models have been refreshed
+ * @returns Promise that resolves when the refresh is complete
+ */
+ async refreshOllamaModels(showLoading: boolean, ollamaModelsRefreshed: boolean): Promise {
+ if (!this.$widget) return false;
+
+ const $refreshModels = this.$widget.find('.refresh-models');
+
+ // If we've already refreshed and we're not forcing a refresh, don't do it again
+ if (ollamaModelsRefreshed && !showLoading) {
+ return ollamaModelsRefreshed;
+ }
+
+ if (showLoading) {
+ $refreshModels.prop('disabled', true);
+ $refreshModels.text(t("ai_llm.refreshing_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.success && response.models && response.models.length > 0) {
+ 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(``);
+ });
+
+ if (embeddingModels.length > 0) {
+ // Add separator if we have embedding models
+ $embedModelSelect.append(``);
+ }
+
+ // Then add general models which can be used for embeddings too
+ const generalModels = response.models.filter(model =>
+ !model.name.includes('embed') && !model.name.includes('bert'));
+
+ generalModels.forEach(model => {
+ $embedModelSelect.append(``);
+ });
+
+ // Try to restore the previously selected value
+ if (currentValue) {
+ $embedModelSelect.val(currentValue);
+ // If the value doesn't exist anymore, select the first option
+ if (!$embedModelSelect.val()) {
+ $embedModelSelect.prop('selectedIndex', 0);
+ }
+ }
+
+ // Also update the LLM model dropdown
+ const $modelSelect = this.$widget.find('.ollama-default-model');
+ const currentModelValue = $modelSelect.val();
+
+ // Clear existing options
+ $modelSelect.empty();
+
+ // Sort models by name to make them easier to find
+ const sortedModels = [...response.models].sort((a, b) => a.name.localeCompare(b.name));
+
+ // Add all models to the dropdown
+ sortedModels.forEach(model => {
+ $modelSelect.append(``);
+ });
+
+ // Try to restore the previously selected value
+ if (currentModelValue) {
+ $modelSelect.val(currentModelValue);
+ // If the value doesn't exist anymore, select the first option
+ if (!$modelSelect.val()) {
+ $modelSelect.prop('selectedIndex', 0);
+ }
+ }
+
+ if (showLoading) {
+ toastService.showMessage(`${response.models.length} Ollama models found.`);
+ }
+
+ return true;
+ } else if (showLoading) {
+ toastService.showError(`No Ollama models found. Please check if Ollama is running.`);
+ }
+
+ return ollamaModelsRefreshed;
+ } catch (e) {
+ console.error(`Error fetching Ollama models:`, e);
+ if (showLoading) {
+ toastService.showError(`Error fetching Ollama models: ${e}`);
+ }
+ return ollamaModelsRefreshed;
+ } finally {
+ if (showLoading) {
+ $refreshModels.prop('disabled', false);
+ $refreshModels.html(``);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/public/app/widgets/type_widgets/options/ai_settings/template.ts b/src/public/app/widgets/type_widgets/options/ai_settings/template.ts
new file mode 100644
index 000000000..ef04243dd
--- /dev/null
+++ b/src/public/app/widgets/type_widgets/options/ai_settings/template.ts
@@ -0,0 +1,326 @@
+import { t } from "../../../../services/i18n.js";
+
+export const TPL = `
+