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
+11 -1
View File
@@ -3,6 +3,7 @@ import {
KeyCipher,
KeyResolver,
MediaStorage,
ModelCatalog,
ProviderRegistry,
ProvidersStore,
createNordRouterProviders,
@@ -20,6 +21,8 @@ export interface AppContext {
storage: MediaStorage;
journal: Journal;
providersStore: ProvidersStore;
/** Model catalogs keyed by provider name (for sync + fuzzy resolution). */
catalogs: Map<string, ModelCatalog>;
/** Effective default provider name after resolving DB/env. */
defaultProvider: string;
}
@@ -47,6 +50,7 @@ export function buildContext(): AppContext {
const resolved = resolver.resolveAll(BUILTINS);
const defaultProvider = resolver.resolveDefault(resolved);
const catalogs = new Map<string, ModelCatalog>();
for (const p of resolved) {
if (!p.enabled) {
@@ -69,6 +73,12 @@ export function buildContext(): AppContext {
Object.defineProperty(nr.search, 'name', { value: p.name });
registry.registerMedia(nr.media);
registry.registerSearch(nr.search);
// Model catalog with periodic background sync + fuzzy resolution.
if (p.apiKey) {
const catalog = new ModelCatalog(nr.media);
catalog.startAutoSync();
catalogs.set(p.name, catalog);
}
log.info(`registered provider "${p.name}" (media + search, key: ${p.keySource})`);
} catch (err) {
log.warn(`failed to register "${p.name}"`, err instanceof Error ? err.message : String(err));
@@ -82,5 +92,5 @@ export function buildContext(): AppContext {
log.info('registered local STT provider');
log.info(`default provider: ${defaultProvider}`);
return { config, registry, storage, journal, providersStore, defaultProvider };
return { config, registry, storage, journal, providersStore, catalogs, defaultProvider };
}
+73 -7
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto';
import {
ModelResolutionError,
NexusError,
NordRouterClient,
buildEmbeddedContent,
@@ -8,6 +9,7 @@ import {
type MediaResult,
} from '@nexusai/core';
import * as fs from 'fs';
import * as path from 'path';
import type { AppContext } from './context.js';
import {
ImageSchema,
@@ -89,13 +91,36 @@ export class Handlers {
const started = Date.now();
const id = randomUUID();
// Resolve the model against the synced catalog: exact, nearest-cheapest, or
// a helpful error listing available models for the agent to relay.
let model = args.model;
let resolutionNote: string | undefined;
const catalog = this.ctx.catalogs.get(providerName);
if (catalog) {
try {
const r = await catalog.resolve(args.model, capability);
model = r.model;
resolutionNote = r.note;
} catch (err) {
if (err instanceof ModelResolutionError) {
throw new NexusError('BAD_REQUEST', err.message, {
capability: err.capability,
requested: args.model,
available: err.suggestions,
hint: 'Ask the user to choose one of the available models (sorted cheapest first).',
});
}
throw err;
}
}
try {
const result = await provider.generate(capability, { model: args.model, input: args.input });
const result = await provider.generate(capability, { model, input: args.input });
this.ctx.journal.record({
id,
provider: providerName,
capability,
model: args.model,
model,
inputSummary: summarize(args.input),
status: 'done',
costUsd: result.costUsd ?? null,
@@ -105,14 +130,14 @@ export class Handlers {
durationMs: Date.now() - started,
createdAt: new Date().toISOString(),
});
return this.mediaResult(result, args.output?.embed ?? false);
return this.mediaResult(result, args.output?.embed ?? false, resolutionNote);
} catch (err) {
const e = NexusError.from(err);
this.ctx.journal.record({
id,
provider: providerName,
capability,
model: args.model,
model,
inputSummary: summarize(args.input),
status: 'failed',
costUsd: null,
@@ -126,7 +151,7 @@ export class Handlers {
}
}
private mediaResult(result: MediaResult, embed: boolean): McpResult {
private mediaResult(result: MediaResult, embed: boolean, note?: string): McpResult {
const content: McpContent[] = [
jsonBlock({
provider: result.provider,
@@ -138,6 +163,7 @@ export class Handlers {
result_url: result.resultUrl,
result_url_2: result.resultUrl2,
cost_usd: result.costUsd,
...(note ? { note } : {}),
}),
];
@@ -159,6 +185,9 @@ export class Handlers {
const id = randomUUID();
try {
const res = await provider.transcribe(args.input);
// Persist the transcript as a Markdown file so it shows up in the gallery
// and can be read in the modal viewer.
const transcriptPath = this.writeTranscript(id, res, args.input);
this.ctx.journal.record({
id,
provider: providerName,
@@ -167,8 +196,7 @@ export class Handlers {
inputSummary: summarize({ ...args.input, audioBase64: args.input.audioBase64 ? '[base64]' : undefined }),
status: 'done',
costUsd: 0,
// STT does not produce a local media file; the source audio path stays in inputSummary.
filePath: null,
filePath: transcriptPath,
resultUrl: null,
error: null,
durationMs: Date.now() - started,
@@ -205,6 +233,44 @@ export class Handlers {
}
}
/** Write the STT transcript to a Markdown file in the media dir. Returns the path or null. */
private writeTranscript(
id: string,
res: { text: string; language?: string; durationSec?: number; segments?: Array<{ start: number; end: number; text: string }> },
input: { audioPath?: string; audioUrl?: string; model?: string }
): string | null {
try {
const source = input.audioPath || input.audioUrl || '(inline audio)';
const lines: string[] = [
'# Транскрипция',
'',
`- **Источник:** ${source}`,
`- **Язык:** ${res.language ?? 'auto'}`,
`- **Длительность:** ${res.durationSec != null ? res.durationSec + ' сек' : '—'}`,
`- **Модель:** ${input.model || 'default'}`,
`- **Создано:** ${new Date().toISOString()}`,
'',
'## Текст',
'',
res.text || '_(пусто)_',
];
if (res.segments?.length) {
lines.push('', '## Сегменты', '');
for (const s of res.segments) {
lines.push(`- \`[${s.start.toFixed(2)}${s.end.toFixed(2)}]\` ${s.text.trim()}`);
}
}
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const filePath = path.join(this.ctx.config.mediaDir, `transcript-${stamp}-${id.slice(0, 8)}.md`);
fs.mkdirSync(this.ctx.config.mediaDir, { recursive: true });
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
return filePath;
} catch (err) {
log.warn('failed to write transcript', err instanceof Error ? err.message : String(err));
return null;
}
}
private async search(args: {
provider?: string;
query: string;