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

203 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 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 = `
<div class="voice-panel-container">
<div class="voice-header">
<div class="v-controls">
<button id="v-new" class="mod-cta">Start New</button>
<button id="v-cont">Continue</button>
<button id="v-stop" class="mod-warning">Stop</button>
</div>
<div class="v-tabs">
<button class="v-tab active" data-tab="res1">Response 1</button>
<button class="v-tab" data-tab="res2">Response 2</button>
<button class="v-tab" data-tab="trans">Transcription</button>
<button class="v-tab" data-tab="metrics">Metrics</button>
</div>
</div>
<div id="v-body" class="voice-body selectable"></div>
</div>
`;
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' // Прокрутить так, чтобы начало элемента совпало с верхом контейнера
});
});
}
}