Add chat message toolset with copy text button

This commit is contained in:
dimitrievgs 2025-09-29 00:29:46 +03:00
parent 326198bb1d
commit 6595ab0eb1

View File

@ -12,7 +12,7 @@ import { ReferenceParser, FileReference, ReferenceMatch } from '../references/Re
import { ReferenceBox } from '../references/ReferenceBox'; import { ReferenceBox } from '../references/ReferenceBox';
import { AutocompleteItem } from '../references/AutocompleteManager'; import { AutocompleteItem } from '../references/AutocompleteManager';
import LLMAgentPlugin from 'main'; import LLMAgentPlugin from 'main';
import { MarkdownRenderer } from 'obsidian'; // Добавляем импорт MarkdownRenderer import { MarkdownRenderer, setIcon } from 'obsidian'; // Добавляем импорт MarkdownRenderer
export class ChatPanel { export class ChatPanel {
container: HTMLElement; container: HTMLElement;
@ -119,7 +119,7 @@ export class ChatPanel {
this.modelDropdown.style.left = `${left}px`; this.modelDropdown.style.left = `${left}px`;
this.modelDropdown.style.right = 'auto'; // Сброс right, чтобы left работал корректно this.modelDropdown.style.right = 'auto'; // Сброс right, чтобы left работал корректно
// ДОБАВЛЕНО: Устанавливаем минимальную ширину дропдауна, равную ширине кнопки // Устанавливаем минимальную ширину дропдауна, равную ширине кнопки
let minWidth = Math.min(containerRect.width / 2, dropdownRect.width); let minWidth = Math.min(containerRect.width / 2, dropdownRect.width);
this.modelDropdown.style.minWidth = `${minWidth}px`; this.modelDropdown.style.minWidth = `${minWidth}px`;
this.modelDropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth this.modelDropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth
@ -203,7 +203,7 @@ export class ChatPanel {
historyContainer.empty(); // Очищаем контейнер перед добавлением новых сообщений historyContainer.empty(); // Очищаем контейнер перед добавлением новых сообщений
for (const msg of this.chatHistory) { for (const msg of this.chatHistory) {
const messageEl = historyContainer.createDiv({ cls: `message ${msg.role} message-type-${msg.type || 'default'}` }); const messageEl = historyContainer.createDiv({ cls: `message ${msg.role} message-type-${msg.type || 'default'} group/turn-messages` });
// Информация об отправителе // Информация об отправителе
const metaDiv = messageEl.createDiv({ cls: 'chat-meta' }); const metaDiv = messageEl.createDiv({ cls: 'chat-meta' });
@ -217,12 +217,13 @@ export class ChatPanel {
// Контент сообщения, рендерится Markdown // Контент сообщения, рендерится Markdown
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' }); const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
await MarkdownRenderer.renderMarkdown( await MarkdownRenderer.renderMarkdown(
msg.content, msg.content,
contentDiv, contentDiv,
'', // Путь к источнику, может быть пустым для сообщений чата '', // Путь к источнику, может быть пустым для сообщений чата
this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера
); );
this.createMessageToolset(messageEl, msg.content);
} }
// Прокручиваем к самому низу родительского контейнера (view-content) // Прокручиваем к самому низу родительского контейнера (view-content)
@ -677,46 +678,6 @@ export class ChatPanel {
selection.removeAllRanges(); selection.removeAllRanges();
} }
/**
* Вставляет пробел после указанного элемента.
* @param element - HTML элемент, после которого нужно вставить пробел.
*/
private insertSpaceAfterElement(element: HTMLElement): void {
const spaceNode = document.createTextNode('\u00A0'); // Неразрывный пробел
if (element.nextSibling) {
element.parentNode?.insertBefore(spaceNode, element.nextSibling);
} else {
element.parentNode?.appendChild(spaceNode);
}
}
/**
* Устанавливает курсор после указанного элемента.
* @param element - HTML элемент, после которого нужно установить курсор.
*/
private setCursorAfterElement(element: HTMLElement): void {
const selection = window.getSelection();
if (!selection) return;
// Проверяем, есть ли после элемента текстовый узел (который мы только что вставили)
let targetNode = element.nextSibling;
if (targetNode?.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
range.setStart(targetNode, targetNode.textContent!.length);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
} else {
// Если нет текстового узла (такого быть не должно после insertSpaceAfterElement),
// ставим курсор максимально близко к элементу.
const range = document.createRange();
range.setStartAfter(element);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
/** /**
* Получает позицию курсора в тексте * Получает позицию курсора в тексте
* (теперь используем findNodesAndOffsetsForMatch для более точного определения текстового смещения) * (теперь используем findNodesAndOffsetsForMatch для более точного определения текстового смещения)
@ -765,21 +726,6 @@ export class ChatPanel {
return currentTextOffset; return currentTextOffset;
} }
/**
* Вычисляет эквивалентную текстовую длину HTML-элемента, такую как reference-box.
* @param node - HTML-элемент.
* @returns эквивалентная текстовая длина элемента.
*/
private getNodeEquivalentLength(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')) {
return 1; // Учитываем как 1 позицию, т.к. курсор не может быть внутри
}
// Для других элементов, считаем 0, если они не содержат текст
return 0;
}
/** /**
* Получает текстовое содержимое input элемента (без HTML), * Получает текстовое содержимое input элемента (без HTML),
* заменяя reference-box их текстовым представлением. * заменяя reference-box их текстовым представлением.
@ -897,16 +843,6 @@ export class ChatPanel {
} }
} }
/*handleSend(): void {
const message = this.messageInput.textContent?.trim() || '';
if (message && this.props.onSendMessage) {
this.props.onSendMessage(message, this.selectedModel);
this.messageInput.textContent = '';
this.updatePlaceholder();
this.hideSuggestions();
}
}*/
// Обновить метод handleSend() для совместимости: // Обновить метод handleSend() для совместимости:
handleSend(): void { handleSend(): void {
const message = this.messageInput.textContent?.trim() || ''; const message = this.messageInput.textContent?.trim() || '';
@ -915,4 +851,64 @@ export class ChatPanel {
this.clearInput(); this.clearInput();
} }
} }
/**
* Создает панель инструментов под сообщением с кнопками для взаимодействия.
* @param messageElement - Элемент сообщения, к которому добавляется панель инструментов.
* @param messageContent - Содержимое сообщения для функции копирования.
*/
private createMessageToolset(messageElement: HTMLElement, messageContent: 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);
});
// Кнопка "Попробовать снова"
/*const retryButton = toolsetContainer.createEl('button', {
cls: 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg',
attr: {
'aria-label': 'Попробовать снова',
'aria-pressed': 'false',
'data-testid': 'refresh-turn-action-button',
'data-state': 'closed',
},
});
const retryIconContainer = retryButton.createSpan({ cls: 'flex items-center justify-center touch:w-10 h-8 w-8' });
setIcon(retryIconContainer, 'refresh-cw'); // Используем импортированную функцию setIcon
retryButton.addEventListener('click', () => {
this.copyMessageContent(messageContent);
});*/ // TODO: реализовать переотправку сообщения
}
private copyMessageContent(content: string): void {
navigator.clipboard.writeText(content)
.then(() => {
console.log('Message content copied to clipboard.');
// Optionally: show a notice to the user
// new Notice('Сообщение скопировано!');
})
.catch(err => {
console.error('Failed to copy message content:', err);
// Optionally: show an error notice
// new Notice('Не удалось скопировать сообщение.');
});
}
} }