/* * Управляет пользовательским интерфейсом чата, включая ввод сообщений, * отображение истории и подсказки команд для взаимодействия с пользователем. */ 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) => `
${model}
` ) .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) => `
${suggestion}
`).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) }); // Отображаем вложения как боксы 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 { // Проверяем существование кеша 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} */ async updateHistory(newHistory: ChatHistoryItem[]): Promise { 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 = `
`; 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(); } }); // Обработчики Drag-n-Drop для внешних файлов this.setupDragAndDrop(); } // ----------------------------------------------- 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 && 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 { 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(); } } // ----------------------------------------------- Drag-and-Drop --------------------------------------------------------------- private setupDragAndDrop() { const inputContainer = this.container.querySelector('.chat-input-container') as HTMLElement; if (inputContainer) { inputContainer.addEventListener('dragover', (e) => { e.preventDefault(); // Добавляем класс для создания визуального эффекта (подсветка поля) inputContainer.classList.add('drag-over'); }); inputContainer.addEventListener('dragenter', (e) => { e.preventDefault(); inputContainer.classList.add('drag-over'); }); inputContainer.addEventListener('dragleave', (e) => { e.preventDefault(); const relatedTarget = e.relatedTarget as Node | null; // Проверяем, что курсор действительно покинул зону, а не перешел на дочерний элемент (текст, кнопку) if (!inputContainer.contains(relatedTarget)) { inputContainer.classList.remove('drag-over'); } }); inputContainer.addEventListener('drop', async (e) => { e.preventDefault(); inputContainer.classList.remove('drag-over'); await this.handleDrop(e); }); } } /** * Обрабатывает событие сброса внешних файлов (Drag-and-Drop) из ОС */ private async handleDrop(e: DragEvent): Promise { if (!e.dataTransfer?.files || e.dataTransfer.files.length === 0) { return; // Оброшен не файл (например, просто выделенный текст) } const isCtrlPressed = e.ctrlKey || e.metaKey; const shouldBePermanent = !isCtrlPressed; const files = Array.from(e.dataTransfer.files); let addedCount = 0; for (const file of files) { let absolutePath = ''; // 1. Попытка через скрытое свойство 'path' (иногда оно есть, но не перечисляемо) absolutePath = (file as any).path; // 2. Попытка через Electron (если доступен window.require) if (!absolutePath && window.require) { try { const electron = window.require('electron'); // В некоторых версиях путь можно достать через webUtils из модуля if (electron.webUtils) { absolutePath = electron.webUtils.getPathForFile(file); } } catch (err) {} } try { // Кэшируем файл (копируем в папку плагина) и получаем метаинформацию const cacheResult = await this.cacheManager.cacheExternalFile(absolutePath); // Формируем Attachment объект const newAttachment: Attachment = { type: 'external', name: cacheResult.name, sourcePath: absolutePath, cachedPath: cacheResult.cachedPath, uuid: cacheResult.uuid, permanent: shouldBePermanent, // D&D файлы прикрепляем разово (как $файл, а не !$) mimeType: cacheResult.mimeType }; this.currentAttachments.push(newAttachment); addedCount++; } catch (error: any) { console.error(`Ошибка обработки D&D файла ${file.name}:`, error); new Notice(`Не удалось прикрепить ${file.name}. ${error.message || error}`); } } if (addedCount > 0) { // Перерисовываем UI "Скрепки" над полем ввода this.renderAttachedFiles(); new Notice(`Прикреплено файлов: ${addedCount}`, 2000); } } }