Files
9router/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js
T
fasilu bb3cb43e09 fix(dashboard): clamp API key mask length for short keys
"•".repeat(apiKey.length - 8) threw RangeError when the key was
shorter than 8 chars, crashing the media-provider detail page.
2026-08-28 16:53:14 +07:00

291 lines
13 KiB
JavaScript

"use client";
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { getProviderAlias } from "@/shared/constants/providers";
import { getModelKind } from "@/shared/constants/models";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { Row } from "./exampleShared";
export function SttExampleCard({ providerId }) {
const providerAlias = getProviderAlias(providerId);
const builtinSttModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "stt");
const [customSttModels, setCustomSttModels] = useState([]);
const sttModels = [...builtinSttModels, ...customSttModels];
const [selectedModel, setSelectedModel] = useState(builtinSttModels[0]?.id ?? "");
const selectedModelObj = sttModels.find((m) => m.id === selectedModel);
const allowedParams = Array.isArray(selectedModelObj?.params) ? selectedModelObj.params : [];
const [audioFile, setAudioFile] = useState(null);
const [language, setLanguage] = useState("");
const [prompt, setPrompt] = useState("");
const [responseFormat, setResponseFormat] = useState("json");
const [temperature, setTemperature] = useState("");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [result, setResult] = useState(null);
const [latency, setLatency] = useState(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
useEffect(() => {
setLocalEndpoint(window.location.origin);
fetch("/api/keys")
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
.catch(() => {});
const loadCustom = () => {
fetch("/api/models/custom", { cache: "no-store" })
.then((r) => r.json())
.then((d) => {
const list = (d.models || []).filter((m) => getModelKind(m) === "stt" && m.providerAlias === providerAlias);
setCustomSttModels(list);
})
.catch(() => {});
};
loadCustom();
window.addEventListener("focus", loadCustom);
window.addEventListener("customModelChanged", loadCustom);
return () => {
window.removeEventListener("focus", loadCustom);
window.removeEventListener("customModelChanged", loadCustom);
};
}, [providerAlias]);
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
-F "file=@${audioFile?.name || "audio.mp3"}" \\
-F "model=${modelFull}"${allowedParams.includes("language") && language ? ` \\\n -F "language=${language}"` : ""}${allowedParams.includes("response_format") ? ` \\\n -F "response_format=${responseFormat}"` : ""}${allowedParams.includes("temperature") && temperature ? ` \\\n -F "temperature=${temperature}"` : ""}${allowedParams.includes("prompt") && prompt ? ` \\\n -F "prompt=${prompt}"` : ""}`;
const handleRun = async () => {
if (!audioFile || !modelFull) return;
setRunning(true);
setError("");
setResult(null);
const start = Date.now();
try {
const fd = new FormData();
fd.append("file", audioFile);
fd.append("model", modelFull);
if (allowedParams.includes("language") && language) fd.append("language", language);
if (allowedParams.includes("response_format")) fd.append("response_format", responseFormat);
if (allowedParams.includes("temperature") && temperature) fd.append("temperature", temperature);
if (allowedParams.includes("prompt") && prompt) fd.append("prompt", prompt);
const headers = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", headers, body: fd });
setLatency(Date.now() - start);
const ct = res.headers.get("content-type") || "";
const data = ct.includes("application/json") ? await res.json() : await res.text();
if (!res.ok) {
setError(data?.error?.message || data?.error || data || `HTTP ${res.status}`);
return;
}
setResult(data);
} catch (e) {
setError(e.message || "Network error");
} finally {
setRunning(false);
}
};
const resultStr = typeof result === "string" ? result : (result ? JSON.stringify(result, null, 2) : `{\n "text": "Hello world..."\n}`);
return (
<Card>
<h2 className="text-lg font-semibold mb-4">Example</h2>
<div className="flex flex-col gap-2.5">
{/* Model */}
{sttModels.length > 0 ? (
<Row label="Model">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
{sttModels.map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
</Row>
) : (
<Row label="Model">
<input
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="Enter model id"
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
/>
</Row>
)}
{/* Endpoint */}
<Row label="Endpoint">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
{endpoint}/v1/audio/transcriptions
</span>
{tunnelEndpoint && (
<button
onClick={() => setUseTunnel((v) => !v)}
title={useTunnel ? "Using tunnel" : "Using local"}
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
}`}
>
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
Tunnel
</button>
)}
</div>
</Row>
{/* API Key */}
<Row label="API Key">
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
{apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, Math.max(0, apiKey.length - 8)))}` : <span className="text-text-muted italic">No key configured</span>}
</span>
</Row>
{/* Audio file */}
<Row label="Audio File">
<div className="flex flex-col gap-2">
<input
type="file"
accept="audio/*,video/mp4,.m4a,.mp3,.wav,.ogg,.flac,.webm,.opus"
onChange={(e) => setAudioFile(e.target.files?.[0] || null)}
className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer"
/>
{audioFile && (
<span className="text-xs text-text-muted font-mono">
{audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB
</span>
)}
</div>
</Row>
{/* Language (if model supports) */}
{allowedParams.includes("language") && (
<Row label="Language">
<input
value={language}
onChange={(e) => setLanguage(e.target.value)}
placeholder="e.g. en, vi, ja (auto-detect if empty)"
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
/>
</Row>
)}
{/* Prompt (if model supports) */}
{allowedParams.includes("prompt") && (
<Row label="Prompt">
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="optional context to improve accuracy"
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</Row>
)}
{/* Temperature (if model supports) */}
{allowedParams.includes("temperature") && (
<Row label="Temperature">
<input
type="number"
step="0.1"
min="0"
max="1"
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
placeholder="0 - 1 (default 0)"
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</Row>
)}
{/* Response format (if model supports) */}
{allowedParams.includes("response_format") && (
<Row label="Response Format">
<select
value={responseFormat}
onChange={(e) => setResponseFormat(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="json">json</option>
<option value="text">text</option>
<option value="srt">srt</option>
<option value="verbose_json">verbose_json</option>
<option value="vtt">vtt</option>
</select>
</Row>
)}
{/* Curl + Run */}
<div className="mt-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<button
onClick={() => copyCurl(curlSnippet)}
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
{copiedCurl ? "Copied" : "Copy"}
</button>
<button
onClick={handleRun}
disabled={running || !audioFile || !modelFull}
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
play_arrow
</span>
{running ? "Transcribing..." : "Run"}
</button>
</div>
</div>
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
</div>
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
{/* Response */}
<div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Response {result && latency && <span className="font-normal normal-case">&#9889; {latency}ms</span>}
</span>
{result && (
<button
onClick={() => copyRes(resultStr)}
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
{copiedRes ? "Copied" : "Copy"}
</button>
)}
</div>
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
{resultStr}
</pre>
</div>
</div>
</Card>
);
}