import LLMAgentPlugin from 'main'; import { MarkdownRenderer, setIcon, Notice } from 'obsidian'; export class VoicePanel { private activeTab: 'res1' | 'res2' | 'trans' | 'metrics' = 'res1'; constructor( private container: HTMLElement, private plugin: LLMAgentPlugin ) { this.render(); this.startPolling(); } render() { this.container.innerHTML = `
`; this.container.querySelectorAll('.v-tab').forEach((tab) => { tab.addEventListener('click', (e) => { const target = e.target as HTMLElement; this.container.querySelectorAll('.v-tab').forEach((t) => t.classList.remove('active')); target.classList.add('active'); this.activeTab = target.dataset.tab as any; // При переключении вкладок очищаем контейнер, чтобы отрисовать актуальный список const body = this.container.querySelector('#v-body') as HTMLElement; if (body) body.empty(); }); }); const control = async (action: string) => { await fetch(`${this.plugin.settings.apiBaseUrl}/voice/control`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }); }; this.container.querySelector('#v-new')?.addEventListener('click', () => control('start')); this.container.querySelector('#v-cont')?.addEventListener('click', () => control('continue')); this.container.querySelector('#v-stop')?.addEventListener('click', () => control('stop')); } async startPolling() { setInterval(async () => { try { const res = await fetch(`${this.plugin.settings.apiBaseUrl}/voice/status`); const data = await res.json(); this.updateContent(data); } catch (e) { console.error('Voice status fetch error', e); } }, 500); } async updateContent(data: any) { const body = this.container.querySelector('#v-body') as HTMLElement; if (!body) return; if (data.obsidian_settings_fetched === false) { this.plugin.syncSettings(); } if (this.activeTab === 'trans') { // Для транскрибации оставляем простой текст, так как она меняется постоянно if (body.innerText !== data.transcription) { body.innerText = data.transcription || ''; } return; } if (this.activeTab === 'metrics') { if (!data.metrics) return; // 1. Строгая проверка через JSON.stringify, чтобы отсечь лишние апдейты // и не перестраивать DOM (что и вызывает дергание) const newDataString = JSON.stringify(data.metrics); if ((body as any).lastMetricsData === newDataString) return; (body as any).lastMetricsData = newDataString; // 2. Запоминаем позицию скролла перед обновлением const scrollTop = body.scrollTop; body.empty(); // Рендер графиков if (data.metrics.graphs && data.metrics.graphs.length > 0) { const graphsContainer = document.createElement('div'); graphsContainer.style.display = "flex"; graphsContainer.style.flexDirection = "column"; graphsContainer.innerHTML = data.metrics.graphs.join(''); body.appendChild(graphsContainer); } // Рендер текста if (data.metrics.text) { const textContainer = document.createElement('div'); textContainer.addClass('markdown-rendered'); await MarkdownRenderer.renderMarkdown(data.metrics.text, textContainer, '', this.plugin); body.appendChild(textContainer); } // 3. Возвращаем скролл на место сразу после рендера в следующем микротаске setTimeout(() => { body.scrollTop = scrollTop; }, 0); return; } // Получаем записи (они уже приходят отсортированные: новые в начале) const entries: { id: string; content: string }[] = this.activeTab === 'res1' ? data.response1 : data.response2; // 1. Удаляем элементы, которых больше нет в данных (те, что вытеснены лимитом на беке) const currentIds = new Set(entries.map((e) => e.id)); const domElements = body.querySelectorAll('.voice-message-block'); domElements.forEach((el) => { const id = el.getAttr('data-id'); if (id && !currentIds.has(id)) { el.remove(); } }); // 2. Добавляем новые элементы. // Важно: перебираем в ОБРАТНОМ порядке (от старых к новым), // чтобы при использовании prepend в итоге самый новый оказался самым первым. const reversedEntries = [...entries].reverse(); for (const entry of reversedEntries) { let existing = body.querySelector(`[data-id="${entry.id}"]`); if (!existing) { // Создаем контейнер const msgContainer = document.createElement('div'); msgContainer.addClass('voice-message-block', 'markdown-rendered', 'group/turn-messages'); // Добавлен класс group для hover-эффекта msgContainer.setAttr('data-id', entry.id); // Добавляем В НАЧАЛО контейнера body.prepend(msgContainer); // Рендерим Markdown await MarkdownRenderer.renderMarkdown(entry.content, msgContainer, '', this.plugin); // ДОБАВЛЯЕМ КНОПКИ (Copy и Scroll to Top) this.createVoiceMessageToolset(msgContainer, entry.content); } } } private createVoiceMessageToolset(messageElement: HTMLElement, content: string): void { const toolsetContainer = messageElement.createDiv({ cls: 'voice-message-toolset' }); // Кнопка "Копировать" const copyButton = toolsetContainer.createEl('button', { cls: 'voice-tool-btn', attr: { 'aria-label': 'Копировать всё' } }); const copyIconContainer = copyButton.createSpan(); setIcon(copyIconContainer, 'copy'); copyButton.addEventListener('click', async () => { try { await navigator.clipboard.writeText(content); setIcon(copyIconContainer, 'check'); setTimeout(() => setIcon(copyIconContainer, 'copy'), 1000); } catch (err) { new Notice('Ошибка копирования'); } }); // Кнопка "Наверх" (перемотка к началу сообщения) const scrollToTopButton = toolsetContainer.createEl('button', { cls: 'voice-tool-btn', attr: { 'aria-label': 'К началу сообщения' } }); const scrollIconContainer = scrollToTopButton.createSpan(); setIcon(scrollIconContainer, 'arrow-up'); scrollToTopButton.addEventListener('click', () => { // Используем встроенный метод scrollIntoView для точного позиционирования messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' // Прокрутить так, чтобы начало элемента совпало с верхом контейнера }); }); } }