import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, ItemView, WorkspaceLeaf, TFile, FileSystemAdapter } from 'obsidian'; import { EventBus } from './src/utils/EventBus'; import { GraphView, GRAPH_VIEW_TYPE } from './src/views/GraphView'; import { ChatView, CHAT_VIEW_TYPE } from './src/views/ChatView'; import { WebSocketService } from './src/utils/WebSocketService'; import { TemplateEngine } from './src/references/TemplateEngine'; import { AutocompleteManager } from './src/references/AutocompleteManager'; import { ReferenceAutocomplete } from './src/references/ReferenceAutocomplete'; import { ReferenceParser } from './src/references/ReferenceParser'; import 'src/css/styles.css'; import { LLM_DEFAULTS } from 'src/constants'; // ----------------------------------------------- Settings --------------------------------------------------------------- interface LLMAgentSettings { apiBaseUrl: string; systemPrompt: string; defaultModel: string; promptsFolder: string; enableFileReferences: boolean; enablePromptReferences: boolean; excludedFolders: string[]; refCacheFolder: string; temperatureChat: number; maxTokensChatDefault: string; summarizationModel: string; voiceCommandModel: string; logsFolder: string; voiceInterval: number; metricsInterval: number; voiceDisplayLimit: number; voiceContextLimit: number; temperatureVoice: number; maxTokensVoiceRegular: string; maxTokensVoiceCommands: string; promptRegular: string; promptCommands: string; promptMetrics: string; markerStart: string; markerStop: string; lastActiveGraphId: string | null; lastActiveNodeId: string | null; } const DEFAULT_SETTINGS: LLMAgentSettings = { apiBaseUrl: 'http://localhost:5000/api', systemPrompt: 'Ты полезный ИИ ассистент.', defaultModel: LLM_DEFAULTS.MODEL, promptsFolder: 'prompts', enableFileReferences: true, enablePromptReferences: true, excludedFolders: ['.obsidian', 'llm-agent-backend', 'templates', 'ref-cache'], refCacheFolder: 'ref-cache', temperatureChat: LLM_DEFAULTS.TEMPERATURE, maxTokensChatDefault: '', summarizationModel: '', voiceCommandModel: '', logsFolder: 'audio-logs', voiceInterval: 1, metricsInterval: 15, voiceDisplayLimit: 30000, voiceContextLimit: 30000, temperatureVoice: LLM_DEFAULTS.TEMPERATURE, maxTokensVoiceRegular: '', maxTokensVoiceCommands: '', promptRegular: '', promptCommands: '', promptMetrics: '', markerStart: 'старт', markerStop: 'стоп', lastActiveGraphId: null, lastActiveNodeId: null, }; // ----------------------------------------------- Main Plugin Class --------------------------------------------------------------- export default class LLMAgentPlugin extends Plugin { settings: LLMAgentSettings; eventBus: EventBus; webSocketService: WebSocketService; activeStreamControllers: Map; availableModels: string[]; async syncSettings() { try { const engine = new TemplateEngine(this); // Клонируем настройки, чтобы не испортить исходные пути в конфиге плагина const settingsToSync = JSON.parse(JSON.stringify(this.settings)); // Собираем промпты (раскрываем все цепочки ссылок) для работы с аудиологами на бекенде settingsToSync.systemPrompt = await engine.processRawSettingValue(this.settings.systemPrompt); settingsToSync.promptRegular = await engine.processRawSettingValue(this.settings.promptRegular); settingsToSync.promptCommands = await engine.processRawSettingValue(this.settings.promptCommands); settingsToSync.promptMetrics = await engine.processRawSettingValue(this.settings.promptMetrics); const adapter = this.app.vault.adapter; if (adapter instanceof FileSystemAdapter) { // Соединяем базовый путь хранилища и относительный путь к логам settingsToSync.absoluteLogsPath = adapter.getBasePath() + (this.settings.logsFolder ? '/' + this.settings.logsFolder : ''); } const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settingsToSync) }); if (response.ok) { console.log("LLM Agent: Settings synced with backend."); } } catch (e) { console.error("LLM Agent: Failed to sync settings", e); } } /** * Загружает список доступных моделей с бэкенда. * @returns {Promise} */ async fetchAvailableModels(): Promise { try { const response = await fetch(`${this.settings.apiBaseUrl}/models`); if (!response.ok) { throw new Error(`Ошибка загрузки моделей: ${response.status}`); } this.availableModels = await response.json(); } catch (error) { console.error('Ошибка загрузки доступных моделей:', error); // Fallback к базовым моделям this.availableModels = [LLM_DEFAULTS.MODEL, 'gpt-4o-mini', 'claude-3-5-sonnet-20241022']; } } async onload() { await this.loadSettings(); this.eventBus = new EventBus(); this.webSocketService = new WebSocketService(this.eventBus, this.settings.apiBaseUrl); this.webSocketService.connect(); // Регистрируем кастомные views this.registerView(GRAPH_VIEW_TYPE, (leaf) => new GraphView(leaf, this)); this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)); // Добавляем команды this.addCommand({ id: 'open-llm-agent-graph', name: 'Открыть граф диалогов LLM Agent', callback: () => this.activateGraphView() }); this.addCommand({ id: 'open-llm-agent-chat', name: 'Открыть чат LLM Agent', callback: () => this.activateChatView() }); this.addCommand({ id: 'new-llm-agent-chat', name: 'Новый чат LLM Agent', callback: () => this.handleNewChat() }); // Добавляем иконку в ribbon this.addRibbonIcon('brain-circuit', 'LLM Agent', () => { this.activateGraphView(); this.activateChatView(); }); console.log('LLM Agent plugin загружен'); this.activeStreamControllers = new Map(); this.eventBus.on('stream-started', this.handleStreamStarted.bind(this)); this.eventBus.on('stream-ended', this.handleStreamEnded.bind(this)); // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new SampleSettingTab(this.app, this)); await this.fetchAvailableModels(); // Ждем полной инициализации хранилища перед вызовом зависимых функций // await this.syncSettings(); this.app.workspace.onLayoutReady(async () => { await this.syncSettings(); }); this.registerEvent( this.app.vault.on('modify', async (file) => { // Если изменился любой md файл, перестраховываемся и синхроним настройки // (Backend получит обновленный текст промптов, если на них есть ссылки) if (file instanceof TFile && file.extension === 'md' && file.path.startsWith(this.settings.promptsFolder)) { // TODO: Проверить, что срабатывает await this.syncSettings(); } }) ); } onunload() { if (this.webSocketService) { this.webSocketService.disconnect(); } this.activeStreamControllers.clear(); this.eventBus.off('stream-started', this.handleStreamStarted); this.eventBus.off('stream-ended', this.handleStreamEnded); console.log('LLM Agent plugin выгружен'); } // ----------------------------------------------- Settings Management --------------------------------------------------------------- async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings() { await this.saveData(this.settings); this.eventBus.emit('settings-changed'); } // ----------------------------------------------- View Management --------------------------------------------------------------- async activateGraphView() { const existingLeaf = this.app.workspace.getLeavesOfType(GRAPH_VIEW_TYPE)[0]; if (existingLeaf) { this.app.workspace.revealLeaf(existingLeaf); } else { await this.app.workspace.getLeaf('tab').setViewState({ type: GRAPH_VIEW_TYPE, active: true }); } } async activateChatView() { const existingLeaf = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0]; if (existingLeaf) { this.app.workspace.revealLeaf(existingLeaf); } else { const rightLeaf = this.app.workspace.getRightLeaf(false); if (rightLeaf) { await rightLeaf.setViewState({ type: CHAT_VIEW_TYPE, active: true }); } } } // ----------------------------------------------- Event Handlers --------------------------------------------------------------- handleNewChat() { this.eventBus.emit('new-chat'); } handleStreamStarted(data: { nodeId: string; controller: AbortController }): void { this.activeStreamControllers.set(data.nodeId, data.controller); } handleStreamEnded(data: { nodeId: string }): void { this.activeStreamControllers.delete(data.nodeId); } } // ----------------------------------------------- Sample Setting Tab --------------------------------------------------------------- class SampleSettingTab extends PluginSettingTab { plugin: LLMAgentPlugin; constructor(app: App, plugin: LLMAgentPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.createEl('h2', { text: 'Основные настройки' }); new Setting(containerEl) .setName('Модель по умолчанию') .setDesc('Выберите модель для основного чата.') .addDropdown((dropdown) => { this.plugin.availableModels.forEach(model => dropdown.addOption(model, model)); dropdown.setValue(this.plugin.settings.defaultModel) .onChange(async (value) => { this.plugin.settings.defaultModel = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }); }); new Setting(containerEl) .setName('Модель суммаризации') .setDesc('Выберите модель для суммаризации.') .addDropdown((dropdown) => { this.plugin.availableModels.forEach(model => dropdown.addOption(model, model)); dropdown.setValue(this.plugin.settings.summarizationModel) .onChange(async (value) => { this.plugin.settings.summarizationModel = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }); }); // Этот промпт будет отправляться LLM перед каждым диалогом this.addPromptAutocomplete(new Setting(containerEl).setName('Системный промпт'), 'systemPrompt'); new Setting(containerEl) .setName('Температура чата по умолчанию') .setDesc(`Значение от 0.0 до 2.0 (по умолчанию ${LLM_DEFAULTS.TEMPERATURE}). Влияет на креативность.`) .addText((text) => text .setPlaceholder(String(LLM_DEFAULTS.TEMPERATURE)) .setValue(String(this.plugin.settings.temperatureChat)) .onChange(async (value) => { const num = parseFloat(value); this.plugin.settings.temperatureChat = isNaN(num) ? LLM_DEFAULTS.TEMPERATURE : num; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Макс. токенов чата по умолчанию') .setDesc('Оставьте пустым для отсутствия лимита.') .addText((text) => text .setPlaceholder('Лимит') .setValue(this.plugin.settings.maxTokensChatDefault) .onChange(async (value) => { this.plugin.settings.maxTokensChatDefault = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('API Base URL') .setDesc('Базовый URL для API LLM Agent') .addText((text) => text .setPlaceholder('http://localhost:5000/api') .setValue(this.plugin.settings.apiBaseUrl) .onChange(async (value) => { this.plugin.settings.apiBaseUrl = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); containerEl.createEl('h2', { text: 'Голосовое управление (Voice Service)' }); new Setting(containerEl) .setName('Модель команд') .setDesc('LLM для обработки голосовых команд.') .addDropdown((dropdown) => { this.plugin.availableModels.forEach(model => dropdown.addOption(model, model)); dropdown.setValue(this.plugin.settings.voiceCommandModel) .onChange(async (value) => { this.plugin.settings.voiceCommandModel = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }); }); // Инструкции для Промпта №1. this.addPromptAutocomplete( new Setting(containerEl).setName('Регулярный промпт'), 'promptRegular' ); // Инструкции для Промпта №2. this.addPromptAutocomplete( new Setting(containerEl).setName('Промпт команд'), 'promptCommands' ); this.addPromptAutocomplete( new Setting(containerEl).setName('Промпт качественной аналитики'), 'promptMetrics' ); new Setting(containerEl) .setName('Температура Voice') .setDesc(`Температура для голосовых запросов (по умолчанию ${LLM_DEFAULTS.TEMPERATURE})`) .addText((text) => text .setPlaceholder(String(LLM_DEFAULTS.TEMPERATURE)) .setValue(String(this.plugin.settings.temperatureVoice)) .onChange(async (value) => { const num = parseFloat(value); this.plugin.settings.temperatureVoice = isNaN(num) ? LLM_DEFAULTS.TEMPERATURE : num; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Макс. токенов Voice (Регулярный)') .setDesc('Лимит токенов для регулярного анализа. Оставьте пустым для безлимита.') .addText((text) => text .setPlaceholder('Лимит') .setValue(this.plugin.settings.maxTokensVoiceRegular) .onChange(async (value) => { this.plugin.settings.maxTokensVoiceRegular = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Макс. токенов Voice (Команды)') .setDesc('Лимит токенов для голосовых команд. Оставьте пустым для безлимита.') .addText((text) => text .setPlaceholder('Лимит') .setValue(this.plugin.settings.maxTokensVoiceCommands) .onChange(async (value) => { this.plugin.settings.maxTokensVoiceCommands = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Интервал регулярного анализа (мин)') .setDesc('Как часто LLM анализирует последние фразы (Prompt 1).') .addText((text) => text .setValue(String(this.plugin.settings.voiceInterval)) .onChange(async (value) => { this.plugin.settings.voiceInterval = Number(value); await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Интервал аналитики (мин)') .setDesc('Как часто Python и LLM генерируют графики и качественный анализ (Таб 4).') .addText((text) => text .setValue(String(this.plugin.settings.metricsInterval)) .onChange(async (value) => { this.plugin.settings.metricsInterval = Number(value) || 15; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Папка аудио-логов') .setDesc('Папка внутри Obsidian, куда будут сохраняться текстовые расшифровки (в формате .md). Относительно корня хранилища.') .addText((text) => text .setPlaceholder('audio-logs') .setValue(this.plugin.settings.logsFolder) .onChange(async (value) => { // Убираем лишние слэши для корректности пути this.plugin.settings.logsFolder = value.replace(/^\/+|\/+$/g, '').trim(); await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Объем контекста (символы)') .setDesc('Сколько последних символов транскрибации отправлять в модель.') .addText((text) => text .setValue(String(this.plugin.settings.voiceContextLimit)) .onChange(async (value) => { this.plugin.settings.voiceContextLimit = Number(value); await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Маркер начала команды') .setDesc('Слово, после которого текст считается командой (Response 2).') .addText((text) => text .setPlaceholder('старт') .setValue(this.plugin.settings.markerStart) .onChange(async (value) => { this.plugin.settings.markerStart = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Маркер конца команды') .setDesc('Слово, завершающее захват голосовой команды.') .addText((text) => text .setPlaceholder('стоп') .setValue(this.plugin.settings.markerStop) .onChange(async (value) => { this.plugin.settings.markerStop = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); containerEl.createEl('h2', { text: 'Управление ссылками и файлами' }); new Setting(containerEl) .setName('Папка с промптами') .setDesc('Путь к папке, содержащей промпты для команд #/##') .addText((text) => text .setPlaceholder('prompts') .setValue(this.plugin.settings.promptsFolder) .onChange(async (value) => { this.plugin.settings.promptsFolder = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); new Setting(containerEl) .setName('Папка кеша для вложений') .setDesc('Абсолютный или относительный путь к папке для кеширования файлов') .addText((text) => text .setPlaceholder('ref-cache') .setValue(this.plugin.settings.refCacheFolder) .onChange(async (value) => { this.plugin.settings.refCacheFolder = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); // Настройки ссылок на файлы new Setting(containerEl) .setName('Включить ссылки на файлы') .setDesc('Разрешить использование @/@@ ссылок на файлы в хранилище') .addToggle((toggle) => toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => { this.plugin.settings.enableFileReferences = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); // Настройки ссылок на промпты new Setting(containerEl) .setName('Включить ссылки на промпты') .setDesc('Разрешить использование #/## ссылок на промпты') .addToggle((toggle) => toggle.setValue(this.plugin.settings.enablePromptReferences).onChange(async (value) => { this.plugin.settings.enablePromptReferences = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); // Новое поле для настройки исключенных папок new Setting(containerEl) .setName('Исключенные папки для ссылок') .setDesc('Список относительных путей к папкам, файлы из которых не будут предлагаться в автодополнении ссылок (@/@@).') .addToggle((toggle) => toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => { this.plugin.settings.enableFileReferences = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); }) ); // Динамический список исключенных папок containerEl.createEl('h3', { text: 'Исключенные папки' }); this.plugin.settings.excludedFolders.forEach((folder, index) => { new Setting(containerEl) .setClass('llm-agent-excluded-folder-item') // Добавляем класс для стилизации .addText((text) => { text.setPlaceholder('Путь к папке (например, templates/)' + index) .setValue(folder) .onChange(async (value) => { this.plugin.settings.excludedFolders[index] = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); // Очищаем кэш автокомплита при изменении настроек this.plugin.eventBus.emit('settings-changed'); }); }) .addExtraButton((button) => { button .setIcon('x') .setTooltip('Удалить папку') .onClick(async () => { this.plugin.settings.excludedFolders.splice(index, 1); await this.plugin.saveSettings(); await this.plugin.syncSettings(); this.display(); // Перерисовать настройки для обновления списка // Очищаем кэш автокомплита при изменении настроек this.plugin.eventBus.emit('settings-changed'); }); }); }); new Setting(containerEl).addButton((button) => { button .setButtonText('Добавить папку') .setIcon('plus') .onClick(async () => { this.plugin.settings.excludedFolders.push(''); // Добавляем пустое поле await this.plugin.saveSettings(); await this.plugin.syncSettings(); this.display(); // Перерисовать настройки для обновления списка }); }); } private addPromptAutocomplete(setting: Setting, settingKey: keyof LLMAgentSettings) { const manager = new AutocompleteManager(this.plugin); setting.addTextArea((text) => { const el = text.inputEl; const autocomplete = new ReferenceAutocomplete(el, manager); autocomplete.onItemSelected(async (item, match) => { const val = el.value; const before = val.substring(0, match.startIndex); const after = val.substring(match.endIndex); // Если в имени есть пробел — оборачиваем в кавычки const nameToInsert = item.name.includes(' ') ? `"${item.name}"` : item.name; const newValue = before + "#" + nameToInsert + after; // Обновляем визуально в поле el.value = newValue; // Обновляем в конфиге (используем приведение типа, чтобы TS не ругался) (this.plugin.settings as any)[settingKey] = newValue; await this.plugin.saveSettings(); await this.plugin.syncSettings(); autocomplete.hideAutocomplete(); }); text.setPlaceholder('Впишите текст или начните с # для выбора промпта') .setValue(String(this.plugin.settings[settingKey])) .onChange(async (value) => { // Прямое присваивание через приведение к any для обхода строгой проверки типов (this.plugin.settings as any)[settingKey] = value; await this.plugin.saveSettings(); await this.plugin.syncSettings(); const cursor = el.selectionStart; const textValue = el.value; const ref = ReferenceParser.getCurrentReference(textValue, cursor); if (ref && ref.type === 'prompt') { // Используем getBoundingClientRect для позиционирования меню под полем ввода const rect = el.getBoundingClientRect(); autocomplete.showAutocomplete(ref, rect); } else { autocomplete.hideAutocomplete(); } }); el.addEventListener('keydown', (e) => { if (autocomplete.isVisible()) { if (autocomplete.handleKeydown(e)) { e.stopPropagation(); } } }); }); } }