`).join('');
this.suggestionsContainer.style.display = 'block';
// Добавляем обработчики кликов на предложения
this.suggestionsContainer.querySelectorAll('.suggestion-item').forEach((item) => {
item.addEventListener('click', () => {
this.messageInput.textContent = item.textContent || '';
this.hideSuggestions();
this.messageInput.focus();
});
});
}
hideSuggestions(): void {
this.suggestionsContainer.style.display = 'none';
}
/**
* Рендерит историю сообщений в контейнер чата.
* @returns {Promise}
*/
async renderHistory(): Promise {
const historyContainer = this.container.querySelector('#chat-history') as HTMLElement;
historyContainer.empty(); // Очищаем контейнер перед добавлением новых сообщений
for (const msg of this.chatHistory) {
const messageEl = historyContainer.createDiv({
cls: `message ${msg.role} message-type-${msg.type || 'default'} group/turn-messages`
});
// Информация об отправителе
const metaDiv = messageEl.createDiv({ cls: 'chat-meta' });
const senderInfoDiv = metaDiv.createDiv({ cls: 'chat-sender-info' });
const iconDiv = senderInfoDiv.createDiv({ cls: 'chat-message-icon' });
iconDiv.innerHTML =
msg.role === 'user'
? ``
: ``;
senderInfoDiv.createSpan({ cls: 'chat-role-label', text: this.getRoleLabel(msg.role) });
// Контент сообщения, рендерится Markdown
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
await MarkdownRenderer.renderMarkdown(
msg.content,
contentDiv,
'', // Путь к источнику, может быть пустым для сообщений чата
this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера
);
// Передаем graphId и nodeId
// msg.node_id и this.props.plugin.activeGraphId используются для кнопок удаления
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
}
// Прокручиваем к самому низу родительского контейнера (view-content)
if (this.chatHistory.length > 0) {
setTimeout(() => {
const scrollContainer = this.container.closest('.view-content') as HTMLElement;
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}, 0);
}
}
/**
* Возвращает удобочитаемую метку для роли сообщения.
* @param {string} role - Роль отправителя сообщения (например, 'user', 'assistant').
* @returns {string} Локализованная метка роли.
*/
getRoleLabel(role: string): string {
switch (role) {
case 'user':
return 'Вы:';
case 'assistant':
return 'LLM Агент:';
default:
return `${role}:`;
}
}
/**
* Обновляет историю чата и перерисовывает её.
* @param {ChatHistoryItem[]} newHistory - Новый массив элементов истории чата.
* @returns {Promise}
*/
async updateHistory(newHistory: ChatHistoryItem[]): Promise {
this.chatHistory = newHistory;
await this.renderHistory();
}
updateAvailableModels(models: string[]): void {
this.props.availableModels = models;
this.updateModelDropdown();
}
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: Node | null; // Указываем, что node может быть null
while ((node = walker.nextNode())) {
if (!node) break; // Добавляем проверку на null
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);
}
/**
* Получает позицию курсора в тексте
* (теперь используем 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;
}
/**
* Получает текстовое содержимое 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() для совместимости:
handleSend(): void {
const message = this.messageInput.textContent?.trim() || '';
if (message && this.props.onSendMessage) {
this.props.onSendMessage(message, this.selectedModel);
this.clearInput();
}
}
/**
* Создает панель инструментов под сообщением с кнопками для взаимодействия.
* @param messageElement - Элемент сообщения, к которому добавляется панель инструментов.
* @param messageContent - Содержимое сообщения для функции копирования.
* @param nodeId - ID узла, соответствующего сообщению, для функции удаления.
* @param graphId - ID графа, которому принадлежит сообщение, для функции удаления.
*/
private createMessageToolset(messageElement: HTMLElement, messageContent: string, nodeId: string, graphId: string): void {
const toolsetWrapper = messageElement.createDiv({
cls: 'z-0 flex min-h-[46px] justify-start'
});
const toolsetContainer = toolsetWrapper.createDiv({
cls: 'touch:-me-2 touch:-ms-3.5 -ms-2.5 -me-1 flex flex-wrap items-center gap-y-4 p-1 select-none touch:w-[calc(100%+--spacing(3.5))] -mt-1 w-[calc(100%+--spacing(2.5))] duration-[1.5s] focus-within:transition-none hover:transition-none pointer-events-none [mask-image:linear-gradient(to_right,black_33%,transparent_66%)] [mask-size:300%_100%] [mask-position:100%_0%] motion-safe:transition-[mask-position] group-hover/turn-messages:pointer-events-auto group-hover/turn-messages:[mask-position:0_0] group-focus-within/turn-messages:pointer-events-auto group-focus-within/turn-messages:[mask-position:0_0] has-data-[state=open]:pointer-events-auto has-data-[state=open]:[mask-position:0_0]'
});
// Кнопка "Копировать"
const copyButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',
attr: {
'aria-label': 'Копировать',
'aria-pressed': 'false',
'data-testid': 'copy-turn-action-button',
'data-state': 'closed'
}
});
const copyIconContainer = copyButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(copyIconContainer, 'copy'); // Используем импортированную функцию setIcon
copyButton.addEventListener('click', () => {
this.copyMessageContent(messageContent)
.then(() => {
// В случае успешного копирования меняем иконку на галочку
setIcon(copyIconContainer, 'check');
// Возвращаем иконку копирования через 1 секунду
setTimeout(() => {
setIcon(copyIconContainer, 'copy');
}, 1000);
})
.catch((err) => {
console.error('Не удалось скопировать сообщение:', err);
new Notice('Не удалось скопировать сообщение.', 3000); // Используем Notice
});
});
// Кнопка "Удалить"
const deleteButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg ml-1', // Добавляем немного отступа
attr: {
'aria-label': 'Удалить сообщение',
'data-testid': 'delete-turn-action-button',
'data-state': 'closed'
}
});
const deleteIconContainer = deleteButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(deleteIconContainer, 'trash-2'); // Иконка корзины
deleteButton.addEventListener('click', async () => {
const confirmed = await Dialog.confirm(
this.props.plugin.app,
'Вы уверены, что хотите удалить это сообщение? Это также удалит соответствующий узел в графе.'
);
if (confirmed) {
this.props.plugin.eventBus.emit('delete-node', { graphId, nodeId });
}
});
// Кнопка "Регенерировать"
const regenerateButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg ml-1',
attr: {
'aria-label': 'Регенерировать сообщение',
'data-testid': 'regenerate-turn-action-button',
'data-state': 'closed'
}
});
const regenerateIconContainer = regenerateButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(regenerateIconContainer, 'refresh-cw');
regenerateButton.addEventListener('click', async () => {
// Передаем выбранную модель при регенерации
this.props.plugin.eventBus.emit('regenerate-message', { graphId, nodeId, model: this.selectedModel });
});
// Кнопка "Наверх сообщения"
const scrollToTopButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg ml-1',
attr: {
'aria-label': 'Перейти к началу сообщения',
'data-testid': 'scroll-to-message-top-button',
'data-state': 'closed'
}
});
const scrollToTopIconContainer = scrollToTopButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(scrollToTopIconContainer, 'arrow-up'); // Иконка стрелки вверх
scrollToTopButton.addEventListener('click', () => {
// Прокручиваем родительский контейнер (view-content) к началу этого сообщения
const scrollContainer = this.container.closest('.view-content') as HTMLElement;
if (scrollContainer) {
scrollContainer.scrollTop = messageElement.offsetTop;
}
});
}
private copyMessageContent(content: string): Promise {
return navigator.clipboard.writeText(content);
}
}