From d6dbb2cf6641449f8a7cfdccd5cee1a1989bb581 Mon Sep 17 00:00:00 2001 From: Dawid Wenderski Date: Thu, 23 Jul 2026 16:36:07 +0200 Subject: [PATCH 1/3] DEV-1890 UI: transcription ts_level/diarize/metadata + JSON vs TXT results The transcription form only had include_ts, so the v2 parameters added alongside whisper-ct2 were unreachable from the tester, and a structured result was just an opaque download link. Form (all four /audio/transcriptions endpoints, via a shared builder): - ts_level, diarize, lang, and include_metadata (URL sources only -- the API returns null metadata for an uploaded file) - capability-gated rather than hardcoded: ts_level appears only for models publishing info.limits.timestamp_levels and takes its options from that list, diarize only for info.features.supports_diarization. Two new registry hooks carry this: optionsFromModel and visibleFromModel. - ts_level is clamped to the selected model's list, so switching models can't leave a value the API rejects - visibleWhen now accepts an array of conditions (OR) and compares on the string form, so it works for booleans: ts_level shows for include_ts OR diarize. Hidden fields stay out of the payload, price calc included. Results: the shape is the model's choice, reported as `structured` on the job status. The transcript strip shows JSON/TXT, language, delivered ts_level and whether speakers came back; expanding renders plain text as text, and a structured result as segments with ms times, coloured speakers, avg_logprob and per-word spans. Source metadata renders as a key/value block, generically, so new fields need no code change. Result files are fetched through a new /api/result -- result URLs are presigned and unreachable from the browser -- and it names the host on a connection failure, since an internal storage hostname that only resolves inside the API's network is otherwise a bare "fetch failed". Layout: the toggles move from the right column to the centre one. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/result/route.ts | 57 ++++ src/components/EndpointForm.tsx | 129 +++++++-- src/components/jobs/JobRow.tsx | 5 + src/components/jobs/JobTranscription.tsx | 342 +++++++++++++++++++++++ src/lib/endpoint-registry.ts | 104 +++++-- src/lib/format-utils.ts | 57 +++- src/lib/types.ts | 73 ++++- 7 files changed, 707 insertions(+), 60 deletions(-) create mode 100644 src/app/api/result/route.ts create mode 100644 src/components/jobs/JobTranscription.tsx diff --git a/src/app/api/result/route.ts b/src/app/api/result/route.ts new file mode 100644 index 0000000..14fed30 --- /dev/null +++ b/src/app/api/result/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; + +// Transcription results are text/JSON files on (often internal or presigned) +// storage that the browser cannot fetch directly, so the preview goes through +// the server like every other deAPI call. Capped so a huge transcript can never +// blow up the UI. +const MAX_BYTES = 4 * 1024 * 1024; + +// GET /api/result?url=... — fetch a text/JSON result file for in-app preview. +export async function GET(request: Request) { + const url = new URL(request.url).searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); + } + + let target: URL; + try { + target = new URL(url); + } catch { + return NextResponse.json({ error: 'Invalid url parameter' }, { status: 400 }); + } + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return NextResponse.json({ error: 'Only http(s) URLs are supported' }, { status: 400 }); + } + + try { + const response = await fetch(target, { cache: 'no-store' }); + if (!response.ok) { + return NextResponse.json( + { error: `Failed to fetch result: HTTP ${response.status}` }, + { status: 502 } + ); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const truncated = buffer.length > MAX_BYTES; + + return NextResponse.json({ + contentType: response.headers.get('content-type') || '', + size: buffer.length, + truncated, + text: buffer.subarray(0, MAX_BYTES).toString('utf-8'), + }); + } catch (error) { + console.error('[deapi-tester] GET /api/result error:', error); + // fetch() reports connection problems as a bare "fetch failed"; name the host + // so an unreachable result URL (e.g. an internal storage hostname that only + // resolves inside the API's network) is obvious rather than mysterious. + const cause = error instanceof Error && error.cause instanceof Error ? error.cause.message : null; + const message = error instanceof Error ? error.message : 'Unknown error'; + return NextResponse.json( + { error: `${message} (${target.host})${cause ? ` — ${cause}` : ''}` }, + { status: 502 } + ); + } +} diff --git a/src/components/EndpointForm.tsx b/src/components/EndpointForm.tsx index 11bf862..5a90672 100644 --- a/src/components/EndpointForm.tsx +++ b/src/components/EndpointForm.tsx @@ -244,6 +244,30 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm }); }, [selectedModelSlug, selectedModel, resolveLangSlug, resolveVoiceSlug]); + // Keep model-driven selects (e.g. ts_level) on a value the selected model + // actually offers — switching to a model with a shorter list would otherwise + // keep a value the API rejects. + useEffect(() => { + const limits = (modelLimits ?? {}) as Record; + const modelDriven = endpoint.params.filter((p) => p.optionsFromModel); + if (modelDriven.length === 0) return; + + setValues((prev) => { + let changed = false; + const next = { ...prev }; + modelDriven.forEach((param) => { + const list = limits[param.optionsFromModel as string]; + if (!Array.isArray(list) || list.length === 0) return; + const allowed = list.map((v) => String(v)); + if (!allowed.includes(String(next[param.name] ?? ''))) { + next[param.name] = allowed[0]; + changed = true; + } + }); + return changed ? next : prev; + }); + }, [modelLimits, endpoint.params]); + // Apply a "duplicate request" prefill — load params from a history job into the form. // Declared after the init / auto-select / auto-default effects so it runs last and its // values win. Keyed on a one-shot nonce so it never re-applies on later re-renders. @@ -567,6 +591,20 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm return selectedModel.languages.map((l) => ({ value: l.slug, label: l.name })); } + // Options published by the model itself, e.g. `info.limits.timestamp_levels`. + const fromModel = endpoint.params.find((p) => p.name === paramName)?.optionsFromModel; + if (fromModel) { + const limits = (modelLimits ?? {}) as Record; + const list = limits[fromModel]; + if (Array.isArray(list) && list.length > 0) { + return list.map((v) => { + const value = String(v); + return { value, label: value.charAt(0).toUpperCase() + value.slice(1) }; + }); + } + return undefined; + } + if (paramName === 'voice' && selectedModel.languages) { const selectedLang = values['lang'] as string; // Also try matching by name (API defaults may use name instead of slug) @@ -584,24 +622,48 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm } return undefined; - }, [endpoint.id, endpoint.inferenceType, models, selectedModel, values, enhancementInferenceType]); + }, [endpoint.id, endpoint.inferenceType, endpoint.params, models, selectedModel, modelLimits, values, enhancementInferenceType]); + + // Whether the selected model publishes a given capability. The key is looked up + // in `info.features` first, then `info.limits`; an empty array counts as "not + // supported" (the API validates the field against the published list). + const modelPublishesCapability = useCallback((key: string): boolean => { + const features = (modelFeatures ?? {}) as Record; + if (key in features) return features[key] === true; + const limits = (modelLimits ?? {}) as Record; + if (key in limits) { + const value = limits[key]; + return Array.isArray(value) ? value.length > 0 : Boolean(value); + } + return false; + }, [modelFeatures, modelLimits]); - // Check if a field should be visible based on visibleWhen condition + // Check if a field should be visible: model capability gate first, then the + // value-based visibleWhen condition (an array of conditions means OR). const isFieldVisible = useCallback((param: EndpointParam): boolean => { - if (!param.visibleWhen) return true; - const currentValue = values[param.visibleWhen.field]; - if (param.visibleWhen.matchEmpty && (currentValue === null || currentValue === undefined || currentValue === '')) { - return true; + if (param.visibleFromModel && !modelPublishesCapability(param.visibleFromModel)) { + return false; } - return param.visibleWhen.values.includes(currentValue as string); - }, [values]); + if (!param.visibleWhen) return true; + + const conditions = Array.isArray(param.visibleWhen) ? param.visibleWhen : [param.visibleWhen]; + return conditions.some((condition) => { + const currentValue = values[condition.field]; + if (condition.matchEmpty && (currentValue === null || currentValue === undefined || currentValue === '')) { + return true; + } + // Booleans are stored as real booleans; compare on their string form so a + // condition can be written as values: ['true']. + return condition.values.includes(String(currentValue)); + }); + }, [values, modelPublishesCapability]); const buildFilteredValues = useCallback((): Record => { const filteredValues: Record = {}; Object.entries(values).forEach(([key, value]) => { - // Exclude hidden fields from payload + // Exclude hidden fields from payload (value-gated or model-capability-gated) const param = endpoint.params.find(p => p.name === key); - if (param?.visibleWhen && !isFieldVisible(param)) return; + if (param && !isFieldVisible(param)) return; if (value !== null) { // Split into array if arrayMode is on for this field @@ -734,7 +796,7 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm Object.entries(files).forEach(([key, fileOrFiles]) => { const param = endpoint.params.find((p) => p.name === key); - if (param?.visibleWhen && !isFieldVisible(param)) return; + if (param && !isFieldVisible(param)) return; if (Array.isArray(fileOrFiles)) { fileOrFiles.forEach((file) => formData.append(key, file)); } else { @@ -791,7 +853,7 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm Object.entries(files).forEach(([key, fileOrFiles]) => { const param = endpoint.params.find((p) => p.name === key); // Skip hidden file fields - if (param?.visibleWhen && !isFieldVisible(param)) return; + if (param && !isFieldVisible(param)) return; const isMultiMode = param?.multiFieldName && multiFileMode[key]; const fieldName = isMultiMode && param?.multiFieldName ? param.multiFieldName : key; @@ -982,8 +1044,8 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm )} - {/* Center: Params with defaults/limits */} - {compactParams.length > 0 && ( + {/* Center: Params with defaults/limits, plus toggles */} + {(compactParams.length > 0 || booleanParams.length > 0) && (
{compactParams.map((param) => { @@ -1019,6 +1081,29 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm ); })}
+ + {booleanParams.length > 0 && ( +
+ {booleanParams.map((param) => ( +
+ + +
+ ))} +
+ )}
)} @@ -1059,22 +1144,6 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm ))} - {booleanParams.map((param) => ( -
- - -
- ))} - {otherParams.length > 0 && (
diff --git a/src/components/jobs/JobRow.tsx b/src/components/jobs/JobRow.tsx index 4b23b65..154fe8f 100644 --- a/src/components/jobs/JobRow.tsx +++ b/src/components/jobs/JobRow.tsx @@ -6,6 +6,7 @@ import { Job, JsonValue } from '@/lib/types'; import { STATUS_BG_COLORS } from '@/lib/constants'; import { formatTime, formatCost, formatProgress, getResultType, getResultText } from '@/lib/format-utils'; import { useSettings } from '@/components/SettingsContext'; +import { JobTranscription } from '@/components/jobs/JobTranscription'; interface PollUpdate { timestamp: number; @@ -328,6 +329,10 @@ export function JobRow({ + {/* Transcription result — renders only for jobs deAPI reported a + `transcription` block for (self-hiding otherwise). */} + + {/* Expanded Raw Request & Response */} {isRawExpanded && (job.rawRequest || job.rawResponse || (showResponseHeaders && hasResponseHeaders)) && (
diff --git a/src/components/jobs/JobTranscription.tsx b/src/components/jobs/JobTranscription.tsx new file mode 100644 index 0000000..c66082b --- /dev/null +++ b/src/components/jobs/JobTranscription.tsx @@ -0,0 +1,342 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { ChevronRight, ClipboardCheck, ClipboardCopy, Loader2 } from 'lucide-react'; +import { AsrSegment, AsrTranscription, AsrWord, Job, JsonValue, TranscriptionMeta } from '@/lib/types'; +import { + formatFileSize, + formatTimestamp, + getSourceMetadata, + getTranscriptionMeta, +} from '@/lib/format-utils'; + +interface JobTranscriptionProps { + job: Job; + resultUrl: string | null; +} + +interface LoadedResult { + text: string; + size: number; + truncated: boolean; + parsed: AsrTranscription | null; +} + +// Speaker labels come back as SPEAKER_00, SPEAKER_01… — give each a stable colour +// so a diarized transcript is readable at a glance. +const SPEAKER_COLORS = [ + 'text-blue-400', + 'text-green-400', + 'text-purple-400', + 'text-orange-400', + 'text-pink-400', + 'text-teal-400', +]; + +function speakerColor(speaker: string): string { + let hash = 0; + for (let i = 0; i < speaker.length; i++) hash = (hash * 31 + speaker.charCodeAt(i)) >>> 0; + return SPEAKER_COLORS[hash % SPEAKER_COLORS.length]; +} + +// The structured result is a JSON object with `text` and (when timestamps were +// requested) `segments`. Anything else — plain transcripts, error pages — stays text. +function parseStructured(text: string): AsrTranscription | null { + try { + const value: unknown = JSON.parse(text); + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const obj = value as Record; + if (typeof obj.text !== 'string' && !Array.isArray(obj.segments)) return null; + return obj as AsrTranscription; + } catch { + return null; + } +} + +function MetaBadges({ meta }: { meta: TranscriptionMeta }) { + const tsLevel = meta.ts_level ?? 'none'; + return ( +
+ + {meta.structured ? 'JSON' : 'TXT'} + + {meta.language && ( + + {meta.language} + + )} + + ts: {tsLevel} + + {meta.diarization_available && ( + + speakers + + )} +
+ ); +} + +// Rendered generically: which fields a platform reports varies, and deAPI can +// grow the block without a change here. +function SourceMetadata({ metadata }: { metadata: Record }) { + return ( +
+ {Object.entries(metadata).map(([key, value]) => ( +
+ {key} + + {typeof value === 'object' ? JSON.stringify(value) : String(value)} + +
+ ))} +
+ ); +} + +function WordList({ words }: { words: AsrWord[] }) { + return ( +
+ {words.map((word, idx) => ( + + {word.word} + + ))} +
+ ); +} + +function SegmentRow({ segment }: { segment: AsrSegment }) { + const [showWords, setShowWords] = useState(false); + const hasWords = !!segment.words && segment.words.length > 0; + + return ( +
+
+ + {formatTimestamp(segment.start)} – {formatTimestamp(segment.end)} + + {segment.speaker && ( + + {segment.speaker} + + )} + {segment.text} + {hasWords && ( + + )} + {segment.avg_logprob !== undefined && ( + + {segment.avg_logprob.toFixed(2)} + + )} +
+ {showWords && hasWords && } +
+ ); +} + +/** + * Transcription result panel for a job. + * + * The shape of the result is decided by the model, not the request: deAPI + * reports it as `structured` on the job status, and the file behind + * `result_url` is either a JSON transcription object (rendered as segments / + * words / speakers) or plain text. Both are fetched through the server, since + * result URLs are presigned and often unreachable from the browser. + */ +export function JobTranscription({ job, resultUrl }: JobTranscriptionProps) { + const meta = getTranscriptionMeta(job); + const sourceMetadata = getSourceMetadata(job); + const [isExpanded, setIsExpanded] = useState(false); + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [showRaw, setShowRaw] = useState(false); + + const load = useCallback(async () => { + if (!resultUrl) return; + setIsLoading(true); + setError(null); + try { + const res = await fetch(`/api/result?url=${encodeURIComponent(resultUrl)}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load result'); + setResult({ + text: data.text, + size: data.size, + truncated: data.truncated, + parsed: parseStructured(data.text), + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load result'); + } finally { + setIsLoading(false); + } + }, [resultUrl]); + + if (!meta && !sourceMetadata) return null; + + const toggle = () => { + const next = !isExpanded; + setIsExpanded(next); + // Metadata is already in hand; only the result file needs fetching. + if (next && resultUrl && !result && !isLoading) load(); + }; + + const copy = () => { + if (!result) return; + const text = result.parsed?.text ?? result.text; + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const parsed = result?.parsed; + const segments = parsed?.segments ?? []; + + return ( +
+
+
+ + {meta && } + {sourceMetadata && ( + + metadata + + )} +
+ {result && ( + <> + + {formatFileSize(result.size)} + {result.truncated && ' (truncated)'} + + + + )} + {isLoading && } +
+ + {isExpanded && ( +
+ {sourceMetadata && } + + {error &&

{error}

} + + {result && !parsed && ( +
+                {result.text}
+              
+ )} + + {parsed && ( +
+
+ {parsed.language && language: {parsed.language}} + {parsed.language_probability !== undefined && ( + p: {parsed.language_probability} + )} + {parsed.ts_level && ts_level: {parsed.ts_level}} + segments: {segments.length} + +
+ + {showRaw ? ( +
+                    {result!.text}
+                  
+ ) : ( + <> + {parsed.text && ( +
+                        {parsed.text}
+                      
+ )} + {segments.length > 0 && ( +
+ {segments.map((segment, idx) => ( + + ))} +
+ )} + + )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/lib/endpoint-registry.ts b/src/lib/endpoint-registry.ts index d1a78d4..baf1c3c 100644 --- a/src/lib/endpoint-registry.ts +++ b/src/lib/endpoint-registry.ts @@ -156,6 +156,78 @@ const lorasParam = (): EndpointParam => ({ description: 'Array of LoRA adapters [{name, weight}]. Leave empty if not using.', }); +// Shared controls for every /audio/transcriptions endpoint. +// +// What a model can actually do is published by GET /models and the API +// validates against it, so these fields are capability-gated rather than +// hardcoded: `ts_level` appears only for models publishing +// `info.limits.timestamp_levels` (and takes its options from that list), +// `diarize` only for models with `info.features.supports_diarization`. +// A legacy model that publishes neither shows just Include Timestamps. +// +// `urlSource` adds Include Metadata, which only makes sense for URL sources — +// the API returns null metadata for an uploaded file. +const transcriptionParams = (opts?: { urlSource?: boolean }): EndpointParam[] => [ + { + name: 'include_ts', + label: 'Include Timestamps', + type: 'boolean', + required: false, + default: false, + description: 'Return times for each unit. Implied when Diarize is on.', + }, + { + name: 'ts_level', + label: 'Timestamp Level', + type: 'select', + required: false, + default: 'segment', + // Fallback list; replaced by the model's own `timestamp_levels` when published. + options: [ + { value: 'segment', label: 'Segment' }, + { value: 'word', label: 'Word' }, + { value: 'char', label: 'Char' }, + ], + optionsFromModel: 'timestamp_levels', + visibleFromModel: 'timestamp_levels', + visibleWhen: [ + { field: 'include_ts', values: ['true'] }, + { field: 'diarize', values: ['true'] }, + ], + description: 'Granularity of the timestamps. Sent only when timestamps are requested.', + }, + { + name: 'diarize', + label: 'Diarize (speakers)', + type: 'boolean', + required: false, + default: false, + visibleFromModel: 'supports_diarization', + description: 'Label each timed unit with a detected speaker. Implies timestamps.', + }, + { + name: 'lang', + label: 'Language', + type: 'text', + required: false, + placeholder: 'auto', + description: 'ISO code hint (e.g. en, pl). Blank or "auto" = auto-detect.', + }, + ...(opts?.urlSource + ? [ + { + name: 'include_metadata', + label: 'Include Metadata', + type: 'boolean' as const, + required: false, + default: false, + description: + 'Return source info (title, channel, uploader, upload date, view/like/comment counts) on the job status.', + }, + ] + : []), +]; + // ============================================================ // ENDPOINT DEFINITIONS // ============================================================ @@ -724,13 +796,7 @@ export const ENDPOINTS: EndpointDefinition[] = [ description: 'YouTube, X, or Twitch video URL', }, modelSelectParam(), - { - name: 'include_ts', - label: 'Include Timestamps', - type: 'boolean', - required: true, - default: false, - }, + ...transcriptionParams({ urlSource: true }), ], }, @@ -754,14 +820,8 @@ export const ENDPOINTS: EndpointDefinition[] = [ required: true, accept: 'video/*', }, - { - name: 'include_ts', - label: 'Include Timestamps', - type: 'boolean', - required: true, - default: false, - }, modelSelectParam(), + ...transcriptionParams(), ], }, @@ -787,13 +847,7 @@ export const ENDPOINTS: EndpointDefinition[] = [ description: 'X/Twitter Spaces URL', }, modelSelectParam(), - { - name: 'include_ts', - label: 'Include Timestamps', - type: 'boolean', - required: true, - default: false, - }, + ...transcriptionParams({ urlSource: true }), ], }, @@ -817,14 +871,8 @@ export const ENDPOINTS: EndpointDefinition[] = [ required: true, accept: 'audio/*', }, - { - name: 'include_ts', - label: 'Include Timestamps', - type: 'boolean', - required: true, - default: false, - }, modelSelectParam(), + ...transcriptionParams(), ], }, diff --git a/src/lib/format-utils.ts b/src/lib/format-utils.ts index 2563fb8..ef7d2f8 100644 --- a/src/lib/format-utils.ts +++ b/src/lib/format-utils.ts @@ -3,7 +3,7 @@ */ import { getEndpointByApiPath } from './endpoint-registry'; -import { Job, JsonValue } from './types'; +import { Job, JsonValue, TranscriptionMeta } from './types'; /** * Format ISO date string to HH:MM:SS time @@ -97,6 +97,61 @@ export function getResultType( } } +/** + * What a transcription job actually produced, as reported by deAPI under + * `data.transcription` on the job status. Present only for transcription jobs + * that reached "done"; null for everything else. + * + * `ts_level` is the granularity actually delivered, which can be coarser than + * the one requested (a language with no aligner degrades `word` to `segment`), + * and `structured` says whether the result file is a JSON transcription object + * or plain text — the model decides that, not the request. + */ +export function getTranscriptionMeta(job: Job): TranscriptionMeta | null { + const rr = job.rawResponse; + if (!rr || typeof rr !== 'object' || Array.isArray(rr)) return null; + + const data = (rr as Record).data; + if (!data || typeof data !== 'object' || Array.isArray(data)) return null; + + const meta = (data as Record).transcription; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null; + + return meta as unknown as TranscriptionMeta; +} + +/** + * Source metadata deAPI attaches to the job status when `include_metadata` was + * requested — title, channel, uploader, upload date and engagement counts. + * Null for uploaded files and for sources whose resolver reports nothing. + * Rendered generically (key/value), so platform-specific fields still show up. + */ +export function getSourceMetadata(job: Job): Record | null { + const rr = job.rawResponse; + if (!rr || typeof rr !== 'object' || Array.isArray(rr)) return null; + + const data = (rr as Record).data; + if (!data || typeof data !== 'object' || Array.isArray(data)) return null; + + const metadata = (data as Record).metadata; + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null; + + const entries = Object.entries(metadata as Record).filter( + ([, value]) => value !== null && value !== undefined && value !== '' + ); + return entries.length > 0 ? Object.fromEntries(entries) : null; +} + +/** + * Format seconds as M:SS.mmm — the precision the structured result carries. + */ +export function formatTimestamp(seconds: number): string { + if (!Number.isFinite(seconds)) return '—'; + const mins = Math.floor(seconds / 60); + const secs = seconds - mins * 60; + return `${mins}:${secs.toFixed(3).padStart(6, '0')}`; +} + /** * Extract a copyable text output from a job when the result is text rather than * a media file. Returns null when there is no text result. diff --git a/src/lib/types.ts b/src/lib/types.ts index 9be0418..1be8ce6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,13 @@ // Form parameter types export type ParamType = 'text' | 'textarea' | 'number' | 'select' | 'file' | 'boolean' | 'json' | 'lora-array'; +// Condition on another field's current value. An array of these means OR. +export interface VisibleWhen { + field: string; + values: string[]; + matchEmpty?: boolean; +} + export interface EndpointParam { name: string; label: string; @@ -19,8 +26,16 @@ export interface EndpointParam { multiFieldName?: string; // for file with multiple: alternative field name when in multi mode (e.g. "images") isPathParam?: boolean; // for params that go in URL path (e.g. /request-status/{request_id}) supportsArray?: boolean; // allows toggling between single value and array of values (one per line) - visibleWhen?: { field: string; values: string[]; matchEmpty?: boolean }; // conditional visibility based on another field's value + visibleWhen?: VisibleWhen | VisibleWhen[]; // conditional visibility based on other fields (array = OR) valueType?: 'number'; // for select fields: convert string value to number in payload + // Capability gating from /models. The key is looked up in the selected model's + // `info.features` first, then `info.limits`; the field is shown only when it is + // truthy (a non-empty array counts). Models that publish no such capability get + // the field hidden — which is exactly what the API validates against. + visibleFromModel?: string; + // Select options taken from the selected model's `info.limits[]` + // (an array of strings, e.g. `timestamp_levels`). + optionsFromModel?: string; } export interface EndpointDefinition { @@ -158,6 +173,9 @@ export interface ModelFeatures { supports_negative_prompt?: boolean; supports_last_frame?: boolean; supports_custom_output_size?: boolean; + // ASR capabilities (transcription models) + supports_timestamps?: boolean; + supports_diarization?: boolean; } export interface ModelLimits { @@ -183,6 +201,10 @@ export interface ModelLimits { min_scale?: number; max_scale?: number; max_video_duration_seconds?: number; + // ASR: timestamp granularities the model can deliver, e.g. ["segment", "word"] + timestamp_levels?: string[]; + // ASR: language codes the model accepts; absent means "any value accepted" + languages?: string[]; } export interface ModelDefaults { @@ -206,6 +228,55 @@ export interface ModelInfo { features?: ModelFeatures; } +// ── Transcription results ──────────────────────────────────── +// deAPI reports what a transcription job actually produced under +// `data.transcription` on GET /jobs/{id}. `structured` decides how to read the +// result file: true → JSON object with `segments`, false → plain text. +export type TsLevel = 'none' | 'segment' | 'word' | 'char'; + +export interface TranscriptionMeta { + language?: string | null; + ts_level?: TsLevel | null; + diarization_available?: boolean; + structured?: boolean; +} + +export interface AsrChar { + char: string; + start: number; + end: number; + score?: number; +} + +export interface AsrWord { + word: string; + start: number; + end: number; + score?: number; + speaker?: string; + chars?: AsrChar[]; +} + +export interface AsrSegment { + start: number; + end: number; + text: string; + avg_logprob?: number; + speaker?: string; + words?: AsrWord[]; +} + +// The JSON result file produced when `structured` is true. Self-contained: +// full text, language and (when timestamps were asked for) segments. +export interface AsrTranscription { + text?: string; + language?: string; + language_probability?: number; + ts_level?: TsLevel; + diarization_available?: boolean; + segments?: AsrSegment[]; +} + export interface DeApiModel { name: string; slug: string; From 97ed2c4d509d8e74ee35e131d7bdcbceceaa9676 Mon Sep 17 00:00:00 2001 From: Dawid Wenderski Date: Mon, 3 Aug 2026 15:55:03 +0200 Subject: [PATCH 2/3] fix: paginate /models proxy - per_page was ignored by deAPI deAPI ignores per_page and caps limit at 50 (default page size 25), so /api/models was silently returning only the first 25 models. Loop over ?limit=50&page=N until meta.last_page and return the merged list, with a 20-page safety cap. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/models/route.ts | 56 +++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index abfc4ca..69e62b0 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -4,6 +4,16 @@ import { loadConfig } from '@/lib/config'; // Force dynamic to prevent caching - config can change export const dynamic = 'force-dynamic'; +// deAPI paginates /models: default 25 per page, `limit` is capped at 50 server-side. +// Fetch every page so the models cache is always complete. +const PAGE_LIMIT = 50; +const MAX_PAGES = 20; + +interface ModelsPage { + data?: unknown[]; + meta?: { current_page?: number; last_page?: number; total?: number }; +} + // GET /api/models - Proxy to deAPI /models endpoint export async function GET() { try { @@ -16,24 +26,42 @@ export async function GET() { ); } - const url = `${config.apiUrl.replace(/\/$/, '')}/models?per_page=100`; - const response = await fetch(url, { - headers: { - 'Authorization': `Bearer ${config.apiToken}`, - 'Accept': 'application/json', - }, - }); + const baseUrl = `${config.apiUrl.replace(/\/$/, '')}/models`; + const models: unknown[] = []; + let lastMeta: ModelsPage['meta']; + let page = 1; + let lastPage = 1; - const data = await response.json(); + while (page <= lastPage && page <= MAX_PAGES) { + const url = `${baseUrl}?limit=${PAGE_LIMIT}&page=${page}`; + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${config.apiToken}`, + 'Accept': 'application/json', + }, + }); - if (!response.ok) { - return NextResponse.json( - { error: data.error || data.message || `HTTP ${response.status}` }, - { status: response.status } - ); + const data = await response.json(); + + if (!response.ok) { + return NextResponse.json( + { error: data.error || data.message || `HTTP ${response.status}` }, + { status: response.status } + ); + } + + const pageData = data as ModelsPage; + models.push(...(pageData.data ?? [])); + lastMeta = pageData.meta; + lastPage = pageData.meta?.last_page ?? 1; + page += 1; + } + + if (page > MAX_PAGES && page <= lastPage) { + console.warn(`[deapi-tester] /models pagination stopped at ${MAX_PAGES} pages (last_page=${lastPage})`); } - return NextResponse.json(data, { + return NextResponse.json({ data: models, meta: lastMeta }, { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate', }, From 0089374770ea229a77769dfaf3d69aed05af5e30 Mon Sep 17 00:00:00 2001 From: Dawid Wenderski Date: Fri, 7 Aug 2026 10:16:21 +0200 Subject: [PATCH 3/3] feat: refresh balance together with models from header button The header refresh button only reloaded /models. It now also calls refreshBalance(), spins while either request is in flight, and the tooltip reflects both actions. Co-Authored-By: Claude Opus 4.6 --- src/app/page.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 926258b..368ae1e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,7 +31,7 @@ interface ProxyResponse { export default function Home() { const { showError, showSuccess } = useToast(); - const { balance } = useBalance(); + const { balance, refreshBalance, isLoading: balanceLoading } = useBalance(); const { resolvedTheme, toggleTheme } = useTheme(); const { refreshModels, isLoading: modelsLoading } = useModelsContext(); const jobsPanelRef = useRef(null); @@ -143,14 +143,17 @@ export default function Home() {
)} - {/* Refresh models */} + {/* Refresh models + balance */} {/* Theme toggle */}