llm-agent-plugin/src/components/ChatPanel.ts
2025-10-14 21:54:41 +03:00

894 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Управляет пользовательским интерфейсом чата, включая ввод сообщений,
* отображение истории и подсказки команд для взаимодействия с пользователем.
*/
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';
import { MarkdownRenderer, setIcon, Notice } from 'obsidian'; // Добавляем импорт MarkdownRenderer
import { Dialog } from 'src/utils/Dialog';
export class ChatPanel {
container: HTMLElement;
props: {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string) => void;
availableModels?: string[];
selectedModel?: string;
plugin: LLMAgentPlugin;
};
chatHistory: ChatHistoryItem[];
messageInput: HTMLDivElement;
sendButton: HTMLButtonElement;
suggestionsContainer: HTMLDivElement;
modelSelectButton: HTMLButtonElement;
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: {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string) => void;
availableModels?: string[];
selectedModel?: string;
plugin: LLMAgentPlugin; // Обновленный тип
}
) {
this.container = container;
this.props = props;
this.chatHistory = props.chatHistory || [];
this.selectedModel = props.selectedModel || 'gemini-2.5-flash';
// Инициализация системы ссылок
this.initializeReferenceSystem();
this.init();
}
updatePlaceholder(): void {
const isEmpty = this.messageInput.textContent?.trim() === '';
const isFocused = document.activeElement === this.messageInput;
if (isEmpty && !isFocused) {
this.messageInput.classList.add('is-empty');
} else {
this.messageInput.classList.remove('is-empty');
}
}
toggleModelDropdown(): void {
const isVisible = this.modelDropdown.style.display === 'block';
if (isVisible) {
this.modelDropdown.style.display = 'none';
return;
}
// Показываем дропдаун, чтобы получить его размеры и положение
this.modelDropdown.style.display = 'block';
const container = this.modelSelectButton.closest('.chat-input-container') as HTMLElement;
const modelSelectButtonRect = this.modelSelectButton.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); // Общие границы чат-панели
const dropdownRect = this.modelDropdown.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Позиция относительно контейнера chatPanel
// Левый край дропдауна совпадает с левым краем кнопки выбора модели
let left = modelSelectButtonRect.left - containerRect.left - 5;
// Дефолтное положение - под кнопкой
let top = modelSelectButtonRect.height;
// Проверяем, помещается ли dropdown снизу
const spaceBelow = viewportHeight - modelSelectButtonRect.bottom;
const spaceAbove = modelSelectButtonRect.top;
if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) {
// Если снизу мало места и сверху достаточно, размещаем сверху
top = -dropdownRect.height;
}
// Убедимся, что дропдаун не выходит за границы контейнера справа
const maxRight = containerRect.right - containerRect.left;
if (left + dropdownRect.width > maxRight) {
left = maxRight - dropdownRect.width - 5;
}
// Убедимся, что дропдаун не выходит за границы контейнера слева
if (left < 0) {
left = 0;
}
this.modelDropdown.style.top = `${top}px`;
this.modelDropdown.style.left = `${left}px`;
this.modelDropdown.style.right = 'auto'; // Сброс right, чтобы left работал корректно
// Устанавливаем минимальную ширину дропдауна, равную ширине кнопки
let minWidth = Math.min(containerRect.width / 2, dropdownRect.width);
this.modelDropdown.style.minWidth = `${minWidth}px`;
this.modelDropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth
}
updateModelDropdown(): void {
if (!this.props.availableModels) return;
this.modelDropdown.innerHTML = this.props.availableModels
.map(
(model) => `
<div class="model-option" data-model="${model}">
${model}
</div>
`
)
.join('');
// Add event listeners to model options
this.modelDropdown.querySelectorAll('.model-option').forEach((option) => {
option.addEventListener('click', () => {
const model = (option as HTMLElement).dataset.model;
if (model) {
this.setSelectedModel(model);
}
});
});
}
setSelectedModel(model: string): void {
this.selectedModel = model;
const modelNameElement = this.container.querySelector('#selected-model-name');
if (modelNameElement) {
modelNameElement.textContent = model;
}
this.modelDropdown.style.display = 'none';
}
handleInput(): void {
const value = this.messageInput.textContent || '';
const commands = ['/help', '/imagine', '/analyze', '/subtitles_meet', '/subtitles_teams', '/summarize', '/chat'];
if (value.startsWith('/')) {
const filteredCommands = commands.filter((cmd) => cmd.startsWith(value.toLowerCase()));
this.showSuggestions(filteredCommands);
} else {
this.hideSuggestions();
}
}
showSuggestions(suggestions: string[]): void {
if (suggestions.length === 0) {
this.hideSuggestions();
return;
}
this.suggestionsContainer.innerHTML = suggestions.map((suggestion) => `<div class="suggestion-item">${suggestion}</div>`).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<void>}
*/
async renderHistory(): Promise<void> {
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'
? `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>`;
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<void>}
*/
async updateHistory(newHistory: ChatHistoryItem[]): Promise<void> {
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 = `
<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: 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 - это другой элемент (например, <br>),
// то курсор находится до него.
// Мы не должны добавлять его длину, но должны учесть, сколько текста было до него.
// В данном случае `range.endOffset` будет 0, если курсор перед ним.
// Если курсор внутри элемента, нужно по-другому считать.
// В контексте `contenteditable` это обычно означает, что мы либо в текстовом узле,
// либо `endContainer` является родителем, а `endOffset` - индексом дочернего элемента.
// Для `reference-box` курсор внутри быть не должен (т.к. он `<span contenteditable="false">`).
// Для других элементов, если курсор внутри них, `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 (например, <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() для совместимости:
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<void> {
return navigator.clipboard.writeText(content);
}
}