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' // Прокрутить так, чтобы начало элемента совпало с верхом контейнера
});
});
}
}