feat: STT transcripts, model catalog+fuzzy resolve, UI audio player & auto-refresh
Core: - ModelCatalog: periodic background sync of provider models + fuzzy resolve. Misspelled model ids map to the nearest match (cheapest on ties); no match raises ModelResolutionError with cheapest-first suggestions for the agent. mcp-server: - nexus_stt now writes the transcript to a Markdown file in the media dir so it appears in the gallery and is readable in the modal. - media handler resolves model via catalog before generating; returns a note when substituting, or a BAD_REQUEST with available models when nothing fits. - context wires a ModelCatalog per media provider with auto-sync. admin: - /media serves correct Content-Type (incl. text/markdown for transcripts). - Gallery: audio play button on cards, dedicated audio player in modal, text/transcript tiles that fetch and render the .md in the modal. - Header: auto-refresh toggle (5/10/30s) that refreshes the active tab without a full page reload; pauses while a modal is open.
This commit is contained in:
@@ -41,18 +41,46 @@ $('#themeToggle').addEventListener('click', () => {
|
||||
applyTheme(localStorage.getItem('nexus-theme') || 'dark');
|
||||
|
||||
// --- Tabs ---
|
||||
let activeTab = 'usage';
|
||||
function refreshActiveTab() {
|
||||
if (activeTab === 'usage') return loadStats();
|
||||
if (activeTab === 'gallery') return loadGallery();
|
||||
if (activeTab === 'providers') return loadProviders();
|
||||
}
|
||||
$$('nav button').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
$$('nav button').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
$$('main section').forEach((s) => s.classList.add('hidden'));
|
||||
$(`#tab-${btn.dataset.tab}`).classList.remove('hidden');
|
||||
if (btn.dataset.tab === 'usage') loadStats();
|
||||
if (btn.dataset.tab === 'gallery') loadGallery();
|
||||
if (btn.dataset.tab === 'providers') loadProviders();
|
||||
activeTab = btn.dataset.tab;
|
||||
refreshActiveTab();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auto-refresh (no page reload) ---
|
||||
let autoTimer = null;
|
||||
function isModalOpen() { return $('#modalRoot').innerHTML.trim() !== ''; }
|
||||
function setupAutoRefresh() {
|
||||
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
|
||||
const on = $('#autoRefresh').checked;
|
||||
localStorage.setItem('nexus-autorefresh', on ? '1' : '0');
|
||||
localStorage.setItem('nexus-autorefresh-int', $('#autoRefreshInterval').value);
|
||||
if (!on) return;
|
||||
const ms = parseInt($('#autoRefreshInterval').value, 10) || 10000;
|
||||
autoTimer = setInterval(() => {
|
||||
// Don't disrupt the user while a modal is open or an audio is playing.
|
||||
if (isModalOpen()) return;
|
||||
if (document.hidden) return;
|
||||
refreshActiveTab();
|
||||
}, ms);
|
||||
}
|
||||
$('#autoRefresh').checked = localStorage.getItem('nexus-autorefresh') === '1';
|
||||
$('#autoRefreshInterval').value = localStorage.getItem('nexus-autorefresh-int') || '10000';
|
||||
$('#autoRefresh').addEventListener('change', setupAutoRefresh);
|
||||
$('#autoRefreshInterval').addEventListener('change', setupAutoRefresh);
|
||||
setupAutoRefresh();
|
||||
|
||||
// --- Usage ---
|
||||
async function loadStats() {
|
||||
const range = $('#statsRange').value;
|
||||
@@ -98,19 +126,33 @@ async function loadGallery() {
|
||||
|
||||
$$('[data-view]').forEach((el) => el.onclick = () => openModal(el.dataset.view));
|
||||
$$('[data-del]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); delGeneration(b.dataset.del); });
|
||||
$$('[data-play]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); openModal(b.dataset.play); });
|
||||
}
|
||||
|
||||
function isAudio(it) { return it.capability === 'tts' || it.capability === 'music'; }
|
||||
function isText(it) { return it.capability === 'stt' || (it.mediaUrl && /\.md$/i.test(it.mediaUrl)) || it.capability === 'vision'; }
|
||||
|
||||
function renderTile(it) {
|
||||
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
|
||||
let media = '';
|
||||
if (it.mediaUrl) {
|
||||
if (it.capability === 'image') media = `<div class="thumb" data-view="${it.id}"><img src="${it.mediaUrl}" loading="lazy" /></div>`;
|
||||
else if (it.capability === 'tts' || it.capability === 'music') media = `<div style="padding:10px"><audio controls src="${it.mediaUrl}"></audio></div>`;
|
||||
else if (it.capability === 'video') media = `<div class="thumb" data-view="${it.id}"><video style="width:100%;height:150px;background:#000" src="${it.mediaUrl}"></video></div>`;
|
||||
else media = `<a href="${it.mediaUrl}" target="_blank" style="display:block;padding:20px;text-align:center">Открыть файл</a>`;
|
||||
if (it.capability === 'image') {
|
||||
media = `<div class="thumb" data-view="${it.id}"><img src="${it.mediaUrl}" loading="lazy" /></div>`;
|
||||
} else if (isAudio(it)) {
|
||||
media = `<div class="thumb audio-thumb" data-view="${it.id}">
|
||||
<button class="play-btn" data-play="${it.id}" data-src="${it.mediaUrl}" title="Прослушать">▶</button>
|
||||
</div>`;
|
||||
} else if (it.capability === 'video') {
|
||||
media = `<div class="thumb" data-view="${it.id}"><video style="width:100%;height:150px;background:#000" src="${it.mediaUrl}"></video></div>`;
|
||||
} else if (isText(it)) {
|
||||
media = `<div class="thumb text-thumb" data-view="${it.id}"><span class="doc-icon">📄</span></div>`;
|
||||
} else {
|
||||
media = `<a href="${it.mediaUrl}" target="_blank" style="display:block;padding:20px;text-align:center">Открыть файл</a>`;
|
||||
}
|
||||
} else {
|
||||
const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла';
|
||||
media = `<div style="height:150px;display:flex;align-items:center;justify-content:center" class="muted">${status}</div>`;
|
||||
media = `<div class="thumb text-thumb ${it.status === 'failed' ? 'err' : ''}" data-view="${it.id}">
|
||||
<span class="muted">${status}</span></div>`;
|
||||
}
|
||||
return `<div class="tile">
|
||||
<button class="del" data-del="${it.id}" title="Удалить">✕</button>
|
||||
@@ -143,10 +185,23 @@ async function openModal(id) {
|
||||
catch (e) { return toast('Ошибка: ' + e.message); }
|
||||
|
||||
let body = '';
|
||||
if (rec.mediaUrl && rec.capability === 'image') body = `<img src="${rec.mediaUrl}" />`;
|
||||
else if (rec.mediaUrl && rec.capability === 'video') body = `<video controls autoplay src="${rec.mediaUrl}"></video>`;
|
||||
else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) body = `<audio controls autoplay src="${rec.mediaUrl}"></audio>`;
|
||||
else body = `<div class="muted">Нет предпросмотра для этого типа.</div>`;
|
||||
if (rec.mediaUrl && rec.capability === 'image') {
|
||||
body = `<img src="${rec.mediaUrl}" />`;
|
||||
} else if (rec.mediaUrl && rec.capability === 'video') {
|
||||
body = `<video controls autoplay src="${rec.mediaUrl}"></video>`;
|
||||
} else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) {
|
||||
body = `<div class="audio-player">
|
||||
<div class="audio-visual">🎵</div>
|
||||
<audio controls autoplay src="${rec.mediaUrl}"></audio>
|
||||
</div>`;
|
||||
} else if (rec.mediaUrl && /\.md$/i.test(rec.mediaUrl)) {
|
||||
// Transcript / text document — fetch and render.
|
||||
let text = '';
|
||||
try { text = await (await fetch(rec.mediaUrl)).text(); } catch { text = '(не удалось загрузить текст)'; }
|
||||
body = `<div class="doc-view">${esc(text)}</div>`;
|
||||
} else {
|
||||
body = `<div class="muted">Нет предпросмотра для этого типа.</div>`;
|
||||
}
|
||||
|
||||
const kv = `
|
||||
<div class="kv">Модель: ${esc(rec.model)}</div>
|
||||
|
||||
Reference in New Issue
Block a user