llm-agent-plugin/src/components/ChatPanel.ts

1163 lines
47 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, Attachment } 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, TFile, FileSystemAdapter } from 'obsidian';
import { Dialog } from 'src/utils/Dialog';
import { CacheManager } from 'src/references/CacheManager';
interface ChatPanelProps {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string, attachments?: Attachment[]) => void;
selectedModel?: string;
plugin: LLMAgentPlugin;
currentGraphCustomSystemPrompt: string | null;
}
export class ChatPanel {
container: HTMLElement;
props: ChatPanelProps;
chatHistory: ChatHistoryItem[];
messageInput: HTMLDivElement;
sendButton: HTMLButtonElement;
suggestionsContainer: HTMLDivElement;
modelSelectButton: HTMLButtonElement;
modelDropdown: HTMLDivElement;
selectedModel: string;
customPromptIndicator: HTMLSpanElement | null = null;
currentStreamingNodeId: string | null;
stopButton: HTMLButtonElement;
// Элементы для файловых вложений
attachFileButton: HTMLButtonElement;
fileInput: HTMLInputElement;
attachedFilesContainer: HTMLDivElement;
currentAttachments: Attachment[] = [];
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
cacheManager: CacheManager;
private autocompleteManager: AutocompleteManager;
private referenceAutocomplete: ReferenceAutocomplete;
private referenceExpander: ReferenceExpander;
private templateEngine: TemplateEngine;
private currentReferences: FileReference[] = [];
private isProcessingReference = false;
constructor(container: HTMLElement, props: ChatPanelProps) {
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.plugin.availableModels) return;
this.modelDropdown.innerHTML = this.props.plugin.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) });
// Отображаем вложения как боксы
if (msg.attachments && msg.attachments.length > 0) {
const attachmentsContainer = messageEl.createDiv({ cls: 'message-attachments' });
for (const attachment of msg.attachments) {
const box = this.createAttachmentBox(attachment);
attachmentsContainer.appendChild(box);
}
}
// Контент сообщения
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin);
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
}
// Прокрутка
if (this.chatHistory.length > 0) {
setTimeout(() => {
const scrollContainer = this.container.closest('.view-content') as HTMLElement;
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}, 0);
}
}
/**
* Создает визуальный бокс для отображения вложения в истории.
*/
private createAttachmentBox(attachment: Attachment): HTMLElement {
const box = document.createElement('span');
// Определяем классы в зависимости от типа и permanent
const permanentClass = attachment.permanent ? 'permanent' : 'single';
box.className = `reference-box attachment-box ${attachment.type} ${permanentClass} clickable`;
box.setAttribute('data-type', attachment.type);
box.setAttribute('data-name', attachment.name);
box.setAttribute('data-source-path', attachment.sourcePath);
box.setAttribute('data-cached-path', attachment.cachedPath);
box.setAttribute('data-uuid', attachment.uuid);
box.setAttribute('data-permanent', String(attachment.permanent));
box.addEventListener('click', () => {
this.handleAttachmentClick(attachment);
});
const icon = document.createElement('span');
icon.className = 'reference-icon';
if (attachment.type === 'prompt') {
icon.textContent = '⚡';
} else if (attachment.type === 'external') {
icon.textContent = '📎';
} else if (attachment.type === 'url') {
icon.textContent = '🌐';
} else {
icon.textContent = '📄';
}
const name = document.createElement('span');
name.className = 'reference-name';
name.textContent = attachment.name;
box.appendChild(icon);
box.appendChild(name);
return box;
}
/**
* Обрабатывает клик по боксу вложения
*/
private async handleAttachmentClick(attachment: Attachment): Promise<void> {
// Проверяем существование кеша
const cacheManager = new CacheManager(this.props.plugin);
const cacheExists = await cacheManager.cachedFileExists(attachment.cachedPath);
let pathToOpen: string;
if (cacheExists) {
// Открываем кешированный файл
const cacheFolderPath = this.props.plugin.settings.refCacheFolder;
if (!(this.props.plugin.app.vault.adapter instanceof FileSystemAdapter)) {
new Notice('Открытие кешированных файлов доступно только в desktop-версии', 3000);
return;
}
pathToOpen = (this.props.plugin.app.vault.adapter as FileSystemAdapter).getBasePath() + '/' + cacheFolderPath + '/' + attachment.cachedPath;
} else {
// Кеш не найден, открываем исходный файл
if (attachment.type === 'external') {
pathToOpen = attachment.sourcePath; // Уже абсолютный
} else if (attachment.type === 'file') {
if (!(this.props.plugin.app.vault.adapter instanceof FileSystemAdapter)) {
new Notice('Открытие файлов промптов доступно только в desktop-версии', 3000);
return;
}
pathToOpen = (this.props.plugin.app.vault.adapter as FileSystemAdapter).getBasePath() + '/' + attachment.sourcePath;
} else {
pathToOpen = this.props.plugin.settings.promptsFolder + '/' + attachment.sourcePath;
}
}
this.openExternalFile(pathToOpen);
}
/**
* Открывает файл из хранилища Obsidian
*/
private openVaultFile(path: string): void {
const file = this.props.plugin.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
this.props.plugin.app.workspace.getLeaf(false).openFile(file);
} else {
new Notice(`Файл не найден: ${path}`, 3000);
}
}
// TODO: Простой window.electron.shell.openPath(absolutePath) не работает
/**
* Открывает внешний файл по абсолютному пути
*/
private openExternalFile(absolutePath: string): void {
if (!window.require) {
console.warn('Невозможно открыть внешний файл в веб-версии:', absolutePath);
new Notice('Открытие внешних файлов доступно только в desktop-версии', 3000);
return;
}
try {
const { exec } = window.require('child_process');
const path = window.require('path');
// Определяем команду в зависимости от платформы
let command: string;
const platform = process.platform;
if (platform === 'win32') {
// Windows: используем start
command = `start "" "${absolutePath}"`;
} else if (platform === 'darwin') {
// macOS: используем open
command = `open "${absolutePath}"`;
} else {
// Linux: используем xdg-open
command = `xdg-open "${absolutePath}"`;
}
exec(command, (error: Error | null) => {
if (error) {
console.error('Ошибка при открытии файла:', error);
new Notice(`Не удалось открыть файл: ${error.message}`, 3000);
}
});
} catch (error) {
console.error('Ошибка при попытке открыть файл:', error);
new Notice(`Не удалось открыть файл: ${error.message}`, 3000);
}
}
/**
* Возвращает удобочитаемую метку для роли сообщения.
* @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();
}
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.cacheManager = new CacheManager(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="Введите сообщение, @файл, #промпт, $внешний_файл, %url или команду (/)"
></div>
<div class="attached-files-container" id="attached-files-container" style="display: none;"></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>
<span class="custom-prompt-indicator" id="custom-prompt-indicator" style="display: none;"
data-tooltip-position="top" aria-label="Используется пользовательский системный промпт для этого графа">
⚙️
</span>
</div>
</div>
<div class="control-buttons-right">
<button class="stop-button" id="stop-button" style="display: none;">
<span class="stop-icon">■</span>
</button>
<button class="submit-button" id="send-button">
<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.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement;
this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement;
this.attachedFilesContainer = this.container.querySelector('#attached-files-container') as HTMLDivElement;
this.setupEventListeners();
this.setupReferenceSystem();
this.renderHistory();
this.updateModelDropdown();
this.updatePlaceholder();
this.updateCustomSystemPromptStatus(this.props.currentGraphCustomSystemPrompt);
}
// ----------------------------------------------- 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();
});
this.modelSelectButton.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleModelDropdown();
});
this.stopButton.addEventListener('click', () => this.handleStopGeneration());
// 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 && this.currentAttachments.length === 0) return;
if (!this.referenceExpander) {
this.handleSend();
return;
}
try {
// ИЗМЕНЕНО: Извлекаем вложения из ссылок И кешируем их
const { originalText, attachments } = await this.referenceExpander.extractAttachments(rawText, this.currentReferences);
// Объединяем вложения из ссылок и текущие (из attached-files-container)
const allAttachments = [...attachments, ...this.currentAttachments];
// Отправляем оригинальный текст и все вложения
if (this.props.onSendMessage) {
this.props.onSendMessage(originalText || '', this.selectedModel, allAttachments);
}
this.clearInput();
} catch (error) {
console.error('Ошибка при обработке ссылок:', error);
if (this.props.onSendMessage) {
this.props.onSendMessage(rawText, this.selectedModel, this.currentAttachments);
}
this.clearInput();
}
}
/**
* Очищает поле ввода и сбрасывает состояние ссылок
*/
private clearInput(): void {
this.messageInput.innerHTML = '';
this.currentReferences = [];
this.currentAttachments = [];
this.renderAttachedFiles();
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.currentAttachments);
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 branchButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg ml-1',
attr: {
'aria-label': 'Перейти к этому сообщению (начать новую ветку отсюда)',
'data-testid': 'branch-turn-action-button',
'data-state': 'closed'
}
});
const branchIconContainer = branchButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(branchIconContainer, 'git-branch'); // Иконка разветвления диалога
branchButton.addEventListener('click', () => {
// Отправляем событие выбора узла. ChatView перехватит его,
// обрежет сообщения ниже этого узла и сделает его текущим.
// Следующее отправленное пользователем сообщение станет новой веткой!
this.props.plugin.eventBus.emit('node-selected', { nodeId: nodeId, graphId: graphId });
// Необязательно, но приятно: уведомляем пользователя
new Notice('История обрезана до этого сообщения. Следующий ответ создаст новую ветку.', 3000);
});
// Кнопка "Наверх сообщения"
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 handleStopGeneration(): void {
if (this.currentStreamingNodeId) {
this.props.plugin.eventBus.emit('stop-stream-request', {
nodeId: this.currentStreamingNodeId
});
}
}
updateStopButtonVisibility(isStreaming: boolean, nodeId: string | null): void {
this.currentStreamingNodeId = nodeId;
if (isStreaming && nodeId) {
this.stopButton.style.display = 'inline-flex';
} else {
this.stopButton.style.display = 'none';
}
}
private copyMessageContent(content: string): Promise<void> {
return navigator.clipboard.writeText(content);
}
updateCustomSystemPromptStatus(customSystemPrompt: string | null): void {
if (this.customPromptIndicator) {
if (customSystemPrompt) {
this.customPromptIndicator.style.display = 'inline-flex';
this.customPromptIndicator.setAttribute(
'aria-label',
`Используется пользовательский системный промпт: ${customSystemPrompt}`
);
} else {
this.customPromptIndicator.style.display = 'none';
this.customPromptIndicator.removeAttribute('aria-label');
}
}
}
// ----------------------------------------------- File Attachments ---------------------------------------------------------------
/**
* Отображает прикрепленные файлы
*/
private renderAttachedFiles(): void {
if (this.currentAttachments.length === 0) {
this.attachedFilesContainer.style.display = 'none';
this.attachedFilesContainer.innerHTML = '';
return;
}
this.attachedFilesContainer.style.display = 'flex';
this.attachedFilesContainer.innerHTML = '';
for (const attachment of this.currentAttachments) {
const box = this.createRemovableAttachmentBox(attachment);
this.attachedFilesContainer.appendChild(box);
}
}
/**
* Создает бокс вложения с кнопкой удаления
*/
private createRemovableAttachmentBox(attachment: Attachment): HTMLElement {
const box = document.createElement('div');
box.className = 'attachment-box removable';
const icon = document.createElement('span');
icon.className = 'attachment-icon';
icon.textContent = '📎';
const name = document.createElement('span');
name.className = 'attachment-name';
name.textContent = attachment.name;
const removeBtn = document.createElement('button');
removeBtn.className = 'attachment-remove';
removeBtn.innerHTML = '×';
removeBtn.setAttribute('aria-label', 'Удалить вложение');
removeBtn.addEventListener('click', () => {
this.removeAttachment(attachment);
});
box.appendChild(icon);
box.appendChild(name);
box.appendChild(removeBtn);
return box;
}
/**
* Удаляет внешнее вложение
*/
private removeAttachment(attachment: Attachment): void {
const index = this.currentAttachments.indexOf(attachment);
if (index > -1) {
this.currentAttachments.splice(index, 1);
this.renderAttachedFiles();
}
}
}