'use strict'; const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); function toast(msg) { const el = document.createElement('div'); el.className = 'toast'; el.textContent = msg; document.body.appendChild(el); setTimeout(() => el.remove(), 2600); } async function api(path, opts) { const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`); return data; } function usd(n) { return (n == null ? 0 : n).toFixed(4); } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } // --- Theme --- function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); const btn = $('#themeToggle'); btn.textContent = theme === 'dark' ? '🌙 Тёмная' : '☀️ Светлая'; localStorage.setItem('nexus-theme', theme); } $('#themeToggle').addEventListener('click', () => { const cur = document.documentElement.getAttribute('data-theme'); applyTheme(cur === 'dark' ? 'light' : 'dark'); }); 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'); 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; const q = range ? `?sinceDays=${range}` : ''; const s = await api(`/api/stats${q}`); $('#statCards').innerHTML = `
$${usd(s.totalCostUsd)}
Всего потрачено
${s.totalCount}
Всего операций
${s.byProvider.length}
Провайдеров
${s.byCapability.length}
Типов операций
`; const fill = (id, rows, key) => { $(`#${id} tbody`).innerHTML = rows.map((r) => `${esc(r[key])}${r.count}$${usd(r.costUsd)}`).join('') || 'Нет данных'; }; fill('byProvider', s.byProvider, 'provider'); fill('byCapability', s.byCapability, 'capability'); fill('byDay', s.byDay, 'day'); } $('#statsRange').addEventListener('change', loadStats); // --- Gallery (with pagination) --- let galleryOffset = 0; let galleryLastCount = 0; function pageSize() { return parseInt($('#pageSize').value, 10) || 50; } async function loadGallery() { const cap = $('#galleryFilter').value; const limit = pageSize(); const params = new URLSearchParams(); params.set('limit', String(limit)); params.set('offset', String(galleryOffset)); if (cap) params.set('capability', cap); const { items } = await api(`/api/generations?${params.toString()}`); galleryLastCount = items.length; $('#gallery').innerHTML = items.map(renderTile).join('') || '
Пока нет сгенерированного контента.
'; const page = Math.floor(galleryOffset / limit) + 1; $('#pageInfo').textContent = `Страница ${page} · показано ${items.length}`; $('#prevPage').disabled = galleryOffset === 0; $('#nextPage').disabled = items.length < limit; $$('[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 = `
`; } else if (isAudio(it)) { media = `
`; } else if (it.capability === 'video') { media = `
`; } else if (isText(it)) { media = `
📄
`; } else { media = `Открыть файл`; } } else { const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла'; media = `
${status}
`; } return `
${media}
${esc(it.capability)} · ${cost}
${esc(it.model)}
${new Date(it.createdAt).toLocaleString()}
`; } $('#galleryReload').addEventListener('click', () => { galleryOffset = 0; loadGallery(); }); $('#galleryFilter').addEventListener('change', () => { galleryOffset = 0; loadGallery(); }); $('#pageSize').addEventListener('change', () => { galleryOffset = 0; loadGallery(); }); $('#prevPage').addEventListener('click', () => { galleryOffset = Math.max(0, galleryOffset - pageSize()); loadGallery(); }); $('#nextPage').addEventListener('click', () => { if (galleryLastCount < pageSize()) return; galleryOffset += pageSize(); loadGallery(); }); // --- Modal viewer --- async function openModal(id) { let rec; try { rec = await api(`/api/generations/${encodeURIComponent(id)}`); } catch (e) { return toast('Ошибка: ' + e.message); } let body = ''; if (rec.mediaUrl && rec.capability === 'image') { body = ``; } else if (rec.mediaUrl && rec.capability === 'video') { body = ``; } else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) { body = `
🎵
`; } 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 = `
${esc(text)}
`; } else { body = `
Нет предпросмотра для этого типа.
`; } const kv = `
Модель: ${esc(rec.model)}
Провайдер: ${esc(rec.provider)} · Возможность: ${esc(rec.capability)}
Стоимость: ${rec.costUsd != null ? '$' + usd(rec.costUsd) : '—'}
Создано: ${new Date(rec.createdAt).toLocaleString()}
${rec.inputSummary ? `
Вход: ${esc(rec.inputSummary)}
` : ''} ${rec.error ? `
Ошибка: ${esc(rec.error)}
` : ''}`; $('#modalRoot').innerHTML = ` `; const close = () => { $('#modalRoot').innerHTML = ''; }; $('#modalClose').onclick = close; $('#modalBackdrop').onclick = (e) => { if (e.target.id === 'modalBackdrop') close(); }; document.addEventListener('keydown', function onEsc(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onEsc); } }); $('#modalDel').onclick = async () => { if (!confirm('Удалить этот контент?')) return; await delGeneration(id, true); close(); }; } async function delGeneration(id, silent) { if (!silent && !confirm('Удалить этот контент?')) return; try { await api(`/api/generations/${encodeURIComponent(id)}`, { method: 'DELETE' }); toast('Удалено'); loadGallery(); } catch (e) { toast('Ошибка: ' + e.message); } } // --- Providers --- let CRYPTO_ENABLED = true; async function loadProviders() { const { providers, knownTypes, cryptoEnabled } = await api('/api/providers'); CRYPTO_ENABLED = cryptoEnabled; $('#cryptoBadge').textContent = cryptoEnabled ? 'Шифрование ключей: включено' : '⚠ NEXUS_MASTER_KEY не задан — ключи из БД недоступны'; const typeSel = $('#pType'); if (!typeSel.options.length) { typeSel.innerHTML = knownTypes.map((t) => ``).join(''); } $('#providersTable tbody').innerHTML = providers.map((p) => { const status = [ p.isDefault ? 'default' : '', p.enabled ? 'включён' : 'выключен', ].join(' '); return ` ${esc(p.name)} ${esc(p.type)} ${p.hasKey ? `${esc(p.keyHint || '••••')}` : 'нет'} ${status} `; }).join('') || 'Нет провайдеров. Добавьте выше.'; $$('[data-check]').forEach((b) => b.onclick = () => checkProvider(b.dataset.check)); $$('[data-toggle]').forEach((b) => b.onclick = () => toggleProvider(b.dataset.toggle, b.dataset.en !== 'true')); $$('[data-default]').forEach((b) => b.onclick = () => setDefault(b.dataset.default)); $$('[data-del-provider]').forEach((b) => b.onclick = () => delProvider(b.dataset.delProvider)); } $('#pSave').addEventListener('click', async () => { try { const body = { name: $('#pName').value.trim(), type: $('#pType').value, baseUrl: $('#pBaseUrl').value.trim() || undefined, apiKey: $('#pKey').value.trim() || undefined, isDefault: $('#pDefault').value === 'true', enabled: $('#pEnabled').value === 'true', }; if (!body.name) return toast('Укажите имя'); if (body.apiKey && !CRYPTO_ENABLED) return toast('Нельзя сохранить ключ без NEXUS_MASTER_KEY'); await api('/api/providers', { method: 'POST', body: JSON.stringify(body) }); $('#pKey').value = ''; toast('Сохранено'); loadProviders(); } catch (e) { toast('Ошибка: ' + e.message); } }); async function checkProvider(name) { try { toast('Проверяю ключ...'); const r = await api(`/api/providers/${encodeURIComponent(name)}/check`, { method: 'POST', body: '{}' }); toast(r.ok ? `OK · моделей: ${r.modelsCount}` : `Ошибка ключа: ${r.error}`); } catch (e) { toast('Ошибка: ' + e.message); } } async function toggleProvider(name, enabled) { try { await api(`/api/providers/${encodeURIComponent(name)}/enabled`, { method: 'POST', body: JSON.stringify({ enabled }) }); loadProviders(); } catch (e) { toast('Ошибка: ' + e.message); } } async function setDefault(name) { try { await api(`/api/providers/${encodeURIComponent(name)}/default`, { method: 'POST', body: '{}' }); loadProviders(); } catch (e) { toast('Ошибка: ' + e.message); } } async function delProvider(name) { if (!confirm(`Удалить провайдера "${name}"?`)) return; try { await api(`/api/providers/${encodeURIComponent(name)}`, { method: 'DELETE' }); loadProviders(); } catch (e) { toast('Ошибка: ' + e.message); } } // initial loadStats();