infio-copilot/src/settings/SettingTab.tsx
2025-04-10 10:59:23 +08:00

567 lines
16 KiB
TypeScript

import {
App,
Modal,
Notice,
PluginSettingTab,
Setting,
TFile
} from 'obsidian';
import * as React from "react";
import { createRoot } from "react-dom/client";
import InfioPlugin from '../main';
import { InfioSettings } from '../types/settings';
import { findFilesMatchingPatterns } from '../utils/glob-utils';
import AdvancedSettings from './components/AdvancedSettings';
import BasicAutoCompleteSettings from './components/BasicAutoCompleteSettings';
import DangerZoneSettings from './components/DangerZoneSettings';
import ModelParametersSettings from './components/ModelParametersSettings';
import CustomProviderSettings from './components/ModelProviderSettings';
import PostprocessingSettings from './components/PostprocessingSettings';
import PreprocessingSettings from './components/PreprocessingSettings';
import PrivacySettings from './components/PrivacySettings';
import TriggerSettingsSection from './components/TriggerSettingsSection';
export class InfioSettingTab extends PluginSettingTab {
plugin: InfioPlugin;
private autoCompleteContainer: HTMLElement | null = null;
private modelsContainer: HTMLElement | null = null;
constructor(app: App, plugin: InfioPlugin) {
super(app, plugin)
this.plugin = plugin
}
display(): void {
const { containerEl } = this
containerEl.empty()
this.renderModelsSection(containerEl)
this.renderFilesSearchSection(containerEl)
this.renderChatBehaviorSection(containerEl)
this.renderDeepResearchSection(containerEl)
this.renderRAGSection(containerEl)
this.renderAutoCompleteSection(containerEl)
}
private renderModelsContent(containerEl: HTMLElement): void {
const div = containerEl.createDiv("div");
const sections = createRoot(div);
sections.render(
<CustomProviderSettings
plugin={this.plugin}
onSettingsUpdate={() => {
if (this.modelsContainer) {
this.modelsContainer.empty();
this.renderModelsContent(this.modelsContainer);
}
}}
/>
);
}
private renderFilesSearchSection(containerEl: HTMLElement): void {
new Setting(containerEl).setHeading().setName('File search')
new Setting(containerEl)
.setName('Files search method')
.setDesc('Choose the method to search for files.')
.addDropdown((dropdown) =>
dropdown
.addOption('auto', 'Auto')
.addOption('semantic', 'Semantic')
.addOption('regex', 'Regex')
.setValue(this.plugin.settings.filesSearchMethod)
.onChange(async (value) => {
await this.plugin.setSettings({
...this.plugin.settings,
filesSearchMethod: value as 'regex' | 'semantic' | 'auto',
})
}),
)
new Setting(containerEl)
.setName('ripgrep path')
.setDesc('Path to the ripgrep binary. When using regex search, this is required.')
.addText((text) =>
text
.setPlaceholder('/opt/homebrew/bin/')
.setValue(this.plugin.settings.ripgrepPath)
.onChange(async (value) => {
await this.plugin.setSettings({
...this.plugin.settings,
ripgrepPath: value,
})
}),
)
}
private renderChatBehaviorSection(containerEl: HTMLElement): void {
new Setting(containerEl).setHeading().setName('Chat Behavior');
new Setting(containerEl)
.setName('Default mention for new chat')
.setDesc('Choose the default file mention behavior when starting a new chat.')
.addDropdown((dropdown) =>
dropdown
.addOption('none', 'None')
.addOption('current-file', 'Current File')
.addOption('vault', 'Vault')
.setValue(this.plugin.settings.defaultMention || 'none')
.onChange(async (value) => {
await this.plugin.setSettings({
...this.plugin.settings,
defaultMention: value as 'none' | 'current-file' | 'vault',
});
}),
);
new Setting(containerEl)
.setName('Mode for new chat')
.setDesc('Choose the mode to use when starting a new chat.')
.addDropdown((dropdown) =>
dropdown
.addOption('ask', 'Ask')
.addOption('write', 'Write')
.addOption('research', 'Research')
.setValue(this.plugin.settings.mode || 'ask')
.onChange(async (value) => {
await this.plugin.setSettings({
...this.plugin.settings,
mode: value as 'ask' | 'write' | 'research',
});
}),
);
}
renderModelsSection(containerEl: HTMLElement): void {
const modelsDiv = containerEl.createDiv("models-section");
this.modelsContainer = modelsDiv;
this.renderModelsContent(modelsDiv);
}
renderDeepResearchSection(containerEl: HTMLElement): void {
new Setting(containerEl)
.setHeading()
.setName('Web search')
new Setting(containerEl)
.setName('Serper API key')
.setDesc(createFragment(el => {
el.appendText('API key for web search functionality. Serper allows the plugin to search the internet for information, similar to a search engine. Get your key from ');
const a = el.createEl('a', {
href: 'https://serpapi.com/manage-api-key',
text: 'https://serpapi.com/manage-api-key'
});
a.setAttr('target', '_blank');
a.setAttr('rel', 'noopener');
}))
.setClass('setting-item-heading-smaller')
.addText((text) => {
const t = text
.setValue(this.plugin.settings.serperApiKey)
.onChange(async (value) => {
await this.plugin.setSettings({
...this.plugin.settings,
serperApiKey: value,
})
});
if (t.inputEl) {
t.inputEl.type = "password";
}
return t;
})
new Setting(containerEl)
.setName('Serper search engine')
.setDesc('Choose the search engine to use for web search.')
.addDropdown((dropdown) =>
dropdown
.addOption('google', 'Google')
.addOption('duckduckgo', 'DuckDuckGo')
.addOption('bing', 'Bing')
.setValue(this.plugin.settings.serperSearchEngine)
.onChange(async (value) => {
await this.plugin.setSettings({
...this.plugin.settings,
// @ts-ignore
serperSearchEngine: value,
})
}),
)
new Setting(containerEl)
.setName('Jina API key (Optional)')
.setDesc(createFragment(el => {
el.appendText('API key for parsing web pages into markdown format. If not provided, local parsing will be used. Get your key from ');
const a = el.createEl('a', {
href: 'https://jina.ai/api-key',
text: 'https://jina.ai/api-key'
});
a.setAttr('target', '_blank');
a.setAttr('rel', 'noopener');
}))
.setClass('setting-item-heading-smaller')
.addText((text) => {
const t = text
.setValue(this.plugin.settings.jinaApiKey)
.onChange(async (value) => {
await this.plugin.setSettings({
...this.plugin.settings,
jinaApiKey: value,
})
});
if (t.inputEl) {
t.inputEl.type = "password";
}
return t;
})
}
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 {
// 创建一个专门的容器来存放 AutoComplete 相关的组件
const autoCompleteDiv = containerEl.createDiv("auto-complete-section");
this.autoCompleteContainer = autoCompleteDiv;
this.renderAutoCompleteContent(autoCompleteDiv);
}
private renderAutoCompleteContent(containerEl: HTMLElement): void {
const updateSettings = async (update: Partial<InfioSettings>) => {
await this.plugin.setSettings({
...this.plugin.settings,
...update
});
// 只重新渲染 AutoComplete 部分
if (this.autoCompleteContainer) {
this.autoCompleteContainer.empty();
this.renderAutoCompleteContent(this.autoCompleteContainer);
}
};
const errors = new Map();
// AutoComplete base
new Setting(containerEl).setName('AutoComplete').setHeading();
this.renderComponent(containerEl,
<BasicAutoCompleteSettings
settings={this.plugin.settings}
updateSettings={updateSettings}
/>
);
// Model parameters
new Setting(containerEl).setName('Model parameters').setHeading();
this.renderComponent(containerEl,
<ModelParametersSettings
settings={this.plugin.settings}
updateSettings={updateSettings}
errors={errors}
/>
);
// Preprocessing
new Setting(containerEl).setName('Preprocessing').setHeading();
this.renderComponent(containerEl,
<PreprocessingSettings
settings={this.plugin.settings}
updateSettings={updateSettings}
errors={errors}
/>
);
// Postprocessing
new Setting(containerEl).setName('Postprocessing').setHeading();
this.renderComponent(containerEl,
<PostprocessingSettings
settings={this.plugin.settings}
updateSettings={updateSettings}
/>
);
// Trigger
new Setting(containerEl).setName('Trigger').setHeading();
this.renderComponent(containerEl,
<TriggerSettingsSection
settings={this.plugin.settings}
updateSettings={updateSettings}
errors={errors}
/>
);
// Privacy
new Setting(containerEl).setName('Privacy').setHeading();
this.renderComponent(containerEl,
<PrivacySettings
settings={this.plugin.settings}
updateSettings={updateSettings}
errors={errors}
/>
);
// Danger zone
new Setting(containerEl).setName('Danger zone').setHeading();
this.renderComponent(containerEl,
<DangerZoneSettings
settings={this.plugin.settings}
updateSettings={updateSettings}
onReset={() => {
new Notice("Factory reset complete.");
}}
/>
);
// Advanced
if (this.plugin.settings.advancedMode) {
new Setting(containerEl).setName('Advanced').setHeading();
this.renderComponent(containerEl,
<AdvancedSettings
settings={this.plugin.settings}
updateSettings={updateSettings}
errors={errors}
/>
);
}
}
private renderComponent(containerEl: HTMLElement, component: React.ReactNode) {
const div = containerEl.createDiv("div");
const root = createRoot(div);
root.render(component);
}
}
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()
}
}