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
+1
View File
@@ -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
);
}
}