Draft solution for file/prompt hot inserting

This commit is contained in:
dimitrievgs 2025-09-25 01:39:05 +03:00
parent d65996539a
commit 24ac848ab9
13 changed files with 2188 additions and 125 deletions

44
main.ts
View File

@ -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();
})
);
}
}

View File

@ -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 = `
<div class="llm-agent-chat-panel">
<div class="chat-history" id="chat-history"></div>
<div class="chat-input-container">
<div class="scroll-container">
<div
contenteditable="true"
data-testid="editor-input-main"
class="message-input-editor"
tabindex="0"
data-placeholder="Введите сообщение или команду (/)"
></div>
</div>
<div class="chat-controls">
<div class="control-buttons-left">
<div class="model-select-container">
<button class="model-select-button" id="model-select-button">
<span class="model-name" id="selected-model-name">${this.selectedModel}</span>
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
</svg>
</button>
<div class="model-dropdown" id="model-dropdown" style="display: none;"></div>
</div>
</div>
<div class="control-buttons-right">
<button class="submit-button" id="send-button" data-tooltip="Enter">
<span class="submit-text"> Enter</span>
</button>
</div>
</div>
<div class="suggestions" id="suggestions" style="display: none;"></div>
</div>
</div>
`;
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 = `
<div class="llm-agent-chat-panel">
<div class="chat-history" id="chat-history"></div>
<div class="chat-input-container">
<div class="scroll-container">
<div
contenteditable="true"
data-testid="editor-input-main"
class="message-input-editor"
tabindex="0"
data-placeholder="Введите сообщение, @файл, #промпт или команду (/)"
></div>
</div>
<div class="chat-controls">
<div class="control-buttons-left">
<div class="model-select-container">
<button class="model-select-button" id="model-select-button">
<span class="model-name" id="selected-model-name">${this.selectedModel}</span>
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
</svg>
</button>
<div class="model-dropdown" id="model-dropdown" style="display: none;"></div>
</div>
</div>
<div class="control-buttons-right">
<button class="submit-button" id="send-button" data-tooltip="Enter">
<span class="submit-text"> Enter</span>
</button>
</div>
</div>
<div class="suggestions" id="suggestions" style="display: none;"></div>
</div>
</div>
`;
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 - это другой элемент (например, <br>),
// то курсор находится до него.
// Мы не должны добавлять его длину, но должны учесть, сколько текста было до него.
// В данном случае `range.endOffset` будет 0, если курсор перед ним.
// Если курсор внутри элемента, нужно по-другому считать.
// В контексте `contenteditable` это обычно означает, что мы либо в текстовом узле,
// либо `endContainer` является родителем, а `endOffset` - индексом дочернего элемента.
// Для `reference-box` курсор внутри быть не должен (т.к. он `<span contenteditable="false">`).
// Для других элементов, если курсор внутри них, `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 (например, <br>),
// нужно решить, как их представлять в текстовом содержимом.
// В данном случае, если это <br>, его можно игнорировать или заменить на \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<void> {
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();
}
}
}

View File

@ -7,4 +7,8 @@ export interface ChatHistoryItem {
role: string;
content: string;
type?: 'user' | 'llm' | 'tool' | 'error' | 'default';
node_id?: string;
// TODO: добавить поля для снапшотов файлов при реализации
// fileSnapshots?: Record<string, string>;
// expandedContent?: string; // содержимое после раскрытия ссылок
}

View File

@ -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);
}

View File

View File

@ -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<string, AutocompleteItem[]> = new Map();
private promptCache: Map<string, AutocompleteItem[]> = 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<AutocompleteItem[]> {
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<AutocompleteItem[]> {
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<void> {
const now = Date.now();
if (now - this.lastCacheUpdate > this.CACHE_TTL) {
await this.updateCache();
this.lastCacheUpdate = now;
}
}
/**
* Обновляет кеш файлов и промптов
*/
private async updateCache(): Promise<void> {
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();
})
);
}
}

View File

@ -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<void> {
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);
}
}
}

View File

@ -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';
}
}
}

View File

@ -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<string> {
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<any[]> {
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<string, string>}> {
// const snapshots: Record<string, string> = {};
// 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<string> {
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<string> {
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<string | null> {
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<string> {
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, '\\$&');
}
}

View File

@ -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;
}
}

View File

@ -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<string> {
// Находим все шаблонные ссылки @{...}
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<string | null> {
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, '\\$&');
}
}

View File

@ -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<void> {
if (this.chatPanel) {
this.chatPanel.destroy();

View File

@ -303,6 +303,7 @@ export class GraphView extends ItemView {
*/
async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise<void> {
this.selectedGraphId = data.graphId;
console.log("11111")
// currentNode здесь может быть неактуальным, fetchGraphData загрузит актуальный current_node_id
await this.fetchGraphData(data.graphId);
// Note: Больше не нужно эмитить 'graph-selected' обратно в ChatView, т.к. ChatView уже инициировал это.