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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user