diff --git a/main.ts b/main.ts index e621462..55bc0fa 100644 --- a/main.ts +++ b/main.ts @@ -10,11 +10,19 @@ import 'src/css/styles.css'; // Путь к твоему новому CSS фай interface LLMAgentSettings { apiBaseUrl: string; systemPrompt: string; + defaultModel: string; + promptsFolder: string; // Новое поле + enableFileReferences: boolean; // Новое поле + enablePromptReferences: boolean; // Новое поле } const DEFAULT_SETTINGS: LLMAgentSettings = { apiBaseUrl: 'http://localhost:5000/api', systemPrompt: 'Ты полезный ИИ ассистент.', + defaultModel: 'gemini-2.5-flash', + promptsFolder: 'prompts', // Папка с промптами по умолчанию + enableFileReferences: true, + enablePromptReferences: true }; // ----------------------------------------------- Main Plugin Class --------------------------------------------------------------- @@ -154,5 +162,41 @@ class SampleSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }) ); + + // Новое поле для папки промптов + 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(); + }) + ); + + // Настройки ссылок на файлы + 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(); + }) + ); + + // Настройки ссылок на промпты + 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(); + }) + ); } } diff --git a/src/components/ChatPanel.ts b/src/components/ChatPanel.ts index 8534260..9816115 100644 --- a/src/components/ChatPanel.ts +++ b/src/components/ChatPanel.ts @@ -5,6 +5,15 @@ import { ChatHistoryItem } from './models/ChatHistoryItem'; +import { AutocompleteManager } from '../references/AutocompleteManager'; +import { ReferenceAutocomplete } from '../references/ReferenceAutocomplete'; +import { ReferenceExpander } from '../references/ReferenceExpander'; +import { TemplateEngine } from '../references/TemplateEngine'; +import { ReferenceParser, FileReference, ReferenceMatch } from '../references/ReferenceParser'; +import { ReferenceBox } from '../references/ReferenceBox'; +import { AutocompleteItem } from '../references/AutocompleteManager'; +import LLMAgentPlugin from 'main'; + export class ChatPanel { container: HTMLElement; props: { @@ -12,6 +21,7 @@ export class ChatPanel { onSendMessage: (message: string, selectedModel?: string) => void; availableModels?: string[]; selectedModel?: string; + plugin: LLMAgentPlugin; }; chatHistory: ChatHistoryItem[]; messageInput: HTMLDivElement; @@ -21,6 +31,14 @@ export class ChatPanel { modelDropdown: HTMLDivElement; selectedModel: string; + // ----------------------------------------------- Reference System Fields --------------------------------------------------------------- + private autocompleteManager: AutocompleteManager; + private referenceAutocomplete: ReferenceAutocomplete; + private referenceExpander: ReferenceExpander; + private templateEngine: TemplateEngine; + private currentReferences: FileReference[] = []; + private isProcessingReference = false; + constructor( container: HTMLElement, props: { @@ -28,6 +46,7 @@ export class ChatPanel { onSendMessage: (message: string, selectedModel?: string) => void; availableModels?: string[]; selectedModel?: string; + plugin: LLMAgentPlugin; // Обновленный тип } ) { this.container = container; @@ -35,107 +54,12 @@ export class ChatPanel { this.chatHistory = props.chatHistory || []; this.selectedModel = props.selectedModel || 'gemini-2.5-flash'; + // Инициализация системы ссылок + this.initializeReferenceSystem(); + this.init(); } - init(): void { - this.container.innerHTML = ` -
-
-
-
-
-
-
-
-
- - -
-
-
- -
-
- -
-
- `; - - this.messageInput = this.container.querySelector('.message-input-editor') as HTMLDivElement; - this.sendButton = this.container.querySelector('#send-button') as HTMLButtonElement; - this.suggestionsContainer = this.container.querySelector('#suggestions') as HTMLDivElement; - this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement; - this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement; - - this.setupEventListeners(); - this.renderHistory(); - this.updateModelDropdown(); - this.updatePlaceholder(); - } - - setupEventListeners(): void { - this.sendButton.addEventListener('click', () => this.handleSend()); - - this.messageInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - if (this.suggestionsContainer.style.display === 'block') { - const firstSuggestion = this.suggestionsContainer.querySelector('.suggestion-item'); - if (firstSuggestion) { - this.messageInput.textContent = firstSuggestion.textContent || ''; - this.hideSuggestions(); - e.preventDefault(); - return; - } - } - this.handleSend(); - e.preventDefault(); - } - }); - - this.messageInput.addEventListener('input', () => { - this.handleInput(); - this.updatePlaceholder(); - }); - - this.messageInput.addEventListener('focus', () => { - this.updatePlaceholder(); - }); - - this.messageInput.addEventListener('blur', () => { - this.updatePlaceholder(); - }); - - // Model selection - this.modelSelectButton.addEventListener('click', (e) => { - e.stopPropagation(); - this.toggleModelDropdown(); - }); - - // Close dropdown when clicking outside - document.addEventListener('click', (e) => { - if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) { - this.modelDropdown.style.display = 'none'; - } - if (!this.suggestionsContainer.contains(e.target as Node)) { - this.hideSuggestions(); - } - }); - } - updatePlaceholder(): void { const isEmpty = this.messageInput.textContent?.trim() === ''; const isFocused = document.activeElement === this.messageInput; @@ -159,24 +83,35 @@ export class ChatPanel { const modelSelectButtonRect = this.modelSelectButton.getBoundingClientRect(); const modelDropdownRect = this.modelDropdown.getBoundingClientRect(); + const chatPanelRect = this.container.getBoundingClientRect(); // Общие границы чат-панели const viewportHeight = window.innerHeight || document.documentElement.clientHeight; // ----------------------------------------------- Секция логики позиционирования дропдауна ---------------------------------------------------- - // Определяем, достаточно ли места снизу + // Позиция относительно контейнера chatPanel (или inputElement) + let top = modelSelectButtonRect.bottom - chatPanelRect.top + 4; // Отступ 4px от кнопки + let left = modelSelectButtonRect.left - chatPanelRect.left; + + // Проверяем, помещается ли dropdown снизу const spaceBelow = viewportHeight - modelSelectButtonRect.bottom; - // Определяем, достаточно ли места сверху const spaceAbove = modelSelectButtonRect.top; - if (spaceBelow >= modelDropdownRect.height || spaceBelow >= spaceAbove) { - // Открываем вниз, если есть достаточно места или места снизу больше - this.modelDropdown.style.top = `${modelSelectButtonRect.height + 4}px`; // 4px отступ от кнопки - this.modelDropdown.style.bottom = 'auto'; - } else { - // Открываем вверх - this.modelDropdown.style.bottom = `${modelSelectButtonRect.height + 4}px`; // 4px отступ от кнопки - this.modelDropdown.style.top = 'auto'; + if (spaceBelow < modelDropdownRect.height + 10 && spaceAbove > modelDropdownRect.height + 10) { + // Если снизу мало места и сверху достаточно, размещаем сверху + top = modelSelectButtonRect.top - chatPanelRect.top - modelDropdownRect.height - 4; // Отступ 4px от кнопки } - this.modelDropdown.style.left = '0'; + + // Убедимся, что дропдаун не выходит за границы контейнера справа + const maxRight = chatPanelRect.width; + if (left + modelDropdownRect.width > maxRight) { + left = maxRight - modelDropdownRect.width; + } + // Убедимся, что дропдаун не выходит за границы контейнера слева + if (left < 0) { + left = 0; + } + + this.modelDropdown.style.top = `${top}px`; + this.modelDropdown.style.left = `${left}px`; this.modelDropdown.style.right = 'auto'; // Сброс right, чтобы left работал корректно // ----------------------------------------------- End of Секция логики позиционирования дропдауна --------------------------------------------- } @@ -250,16 +185,6 @@ export class ChatPanel { this.suggestionsContainer.style.display = 'none'; } - handleSend(): void { - const message = this.messageInput.textContent?.trim() || ''; - if (message && this.props.onSendMessage) { - this.props.onSendMessage(message, this.selectedModel); - this.messageInput.textContent = ''; - this.updatePlaceholder(); - this.hideSuggestions(); - } - } - renderHistory(): void { const historyContainer = this.container.querySelector('#chat-history') as HTMLElement; historyContainer.innerHTML = this.chatHistory @@ -327,6 +252,652 @@ export class ChatPanel { } destroy(): void { + if (this.referenceAutocomplete) { + this.referenceAutocomplete.destroy(); + } this.container.innerHTML = ''; } + + // ----------------------------------------------- Reference System Initialization --------------------------------------------------------------- + + /** + * Инициализирует систему ссылок + */ + private initializeReferenceSystem(): void { + if (!this.props.plugin) { + console.warn('Plugin не передан в ChatPanel, система ссылок отключена'); + return; + } + + this.autocompleteManager = new AutocompleteManager(this.props.plugin); + this.templateEngine = new TemplateEngine(this.props.plugin); + this.referenceExpander = new ReferenceExpander(this.props.plugin, this.templateEngine); + } + + // Обновить метод init(): + init(): void { + this.container.innerHTML = ` +
+
+
+
+
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+ `; + + this.messageInput = this.container.querySelector('.message-input-editor') as HTMLDivElement; + this.sendButton = this.container.querySelector('#send-button') as HTMLButtonElement; + this.suggestionsContainer = this.container.querySelector('#suggestions') as HTMLDivElement; + this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement; + this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement; + + this.setupEventListeners(); + this.setupReferenceSystem(); + this.renderHistory(); + this.updateModelDropdown(); + this.updatePlaceholder(); + } + + // ----------------------------------------------- Reference System Setup --------------------------------------------------------------- + + /** + * Настраивает систему ссылок + */ + private setupReferenceSystem(): void { + if (!this.autocompleteManager) return; + + // Инициализируем автокомплит + this.referenceAutocomplete = new ReferenceAutocomplete(this.messageInput, this.autocompleteManager); + + // Настраиваем обработчик выбора элемента + this.referenceAutocomplete.onItemSelected((item: AutocompleteItem, match: ReferenceMatch) => { + this.replaceReferenceWithBox(match, item); + }); + } + + // Обновить метод setupEventListeners(): + setupEventListeners(): void { + this.sendButton.addEventListener('click', () => this.handleSendWithReferences()); + + this.messageInput.addEventListener('keydown', (e) => { + // Сначала проверяем автокомплит ссылок + if (this.referenceAutocomplete && this.referenceAutocomplete.handleKeydown(e)) { + return; // Событие обработано автокомплитом + } + + if (e.key === 'Enter' && !e.shiftKey) { + if (this.suggestionsContainer.style.display === 'block') { + const firstSuggestion = this.suggestionsContainer.querySelector('.suggestion-item'); + if (firstSuggestion) { + this.messageInput.textContent = firstSuggestion.textContent || ''; + this.hideSuggestions(); + e.preventDefault(); + return; + } + } + this.handleSendWithReferences(); + e.preventDefault(); + } + }); + + this.messageInput.addEventListener('input', () => { + this.handleInput(); + this.handleReferenceInput(); + this.updatePlaceholder(); + }); + + this.messageInput.addEventListener('focus', () => { + this.updatePlaceholder(); + }); + + this.messageInput.addEventListener('blur', () => { + this.updatePlaceholder(); + }); + + // Model selection + this.modelSelectButton.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleModelDropdown(); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) { + this.modelDropdown.style.display = 'none'; + } + if (!this.suggestionsContainer.contains(e.target as Node)) { + this.hideSuggestions(); + } + }); + } + + // ----------------------------------------------- Reference Processing --------------------------------------------------------------- + + /** + * Обрабатывает ввод для автокомплита ссылок + */ + private handleReferenceInput(): void { + if (!this.referenceAutocomplete || this.isProcessingReference) return; + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + this.referenceAutocomplete.hideAutocomplete(); + return; + } + + const range = selection.getRangeAt(0); + const cursorPosition = this.getCursorPositionInText(); + + if (cursorPosition === -1) { + this.referenceAutocomplete.hideAutocomplete(); + return; + } + + const text = this.getTextContent(); + const currentRef = ReferenceParser.getCurrentReference(text, cursorPosition); + + if (currentRef) { + // Получаем позицию курсора на экране + const cursorRect = range.getBoundingClientRect(); + this.referenceAutocomplete.showAutocomplete(currentRef, cursorRect); + } else { + this.referenceAutocomplete.hideAutocomplete(); + } + } + + /** + * Заменяет текст ссылки на визуальный бокс + */ + private replaceReferenceWithBox(match: ReferenceMatch, item: AutocompleteItem): void { + this.isProcessingReference = true; + + const reference: FileReference = { + type: item.type, + name: item.name, + path: item.path, + permanent: match.prefix.length === 2 // @@ или ## + }; + + // Создаем бокс + const box = ReferenceBox.createBox(reference); + + // Используем текущий Range для замены + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + this.isProcessingReference = false; + return; + } + + const currentRange = selection.getRangeAt(0); + + // Создаем новый диапазон для замены, основываясь на match.startIndex и match.endIndex + // Необходимо получить текстовые узлы, соответствующие этим индексам + const { startNode, startOffset, endNode, endOffset } = this.findNodesAndOffsetsForMatch(match); + + if (!startNode || !endNode) { + console.error("Не удалось найти узлы для замены ссылки", match); + this.isProcessingReference = false; + return; + } + + const replacementRange = document.createRange(); + replacementRange.setStart(startNode, startOffset); + replacementRange.setEnd(endNode, endOffset); + + replacementRange.deleteContents(); // Удаляем текст ссылки + replacementRange.insertNode(box); // Вставляем бокс + + // Очищаем выделение + selection.removeAllRanges(); + + // Добавляем ссылку в список текущих ссылок + this.currentReferences.push(reference); + + this.isProcessingReference = false; + + // Вставляем пробел после бокса и перемещаем курсор за него + this.insertSpaceAndSetCursorAfterElement(box); + } + + /** + * Находит узлы и смещения для заданного match в contenteditable элементе. + * @param match - объект ReferenceMatch. + * @returns Объект с startNode, startOffset, endNode, endOffset. + */ + private findNodesAndOffsetsForMatch(match: ReferenceMatch): { + startNode: Node | null, + startOffset: number, + endNode: Node | null, + endOffset: number + } { + let startNode: Node | null = null; + let startOffset = 0; + let endNode: Node | null = null; + let endOffset = 0; + let currentLength = 0; + + const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); // SHOW_ALL, чтобы учитывать и элементы + let node; + + while ((node = walker.nextNode())) { + const nodeTextLength = this.getNodeEquivalentLengthForReferenceParsing(node); + + // Проверяем, начинается ли ссылка в этом узле + if (currentLength <= match.startIndex && match.startIndex < currentLength + nodeTextLength) { + startNode = node; + startOffset = match.startIndex - currentLength; + } + + // Проверяем, заканчивается ли ссылка в этом узле + if (currentLength <= match.endIndex && match.endIndex <= currentLength + nodeTextLength) { + endNode = node; + endOffset = match.endIndex - currentLength; + break; // Нашли оба, выходим + } + + // Если ссылка охватывает несколько узлов, может быть, endNode еще не найден, + // но мы уже прошли startNode. + if (startNode && !endNode && match.endIndex <= currentLength + nodeTextLength) { + // Это случай, когда endMatch.endIndex может быть на границе или внутри следующего узла + endNode = node; + endOffset = match.endIndex - currentLength; + break; + } + + + currentLength += nodeTextLength; + } + + // Если endNode не найден, возможно, match.endIndex указывает на конец inputElement + if (!endNode && startNode && currentLength <= match.endIndex) { + if (this.messageInput.lastChild) { + endNode = this.messageInput.lastChild; + endOffset = this.messageInput.lastChild.textContent?.length || 0; // Или конец всего контента + } + } + + + return { startNode, startOffset, endNode, endOffset }; + } + + /** + * Вычисляет эквивалентную текстовую длину узла для целей парсинга ссылок. + * Это отличается от getNodeEquivalentLength, так как здесь reference-box уже считается как его текстовое представление, + * а не 0, так как мы ищем где находится match. + * @param node - узел DOM. + * @returns эквивалентная текстовая длина узла. + */ + private getNodeEquivalentLengthForReferenceParsing(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.length || 0; + } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).classList.contains('reference-box')) { + const reference = ReferenceBox.extractReference(node as HTMLElement); + if (reference) { + // Если это reference-box, его "текстовая длина" для парсинга - это его исходная @/@@#name + return (reference.permanent ? 2 : 1) + reference.name.length; + } + } + return 0; // Другие элементы не влияют на текстовую длину для этого контекста + } + + /** + * Вставляет пробел после указанного элемента и устанавливает курсор. + * @param element - HTML элемент, после которого нужно вставить пробел и установить курсор. + */ + private insertSpaceAndSetCursorAfterElement(element: HTMLElement): void { + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.setStartAfter(element); + range.collapse(true); // Схлопываем диапазон до одной точки + + // Вставляем неразрывный пробел + const spaceNode = document.createTextNode('\u00A0'); + range.insertNode(spaceNode); + + // Перемещаем курсор после вставленного пробела + range.setStartAfter(spaceNode); + range.collapse(true); + + selection.removeAllRanges(); + selection.addRange(range); + } + + + /** + * Заменяет текст на HTML элемент + */ + private replaceTextWithElement(match: ReferenceMatch, element: HTMLElement): void { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + // Находим текстовый узел с совпадением + const range = document.createRange(); + range.selectNodeContents(this.messageInput); + range.setEnd(selection.focusNode!, selection.focusOffset); // Устанавливаем конец диапазона на текущую позицию курсора + + let textBeforeMatch = ''; + let nodeBeforeMatch: Node | null = null; + let offsetBeforeMatch = 0; + + // Проходим по дочерним элементам до match.startIndex + const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_TEXT); + let node: Node | null; + let currentLength = 0; + + while ((node = walker.nextNode())) { + const nodeText = node.textContent || ''; + if (currentLength + nodeText.length >= match.startIndex) { + nodeBeforeMatch = node; + offsetBeforeMatch = match.startIndex - currentLength; + break; + } + currentLength += nodeText.length; + } + + if (!nodeBeforeMatch) { + console.warn("Не удалось найти текстовый узел для начала замены."); + return; + } + + // Удаляем диапазон, соответствующий тексту ссылки + const deleteRange = document.createRange(); + deleteRange.setStart(nodeBeforeMatch, offsetBeforeMatch); + + // Расширяем диапазон удаления до конца 'fullMatch' + let remainingLengthToDelete = match.fullMatch.length; + let currentNode = nodeBeforeMatch; + let currentOffset = offsetBeforeMatch; + + while (currentNode && remainingLengthToDelete > 0) { + const nodeText = currentNode.textContent || ''; + const canDelete = Math.min(remainingLengthToDelete, nodeText.length - currentOffset); + remainingLengthToDelete -= canDelete; + currentOffset += canDelete; + + if (remainingLengthToDelete > 0) { + // Если не всё удалено в текущем узле, переходим к следующему + currentNode = walker.nextNode(); + currentOffset = 0; // Начинаем с начала следующего узла + } + } + deleteRange.setEnd(currentNode || nodeBeforeMatch, currentOffset); + deleteRange.deleteContents(); + + + // Вставляем новый элемент на место удаленного текста + deleteRange.insertNode(element); + + // Очищаем выделение + selection.removeAllRanges(); + } + + /** + * Вставляет пробел после указанного элемента. + * @param element - HTML элемент, после которого нужно вставить пробел. + */ + private insertSpaceAfterElement(element: HTMLElement): void { + const spaceNode = document.createTextNode('\u00A0'); // Неразрывный пробел + if (element.nextSibling) { + element.parentNode?.insertBefore(spaceNode, element.nextSibling); + } else { + element.parentNode?.appendChild(spaceNode); + } + } + + /** + * Устанавливает курсор после указанного элемента. + * @param element - HTML элемент, после которого нужно установить курсор. + */ + private setCursorAfterElement(element: HTMLElement): void { + const selection = window.getSelection(); + if (!selection) return; + + // Проверяем, есть ли после элемента текстовый узел (который мы только что вставили) + let targetNode = element.nextSibling; + if (targetNode?.nodeType === Node.TEXT_NODE) { + const range = document.createRange(); + range.setStart(targetNode, targetNode.textContent!.length); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + // Если нет текстового узла (такого быть не должно после insertSpaceAfterElement), + // ставим курсор максимально близко к элементу. + const range = document.createRange(); + range.setStartAfter(element); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + /** + * Получает позицию курсора в тексте + * (теперь используем findNodesAndOffsetsForMatch для более точного определения текстового смещения) + */ + private getCursorPositionInText(): number { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return -1; + + const range = selection.getRangeAt(0); + // Получаем "абсолютный" индекс курсора в контексте всего текстового содержимого `messageInput` + // с учетом того, что reference-box'ы представлены их текстовыми аналогами в getTextContent() + let currentTextOffset = 0; + const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); + let node; + + while ((node = walker.nextNode())) { + if (node === range.endContainer) { + if (node.nodeType === Node.TEXT_NODE) { + currentTextOffset += range.endOffset; + } else if (node === this.messageInput) { + // Cлучай, когда курсор в корневом элементе, но не в текстовом узле. + // Может быть, курсор находится между элементами или в конце. + // Нам нужно определить, сколько узлов до курсора. + let nodeBeforeCursorElementCount = 0; + for (let i = 0; i < range.endOffset; i++) { + const childNode = this.messageInput.childNodes[i]; + currentTextOffset += this.getNodeEquivalentLengthForReferenceParsing(childNode); + } + } else { + // Если endContainer - это другой элемент (например,
), + // то курсор находится до него. + // Мы не должны добавлять его длину, но должны учесть, сколько текста было до него. + // В данном случае `range.endOffset` будет 0, если курсор перед ним. + // Если курсор внутри элемента, нужно по-другому считать. + // В контексте `contenteditable` это обычно означает, что мы либо в текстовом узле, + // либо `endContainer` является родителем, а `endOffset` - индексом дочернего элемента. + // Для `reference-box` курсор внутри быть не должен (т.к. он ``). + // Для других элементов, если курсор внутри них, `range.endOffset` будет смещением. + } + break; + } else { + currentTextOffset += this.getNodeEquivalentLengthForReferenceParsing(node); + } + } + + return currentTextOffset; + } + + /** + * Вычисляет эквивалентную текстовую длину HTML-элемента, такую как reference-box. + * @param node - HTML-элемент. + * @returns эквивалентная текстовая длина элемента. + */ + private getNodeEquivalentLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent?.length || 0; + } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).classList.contains('reference-box')) { + return 1; // Учитываем как 1 позицию, т.к. курсор не может быть внутри + } + // Для других элементов, считаем 0, если они не содержат текст + return 0; + } + + /** + * Получает текстовое содержимое input элемента (без HTML), + * заменяя reference-box их текстовым представлением. + */ + private getTextContent(): string { + let result = ''; + const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); // SHOW_ALL для корректного обхода + let node; + + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + result += node.textContent || ''; + } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).classList.contains('reference-box')) { + const reference = ReferenceBox.extractReference(node as HTMLElement); + if (reference) { + const prefix = reference.permanent + ? reference.type === 'file' ? '@@' : '##' + : reference.type === 'file' ? '@' : '#'; + result += prefix + reference.name; + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + // Для других элементов, которые могут быть в contenteditable (например,
), + // нужно решить, как их представлять в текстовом содержимом. + // В данном случае, если это
, его можно игнорировать или заменить на \n. + // Для простоты, пока игнорируем, если это не бокс. + // Если внутри inputElement есть другие элементы, они также будут частью getTextContent, + // поэтому walker.nextNode() будет их учитывать. + // Возможно, здесь нужно быть более точным, основываясь на ожидаемой структуре. + } + } + return result; + } + + /** + * Извлекает текст с заменой боксов на исходные ссылки + */ + private extractTextWithReferences(): string { + let result = ''; + + const childNodes = Array.from(this.messageInput.childNodes); + + for (const childNode of childNodes) { + if (childNode.nodeType === Node.TEXT_NODE) { + result += childNode.textContent; + } else if (childNode.nodeType === Node.ELEMENT_NODE) { + const element = childNode as HTMLElement; + + if (element.classList.contains('reference-box')) { + // Это бокс ссылки - заменяем на исходную ссылку + const reference = ReferenceBox.extractReference(element); + if (reference) { + const prefix = reference.permanent + ? reference.type === 'file' + ? '@@' + : '##' + : reference.type === 'file' + ? '@' + : '#'; + result += `${prefix}${reference.name}`; + } + } else { + // Обычный элемент - берем текстовое содержимое + result += element.textContent; + } + } + } + + return result; + } + + /** + * Обработка отправки сообщения с раскрытием ссылок + */ + private async handleSendWithReferences(): Promise { + const rawText = this.extractTextWithReferences(); + const message = rawText.trim(); + + if (!message) return; + + if (!this.referenceExpander) { + // Если система ссылок не инициализирована, отправляем как обычно + this.handleSend(); + return; + } + + try { + // Раскрываем ссылки перед отправкой + const expandedText = await this.referenceExpander.expandReferences(rawText, this.currentReferences); + + // Отправляем expandedText вместо rawText + if (this.props.onSendMessage) { + this.props.onSendMessage(expandedText, this.selectedModel); + } + + // Очищаем состояние + this.clearInput(); + } catch (error) { + console.error('Ошибка при раскрытии ссылок:', error); + // В случае ошибки отправляем исходный текст + if (this.props.onSendMessage) { + this.props.onSendMessage(rawText, this.selectedModel); + } + this.clearInput(); + } + } + + /** + * Очищает поле ввода и сбрасывает состояние ссылок + */ + private clearInput(): void { + this.messageInput.innerHTML = ''; + this.currentReferences = []; + this.updatePlaceholder(); + this.hideSuggestions(); + if (this.referenceAutocomplete) { + this.referenceAutocomplete.hideAutocomplete(); + } + } + + /*handleSend(): void { + const message = this.messageInput.textContent?.trim() || ''; + if (message && this.props.onSendMessage) { + this.props.onSendMessage(message, this.selectedModel); + this.messageInput.textContent = ''; + this.updatePlaceholder(); + this.hideSuggestions(); + } + }*/ + + // Обновить метод handleSend() для совместимости: + handleSend(): void { + const message = this.messageInput.textContent?.trim() || ''; + if (message && this.props.onSendMessage) { + this.props.onSendMessage(message, this.selectedModel); + this.clearInput(); + } + } } diff --git a/src/components/models/ChatHistoryItem.ts b/src/components/models/ChatHistoryItem.ts index cf4e298..fc46ac2 100644 --- a/src/components/models/ChatHistoryItem.ts +++ b/src/components/models/ChatHistoryItem.ts @@ -7,4 +7,8 @@ export interface ChatHistoryItem { role: string; content: string; type?: 'user' | 'llm' | 'tool' | 'error' | 'default'; -} \ No newline at end of file + node_id?: string; + // TODO: добавить поля для снапшотов файлов при реализации + // fileSnapshots?: Record; + // expandedContent?: string; // содержимое после раскрытия ссылок +} diff --git a/src/css/styles.css b/src/css/styles.css index 379eb5f..a078dd7 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -286,7 +286,7 @@ margin-bottom: 8px; } -.message-input-editor { +/*.message-input-editor { outline: none; font-size: 14px; line-height: 1.4; @@ -294,6 +294,14 @@ padding: 8px 0; color: var(--text-normal); position: relative; +}*/ + +.message-input-editor { + min-height: 20px; + max-height: 200px; + overflow-y: auto; + word-wrap: break-word; + white-space: pre-wrap; } .message-input-editor.is-empty::before { @@ -303,6 +311,34 @@ pointer-events: none; } +.message-input-editor[data-placeholder]::before { + content: attr(data-placeholder); + color: var(--text-muted); + position: absolute; + pointer-events: none; + opacity: 0.6; +} + +.message-input-editor.is-empty[data-placeholder]::before { + display: block; +} + +.message-input-editor:not(.is-empty)[data-placeholder]::before, +.message-input-editor:focus[data-placeholder]::before { + display: none; +} + +.message-input-editor .reference-box { + display: inline-flex !important; + vertical-align: middle; + margin: 0 1px; +} + +.message-input-editor .reference-box:focus { + outline: 2px solid var(--interactive-accent); + outline-offset: 1px; +} + .chat-controls { display: flex; justify-content: space-between; @@ -396,6 +432,11 @@ .model-option:hover { } +.model-option.selected { + background-color: var(--background-modifier-selected); /* Или другой подходящий цвет */ + color: var(--text-highlighted); /* Или другой подходящий цвет */ +} + .model-option:first-child { border-radius: 8px 8px 0 0; } @@ -455,6 +496,11 @@ .suggestion-item:hover { } +.suggestion-item.selected { + background-color: var(--background-modifier-selected); + color: var(--text-highlighted); +} + /* Удаляем старые стили для chat-input-form и message-input */ .chat-input-form { display: none; @@ -486,6 +532,15 @@ overflow: hidden; } +/* Стили для заголовков графов в панели истории */ +.graph-title-generated { + color: var(--text-normal); +} + +.graph-title-pending { + color: var(--text-muted); +} + /* ----------------------------------------------- Toggle Button Styles --------------------------------------------------- */ .history-toggle-button { @@ -521,3 +576,198 @@ position: relative; background-color: var(--background-primary); } + +/* ----------------------------------------------- Reference System Styles --------------------------------------------------- */ + +/* Reference Boxes */ +.reference-box { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + margin: 0 2px; + cursor: pointer; + user-select: none; + vertical-align: middle; + transition: all 0.2s ease; +} + +.reference-box:hover { + opacity: 0.8; + transform: translateY(-1px); +} + +.reference-box-icon { + width: 12px; + height: 12px; + margin-right: 4px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.reference-box-name { + font-weight: 500; + white-space: nowrap; +} + +/* Файлы - одинарная ссылка @ */ +.reference-box.file.single { + background: rgba(100, 150, 255, 0.1); + border: 1px solid rgba(100, 150, 255, 0.3); + color: var(--text-normal); +} + +/* Файлы - постоянная ссылка @@ */ +.reference-box.file.permanent { + background: rgba(100, 150, 255, 0.2); + border: 1px solid rgba(100, 150, 255, 0.6); + color: var(--text-normal); + font-weight: 600; +} + +.reference-box.file.permanent .reference-box-name { + font-weight: 600; +} + +/* Перебиваем стиль Obsidian для prompt*/ +.reference-box.prompt { + display: inline-flex; + flex-direction: row; + + box-shadow: var(--shadow-l); + + z-index: auto; + position: static; + top: auto; + width: auto; + max-width: none; + max-height: none; + overflow: hidden; +} + +/* Промпты - одинарная ссылка # */ +.reference-box.prompt.single { + background: rgba(255, 150, 100, 0.1); + border: 1px solid rgba(255, 150, 100, 0.3); + color: var(--text-normal); +} + +/* Промпты - постоянная ссылка ## */ +.reference-box.prompt.permanent { + background: rgba(238, 122, 69, 0.2); + border: 1px solid rgba(255, 150, 100, 0.6); + color: var(--text-normal); + font-weight: 600; +} + +.reference-box.prompt.permanent .reference-box-name { + font-weight: 600; +} + +/* Reference Autocomplete */ +.reference-autocomplete { + position: absolute; + background: var(--background-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + max-height: 200px; + overflow-y: auto; + min-width: 250px; + max-width: 400px; +} + +.reference-option { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + font-size: 12px; + color: var(--text-normal); + transition: background-color 0.2s; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.reference-option:hover, +.reference-option.selected { + background-color: var(--background-modifier-hover); +} + +.reference-option:last-child { + border-bottom: none; +} + +.reference-option-icon { + width: 16px; + height: 16px; + margin-right: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.reference-option-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +.reference-option-path { + font-size: 10px; + color: var(--text-muted); + margin-left: 8px; + max-width: 150px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Темная тема */ +.theme-dark .reference-autocomplete { + background: var(--background-primary); + border-color: var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.theme-dark .reference-option:hover, +.theme-dark .reference-option.selected { + background-color: rgba(255, 255, 255, 0.1); +} + +/* Светлая тема */ +.theme-light .reference-autocomplete { + background: #ffffff; + border-color: #e0e0e0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.theme-light .reference-option:hover, +.theme-light .reference-option.selected { + background-color: rgba(0, 0, 0, 0.05); +} + +/* Скроллбар для автокомплита */ +.reference-autocomplete::-webkit-scrollbar { + width: 6px; +} + +.reference-autocomplete::-webkit-scrollbar-track { + background: transparent; +} + +.reference-autocomplete::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: 3px; +} + +.reference-autocomplete::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-bg-hover); +} \ No newline at end of file diff --git a/src/docs/Reference-Spec.md b/src/docs/Reference-Spec.md new file mode 100644 index 0000000..e69de29 diff --git a/src/references/AutocompleteManager.ts b/src/references/AutocompleteManager.ts new file mode 100644 index 0000000..c54b9ac --- /dev/null +++ b/src/references/AutocompleteManager.ts @@ -0,0 +1,209 @@ +/** + * Управляет автокомплитом для ссылок на файлы и промпты. + * Обеспечивает поиск файлов в хранилище и кеширование результатов. + */ + +import { TFile } from 'obsidian'; +import LLMAgentPlugin from 'main'; + +// ----------------------------------------------- Interfaces --------------------------------------------------------------- + +export interface AutocompleteItem { + name: string; + path: string; + type: 'file' | 'prompt'; + icon: string; // Obsidian icon name + file?: TFile; // Ссылка на файл Obsidian +} + +// ----------------------------------------------- Autocomplete Manager --------------------------------------------------------------- + +export class AutocompleteManager { + private plugin: LLMAgentPlugin; + private fileCache: Map = new Map(); + private promptCache: Map = new Map(); + private lastCacheUpdate = 0; + private readonly CACHE_TTL = 30000; // 30 секунд + + constructor(plugin: LLMAgentPlugin) { + this.plugin = plugin; + this.setupFileWatcher(); + } + + // ----------------------------------------------- Public Methods --------------------------------------------------------------- + + /** + * Получает список файлов для автокомплита + * @param query - поисковый запрос + * @returns массив файлов для автокомплита + */ + async getFileOptions(query: string): Promise { + if (!this.plugin.settings.enableFileReferences) { + return []; + } + + await this.updateCacheIfNeeded(); + + const allFiles = this.fileCache.get('all') || []; + + if (!query.trim()) { + return allFiles.slice(0, 10); // Показываем первые 10 файлов + } + + const lowercaseQuery = query.toLowerCase(); + return allFiles + .filter(item => + item.name.toLowerCase().includes(lowercaseQuery) || + item.path.toLowerCase().includes(lowercaseQuery) + ) + .slice(0, 10); + } + + /** + * Получает список промптов для автокомплита + * @param query - поисковый запрос + * @returns массив промптов для автокомплита + */ + async getPromptOptions(query: string): Promise { + if (!this.plugin.settings.enablePromptReferences) { + return []; + } + + await this.updateCacheIfNeeded(); + + const allPrompts = this.promptCache.get('all') || []; + + if (!query.trim()) { + return allPrompts.slice(0, 10); + } + + const lowercaseQuery = query.toLowerCase(); + return allPrompts + .filter(item => + item.name.toLowerCase().includes(lowercaseQuery) || + item.path.toLowerCase().includes(lowercaseQuery) + ) + .slice(0, 10); + } + + /** + * Очищает кеш (используется при изменении настроек) + */ + clearCache(): void { + this.fileCache.clear(); + this.promptCache.clear(); + this.lastCacheUpdate = 0; + } + + // ----------------------------------------------- Private Methods --------------------------------------------------------------- + + /** + * Обновляет кеш, если он устарел + */ + private async updateCacheIfNeeded(): Promise { + const now = Date.now(); + if (now - this.lastCacheUpdate > this.CACHE_TTL) { + await this.updateCache(); + this.lastCacheUpdate = now; + } + } + + /** + * Обновляет кеш файлов и промптов + */ + private async updateCache(): Promise { + const files = this.plugin.app.vault.getMarkdownFiles(); + const fileItems: AutocompleteItem[] = []; + const promptItems: AutocompleteItem[] = []; + + for (const file of files) { + const item: AutocompleteItem = { + name: file.basename, + path: file.path, + type: this.isPromptFile(file) ? 'prompt' : 'file', + icon: this.getFileIcon(file), + file: file + }; + + if (item.type === 'prompt') { + promptItems.push(item); + } else { + fileItems.push(item); + } + } + + // Сортируем по имени + fileItems.sort((a, b) => a.name.localeCompare(b.name)); + promptItems.sort((a, b) => a.name.localeCompare(b.name)); + + this.fileCache.set('all', fileItems); + this.promptCache.set('all', promptItems); + } + + /** + * Проверяет, является ли файл промптом + */ + private isPromptFile(file: TFile): boolean { + const promptsFolder = this.plugin.settings.promptsFolder; + if (!promptsFolder) return false; + + // Нормализуем путь к папке промптов + const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, ''); + + return file.path.startsWith(normalizedPromptsPath + '/') || + file.path === normalizedPromptsPath; + } + + /** + * Возвращает иконку для файла на основе Obsidian иконок + */ + private getFileIcon(file: TFile): string { + if (this.isPromptFile(file)) { + return 'zap'; // Иконка промпта + } + + // Определяем иконку по расширению + const extension = file.extension.toLowerCase(); + switch (extension) { + case 'md': + return 'file-text'; + case 'js': + case 'ts': + return 'code'; + case 'json': + return 'brackets'; + case 'css': + return 'palette'; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + return 'image'; + default: + return 'file'; + } + } + + /** + * Настраивает отслеживание изменений файлов для обновления кеша + */ + private setupFileWatcher(): void { + this.plugin.registerEvent( + this.plugin.app.vault.on('create', () => { + this.clearCache(); + }) + ); + + this.plugin.registerEvent( + this.plugin.app.vault.on('delete', () => { + this.clearCache(); + }) + ); + + this.plugin.registerEvent( + this.plugin.app.vault.on('rename', () => { + this.clearCache(); + }) + ); + } +} \ No newline at end of file diff --git a/src/references/ReferenceAutocomplete.ts b/src/references/ReferenceAutocomplete.ts new file mode 100644 index 0000000..e2c3325 --- /dev/null +++ b/src/references/ReferenceAutocomplete.ts @@ -0,0 +1,320 @@ +/** + * Компонент автокомплита для ссылок. Показывает выпадающий список файлов и промптов + * при вводе @/@@ и #/## с умным позиционированием относительно курсора. + */ + +import { AutocompleteItem, AutocompleteManager } from './AutocompleteManager'; +import { ReferenceMatch } from './ReferenceParser'; +import { setIcon } from 'obsidian'; + +// ----------------------------------------------- Reference Autocomplete Component --------------------------------------------------------------- + +export class ReferenceAutocomplete { + private container: HTMLElement; + private dropdown: HTMLElement; + private inputElement: HTMLElement; + private autocompleteManager: AutocompleteManager; + private currentReference: ReferenceMatch | null = null; + private selectedIndex = 0; + private currentOptions: AutocompleteItem[] = []; + private onItemSelectedCallback?: (item: AutocompleteItem, match: ReferenceMatch) => void; + private mouseIsOverDropdown = false; // Флаг для отслеживания наведения мыши + + constructor(inputElement: HTMLElement, autocompleteManager: AutocompleteManager) { + this.inputElement = inputElement; + this.autocompleteManager = autocompleteManager; + this.container = this.inputElement.closest('.chat-input-container') as HTMLElement; + + this.createDropdown(); + this.setupEventListeners(); + } + + // ----------------------------------------------- Public Methods --------------------------------------------------------------- + + /** + * Показывает автокомплит с опциями. + * @param reference - объект ReferenceMatch, определяющий текущий вводимый референс. + * @param cursorRect - DOMRect объекта курсора, для определения позиции. + */ + async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect): Promise { + this.currentReference = reference; + + // Получаем опции в зависимости от типа ссылки + const options = reference.type === 'file' + ? await this.autocompleteManager.getFileOptions(reference.name) + : await this.autocompleteManager.getPromptOptions(reference.name); + + if (options.length === 0) { + this.hideAutocomplete(); + return; + } + + this.currentOptions = options; + // Сбрасываем selection, если новые опции не содержат предыдущий выбранный элемент + if (this.selectedIndex >= this.currentOptions.length) { + this.selectedIndex = 0; + } + this.renderOptions(); + this.positionDropdown(cursorRect); + this.dropdown.style.display = 'block'; + } + + /** + * Скрывает автокомплит + */ + hideAutocomplete(): void { + this.dropdown.style.display = 'none'; + this.currentReference = null; + this.currentOptions = []; + this.selectedIndex = 0; + } + + /** + * Обрабатывает нажатия клавиш для навигации + * @param event - событие клавиатуры + * @returns true если событие было обработано + */ + handleKeydown(event: KeyboardEvent): boolean { + // Мы хотим, чтобы стрелки и Enter обрабатывались только если автокомплит видим + if (!this.isVisible()) { + return false; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); // Предотвращаем прокрутку страницы + this.moveSelection(1); + return true; + + case 'ArrowUp': + event.preventDefault(); // Предотвращаем прокрутку страницы + this.moveSelection(-1); + return true; + + case 'Enter': + case 'Tab': + // Разрешаем Tab завершать автокомплит + event.preventDefault(); + this.selectCurrentItem(); + return true; + + case 'Escape': + event.preventDefault(); + this.hideAutocomplete(); + return true; + + default: + return false; + } + } + + /** + * Устанавливает коллбек для выбора элемента + * @param callback - функция обратного вызова + */ + onItemSelected(callback: (item: AutocompleteItem, match: ReferenceMatch) => void): void { + this.onItemSelectedCallback = callback; + } + + /** + * Проверяет, виден ли автокомплит + * @returns true если виден + */ + isVisible(): boolean { + return this.dropdown.style.display === 'block'; + } + + // ----------------------------------------------- Private Methods --------------------------------------------------------------- + + /** + * Создает элемент выпадающего списка + */ + private createDropdown(): void { + this.dropdown = document.createElement('div'); + this.dropdown.className = 'reference-autocomplete'; + this.dropdown.style.display = 'none'; + + // Добавляем dropdown в контейнер + this.container.appendChild(this.dropdown); + } + + /** + * Настраивает обработчики событий + */ + private setupEventListeners(): void { + // Закрытие при клике вне dropdown + document.addEventListener('click', (e) => { + if (!this.dropdown.contains(e.target as Node) && !this.inputElement.contains(e.target as Node)) { + this.hideAutocomplete(); + } + }); + + // Отслеживание наведения мыши для предотвращения скрытия по blur + this.dropdown.addEventListener('mouseenter', () => { + this.mouseIsOverDropdown = true; + }); + this.dropdown.addEventListener('mouseleave', () => { + this.mouseIsOverDropdown = false; + }); + + // Скрытие при потере фокуса input элементом, если мышь не над дропдауном + this.inputElement.addEventListener('blur', () => { + // Задержка позволяет клику на элемент дропдауна сработать + if (!this.mouseIsOverDropdown) { + this.hideAutocomplete(); + } + }); + } + + /** + * Отрисовывает опции в dropdown + */ + private renderOptions(): void { + this.dropdown.innerHTML = ''; + + this.currentOptions.forEach((option, index) => { + const optionElement = this.createOptionElement(option, index === this.selectedIndex); + this.dropdown.appendChild(optionElement); + }); + this.scrollToSelected(); // Убеждаемся, что выбранный элемент виден + } + + /** + * Создает элемент опции + * @param option - опция для отображения + * @param isSelected - выбрана ли опция + * @returns HTML элемент опции + */ + private createOptionElement(option: AutocompleteItem, isSelected: boolean): HTMLElement { + const optionDiv = document.createElement('div'); + optionDiv.className = `reference-option ${isSelected ? 'selected' : ''}`; + + // Иконка + const iconSpan = document.createElement('span'); + iconSpan.className = 'reference-option-icon'; + setIcon(iconSpan, option.icon); + + // Имя файла + const nameSpan = document.createElement('span'); + nameSpan.className = 'reference-option-name'; + nameSpan.textContent = option.name; + + // Путь к файлу + const pathSpan = document.createElement('span'); + pathSpan.className = 'reference-option-path'; + pathSpan.textContent = option.path; + + optionDiv.appendChild(iconSpan); + optionDiv.appendChild(nameSpan); + optionDiv.appendChild(pathSpan); + + // Обработчик клика + optionDiv.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.selectItem(option); + }); + + return optionDiv; + } + + /** + * Позиционирует dropdown относительно курсора + * @param position - желаемая позиция + */ + private positionDropdown(cursorRect: DOMRect): void { + 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; + + // Дефолтное положение - под курсором + let 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; + } + + this.dropdown.style.top = `${top}px`; + this.dropdown.style.left = `${left}px`; + // Устанавливаем ширину дропдауна равной ширине inputElement, если он больше + // Это предотвратит слишком узкий дропдаун, если имя файла короткое + this.dropdown.style.minWidth = `${inputRect.width / 2}px`; // Минимум половина ширины инпута + this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth + } + + /** + * Перемещает выделение в списке + * @param direction - направление (1 = вниз, -1 = вверх) + */ + private moveSelection(direction: number): void { + this.selectedIndex += direction; + + if (this.selectedIndex < 0) { + this.selectedIndex = this.currentOptions.length - 1; + } else if (this.selectedIndex >= this.currentOptions.length) { + this.selectedIndex = 0; + } + + this.renderOptions(); + this.scrollToSelected(); + } + + /** + * Прокручивает список к выбранному элементу + */ + private scrollToSelected(): void { + const selectedElement = this.dropdown.querySelector('.reference-option.selected') as HTMLElement; + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest' }); + } + } + + /** + * Выбирает текущий элемент + */ + private selectCurrentItem(): void { + if (this.selectedIndex >= 0 && this.selectedIndex < this.currentOptions.length) { + this.selectItem(this.currentOptions[this.selectedIndex]); + } + } + + /** + * Выбирает указанный элемент + * @param item - выбранный элемент + */ + private selectItem(item: AutocompleteItem): void { + if (this.onItemSelectedCallback && this.currentReference) { + this.onItemSelectedCallback(item, this.currentReference); + } + this.hideAutocomplete(); + } + + /** + * Очищает ресурсы + */ + destroy(): void { + if (this.dropdown.parentNode) { + this.dropdown.parentNode.removeChild(this.dropdown); + } + } +} diff --git a/src/references/ReferenceBox.ts b/src/references/ReferenceBox.ts new file mode 100644 index 0000000..0a1804d --- /dev/null +++ b/src/references/ReferenceBox.ts @@ -0,0 +1,140 @@ +/** + * Создает и управляет визуальными боксами для отображения ссылок в чате. + * Поддерживает различные стили для файлов/промптов и одинарных/постоянных ссылок. + */ + +import { FileReference } from './ReferenceParser'; +import { setIcon } from 'obsidian'; + +// ----------------------------------------------- Reference Box Component --------------------------------------------------------------- + +export class ReferenceBox { + + /** + * Создаёт визуальный бокс для отображения ссылки в сообщении + * @param reference - информация о ссылке + * @returns HTML элемент бокса + */ + static createBox(reference: FileReference): HTMLElement { + const box = document.createElement('span'); + box.className = this.getBoxClasses(reference); + box.setAttribute('data-reference-type', reference.type); + box.setAttribute('data-reference-name', reference.name); + box.setAttribute('data-reference-path', reference.path); + box.setAttribute('data-reference-permanent', reference.permanent.toString()); + + // Создаем иконку + const icon = document.createElement('span'); + icon.className = 'reference-box-icon'; + setIcon(icon, this.getIconName(reference)); + + // Создаем текст с именем + const nameSpan = document.createElement('span'); + nameSpan.className = 'reference-box-name'; + nameSpan.textContent = reference.name; + + box.appendChild(icon); + box.appendChild(nameSpan); + + // Добавляем обработчик клика для предпросмотра (TODO: будущая функциональность) + box.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + // TODO: показать предпросмотр содержимого файла + console.log('Clicked reference:', reference); + }); + + return box; + } + + /** + * Обновляет существующий бокс + * @param boxElement - элемент бокса + * @param reference - новая информация о ссылке + */ + static updateBox(boxElement: HTMLElement, reference: FileReference): void { + boxElement.className = this.getBoxClasses(reference); + boxElement.setAttribute('data-reference-type', reference.type); + boxElement.setAttribute('data-reference-name', reference.name); + boxElement.setAttribute('data-reference-path', reference.path); + boxElement.setAttribute('data-reference-permanent', reference.permanent.toString()); + + const iconElement = boxElement.querySelector('.reference-box-icon'); + const nameElement = boxElement.querySelector('.reference-box-name'); + + if (iconElement) { + iconElement.innerHTML = ''; + setIcon(iconElement as HTMLElement, this.getIconName(reference)); + } + + if (nameElement) { + nameElement.textContent = reference.name; + } + } + + /** + * Извлекает информацию о ссылке из элемента бокса + * @param boxElement - элемент бокса + * @returns информация о ссылке + */ + static extractReference(boxElement: HTMLElement): FileReference | null { + const type = boxElement.getAttribute('data-reference-type') as 'file' | 'prompt'; + const name = boxElement.getAttribute('data-reference-name'); + const path = boxElement.getAttribute('data-reference-path'); + const permanent = boxElement.getAttribute('data-reference-permanent') === 'true'; + + if (!type || !name || !path) { + return null; + } + + return { + type, + name, + path, + permanent + }; + } + + // ----------------------------------------------- Private Methods --------------------------------------------------------------- + + /** + * Возвращает CSS классы для бокса + */ + private static getBoxClasses(reference: FileReference): string { + const baseClass = 'reference-box'; + const typeClass = reference.type; + const permanentClass = reference.permanent ? 'permanent' : 'single'; + + return `${baseClass} ${typeClass} ${permanentClass}`; + } + + /** + * Возвращает имя иконки для ссылки + */ + private static getIconName(reference: FileReference): string { + if (reference.type === 'prompt') { + return 'zap'; + } + + // Определяем иконку по расширению файла + const extension = reference.path.split('.').pop()?.toLowerCase(); + switch (extension) { + case 'md': + return 'file-text'; + case 'js': + case 'ts': + return 'code'; + case 'json': + return 'brackets'; + case 'css': + return 'palette'; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + return 'image'; + default: + return 'file'; + } + } +} \ No newline at end of file diff --git a/src/references/ReferenceExpander.ts b/src/references/ReferenceExpander.ts new file mode 100644 index 0000000..5e4be80 --- /dev/null +++ b/src/references/ReferenceExpander.ts @@ -0,0 +1,249 @@ +/** + * Расширяет ссылки на файлы и промпты, заменяя их содержимым перед отправкой на сервер. + * Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки. + */ + +import { TFile } from 'obsidian'; +import LLMAgentPlugin from 'main'; +import { FileReference, ReferenceParser } from './ReferenceParser'; +import { TemplateEngine } from './TemplateEngine'; + +// ----------------------------------------------- Reference Expander --------------------------------------------------------------- + +export class ReferenceExpander { + private plugin: LLMAgentPlugin; + private templateEngine: TemplateEngine; + + constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) { + this.plugin = plugin; + this.templateEngine = templateEngine; + } + + // ----------------------------------------------- Public Methods --------------------------------------------------------------- + + /** + * Заменяет все ссылки на содержимое файлов (перед отправкой на сервер) + * @param text - текст с ссылками + * @param references - массив ссылок для обработки + * @returns текст с развернутыми ссылками + */ + async expandReferences(text: string, references: FileReference[]): Promise { + if (references.length === 0) { + return text; + } + + let expandedText = text; + + // Обрабатываем ссылки от конца к началу, чтобы не сбивать индексы + const sortedReferences = [...references].sort((a, b) => { + const aMatches = ReferenceParser.parseReferences(text).filter(m => m.name === a.name); + const bMatches = ReferenceParser.parseReferences(text).filter(m => m.name === b.name); + const aIndex = aMatches.length > 0 ? aMatches[aMatches.length - 1].startIndex : 0; + const bIndex = bMatches.length > 0 ? bMatches[bMatches.length - 1].startIndex : 0; + return bIndex - aIndex; + }); + + for (const reference of sortedReferences) { + expandedText = await this.expandSingleReference(expandedText, reference); + } + + return expandedText; + } + + /** + * Обрабатывает историю сообщений, раскрывая постоянные ссылки (@@/##) + * @param messages - массив сообщений + * @returns обработанные сообщения + */ + async expandMessageHistory(messages: any[]): Promise { + const expandedMessages = []; + + for (const message of messages) { + if (message.role === 'user') { + const expandedContent = await this.expandPermanentReferences(message.content); + expandedMessages.push({ + ...message, + content: expandedContent + }); + } else { + expandedMessages.push(message); + } + } + + return expandedMessages; + } + + // TODO: При реализации снапшотов - сохранять содержимое файлов + // async expandReferencesWithSnapshots(text: string, references: FileReference[], messageId: string): Promise<{expandedText: string, snapshots: Record}> { + // const snapshots: Record = {}; + // let expandedText = text; + // + // for (const reference of references) { + // const content = await this.loadFileContent(reference); + // if (content !== null) { + // snapshots[reference.path] = content; + // expandedText = await this.expandSingleReference(expandedText, reference, content); + // } + // } + // + // return { expandedText, snapshots }; + // } + + // ----------------------------------------------- Private Methods --------------------------------------------------------------- + + /** + * Раскрывает одну ссылку в тексте + * @param text - текст для обработки + * @param reference - ссылка для раскрытия + * @param preloadedContent - предзагруженное содержимое (для снапшотов) + * @returns текст с раскрытой ссылкой + */ + private async expandSingleReference(text: string, reference: FileReference, preloadedContent?: string): Promise { + const content = preloadedContent || await this.loadFileContent(reference); + + if (content === null) { + console.warn(`Не удалось загрузить содержимое для ссылки: ${reference.name}`); + return text; + } + + // Определяем паттерн для замены + const prefix = reference.permanent + ? (reference.type === 'file' ? '@@' : '##') + : (reference.type === 'file' ? '@' : '#'); + + // Создаем регулярное выражение для поиска ссылки + const escapedName = this.escapeRegExp(reference.name); + const pattern = new RegExp(`${this.escapeRegExp(prefix)}(?:"${escapedName}"|${escapedName})(?=\\s|$|[^\\w-])`, 'g'); + + // Форматируем содержимое для вставки + const formattedContent = this.formatContentForInsertion(content, reference); + + return text.replace(pattern, formattedContent); + } + + /** + * Раскрывает только постоянные ссылки (@@/##) в тексте + * @param text - текст для обработки + * @returns текст с раскрытыми постоянными ссылками + */ + private async expandPermanentReferences(text: string): Promise { + const matches = ReferenceParser.parseReferences(text); + const permanentMatches = matches.filter(match => match.prefix.length === 2); // @@ или ## + + if (permanentMatches.length === 0) { + return text; + } + + let expandedText = text; + + // Обрабатываем ссылки от конца к началу + const sortedMatches = [...permanentMatches].sort((a, b) => b.startIndex - a.startIndex); + + for (const match of sortedMatches) { + const reference: FileReference = { + type: match.type, + name: match.name, + path: await this.resolveFilePath(match.name, match.type), + permanent: true + }; + + const content = await this.loadFileContent(reference); + if (content !== null) { + const formattedContent = this.formatContentForInsertion(content, reference); + expandedText = expandedText.substring(0, match.startIndex) + + formattedContent + + expandedText.substring(match.endIndex); + } + } + + return expandedText; + } + + /** + * Загружает содержимое файла + * @param reference - ссылка на файл + * @returns содержимое файла или null если не найден + */ + private async loadFileContent(reference: FileReference): Promise { + try { + const file = this.findFile(reference.path, reference.type); + if (!file) { + return null; + } + + const content = await this.plugin.app.vault.read(file); + + // Для промптов обрабатываем шаблоны + if (reference.type === 'prompt') { + return await this.templateEngine.expandPromptTemplate(content); + } + + return content; + } catch (error) { + console.error(`Ошибка загрузки файла ${reference.path}:`, error); + return null; + } + } + + /** + * Находит файл по пути + * @param path - путь к файлу + * @param type - тип файла + * @returns файл или null + */ + private findFile(path: string, type: 'file' | 'prompt'): TFile | null { + let file = this.plugin.app.vault.getAbstractFileByPath(path); + + if (!file && type === 'prompt') { + // Попробуем найти в папке промптов + const promptsFolder = this.plugin.settings.promptsFolder; + if (promptsFolder) { + const fullPath = `${promptsFolder}/${path}`; + file = this.plugin.app.vault.getAbstractFileByPath(fullPath); + } + } + + return (file instanceof TFile) ? file : null; + } + + /** + * Разрешает полный путь к файлу по имени + * @param name - имя файла + * @param type - тип файла + * @returns полный путь к файлу + */ + private async resolveFilePath(name: string, type: 'file' | 'prompt'): Promise { + if (type === 'prompt') { + const promptsFolder = this.plugin.settings.promptsFolder; + return promptsFolder ? `${promptsFolder}/${name}.md` : `${name}.md`; + } else { + // Ищем файл в хранилище + const files = this.plugin.app.vault.getMarkdownFiles(); + const matchingFile = files.find(f => f.basename === name); + return matchingFile ? matchingFile.path : `${name}.md`; + } + } + + /** + * Форматирует содержимое для вставки в сообщение + * @param content - содержимое файла + * @param reference - информация о ссылке + * @returns отформатированное содержимое + */ + private formatContentForInsertion(content: string, reference: FileReference): string { + // Добавляем заголовок с информацией о файле + const header = `\n\n--- Содержимое ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n`; + const footer = `\n--- Конец ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n\n`; + + return header + content.trim() + footer; + } + + /** + * Экранирует специальные символы для регулярного выражения + * @param string - строка для экранирования + * @returns экранированная строка + */ + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} \ No newline at end of file diff --git a/src/references/ReferenceParser.ts b/src/references/ReferenceParser.ts new file mode 100644 index 0000000..93f8ffc --- /dev/null +++ b/src/references/ReferenceParser.ts @@ -0,0 +1,145 @@ +/** + * Парсер для обработки ссылок на файлы (@/@@) и промпты (#/##) в тексте сообщений. + * Поддерживает поиск активных ссылок при вводе и валидацию промптов. + */ + +// ----------------------------------------------- Interfaces --------------------------------------------------------------- + +export interface ReferenceMatch { + fullMatch: string; // "@filename" или "@@filename" + prefix: string; // "@" или "@@" + name: string; // "filename" + startIndex: number; + endIndex: number; + type: 'file' | 'prompt'; +} + +export interface FileReference { + type: 'file' | 'prompt'; + name: string; + path: string; + permanent: boolean; // true для @@ и ## + // TODO: добавить snapshotId при реализации снапшотов +} + +// ----------------------------------------------- Reference Parser --------------------------------------------------------------- + +export class ReferenceParser { + + /** + * Парсит текст и находит все @/@@ и #/## ссылки + * @param text - текст для парсинга + * @returns массив найденных ссылок + */ + static parseReferences(text: string): ReferenceMatch[] { + const matches: ReferenceMatch[] = []; + + // Регулярное выражение для поиска @/@@ и #/## ссылок + // Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов + const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g; + + let match; + while ((match = regex.exec(text)) !== null) { + const prefix = match[1]; + const quotedName = match[2]; + const unquotedName = match[3]; + const name = quotedName || unquotedName; + + matches.push({ + fullMatch: match[0], + prefix: prefix, + name: name, + startIndex: match.index, + endIndex: match.index + match[0].length, + type: prefix.startsWith('@') ? 'file' : 'prompt' + }); + } + + return matches; + } + + /** + * Проверяет, является ли позиция курсора внутри незавершённой ссылки + * @param text - текст сообщения + * @param cursorPosition - позиция курсора + * @returns информация о текущей ссылке или null + */ + static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null { + // Ищем незавершенные ссылки перед курсором + const beforeCursor = text.substring(0, cursorPosition); + + // Регулярное выражение для поиска начала ссылки без завершения + const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/; + const match = beforeCursor.match(incompleteRegex); + + if (match) { + const prefix = match[1]; + const partialName = match[2]; + const startIndex = beforeCursor.length - match[0].length; + + return { + fullMatch: match[0], + prefix: prefix, + name: partialName, + startIndex: startIndex, + endIndex: cursorPosition, + type: prefix.startsWith('@') ? 'file' : 'prompt' + }; + } + + return null; + } + + /** + * Валидирует промпт на корректность @{...} ссылок + * @param promptContent - содержимое промпта + * @returns результат валидации + */ + static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Ищем шаблонные ссылки @{...} + const templateRegex = /@\{([^}]+)\}/g; + const matches = promptContent.matchAll(templateRegex); + + for (const match of matches) { + const referenceName = match[1].trim(); + + // Проверяем, что имя ссылки не пустое + if (!referenceName) { + errors.push(`Пустая ссылка в позиции ${match.index}`); + continue; + } + + // Проверяем, что имя содержит только допустимые символы + if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) { + errors.push(`Недопустимые символы в ссылке "${referenceName}"`); + } + } + + return { + valid: errors.length === 0, + errors: errors + }; + } + + /** + * Извлекает все шаблонные ссылки @{...} из промпта + * @param promptContent - содержимое промпта + * @returns массив имен ссылок + */ + static extractTemplateReferences(promptContent: string): string[] { + const references: string[] = []; + const templateRegex = /@\{([^}]+)\}/g; + const matches = promptContent.matchAll(templateRegex); + + for (const match of matches) { + const referenceName = match[1].trim(); + if (referenceName && !references.includes(referenceName)) { + references.push(referenceName); + } + } + + return references; + } +} \ No newline at end of file diff --git a/src/references/TemplateEngine.ts b/src/references/TemplateEngine.ts new file mode 100644 index 0000000..c0c3465 --- /dev/null +++ b/src/references/TemplateEngine.ts @@ -0,0 +1,130 @@ +/** + * Движок шаблонов для обработки промптов с ссылками @{...}. + * Поддерживает один уровень вложенности и валидацию ссылок. + */ + +import { TFile } from 'obsidian'; +import LLMAgentPlugin from 'main'; +import { ReferenceParser } from './ReferenceParser'; + +// ----------------------------------------------- Template Engine --------------------------------------------------------------- + +export class TemplateEngine { + private plugin: LLMAgentPlugin; + + constructor(plugin: LLMAgentPlugin) { + this.plugin = plugin; + } + + // ----------------------------------------------- Public Methods --------------------------------------------------------------- + + /** + * Рекурсивно разворачивает промпт, заменяя @{...} ссылки + * ВАЖНО: НЕ поддерживает рекурсию - только один уровень вложенности + * @param promptContent - содержимое промпта + * @returns развернутый промпт + */ + async expandPromptTemplate(promptContent: string): Promise { + // Находим все шаблонные ссылки @{...} + const templateReferences = ReferenceParser.extractTemplateReferences(promptContent); + + if (templateReferences.length === 0) { + return promptContent; + } + + let expandedContent = promptContent; + + // Заменяем каждую ссылку на содержимое соответствующего промпта + for (const referenceName of templateReferences) { + const referencedContent = await this.loadPromptContent(referenceName); + if (referencedContent !== null) { + // Заменяем все вхождения @{referenceName} на содержимое + const templatePattern = new RegExp(`@\\{${this.escapeRegExp(referenceName)}\\}`, 'g'); + expandedContent = expandedContent.replace(templatePattern, referencedContent); + } else { + // Если промпт не найден, оставляем ссылку как есть + console.warn(`Промпт "${referenceName}" не найден`); + } + } + + return expandedContent; + } + + /** + * Валидирует промпт на корректность @{...} ссылок + * @param promptContent - содержимое промпта + * @returns результат валидации + */ + validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } { + const parseResult = ReferenceParser.validatePromptReferences(promptContent); + const errors = [...parseResult.errors]; + + // Дополнительная проверка - существуют ли referenced промпты + const templateReferences = ReferenceParser.extractTemplateReferences(promptContent); + + for (const referenceName of templateReferences) { + const promptFile = this.findPromptFile(referenceName); + if (!promptFile) { + errors.push(`Промпт "${referenceName}" не найден в папке ${this.plugin.settings.promptsFolder}`); + } + } + + return { + valid: errors.length === 0, + errors: errors + }; + } + + // ----------------------------------------------- Private Methods --------------------------------------------------------------- + + /** + * Загружает содержимое промпта по имени + * @param promptName - имя промпта + * @returns содержимое промпта или null если не найден + */ + private async loadPromptContent(promptName: string): Promise { + const promptFile = this.findPromptFile(promptName); + + if (!promptFile) { + return null; + } + + try { + const content = await this.plugin.app.vault.read(promptFile); + return content.trim(); + } catch (error) { + console.error(`Ошибка чтения промпта "${promptName}":`, error); + return null; + } + } + + /** + * Находит файл промпта по имени + * @param promptName - имя промпта + * @returns файл промпта или null + */ + private findPromptFile(promptName: string): TFile | null { + const promptsFolder = this.plugin.settings.promptsFolder; + if (!promptsFolder) { + return null; + } + + // Нормализуем путь к папке промптов + const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, ''); + + // Ищем файл с именем promptName.md в папке промптов + const promptPath = `${normalizedPromptsPath}/${promptName}.md`; + const file = this.plugin.app.vault.getAbstractFileByPath(promptPath); + + return (file instanceof TFile) ? file : null; + } + + /** + * Экранирует специальные символы для регулярного выражения + * @param string - строка для экранирования + * @returns экранированная строка + */ + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} \ No newline at end of file diff --git a/src/views/ChatView.ts b/src/views/ChatView.ts index ea1dcfc..338bf49 100644 --- a/src/views/ChatView.ts +++ b/src/views/ChatView.ts @@ -56,20 +56,20 @@ export class ChatView extends ItemView { // Загружаем доступные модели await this.fetchAvailableModels(); - // Инициализируем только чат панель + // Инициализируем только чат панель с передачей plugin this.chatPanel = new ChatPanel(this.chatContainer, { chatHistory: this.chatHistory, onSendMessage: this.handleSendMessage.bind(this), availableModels: this.availableModels, - selectedModel: this.plugin.settings.defaultModel || 'gemini-2.5-flash' + selectedModel: this.plugin.settings.defaultModel || 'gemini-2.5-flash', + plugin: this.plugin // Добавляем передачу plugin }); - // Подписываемся на события + // Остальной код без изменений... this.plugin.eventBus.on('node-selected', this.handleNodeSelected.bind(this)); this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this)); this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this)); } - async onClose(): Promise { if (this.chatPanel) { this.chatPanel.destroy(); diff --git a/src/views/GraphView.ts b/src/views/GraphView.ts index e6b6a1a..0efaed1 100644 --- a/src/views/GraphView.ts +++ b/src/views/GraphView.ts @@ -303,6 +303,7 @@ export class GraphView extends ItemView { */ async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise { this.selectedGraphId = data.graphId; + console.log("11111") // currentNode здесь может быть неактуальным, fetchGraphData загрузит актуальный current_node_id await this.fetchGraphData(data.graphId); // Note: Больше не нужно эмитить 'graph-selected' обратно в ChatView, т.к. ChatView уже инициировал это.