diff --git a/main.ts b/main.ts index ec524e2..d2fd3fb 100644 --- a/main.ts +++ b/main.ts @@ -1,8 +1,12 @@ -import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, ItemView, WorkspaceLeaf } from 'obsidian'; +import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, ItemView, WorkspaceLeaf, TFile } 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'; @@ -20,12 +24,12 @@ interface LLMAgentSettings { summarizationModel: string; voiceCommandModel: string; - voiceInterval: number; - voiceDisplayLimit: number; // 3000 - voiceContextLimit: number; // N2 символов для LLM - promptRegular: string; - promptCommands: string; - markerStart: string; // "старт" + voiceInterval: number; + voiceDisplayLimit: number; // 3000 + voiceContextLimit: number; // N2 символов для LLM + promptRegular: string; + promptCommands: string; + markerStart: string; // "старт" markerStop: string; // "стоп" } @@ -58,20 +62,30 @@ export default class LLMAgentPlugin extends Plugin { activeStreamControllers: Map; availableModels: string[]; - async syncSettings() { - try { - const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this.settings) - }); - if (response.ok) { + 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); + + 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) { + } + } catch (e) { console.error("LLM Agent: Failed to sync settings", e); - } - } + } + } /** * Загружает список доступных моделей с бэкенда. @@ -140,7 +154,18 @@ export default class LLMAgentPlugin extends Plugin { await this.fetchAvailableModels(); // Вызываем один раз при запуске - await this.syncSettings(); + 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() { @@ -234,10 +259,10 @@ class SampleSettingTab extends PluginSettingTab { 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(); - }); + this.plugin.settings.defaultModel = value; + await this.plugin.saveSettings(); + await this.plugin.syncSettings(); + }); }); new Setting(containerEl) @@ -247,10 +272,10 @@ class SampleSettingTab extends PluginSettingTab { 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(); - }); + this.plugin.settings.summarizationModel = value; + await this.plugin.saveSettings(); + await this.plugin.syncSettings(); + }); }); new Setting(containerEl) @@ -267,19 +292,8 @@ class SampleSettingTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .setName('Системный промпт') - .setDesc('Этот промпт будет отправляться LLM перед каждым диалогом.') - .addTextArea((text) => - text - .setPlaceholder('Ты полезный ИИ ассистент.') - .setValue(this.plugin.settings.systemPrompt) - .onChange(async (value) => { - this.plugin.settings.systemPrompt = value; - await this.plugin.saveSettings(); - await this.plugin.syncSettings(); - }) - ); + // Этот промпт будет отправляться LLM перед каждым диалогом + this.addPromptAutocomplete(new Setting(containerEl).setName('Системный промпт'), 'systemPrompt'); containerEl.createEl('h2', { text: 'Голосовое управление (Voice Service)' }); @@ -290,10 +304,10 @@ class SampleSettingTab extends PluginSettingTab { 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(); - }); + this.plugin.settings.voiceCommandModel = value; + await this.plugin.saveSettings(); + await this.plugin.syncSettings(); + }); }); new Setting(containerEl) @@ -303,10 +317,10 @@ class SampleSettingTab extends PluginSettingTab { text .setValue(String(this.plugin.settings.voiceInterval)) .onChange(async (value) => { - this.plugin.settings.voiceInterval = Number(value); - await this.plugin.saveSettings(); - await this.plugin.syncSettings(); - }) + this.plugin.settings.voiceInterval = Number(value); + await this.plugin.saveSettings(); + await this.plugin.syncSettings(); + }) ); new Setting(containerEl) @@ -316,10 +330,10 @@ class SampleSettingTab extends PluginSettingTab { text .setValue(String(this.plugin.settings.voiceContextLimit)) .onChange(async (value) => { - this.plugin.settings.voiceContextLimit = Number(value); - await this.plugin.saveSettings(); - await this.plugin.syncSettings(); - }) + this.plugin.settings.voiceContextLimit = Number(value); + await this.plugin.saveSettings(); + await this.plugin.syncSettings(); + }) ); new Setting(containerEl) @@ -350,33 +364,17 @@ class SampleSettingTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .setName('Регулярный промпт') - .setDesc('Инструкции для Промпта №1.') - .addTextArea((text) => - text - // .setPlaceholder('prompts/regular_analysis.md') - .setValue(this.plugin.settings.promptRegular) - .onChange(async (value) => { - this.plugin.settings.promptRegular = value; - await this.plugin.saveSettings(); - await this.plugin.syncSettings(); - }) - ); + // Инструкции для Промпта №1. + this.addPromptAutocomplete( + new Setting(containerEl).setName('Регулярный промпт'), + 'promptRegular' + ); - new Setting(containerEl) - .setName('Промпт команд') - .setDesc('Инструкции для Промпта №2.') - .addTextArea((text) => - text - // .setPlaceholder('prompts/voice_commands.md') - .setValue(this.plugin.settings.promptCommands) - .onChange(async (value) => { - this.plugin.settings.promptCommands = value; - await this.plugin.saveSettings(); - await this.plugin.syncSettings(); - }) - ); + // Инструкции для Промпта №2. + this.addPromptAutocomplete( + new Setting(containerEl).setName('Промпт команд'), + 'promptCommands' + ); containerEl.createEl('h2', { text: 'Управление ссылками и файлами' }); @@ -439,9 +437,9 @@ class SampleSettingTab extends PluginSettingTab { .addToggle((toggle) => toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => { this.plugin.settings.enableFileReferences = value; - await this.plugin.saveSettings(); - await this.plugin.syncSettings(); - }) + await this.plugin.saveSettings(); + await this.plugin.syncSettings(); + }) ); // Динамический список исключенных папок @@ -458,7 +456,7 @@ class SampleSettingTab extends PluginSettingTab { await this.plugin.syncSettings(); // Очищаем кэш автокомплита при изменении настроек this.plugin.eventBus.emit('settings-changed'); - }); + }); }) .addExtraButton((button) => { button @@ -487,4 +485,64 @@ class SampleSettingTab extends PluginSettingTab { }); }); } + + 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(); + } + } + }); + }); + } } diff --git a/src/references/ReferenceAutocomplete.ts b/src/references/ReferenceAutocomplete.ts index 5a2b3b0..b7b5fe8 100644 --- a/src/references/ReferenceAutocomplete.ts +++ b/src/references/ReferenceAutocomplete.ts @@ -9,6 +9,9 @@ import { setIcon } from 'obsidian'; // ----------------------------------------------- Reference Autocomplete Component --------------------------------------------------------------- +const CHAT_CONTAINER_SELECTOR = 'chat-input-container'; +const SETTINGS_CONTAINER_SELECTOR = 'vertical-tab-content-container'; + export class ReferenceAutocomplete { private container: HTMLElement; private dropdown: HTMLElement; @@ -23,7 +26,13 @@ export class ReferenceAutocomplete { constructor(inputElement: HTMLElement, autocompleteManager: AutocompleteManager) { this.inputElement = inputElement; this.autocompleteManager = autocompleteManager; - this.container = this.inputElement.closest('.chat-input-container') as HTMLElement; + + // Ищем контейнер чата, а если не находим (мы в настройках), + // используем родителя элемента или body + const chatContainer = this.inputElement.closest(`.${CHAT_CONTAINER_SELECTOR}`); + const settingsContainer = this.inputElement.closest(`.${SETTINGS_CONTAINER_SELECTOR}`); + + this.container = (chatContainer || settingsContainer || document.body) as HTMLElement; this.createDropdown(); this.setupEventListeners(); @@ -143,6 +152,11 @@ export class ReferenceAutocomplete { this.dropdown.className = 'reference-autocomplete'; this.dropdown.style.display = 'none'; + // Если мы в настройках, добавляем фиксированное позиционирование + if (this.container === document.body) { + this.dropdown.style.position = 'fixed'; + } + // Добавляем dropdown в контейнер this.container.appendChild(this.dropdown); } @@ -235,43 +249,78 @@ export class ReferenceAutocomplete { // Чтобы получить корректные размеры, устанавливаем display: 'block' this.dropdown.style.display = 'block'; + const dropdownRect = this.dropdown.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + + let top: number; + let left: number; + let dropdownMinWidth: number; + + const isChat = this.container.classList.contains(CHAT_CONTAINER_SELECTOR); + + console.log("isChat: " + isChat) + const inputRect = this.inputElement.getBoundingClientRect(); const containerRect = this.container.getBoundingClientRect(); - const dropdownRect = this.dropdown.getBoundingClientRect(); // Получаем размеры дропдауна - const viewportHeight = window.innerHeight; - // Позиционируем относительно inputElement, т.к. он уже в потоке документа - // Левый край дропдауна совпадает с левым краем курсора - let left = cursorRect.left - containerRect.left; + if (!isChat) { + // Позиционируем относительно inputElement, т.к. он уже в потоке документа + // Левый край дропдауна совпадает с левым краем курсора + left = containerRect.x - 35; // 35 - эмпирика, но что там должно быть, не понял + console.log("left: " + left) - // Дефолтное положение - под курсором - let top = cursorRect.bottom - containerRect.top + 5; // +5px отступ + // Дефолтное положение - под курсором + top = cursorRect.bottom - containerRect.top + 5; // +5px отступ - // Проверяем, есть ли место снизу - const spaceBelow = viewportHeight - cursorRect.bottom; - const spaceAbove = cursorRect.top; + // Проверяем, есть ли место снизу + const spaceBelow = viewportHeight - cursorRect.bottom; + const spaceAbove = cursorRect.top; - if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) { - // Если снизу мало места и сверху достаточно, размещаем сверху - top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ - } + if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) { + // Если снизу мало места и сверху достаточно, размещаем сверху + top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ + } - // Убедимся, что дропдаун не выходит за границы контейнера справа - const maxRight = inputRect.right - containerRect.left; - if (left + dropdownRect.width > maxRight) { - left = maxRight - dropdownRect.width; - if (left < 0) left = 0; // Если дропдаун шире контейнера, прижимаем к левому краю - } - // Убедимся, что дропдаун не выходит за границы контейнера слева - if (left < 0) { - left = 0; + dropdownMinWidth = 200; + } else { + // Старая логика для чата (Relative к container) + + // Позиционируем относительно inputElement, т.к. он уже в потоке документа + // Левый край дропдауна совпадает с левым краем курсора + left = cursorRect.left - containerRect.left; + + // Дефолтное положение - под курсором + top = cursorRect.bottom - containerRect.top + 5; // +5px отступ + + // Проверяем, есть ли место снизу + const spaceBelow = viewportHeight - cursorRect.bottom; + const spaceAbove = cursorRect.top; + + if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) { + // Если снизу мало места и сверху достаточно, размещаем сверху + top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ + } + + // Убедимся, что дропдаун не выходит за границы контейнера справа + const maxRight = inputRect.right - containerRect.left; + if (left + dropdownRect.width > maxRight) { + left = maxRight - dropdownRect.width; + if (left < 0) left = 0; // Если дропдаун шире контейнера, прижимаем к левому краю + } + // Убедимся, что дропдаун не выходит за границы контейнера слева + if (left < 0) { + left = 0; + } + + // Устанавливаем ширину дропдауна равной ширине inputElement, если он больше + // Это предотвратит слишком узкий дропдаун, если имя файла короткое + dropdownMinWidth = inputRect.width / 2; // Минимум половина ширины инпута } this.dropdown.style.top = `${top}px`; this.dropdown.style.left = `${left}px`; - // Устанавливаем ширину дропдауна равной ширине inputElement, если он больше - // Это предотвратит слишком узкий дропдаун, если имя файла короткое - this.dropdown.style.minWidth = `${inputRect.width / 2}px`; // Минимум половина ширины инпута + this.dropdown.style.minWidth = `${dropdownMinWidth}px`; this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth } diff --git a/src/references/ReferenceParser.ts b/src/references/ReferenceParser.ts index d248ac0..f135d56 100644 --- a/src/references/ReferenceParser.ts +++ b/src/references/ReferenceParser.ts @@ -6,21 +6,21 @@ // ----------------------------------------------- Interfaces --------------------------------------------------------------- export interface ReferenceMatch { - fullMatch: string; - prefix: string; // Теперь может быть @, !@, #, !#, $, !$ - name: string; - startIndex: number; - endIndex: number; - type: 'file' | 'prompt' | 'external'; - permanent: boolean; + fullMatch: string; + prefix: string; // Теперь может быть @, !@, #, !#, $, !$ + name: string; + startIndex: number; + endIndex: number; + type: 'file' | 'prompt' | 'external'; + permanent: boolean; } export interface FileReference { - type: 'file' | 'prompt' | 'external'; - name: string; - path: string; - permanent: boolean; - uuid?: string; + type: 'file' | 'prompt' | 'external'; + name: string; + path: string; + permanent: boolean; + uuid?: string; } // ----------------------------------------------- Reference Parser --------------------------------------------------------------- @@ -74,13 +74,17 @@ export class ReferenceParser { static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null { const beforeCursor = text.substring(0, cursorPosition); - // ИЗМЕНЕНО: Поддержка !@, !#, !$ - const incompleteRegex = /(![@#$]|[@#$])([^\s@#$"!]*?)$/; + // Измененная регулярка: теперь она понимает, если после символа # идет кавычка + // Группа 1: Префикс, Группа 2: Имя в кавычках, Группа 3: Имя без кавычек + const incompleteRegex = /(![@#$]|[@#$])(?:"([^"]*)|([^\s@#$"!]*?))$/; const match = beforeCursor.match(incompleteRegex); if (match) { const prefix = match[1]; - const partialName = match[2]; + const nameInQuotes = match[2]; // текст внутри кавычек + const nameWithoutQuotes = match[3]; // текст до первого пробела + const partialName = nameInQuotes !== undefined ? nameInQuotes : nameWithoutQuotes; + const startIndex = beforeCursor.length - match[0].length; const typeChar = prefix.replace('!', ''); diff --git a/src/references/TemplateEngine.ts b/src/references/TemplateEngine.ts index 9940706..abd9bdf 100644 --- a/src/references/TemplateEngine.ts +++ b/src/references/TemplateEngine.ts @@ -71,6 +71,32 @@ export class TemplateEngine { return expandedContent; } + /** + * Главный метод для "сборки" промпта. + * Принимает либо чистый текст, либо путь вида #filename или просто текст с вкраплениями @{file} + */ + async processRawSettingValue(value: string): Promise { + if (!value) return ""; + + // Регулярка теперь поддерживает опциональные кавычки: + // ^# - начинается на решетку + // (?:"([^"]+)"|([^\s]+)) - либо "имя с пробелом", либо имя_без_пробела + const fullLinkMatch = value.trim().match(/^#(?:"([^"]+)"|([^\s]+))$/); + + if (fullLinkMatch) { + const promptName = fullLinkMatch[1] || fullLinkMatch[2]; // Берем из той группы, которая сработала + const allFiles = this.plugin.app.vault.getFiles(); + const content = await this.loadPromptContent(promptName, allFiles); + if (content) { + return await this.expandPromptTemplate(content); + } + } + + // Если это не прямая ссылка на файл, а просто текст, + // всё равно прогоняем через расширитель (на случай если там внутри есть @{...}) + return await this.expandPromptTemplate(value); + } + // ----------------------------------------------- Private Methods --------------------------------------------------------------- /**