1163 lines
47 KiB
TypeScript
1163 lines
47 KiB
TypeScript
/*
|
||
* Управляет пользовательским интерфейсом чата, включая ввод сообщений,
|
||
* отображение истории и подсказки команд для взаимодействия с пользователем.
|
||
*/
|
||
|
||
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();
|
||
}
|
||
}
|
||
}
|