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