Add admin web UI (Phase 2): spend dashboard, media gallery, provider management

Fastify + single-page frontend on shared @nexusai/core. Reads the same SQLite
journal and media dir. Features: usage stats (by provider/capability/day),
content gallery (image/audio/video with local file serving + path-traversal
guard), provider CRUD with encrypted keys (masked in UI), enable/disable,
set-default, and real key validation via zero-cost /media/models call.
Adds Journal.getById and NordRouterMediaProvider.checkKey to core.
This commit is contained in:
OpenCode
2026-08-17 17:22:07 +07:00
parent 62c692c5a1
commit 679e56424b
13 changed files with 1495 additions and 5 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@nexusai/admin",
"version": "0.1.0",
"description": "NexusAI admin: web dashboard for content gallery, spend tracking and provider/key management",
"main": "dist/server.js",
"type": "commonjs",
"bin": {
"nexusai-admin": "dist/server.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/server.js"
},
"dependencies": {
"@fastify/static": "^7.0.0",
"@nexusai/core": "*",
"fastify": "^4.28.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"typescript": "^5.8.0"
}
}
+169
View File
@@ -0,0 +1,169 @@
'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); }
// --- Tabs ---
$$('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();
});
});
// --- Usage ---
async function loadStats() {
const range = $('#statsRange').value;
const q = range ? `?sinceDays=${range}` : '';
const s = await api(`/api/stats${q}`);
$('#statCards').innerHTML = `
<div class="stat"><div class="n">$${usd(s.totalCostUsd)}</div><div class="l">Всего потрачено</div></div>
<div class="stat"><div class="n">${s.totalCount}</div><div class="l">Всего операций</div></div>
<div class="stat"><div class="n">${s.byProvider.length}</div><div class="l">Провайдеров</div></div>
<div class="stat"><div class="n">${s.byCapability.length}</div><div class="l">Типов операций</div></div>`;
const fill = (id, rows, key) => {
$(`#${id} tbody`).innerHTML = rows.map((r) =>
`<tr><td>${r[key]}</td><td>${r.count}</td><td>$${usd(r.costUsd)}</td></tr>`).join('')
|| '<tr><td colspan="3" class="muted">Нет данных</td></tr>';
};
fill('byProvider', s.byProvider, 'provider');
fill('byCapability', s.byCapability, 'capability');
fill('byDay', s.byDay, 'day');
}
$('#statsRange').addEventListener('change', loadStats);
// --- Gallery ---
async function loadGallery() {
const cap = $('#galleryFilter').value;
const q = cap ? `?capability=${cap}&limit=60` : '?limit=60';
const { items } = await api(`/api/generations${q}`);
$('#gallery').innerHTML = items.map(renderTile).join('')
|| '<div class="muted">Пока нет сгенерированного контента.</div>';
}
function renderTile(it) {
const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : '';
let media = '';
if (it.mediaUrl) {
if (it.capability === 'image') media = `<img src="${it.mediaUrl}" loading="lazy" />`;
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 = `<video controls style="width:100%;height:150px;background:#000" src="${it.mediaUrl}"></video>`;
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>`;
}
return `<div class="tile">${media}<div class="meta">
<div>${it.capability} · <span class="m">${cost}</span></div>
<div class="m">${it.model}</div>
<div class="m">${new Date(it.createdAt).toLocaleString()}</div>
</div></div>`;
}
$('#galleryReload').addEventListener('click', loadGallery);
$('#galleryFilter').addEventListener('change', loadGallery);
// --- 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) => `<option value="${t}">${t}</option>`).join('');
}
$('#providersTable tbody').innerHTML = providers.map((p) => {
const status = [
p.isDefault ? '<span class="pill def">default</span>' : '',
p.enabled ? '<span class="pill on">включён</span>' : '<span class="pill off">выключен</span>',
].join(' ');
return `<tr>
<td>${p.name}</td>
<td>${p.type}</td>
<td>${p.hasKey ? `<code>${p.keyHint || '••••'}</code>` : '<span class="muted">нет</span>'}</td>
<td>${status}</td>
<td>
<button class="ghost" data-check="${p.name}">Проверить</button>
<button class="ghost" data-toggle="${p.name}" data-en="${p.enabled}">${p.enabled ? 'Выключить' : 'Включить'}</button>
<button class="ghost" data-default="${p.name}">Сделать default</button>
<button class="ghost danger" data-del="${p.name}">Удалить</button>
</td>
</tr>`;
}).join('') || '<tr><td colspan="5" class="muted">Нет провайдеров. Добавьте выше.</td></tr>';
$$('[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]').forEach((b) => b.onclick = () => delProvider(b.dataset.del));
}
$('#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();
+148
View File
@@ -0,0 +1,148 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NexusAI Admin</title>
<style>
:root {
--bg: #0f1115; --panel: #171a21; --panel2: #1f242e; --line: #2a2f3a;
--text: #e6e9ef; --muted: #9aa4b2; --accent: #4f8cff; --ok: #35c46a;
--warn: #f0b429; --err: #f26d6d;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { padding: 14px 20px; border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 16px; }
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
.badge { font-size: 12px; color: var(--muted); }
nav { display: flex; gap: 6px; padding: 12px 20px 0; }
nav button { background: var(--panel); color: var(--muted); border: 1px solid var(--line);
padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; }
nav button.active { background: var(--panel2); color: var(--text); border-bottom-color: var(--panel2); }
main { padding: 20px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 16px; margin-bottom: 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.stat { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
.stat .n { font-size: 22px; font-weight: 700; }
.stat .l { color: var(--muted); font-size: 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 13px; }
th { color: var(--muted); font-weight: 500; }
input, select { background: var(--panel2); color: var(--text); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 10px; font: inherit; width: 100%; }
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
button.act { background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: 8px 14px; cursor: pointer; font: inherit; }
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line);
border-radius: 8px; padding: 6px 10px; cursor: pointer; }
button.danger { color: var(--err); border-color: var(--err); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; }
.row > div { flex: 1; min-width: 140px; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
.pill.on { background: rgba(53,196,106,.15); color: var(--ok); }
.pill.off { background: rgba(242,109,109,.15); color: var(--err); }
.pill.def { background: rgba(79,140,255,.15); color: var(--accent); }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.tile { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.tile img { width: 100%; height: 150px; object-fit: cover; display: block; background: #000; }
.tile .meta { padding: 8px 10px; font-size: 12px; }
.tile .meta .m { color: var(--muted); }
.muted { color: var(--muted); }
.hidden { display: none; }
.toast { position: fixed; right: 16px; bottom: 16px; background: var(--panel2);
border: 1px solid var(--line); padding: 10px 14px; border-radius: 8px; }
audio { width: 100%; }
code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; }
</style>
</head>
<body>
<header>
<h1>NexusAI Admin</h1>
<span class="badge" id="cryptoBadge"></span>
</header>
<nav>
<button data-tab="usage" class="active">Потребление</button>
<button data-tab="gallery">Галерея</button>
<button data-tab="providers">Провайдеры</button>
</nav>
<main>
<section id="tab-usage">
<div class="card">
<div class="row" style="margin-bottom:12px">
<div style="max-width:200px">
<label>Период</label>
<select id="statsRange">
<option value="">Всё время</option>
<option value="1">1 день</option>
<option value="7" selected>7 дней</option>
<option value="30">30 дней</option>
</select>
</div>
</div>
<div class="grid" id="statCards"></div>
</div>
<div class="card">
<h3 style="margin-top:0">По провайдерам</h3>
<table id="byProvider"><thead><tr><th>Провайдер</th><th>Кол-во</th><th>Стоимость, $</th></tr></thead><tbody></tbody></table>
</div>
<div class="card">
<h3 style="margin-top:0">По возможностям</h3>
<table id="byCapability"><thead><tr><th>Возможность</th><th>Кол-во</th><th>Стоимость, $</th></tr></thead><tbody></tbody></table>
</div>
<div class="card">
<h3 style="margin-top:0">По дням</h3>
<table id="byDay"><thead><tr><th>День</th><th>Кол-во</th><th>Стоимость, $</th></tr></thead><tbody></tbody></table>
</div>
</section>
<section id="tab-gallery" class="hidden">
<div class="card">
<div class="row">
<div style="max-width:220px">
<label>Фильтр по возможности</label>
<select id="galleryFilter">
<option value="">Все</option>
<option value="image">image</option>
<option value="tts">tts</option>
<option value="music">music</option>
<option value="video">video</option>
<option value="stt">stt</option>
<option value="search">search</option>
</select>
</div>
<div style="max-width:120px"><label>&nbsp;</label><button class="ghost" id="galleryReload">Обновить</button></div>
</div>
</div>
<div class="gallery" id="gallery"></div>
</section>
<section id="tab-providers" class="hidden">
<div class="card">
<h3 style="margin-top:0">Добавить / обновить провайдера</h3>
<div class="row">
<div><label>Имя</label><input id="pName" placeholder="nordrouter" /></div>
<div><label>Тип</label><select id="pType"></select></div>
<div><label>Base URL (опц.)</label><input id="pBaseUrl" placeholder="https://nordrouter.com" /></div>
</div>
<div class="row">
<div><label>API ключ (опц., шифруется)</label><input id="pKey" type="password" placeholder="sk-..." /></div>
<div style="max-width:140px"><label>По умолчанию</label><select id="pDefault"><option value="false">Нет</option><option value="true">Да</option></select></div>
<div style="max-width:140px"><label>Включён</label><select id="pEnabled"><option value="true">Да</option><option value="false">Нет</option></select></div>
</div>
<div style="margin-top:12px"><button class="act" id="pSave">Сохранить</button></div>
</div>
<div class="card">
<h3 style="margin-top:0">Провайдеры</h3>
<table id="providersTable">
<thead><tr><th>Имя</th><th>Тип</th><th>Ключ</th><th>Статус</th><th>Действия</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
import {
Journal,
KeyCipher,
ProvidersStore,
loadConfig,
createLogger,
type NexusConfig,
} from '@nexusai/core';
const log = createLogger('admin:context');
export interface AdminContext {
config: NexusConfig;
journal: Journal;
store: ProvidersStore;
cipher: KeyCipher;
}
export function buildAdminContext(): AdminContext {
const config = loadConfig();
const journal = new Journal(config.dbPath);
const cipher = new KeyCipher(config.masterKey);
const store = new ProvidersStore(config.providersDbPath, cipher);
if (!cipher.enabled) {
log.warn('NEXUS_MASTER_KEY not set — provider keys cannot be added/decrypted from the admin');
}
return { config, journal, store, cipher };
}
+165
View File
@@ -0,0 +1,165 @@
import * as fs from 'fs';
import * as path from 'path';
import type { FastifyInstance } from 'fastify';
import {
NexusError,
NordRouterClient,
NordRouterMediaProvider,
MediaStorage,
createLogger,
} from '@nexusai/core';
import type { AdminContext } from './context.js';
const log = createLogger('admin:api');
const KNOWN_TYPES = ['nordrouter'];
function errorReply(err: unknown): { statusCode: number; body: { error: string; code: string } } {
const e = NexusError.from(err);
const statusCode =
e.code === 'NOT_FOUND' ? 404 : e.code === 'CONFIG' || e.code === 'BAD_REQUEST' ? 400 : 500;
return { statusCode, body: { error: e.message, code: e.code } };
}
export function registerRoutes(app: FastifyInstance, ctx: AdminContext): void {
// --- Providers ---
app.get('/api/providers', async () => {
return { providers: ctx.store.listViews(), knownTypes: KNOWN_TYPES, cryptoEnabled: ctx.cipher.enabled };
});
app.post('/api/providers', async (req, reply) => {
try {
const body = req.body as {
name?: string;
type?: string;
baseUrl?: string;
apiKey?: string;
enabled?: boolean;
isDefault?: boolean;
};
if (!body?.name || !body?.type) {
throw new NexusError('BAD_REQUEST', 'name and type are required');
}
if (body.apiKey && !ctx.cipher.enabled) {
throw new NexusError('CONFIG', 'NEXUS_MASTER_KEY must be set to store an API key');
}
ctx.store.upsert({
name: body.name,
type: body.type,
baseUrl: body.baseUrl ?? null,
apiKey: body.apiKey,
enabled: body.enabled,
isDefault: body.isDefault,
});
return { ok: true, provider: ctx.store.get(body.name) };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
app.post('/api/providers/:name/enabled', async (req, reply) => {
try {
const { name } = req.params as { name: string };
const { enabled } = req.body as { enabled: boolean };
ctx.store.setEnabled(name, enabled);
return { ok: true };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
app.post('/api/providers/:name/default', async (req, reply) => {
try {
const { name } = req.params as { name: string };
ctx.store.setDefault(name);
return { ok: true };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
app.delete('/api/providers/:name', async (req, reply) => {
try {
const { name } = req.params as { name: string };
ctx.store.remove(name);
return { ok: true };
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
// Validate a key: uses provided key, or the stored one, or env fallback.
app.post('/api/providers/:name/check', async (req, reply) => {
try {
const { name } = req.params as { name: string };
const body = (req.body ?? {}) as { apiKey?: string; baseUrl?: string };
const row = ctx.store.get(name);
const type = row?.type ?? name;
if (!KNOWN_TYPES.includes(type)) {
throw new NexusError('UNSUPPORTED', `Key check not implemented for type "${type}"`);
}
const apiKey =
body.apiKey ||
ctx.store.getApiKey(name) ||
process.env[`${name.toUpperCase()}_API_KEY`];
if (!apiKey) throw new NexusError('CONFIG', 'No API key available to check');
const baseUrl = body.baseUrl || row?.baseUrl || 'https://nordrouter.com';
const client = new NordRouterClient(apiKey, baseUrl);
const provider = new NordRouterMediaProvider(client, { save: false });
const result = await provider.checkKey();
return result;
} catch (err) {
const { statusCode, body } = errorReply(err);
return reply.code(statusCode).send(body);
}
});
// --- Usage / stats ---
app.get('/api/stats', async (req) => {
const q = req.query as { sinceDays?: string };
const sinceDays = q.sinceDays ? Number.parseInt(q.sinceDays, 10) : undefined;
return ctx.journal.stats(Number.isFinite(sinceDays as number) ? sinceDays : undefined);
});
// --- Generations (gallery) ---
app.get('/api/generations', async (req) => {
const q = req.query as { limit?: string; offset?: string; capability?: string };
const limit = Math.min(200, q.limit ? Number.parseInt(q.limit, 10) : 50);
const offset = q.offset ? Number.parseInt(q.offset, 10) : 0;
const items = ctx.journal.list(limit, offset, q.capability);
// Add a media URL for locally-saved files that still exist.
const withUrls = items.map((it) => ({
...it,
mediaUrl: it.filePath && fs.existsSync(it.filePath) ? `/media/${path.basename(it.filePath)}` : null,
}));
return { items: withUrls, limit, offset };
});
app.get('/api/generations/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const rec = ctx.journal.getById(id);
if (!rec) return reply.code(404).send({ error: 'not found', code: 'NOT_FOUND' });
return {
...rec,
mediaUrl: rec.filePath && fs.existsSync(rec.filePath) ? `/media/${path.basename(rec.filePath)}` : null,
};
});
log.info('routes registered');
}
/** Guard: only serve files that live inside the media dir. */
export function isInsideMediaDir(mediaDir: string, requested: string): boolean {
const resolved = path.resolve(mediaDir, requested);
const rel = path.relative(path.resolve(mediaDir), resolved);
return !rel.startsWith('..') && !path.isAbsolute(rel);
}
export function ensureStorage(mediaDir: string): MediaStorage {
return new MediaStorage(mediaDir);
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
import * as fs from 'fs';
import * as path from 'path';
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import { createLogger } from '@nexusai/core';
import { buildAdminContext } from './context.js';
import { registerRoutes } from './routes.js';
const log = createLogger('admin');
async function main(): Promise<void> {
const ctx = buildAdminContext();
const app = Fastify({ logger: false });
registerRoutes(app, ctx);
// Serve generated media files from the configured media dir.
const mediaDir = path.resolve(ctx.config.mediaDir);
app.get('/media/:file', async (req, reply) => {
const { file } = req.params as { file: string };
// Prevent path traversal: only a bare filename is allowed.
if (file.includes('/') || file.includes('..') || path.isAbsolute(file)) {
return reply.code(400).send({ error: 'invalid file name' });
}
const full = path.join(mediaDir, file);
if (!full.startsWith(mediaDir + path.sep) || !fs.existsSync(full)) {
return reply.code(404).send({ error: 'not found' });
}
const stream = fs.createReadStream(full);
reply.header('Cache-Control', 'private, max-age=60');
return reply.send(stream);
});
// Serve the static frontend (built into ./public next to dist).
const publicDir = path.resolve(__dirname, '../public');
if (fs.existsSync(publicDir)) {
await app.register(fastifyStatic, { root: publicDir, prefix: '/' });
} else {
log.warn(`public dir not found at ${publicDir}; UI will not be served`);
}
const port = Number.parseInt(process.env.NEXUS_ADMIN_PORT || '4123', 10);
const host = process.env.NEXUS_ADMIN_HOST || '127.0.0.1';
try {
await app.listen({ port, host });
log.info(`NexusAI admin running at http://${host}:${port}`);
log.info(`media dir: ${mediaDir}`);
if (!ctx.cipher.enabled) {
log.warn('crypto disabled (NEXUS_MASTER_KEY unset): cannot add/decrypt provider keys');
}
} catch (err) {
log.error('failed to start admin', err instanceof Error ? err.message : String(err));
process.exit(1);
}
}
main();
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"declaration": false,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
+12
View File
@@ -79,6 +79,18 @@ export class Journal {
}
}
getById(id: string): GenerationRecord | undefined {
const row = this.db
.prepare(
`SELECT id, provider, capability, model, input_summary as inputSummary, status,
cost_usd as costUsd, file_path as filePath, result_url as resultUrl,
error, duration_ms as durationMs, created_at as createdAt
FROM generations WHERE id = ?`
)
.get(id);
return row as GenerationRecord | undefined;
}
list(limit = 50, offset = 0, capability?: string): GenerationRecord[] {
const where = capability ? 'WHERE capability = ?' : '';
const params = capability ? [capability, limit, offset] : [limit, offset];
@@ -147,6 +147,16 @@ export class NordRouterMediaProvider implements MediaProvider {
return { ref: res, url: res.url };
}
/** Validate the API key with a zero-cost authenticated call. */
async checkKey(): Promise<{ ok: boolean; modelsCount?: number; error?: string }> {
try {
const models = await this.listModels();
return { ok: true, modelsCount: models.length };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async listModels(): Promise<ModelInfo[]> {
const now = Date.now();
if (this.modelsCache && now - this.modelsCache.at < this.modelsTtlMs) {