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