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:
@@ -11,5 +11,6 @@ export * from './media/storage.js';
|
||||
export * from './media/content.js';
|
||||
export * from './providers/types.js';
|
||||
export * from './providers/registry.js';
|
||||
export * from './providers/model-catalog.js';
|
||||
export * from './providers/nordrouter/index.js';
|
||||
export * from './providers/local-stt/whisper.js';
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
import type { Capability, MediaProvider, ModelInfo } from './types.js';
|
||||
|
||||
const log = createLogger('model-catalog');
|
||||
|
||||
/** Map a NordRouter model `type` to our Capability. */
|
||||
export function typeToCapability(type?: string): Capability | undefined {
|
||||
switch ((type || '').toLowerCase()) {
|
||||
case 'audio':
|
||||
return 'tts';
|
||||
case 'image':
|
||||
return 'image';
|
||||
case 'video':
|
||||
return 'video';
|
||||
case 'music':
|
||||
return 'music';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
/** Exact match found. */
|
||||
exact: boolean;
|
||||
/** The chosen model id (may differ from the requested one when fuzzy-matched). */
|
||||
model: string;
|
||||
/** Human note explaining substitution, if any. */
|
||||
note?: string;
|
||||
/** Alternatives in the same category (for error messages / agent guidance). */
|
||||
suggestions?: Array<{ id: string; label?: string; estUsd?: number }>;
|
||||
}
|
||||
|
||||
export class ModelResolutionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly capability: Capability,
|
||||
public readonly suggestions: Array<{ id: string; label?: string; estUsd?: number }>
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ModelResolutionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize an id/label for fuzzy comparison. */
|
||||
function norm(s: string): string {
|
||||
return s.toLowerCase().replace(/^(image|video|audio|music)\//, '').replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
/** Length of the longest common subsequence. */
|
||||
function lcs(a: string, b: string): number {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
if (!m || !n) return 0;
|
||||
let prev = new Array(n + 1).fill(0);
|
||||
let cur = new Array(n + 1).fill(0);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
|
||||
}
|
||||
[prev, cur] = [cur, prev];
|
||||
}
|
||||
return prev[n];
|
||||
}
|
||||
|
||||
/** Cheap similarity in [0,1] combining token overlap and character-level LCS. */
|
||||
function similarity(a: string, b: string): number {
|
||||
const na = norm(a);
|
||||
const nb = norm(b);
|
||||
if (!na || !nb) return 0;
|
||||
if (na === nb) return 1;
|
||||
if (na.includes(nb) || nb.includes(na)) return 0.85;
|
||||
|
||||
// Token overlap on the original strings.
|
||||
const ta = new Set(a.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
||||
const tb = new Set(b.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
||||
let inter = 0;
|
||||
for (const t of ta) if (tb.has(t)) inter += 1;
|
||||
const tokenScore = ta.size && tb.size ? inter / Math.max(ta.size, tb.size) : 0;
|
||||
|
||||
// Character-level LCS on normalized strings (handles minor typos / punctuation).
|
||||
const charScore = lcs(na, nb) / Math.max(na.length, nb.length);
|
||||
|
||||
return Math.max(tokenScore, charScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caches a provider's model catalog and resolves (possibly misspelled) model ids
|
||||
* to the nearest valid model within the same capability, preferring the cheapest
|
||||
* when several are similar. Periodically refreshed in the background.
|
||||
*/
|
||||
export class ModelCatalog {
|
||||
private models: ModelInfo[] = [];
|
||||
private lastSync = 0;
|
||||
private syncing?: Promise<void>;
|
||||
private timer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly provider: MediaProvider,
|
||||
private readonly opts: { ttlMs?: number; minSimilarity?: number } = {}
|
||||
) {}
|
||||
|
||||
get ttlMs(): number {
|
||||
return this.opts.ttlMs ?? 10 * 60 * 1000;
|
||||
}
|
||||
get minSimilarity(): number {
|
||||
return this.opts.minSimilarity ?? 0.4;
|
||||
}
|
||||
|
||||
/** Start periodic background sync. Safe to call once. */
|
||||
startAutoSync(intervalMs = 30 * 60 * 1000): void {
|
||||
if (this.timer) return;
|
||||
// Fire once soon, then on interval. Unref so it never blocks process exit.
|
||||
this.timer = setInterval(() => void this.sync().catch(() => {}), intervalMs);
|
||||
if (typeof this.timer.unref === 'function') this.timer.unref();
|
||||
void this.sync().catch(() => {});
|
||||
}
|
||||
|
||||
stopAutoSync(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async sync(force = false): Promise<void> {
|
||||
if (!this.provider.listModels) return;
|
||||
if (!force && Date.now() - this.lastSync < this.ttlMs && this.models.length) return;
|
||||
if (this.syncing) return this.syncing;
|
||||
this.syncing = (async () => {
|
||||
try {
|
||||
const models = await this.provider.listModels!();
|
||||
this.models = models;
|
||||
this.lastSync = Date.now();
|
||||
log.debug(`synced ${models.length} models for provider "${this.provider.name}"`);
|
||||
} catch (err) {
|
||||
log.warn('model sync failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
this.syncing = undefined;
|
||||
}
|
||||
})();
|
||||
return this.syncing;
|
||||
}
|
||||
|
||||
/** All known models, syncing lazily if the cache is empty/stale. */
|
||||
async all(): Promise<ModelInfo[]> {
|
||||
await this.sync();
|
||||
return this.models;
|
||||
}
|
||||
|
||||
/** Models for a given capability. */
|
||||
async byCapability(capability: Capability): Promise<ModelInfo[]> {
|
||||
const all = await this.all();
|
||||
return all.filter((m) => typeToCapability(m.type) === capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a requested model id for a capability.
|
||||
* - Exact id match → return as-is.
|
||||
* - Otherwise pick the most similar model above `minSimilarity`; when several
|
||||
* are equally close, prefer the cheapest.
|
||||
* - If nothing is close enough, throw ModelResolutionError with suggestions.
|
||||
*/
|
||||
async resolve(requested: string, capability: Capability): Promise<ResolveResult> {
|
||||
const pool = await this.byCapability(capability);
|
||||
|
||||
// If we couldn't load a catalog at all, don't block the call — trust the id.
|
||||
if (!pool.length) {
|
||||
return { exact: true, model: requested, note: 'catalog unavailable; passing id through' };
|
||||
}
|
||||
|
||||
const exact = pool.find((m) => m.id === requested);
|
||||
if (exact) return { exact: true, model: exact.id };
|
||||
|
||||
const scored = pool
|
||||
.map((m) => ({ m, score: Math.max(similarity(requested, m.id), similarity(requested, m.label || '')) }))
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return (a.m.estUsd ?? Infinity) - (b.m.estUsd ?? Infinity); // tie-break: cheapest
|
||||
});
|
||||
|
||||
const best = scored[0];
|
||||
const suggestions = pool
|
||||
.slice()
|
||||
.sort((a, b) => (a.estUsd ?? Infinity) - (b.estUsd ?? Infinity))
|
||||
.slice(0, 10)
|
||||
.map((m) => ({ id: m.id, label: m.label, estUsd: m.estUsd }));
|
||||
|
||||
if (best && best.score >= this.minSimilarity) {
|
||||
return {
|
||||
exact: false,
|
||||
model: best.m.id,
|
||||
note: `Model "${requested}" not found; using closest match "${best.m.id}"${
|
||||
best.m.estUsd != null ? ` (est $${best.m.estUsd})` : ''
|
||||
}.`,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
throw new ModelResolutionError(
|
||||
`No model matching "${requested}" for capability "${capability}". Ask the user to pick one of the available models.`,
|
||||
capability,
|
||||
suggestions
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user