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:
OpenCode
2026-08-18 12:25:42 +07:00
parent b2f57cc361
commit 8bd2e89907
8 changed files with 767 additions and 20 deletions
+67 -12
View File
@@ -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>
+29
View File
@@ -85,6 +85,26 @@
.modal-body img, .modal-body video { max-width: 88vw; max-height: 72vh; display: block; margin: 0 auto; }
.modal-body audio { width: 60vw; max-width: 600px; }
.kv { color: var(--muted); font-size: 12px; margin-top: 10px; word-break: break-all; }
/* Auto-refresh control */
.autorefresh { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
.autorefresh select { width: auto; padding: 4px 6px; }
.autorefresh input[type="checkbox"] { width: auto; }
/* Audio card thumb + play button */
.audio-thumb, .text-thumb { height: 150px; display: flex; align-items: center; justify-content: center;
cursor: pointer; background: linear-gradient(135deg, var(--panel2), var(--panel)); }
.text-thumb.err { background: rgba(242,109,109,.08); }
.play-btn { width: 56px; height: 56px; border-radius: 50%; border: none; cursor: pointer;
background: var(--accent); color: #fff; font-size: 20px; line-height: 1;
box-shadow: 0 6px 18px var(--shadow); transition: transform .1s; }
.play-btn:hover { transform: scale(1.08); }
.doc-icon { font-size: 42px; opacity: .8; }
/* Modal audio player */
.audio-player { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 20px; }
.audio-visual { font-size: 64px; }
/* Transcript / doc view */
.doc-view { white-space: pre-wrap; word-break: break-word; font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 16px;
max-width: 80vw; max-height: 70vh; overflow: auto; text-align: left; }
</style>
</head>
<body>
@@ -92,6 +112,14 @@
<h1>NexusAI Admin</h1>
<span class="badge" id="cryptoBadge"></span>
<span class="spacer"></span>
<label class="autorefresh" title="Автообновление активной вкладки">
<input type="checkbox" id="autoRefresh" /> Авто-обновление
<select id="autoRefreshInterval">
<option value="5000">5с</option>
<option value="10000" selected>10с</option>
<option value="30000">30с</option>
</select>
</label>
<button id="themeToggle" title="Переключить тему">🌙 Тёмная</button>
</header>
<nav>
@@ -141,6 +169,7 @@
<option value="music">music</option>
<option value="video">video</option>
<option value="stt">stt</option>
<option value="vision">vision</option>
<option value="search">search</option>
</select>
</div>