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 уже инициировал это.