import { App, DropdownComponent, Modal, PluginSettingTab, Setting, TFile, } from 'obsidian'; import * as React from "react"; import { createRoot } from "react-dom/client"; // import { // EMBEDDING_MODEL_OPTIONS, // } from '../constants' import InfioPlugin from '../main'; import { findFilesMatchingPatterns } from '../utils/glob-utils'; import { getOllamaModels } from '../utils/ollama'; import AutoCompleteSettings from './AutoCompleteSettings'; import CustomSettings from './CustomSettings'; export class InfioSettingTab extends PluginSettingTab { plugin: InfioPlugin constructor(app: App, plugin: InfioPlugin) { super(app, plugin) this.plugin = plugin } display(): void { const { containerEl } = this containerEl.empty() this.renderModelsSection(containerEl) this.renderRAGSection(containerEl) this.renderAutoCompleteSection(containerEl) } renderModelsSection(containerEl: HTMLElement): void { const div = containerEl.createDiv("div"); const sections = createRoot(div); sections.render(); } renderAPIKeysSection(containerEl: HTMLElement): void { new Setting(containerEl) .setHeading() .setName('API keys') new Setting(containerEl) .setName('Infio API key') .setClass("infio-chat-setting-item-container") .addText((text) => text .setPlaceholder('Enter your API key') .setValue(this.plugin.settings.infioApiKey) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, infioApiKey: value, }) }), ) new Setting(containerEl) .setName('Anthropic API key') .setClass("infio-chat-setting-item-container-append") .addText((text) => text .setPlaceholder('Enter your API key') .setValue(this.plugin.settings.anthropicApiKey) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, anthropicApiKey: value, }) }), ) new Setting(containerEl) .setName('Deepseek API key') .setClass("infio-chat-setting-item-container-append") .addText((text) => text .setPlaceholder('Enter your API key') .setValue(this.plugin.settings.deepseekApiKey) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, deepseekApiKey: value, }) }), ) new Setting(containerEl) .setName('OpenAI API key') .setClass("infio-chat-setting-item-container-append") .addText((text) => text .setPlaceholder('Enter your API key') .setValue(this.plugin.settings.openAIApiKey) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, openAIApiKey: value, }) }), ) new Setting(containerEl) .setName('Google API key') .setClass("infio-chat-setting-item-container-append") .addText((text) => text .setPlaceholder('Enter your API key') .setValue(this.plugin.settings.geminiApiKey) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, geminiApiKey: value, }) }), ) new Setting(containerEl) .setName('Groq API key') .setClass("infio-chat-setting-item-container-append") .addText((text) => text .setPlaceholder('Enter your API key') .setValue(this.plugin.settings.groqApiKey) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, groqApiKey: value, }) }), ) } renderDefaultModelSection(containerEl: HTMLElement): void { new Setting(containerEl).setHeading().setName('Default Model') new Setting(containerEl) .setName('Default chat model') .setClass("infio-chat-setting-item-container") .addDropdown((dropdown) => dropdown .addOptions( this.plugin.settings.activeModels .reduce>((acc, option) => { if (!option.isEmbeddingModel && option.enabled) { acc[option.name] = option.name } return acc }, {}), ) .setValue(this.plugin.settings.chatModelId) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, chatModelId: value, }) // Force refresh to show/hide Ollama and OpenAI-compatible settings this.display() }), ) if (this.plugin.settings.chatModelId === 'ollama') { this.renderOllamaChatModelSettings(containerEl) } if (this.plugin.settings.chatModelId === 'openai-compatible') { this.renderOpenAICompatibleChatModelSettings(containerEl) } new Setting(containerEl) .setName('Default apply model') .setClass("infio-chat-setting-item-container-append") .addDropdown((dropdown) => dropdown .addOptions( this.plugin.settings.activeModels .reduce>( (acc, option) => { if (!option.isEmbeddingModel && option.enabled) { acc[option.name] = option.name } return acc }, {}, ), ) .setValue(this.plugin.settings.applyModelId) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, applyModelId: value, }) // Force refresh to show/hide Ollama and OpenAI-compatible settings this.display() }), ) if (this.plugin.settings.applyModelId === 'ollama') { this.renderOllamaApplyModelSettings(containerEl) } if (this.plugin.settings.applyModelId === 'openai-compatible') { this.renderOpenAICompatibleApplyModelSettings(containerEl) } new Setting(containerEl) .setName('Default embedding model') .setClass("infio-chat-setting-item-container-append") .addDropdown((dropdown) => dropdown .addOptions( this.plugin.settings.activeModels .reduce>( (acc, option) => { if (option.isEmbeddingModel && option.enabled) { acc[option.name] = option.name } return acc }, {}, ), ) .setValue(this.plugin.settings.embeddingModelId) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, embeddingModelId: value, }) // Force refresh to show/hide Ollama settings this.display() }), ) if (this.plugin.settings.embeddingModelId.startsWith('ollama/')) { this.renderOllamaEmbeddingModelSettings(containerEl) } new Setting(containerEl) .setHeading() .setName('System prompt') .setDesc('This prompt will be added to the beginning of every chat.') new Setting(containerEl) .setClass('infio-chat-settings-textarea') .addTextArea((text) => text .setValue(this.plugin.settings.systemPrompt) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, systemPrompt: value, }) }), ) } renderOllamaChatModelSettings(containerEl: HTMLElement): void { const ollamaContainer = containerEl.createDiv( 'infio-chat-settings-model-container', ) let modelDropdown: DropdownComponent | null = null // Store reference to the dropdown // Base URL Setting new Setting(ollamaContainer) .setName('Base URL') .setClass("infio-chat-setting-item-container-append") .setDesc( 'The API endpoint for your Ollama service (e.g., http://127.0.0.1:11434)', ) .addText((text) => { text .setPlaceholder('http://127.0.0.1:11434') .setValue(this.plugin.settings.ollamaChatModel.baseUrl || '') .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, ollamaChatModel: { ...this.plugin.settings.ollamaChatModel, baseUrl: value, }, }) if (modelDropdown) { await this.updateOllamaModelOptions({ baseUrl: value, dropdown: modelDropdown, onModelChange: async (model: string) => { await this.plugin.setSettings({ ...this.plugin.settings, ollamaChatModel: { ...this.plugin.settings.ollamaChatModel, model, }, }) }, }) } }) }) // Model Setting new Setting(ollamaContainer) .setName('Model Name') .setDesc('Select a model from your Ollama instance') .addDropdown(async (dropdown) => { const currentModel = this.plugin.settings.ollamaChatModel.model modelDropdown = dropdown .addOption(currentModel, currentModel) .setValue(currentModel) await this.updateOllamaModelOptions({ baseUrl: this.plugin.settings.ollamaChatModel.baseUrl, dropdown, onModelChange: async (model: string) => { await this.plugin.setSettings({ ...this.plugin.settings, ollamaChatModel: { ...this.plugin.settings.ollamaChatModel, model, }, }) }, }) }) } renderOpenAICompatibleChatModelSettings(containerEl: HTMLElement): void { const openAICompatContainer = containerEl.createDiv( 'infio-chat-settings-model-container', ) new Setting(openAICompatContainer) .setName('Base URL') .setDesc( 'The API endpoint for your OpenAI-compatible service (e.g., https://api.example.com/v1)', ) .addText((text) => text .setPlaceholder('https://api.example.com/v1') .setValue( this.plugin.settings.openAICompatibleChatModel.baseUrl || '', ) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, openAICompatibleChatModel: { ...this.plugin.settings.openAICompatibleChatModel, baseUrl: value, }, }) }), ) new Setting(openAICompatContainer) .setName('API Key') .setDesc('Your authentication key for the OpenAI-compatible service') .addText((text) => text .setPlaceholder('Enter your API key') .setValue(this.plugin.settings.openAICompatibleChatModel.apiKey || '') .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, openAICompatibleChatModel: { ...this.plugin.settings.openAICompatibleChatModel, apiKey: value, }, }) }), ) new Setting(openAICompatContainer) .setName('Model Name') .setDesc( 'The specific model to use with your service (e.g., llama-3.1-70b, mixtral-8x7b)', ) .addText((text) => text .setPlaceholder('llama-3.1-70b') .setValue(this.plugin.settings.openAICompatibleChatModel.model || '') .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, openAICompatibleChatModel: { ...this.plugin.settings.openAICompatibleChatModel, model: value, }, }) }), ) } renderOllamaApplyModelSettings(containerEl: HTMLElement): void { const ollamaContainer = containerEl.createDiv( 'infio-chat-settings-model-container', ) let modelDropdown: DropdownComponent | null = null // Store reference to the dropdown // Base URL Setting new Setting(ollamaContainer) .setName('Base URL') .setDesc( 'The API endpoint for your Ollama service (e.g., http://127.0.0.1:11434)', ) .addText((text) => { text .setPlaceholder('http://127.0.0.1:11434') .setValue(this.plugin.settings.ollamaApplyModel.baseUrl || '') .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, ollamaApplyModel: { ...this.plugin.settings.ollamaApplyModel, baseUrl: value, }, }) if (modelDropdown) { await this.updateOllamaModelOptions({ baseUrl: value, dropdown: modelDropdown, onModelChange: async (model: string) => { await this.plugin.setSettings({ ...this.plugin.settings, ollamaApplyModel: { ...this.plugin.settings.ollamaApplyModel, model, }, }) }, }) } }) }) // Model Setting new Setting(ollamaContainer) .setName('Model Name') .setDesc('Select a model from your Ollama instance') .addDropdown(async (dropdown) => { const currentModel = this.plugin.settings.ollamaApplyModel.model modelDropdown = dropdown .addOption(currentModel, currentModel) .setValue(currentModel) await this.updateOllamaModelOptions({ baseUrl: this.plugin.settings.ollamaApplyModel.baseUrl, dropdown, onModelChange: async (model: string) => { await this.plugin.setSettings({ ...this.plugin.settings, ollamaApplyModel: { ...this.plugin.settings.ollamaApplyModel, model, }, }) }, }) }) } renderOpenAICompatibleApplyModelSettings(containerEl: HTMLElement): void { const openAICompatContainer = containerEl.createDiv( 'infio-chat-settings-model-container', ) new Setting(openAICompatContainer) .setName('Base URL') .setDesc( 'The API endpoint for your OpenAI-compatible service (e.g., https://api.example.com/v1)', ) .addText((text) => text .setPlaceholder('https://api.example.com/v1') .setValue( this.plugin.settings.openAICompatibleApplyModel.baseUrl || '', ) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, openAICompatibleApplyModel: { ...this.plugin.settings.openAICompatibleApplyModel, baseUrl: value, }, }) }), ) new Setting(openAICompatContainer) .setName('API Key') .setDesc('Your authentication key for the OpenAI-compatible service') .addText((text) => text .setPlaceholder('Enter your API key') .setValue( this.plugin.settings.openAICompatibleApplyModel.apiKey || '', ) .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, openAICompatibleApplyModel: { ...this.plugin.settings.openAICompatibleApplyModel, apiKey: value, }, }) }), ) new Setting(openAICompatContainer) .setName('Model Name') .setDesc( 'The specific model to use with your service (e.g., llama-3.1-70b, mixtral-8x7b)', ) .addText((text) => text .setPlaceholder('llama-3.1-70b') .setValue(this.plugin.settings.openAICompatibleApplyModel.model || '') .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, openAICompatibleApplyModel: { ...this.plugin.settings.openAICompatibleApplyModel, model: value, }, }) }), ) } renderOllamaEmbeddingModelSettings(containerEl: HTMLElement): void { const ollamaContainer = containerEl.createDiv( 'infio-chat-settings-model-container', ) new Setting(ollamaContainer) .setName('Base URL') .setDesc( 'The API endpoint for your Ollama service (e.g., http://127.0.0.1:11434)', ) .addText((text) => text .setPlaceholder('http://127.0.0.1:11434') .setValue(this.plugin.settings.ollamaEmbeddingModel.baseUrl || '') .onChange(async (value) => { await this.plugin.setSettings({ ...this.plugin.settings, ollamaEmbeddingModel: { ...this.plugin.settings.ollamaEmbeddingModel, baseUrl: value, }, }) }), ) } renderRAGSection(containerEl: HTMLElement): void { new Setting(containerEl).setHeading().setName('RAG') new Setting(containerEl) .setName('Include patterns') .setDesc( 'If any patterns are specified, ONLY files matching at least one pattern will be included in indexing. One pattern per line. Uses glob patterns (e.g., "notes/*", "*.md"). Leave empty to include all files not excluded by exclude patterns. After changing this, use the command "Rebuild entire vault index" to apply changes.', ) .addButton((button) => button.setButtonText('Test patterns').onClick(async () => { const patterns = this.plugin.settings.ragOptions.includePatterns const includedFiles = await findFilesMatchingPatterns( patterns, this.plugin.app.vault, ) new IncludedFilesModal(this.app, includedFiles, patterns).open() }), ) new Setting(containerEl) .setClass('infio-chat-settings-textarea') .addTextArea((text) => text .setValue(this.plugin.settings.ragOptions.includePatterns.join('\n')) .onChange(async (value) => { const patterns = value .split('\n') .map((p) => p.trim()) .filter((p) => p.length > 0) await this.plugin.setSettings({ ...this.plugin.settings, ragOptions: { ...this.plugin.settings.ragOptions, includePatterns: patterns, }, }) }), ) new Setting(containerEl) .setName('Exclude patterns') .setDesc( 'Files matching ANY of these patterns will be excluded from indexing. One pattern per line. Uses glob patterns (e.g., "private/*", "*.tmp"). Leave empty to exclude nothing. After changing this, use the command "Rebuild entire vault index" to apply changes.', ) .addButton((button) => button.setButtonText('Test patterns').onClick(async () => { const patterns = this.plugin.settings.ragOptions.excludePatterns const excludedFiles = await findFilesMatchingPatterns( patterns, this.plugin.app.vault, ) new ExcludedFilesModal(this.app, excludedFiles).open() }), ) new Setting(containerEl) .setClass('infio-chat-settings-textarea') .addTextArea((text) => text .setValue(this.plugin.settings.ragOptions.excludePatterns.join('\n')) .onChange(async (value) => { const patterns = value .split('\n') .map((p) => p.trim()) .filter((p) => p.length > 0) await this.plugin.setSettings({ ...this.plugin.settings, ragOptions: { ...this.plugin.settings.ragOptions, excludePatterns: patterns, }, }) }), ) new Setting(containerEl) .setName('Chunk size') .setDesc( 'Set the chunk size for text splitting. After changing this, please re-index the vault using the "Rebuild entire vault index" command.', ) .addText((text) => text .setPlaceholder('1000') .setValue(String(this.plugin.settings.ragOptions.chunkSize)) .onChange(async (value) => { const chunkSize = parseInt(value, 10) if (!isNaN(chunkSize)) { await this.plugin.setSettings({ ...this.plugin.settings, ragOptions: { ...this.plugin.settings.ragOptions, chunkSize, }, }) } }), ) new Setting(containerEl) .setName('Threshold tokens') .setDesc( 'Maximum number of tokens before switching to RAG. If the total tokens from mentioned files exceed this, RAG will be used instead of including all file contents.', ) .addText((text) => text .setPlaceholder('8192') .setValue(String(this.plugin.settings.ragOptions.thresholdTokens)) .onChange(async (value) => { const thresholdTokens = parseInt(value, 10) if (!isNaN(thresholdTokens)) { await this.plugin.setSettings({ ...this.plugin.settings, ragOptions: { ...this.plugin.settings.ragOptions, thresholdTokens, }, }) } }), ) new Setting(containerEl) .setName('Minimum similarity') .setDesc( 'Minimum similarity score for RAG results. Higher values return more relevant but potentially fewer results.', ) .addText((text) => text .setPlaceholder('0.0') .setValue(String(this.plugin.settings.ragOptions.minSimilarity)) .onChange(async (value) => { const minSimilarity = parseFloat(value) if (!isNaN(minSimilarity)) { await this.plugin.setSettings({ ...this.plugin.settings, ragOptions: { ...this.plugin.settings.ragOptions, minSimilarity, }, }) } }), ) new Setting(containerEl) .setName('Limit') .setDesc( 'Maximum number of RAG results to include in the prompt. Higher values provide more context but increase token usage.', ) .addText((text) => text .setPlaceholder('10') .setValue(String(this.plugin.settings.ragOptions.limit)) .onChange(async (value) => { const limit = parseInt(value, 10) if (!isNaN(limit)) { await this.plugin.setSettings({ ...this.plugin.settings, ragOptions: { ...this.plugin.settings.ragOptions, limit, }, }) } }), ) } renderAutoCompleteSection(containerEl: HTMLElement): void { const div = containerEl.createDiv("div"); const sections = createRoot(div); sections.render( { this.plugin.setSettings(settings); // Force refresh the settings page to update dropdowns this.plugin.settingTab.display(); }} settings={this.plugin.settings} /> ); } private async updateOllamaModelOptions({ baseUrl, dropdown, onModelChange, }: { baseUrl: string dropdown: DropdownComponent onModelChange: (model: string) => Promise }): Promise { const currentValue = dropdown.getValue() dropdown.selectEl.empty() try { const models = await getOllamaModels(baseUrl) if (models.length > 0) { const modelOptions = models.reduce>( (acc, model) => { acc[model] = model return acc }, {}, ) dropdown.addOptions(modelOptions) if (models.includes(currentValue)) { dropdown.setValue(currentValue) } else { dropdown.setValue(models[0]) await onModelChange(models[0]) } } else { dropdown.addOption('', 'No models found - check base URL') dropdown.setValue('') await onModelChange('') } } catch (error) { console.error('Failed to fetch Ollama models:', error) dropdown.addOption('', 'No models found - check base URL') dropdown.setValue('') await onModelChange('') } dropdown.onChange(async (value) => { await onModelChange(value) }) } } class ExcludedFilesModal extends Modal { private files: TFile[] constructor(app: App, files: TFile[]) { super(app) this.files = files } onOpen() { const { contentEl } = this contentEl.empty() this.titleEl.setText(`Excluded Files (${this.files.length})`) if (this.files.length === 0) { contentEl.createEl('p', { text: 'No files match the exclusion patterns' }) return } const list = contentEl.createEl('ul') this.files.forEach((file) => { list.createEl('li', { text: file.path }) }) } onClose() { const { contentEl } = this contentEl.empty() } } class IncludedFilesModal extends Modal { private files: TFile[] private patterns: string[] constructor(app: App, files: TFile[], patterns: string[]) { super(app) this.files = files this.patterns = patterns } onOpen() { const { contentEl } = this contentEl.empty() this.titleEl.setText(`Included Files (${this.files.length})`) if (this.patterns.length === 0) { contentEl.createEl('p', { text: 'No inclusion patterns specified - all files will be included (except those matching exclusion patterns)', }) return } if (this.files.length === 0) { contentEl.createEl('p', { text: 'No files match the inclusion patterns', }) return } const list = contentEl.createEl('ul') this.files.forEach((file) => { list.createEl('li', { text: file.path }) }) } onClose() { const { contentEl } = this contentEl.empty() } }