9router/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js

823 lines
35 KiB
JavaScript
Raw Normal View History

2026-04-04 04:07:07 -04:00
"use client";
import { useParams, notFound } from "next/navigation";
import Link from "next/link";
2026-04-09 23:17:53 -04:00
import { useState, useEffect } from "react";
2026-04-04 04:07:07 -04:00
import { Card, Badge } from "@/shared/components";
2026-04-09 23:17:53 -04:00
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
2026-04-04 04:07:07 -04:00
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
2026-04-09 23:17:53 -04:00
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
2026-04-04 04:07:07 -04:00
2026-04-09 23:17:53 -04:00
// Shared row layout — defined outside components to avoid re-mount on re-render
function Row({ label, children }) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-text-muted w-20 shrink-0">{label}</span>
<div className="flex-1">{children}</div>
</div>
);
}
const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
// Example JSON response (response_format=json):
{
"format": "mp3",
"audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
}`;
2026-04-09 23:17:53 -04:00
const DEFAULT_RESPONSE_EXAMPLE = `{
"object": "list",
"data": [{
"object": "embedding",
"index": 0,
"embedding": [0.002301, -0.019212, 0.004815, -0.031249, ...]
}],
"model": "...",
"usage": { "prompt_tokens": 9, "total_tokens": 9 }
}`;
// EmbeddingExampleCard
function EmbeddingExampleCard({ providerId }) {
const providerAlias = getProviderAlias(providerId);
const embeddingModels = getModelsByProviderId(providerId).filter((m) => m.type === "embedding");
const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? "");
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [result, setResult] = 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 endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
-d '{"model": "${modelFull}", "input": "${input}"}'`;
const handleRun = async () => {
if (!input.trim() || !modelFull) return;
setRunning(true);
setError("");
setResult(null);
const start = Date.now();
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch("/api/v1/embeddings", {
method: "POST",
headers,
body: JSON.stringify({ model: modelFull, input: input.trim() }),
});
const latencyMs = Date.now() - start;
const data = await res.json();
if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; }
setResult({ data, latencyMs });
} catch (e) {
setError(e.message || "Network error");
} finally {
setRunning(false);
}
};
// Compact embedding array: first 4 values + count
const formatResultJson = (data) => {
if (!data) return DEFAULT_RESPONSE_EXAMPLE;
const clone = JSON.parse(JSON.stringify(data));
(clone.data || []).forEach((item) => {
if (Array.isArray(item.embedding) && item.embedding.length > 4) {
item.embedding = [...item.embedding.slice(0, 4).map((v) => parseFloat(v.toFixed(6))), `... (${item.embedding.length} dims)`];
}
});
return JSON.stringify(clone, null, 2);
};
const resultJson = result ? JSON.stringify(result.data, null, 2) : "";
return (
<Card>
<h2 className="text-lg font-semibold mb-4">Example</h2>
<div className="flex flex-col gap-2.5">
{/* Model */}
<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"
>
{embeddingModels.map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
</Row>
{/* Endpoint */}
<Row label="Endpoint">
<div className="flex items-center gap-2">
<input
value={endpoint}
onChange={(e) => useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
placeholder="http://localhost:3000"
/>
{/* Tunnel toggle — only show if tunnel URL is available */}
{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">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
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>
{/* Input */}
<Row label="Input">
<div className="relative">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
{input && (
<button
type="button"
onClick={() => setInput("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</Row>
{/* Curl + Run */}
<div className="mt-1">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
<div className="flex items-center gap-2">
<button
onClick={() => copyCurl(curlSnippet)}
className="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 || !input.trim() || !modelFull}
className="flex items-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}>
{running ? "progress_activity" : "play_arrow"}
</span>
{running ? "Running..." : "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">{curlSnippet}</pre>
</div>
{/* Error */}
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
{/* Response — default example or real result */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Response {result && <span className="font-normal normal-case">&#9889; {result.latencyMs}ms</span>}
</span>
{result && (
<button
onClick={() => copyRes(resultJson)}
className="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 opacity-70">
{formatResultJson(result?.data)}
</pre>
</div>
</div>
</Card>
);
}
// ─── TTS Example Card ────────────────────────────────────────────────────────
function TtsExampleCard({ providerId }) {
const providerAlias = getProviderAlias(providerId);
const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"];
// Voice state
const [selectedVoice, setSelectedVoice] = useState("");
const [selectedVoiceName, setSelectedVoiceName] = useState("");
const [voiceId, setVoiceId] = useState(""); // editable voice id (elevenlabs)
// Voices shown below Voice row after language selected
const [countryVoices, setCountryVoices] = useState([]);
const [selectedLang, setSelectedLang] = useState("");
const [selectedModel, setSelectedModel] = useState(() => {
if (config.hasModelSelector && config.modelKey) {
const models = getModelsByProviderId(config.modelKey);
return models?.[0]?.id || "";
}
return "";
});
// Form state
const [input, setInput] = useState("Hello, this is a text to speech test.");
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
const [audioUrl, setAudioUrl] = useState("");
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [latency, setLatency] = useState(null);
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
// Country picker modal state
const [modalOpen, setModalOpen] = useState(false);
const [languages, setLanguages] = useState([]);
const [modalLoading, setModalLoading] = useState(false);
const [modalSearch, setModalSearch] = useState("");
const [modalError, setModalError] = useState("");
const [byLang, setByLang] = useState({});
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(() => {});
// Pre-select default voice based on provider config
if (config.voiceSource === "hardcoded") {
const voiceKey = config.voiceKey || providerId;
const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
if (voices.length) {
if (config.hasBrowseButton) {
// Google TTS: pre-select "en" (English) as default, show as single voice chip
const defaultVoice = voices.find((v) => v.id === "en") || voices[0];
setSelectedLang(defaultVoice.id);
setSelectedVoice(defaultVoice.id);
setSelectedVoiceName(defaultVoice.name);
setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]);
2026-04-09 23:17:53 -04:00
} else {
// OpenAI: set voice chips directly (no language picker)
2026-04-09 23:17:53 -04:00
setCountryVoices(voices);
setSelectedVoice(voices[0].id);
setSelectedVoiceName(voices[0].name || voices[0].id);
}
}
}
// api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language
}, [providerId]);
// Open modal — load language list
const openModal = async () => {
setModalOpen(true);
setModalSearch("");
setModalError("");
if (languages.length) return; // already loaded
setModalLoading(true);
try {
if (config.voiceSource === "hardcoded") {
// Build languages/byLang from static providerModels data
const voiceKey = config.voiceKey || providerId;
const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts");
const byLangMap = {};
for (const v of voices) {
if (!byLangMap[v.id]) byLangMap[v.id] = { code: v.id, name: v.name, voices: [{ id: v.id, name: v.name }] };
}
setByLang(byLangMap);
setLanguages(Object.values(byLangMap).sort((a, b) => a.name.localeCompare(b.name)));
} else {
// Use provider-specific apiEndpoint if available, else default to edge-tts voices API
const url = config.apiEndpoint
? config.apiEndpoint
: `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`;
const r = await fetch(url);
const d = await r.json();
if (d.error) { setModalError(d.error); return; }
setLanguages(d.languages || []);
setByLang(d.byLang || {});
}
2026-04-09 23:17:53 -04:00
} catch (e) {
setModalError(e.message);
} finally {
setModalLoading(false);
}
};
// Click language → close modal → show voices below
const handlePickLanguage = (lang) => {
setModalOpen(false);
setSelectedLang(lang.code);
const voices = byLang[lang.code]?.voices || [];
setCountryVoices(voices);
// Auto-select first voice
if (voices.length) {
setSelectedVoice(voices[0].id);
setSelectedVoiceName(voices[0].name);
if (config.hasVoiceIdInput) setVoiceId(voices[0].id);
}
};
const filteredLanguages = modalSearch
? languages.filter((c) =>
c.name.toLowerCase().includes(modalSearch.toLowerCase()) ||
c.code.toLowerCase().includes(modalSearch.toLowerCase())
)
: languages;
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
// For ElevenLabs: use voiceId (editable) instead of selectedVoice
const activeVoiceId = config.hasVoiceIdInput ? voiceId : selectedVoice;
const modelFull = config.hasModelSelector && activeVoiceId && selectedModel
? `${providerAlias}/${selectedModel}/${activeVoiceId}`
: activeVoiceId ? `${providerAlias}/${activeVoiceId}` : "";
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
-d '{"model": "${modelFull}", "input": "${input}"}' \\
${responseFormat === "json" ? "" : "--output speech.mp3"}`;
const handleRun = async () => {
if (!input.trim() || !modelFull) return;
setRunning(true);
setError("");
setAudioUrl("");
setJsonResponse(null);
const start = Date.now();
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`;
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ model: modelFull, input: input.trim() }),
});
setLatency(Date.now() - start);
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d?.error?.message || d?.error || `HTTP ${res.status}`);
return;
}
if (responseFormat === "json") {
const data = await res.json();
setJsonResponse(data); // Store full JSON response
const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
setAudioUrl(URL.createObjectURL(audioBlob));
} else {
const blob = await res.blob();
setAudioUrl(URL.createObjectURL(blob));
}
} catch (e) {
setError(e.message || "Network error");
} finally {
setRunning(false);
}
};
return (
<>
<Card>
<h2 className="text-lg font-semibold mb-4">Example</h2>
<div className="flex flex-col gap-2.5">
{/* Endpoint + API Key as read-only text */}
<Row label="Endpoint">
<div className="flex items-center gap-2">
<span className="flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
{endpoint}/v1/audio/speech
</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>
<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)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
</span>
</Row>
{/* Model selector (OpenAI, ElevenLabs) */}
{config.hasModelSelector && config.modelKey && (
<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"
>
{(getModelsByProviderId(config.modelKey) || []).map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
</Row>
)}
{/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
{config.hasBrowseButton && (
<Row label="Language">
<div className="flex items-center gap-2">
<button
onClick={openModal}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background font-mono truncate text-left hover:border-primary/40 transition-colors"
>
{selectedLang
? <span className="text-text-main">{languages.find((l) => l.code === selectedLang)?.name || selectedLang}</span>
: <span className="text-text-muted">No language selected</span>}
</button>
<button
onClick={openModal}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors shrink-0"
>
<span className="material-symbols-outlined text-[14px]">language</span>
Select language
2026-04-09 23:17:53 -04:00
</button>
</div>
</Row>
)}
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
{countryVoices.length > 0 && (
<Row label="Voice">
<div className="flex flex-wrap gap-1.5">
{countryVoices.map((v) => (
<button
key={v.id}
onClick={() => {
setSelectedVoice(v.id);
setSelectedVoiceName(v.name);
if (config.hasVoiceIdInput) setVoiceId(v.id);
}}
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
selectedVoice === v.id
? "bg-primary/15 border-primary/40 text-primary font-medium"
: "border-border text-text-muted hover:text-primary hover:border-primary/40"
}`}
>
{v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
{v.free_users_allowed === true && (
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
)}
{v.free_users_allowed === false && (
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-amber-500/15 text-amber-600 border border-amber-500/20">Paid</span>
)}
</button>
))}
</div>
</Row>
)}
{/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
{config.hasVoiceIdInput && (
<Row label="Voice ID">
<div className="flex flex-col gap-1">
<div className="relative">
<input
value={voiceId}
onChange={(e) => {
setVoiceId(e.target.value);
setSelectedVoice(e.target.value);
}}
placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17"
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
/>
{voiceId && (
<button
type="button"
onClick={() => { setVoiceId(""); setSelectedVoice(""); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</div>
</Row>
)}
{/* Google TTS: Language dropdown */}
{config.hasLanguageDropdown && (
<Row label="Language">
<select
value={selectedVoice}
onChange={(e) => {
const m = getModelsByProviderId(providerId).filter((m) => m.type === "tts").find((m) => m.id === e.target.value);
setSelectedVoice(e.target.value);
setSelectedVoiceName(m?.name || 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"
>
{getModelsByProviderId(providerId).filter((m) => m.type === "tts").map((m) => (
<option key={m.id} value={m.id}>{m.name || m.id}</option>
))}
</select>
</Row>
)}
{/* Input */}
<Row label="Input">
<div className="relative">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
{input && (
<button
type="button"
onClick={() => setInput("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</Row>
{/* Output Format */}
<Row label="Output 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="mp3">MP3 (Binary)</option>
<option value="json">JSON (Base64)</option>
</select>
</Row>
{/* Curl + Run */}
<div className="mt-1">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
<div className="flex items-center gap-2">
<button
onClick={() => copyCurl(curlSnippet)}
className="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 || !input.trim() || !modelFull}
className="flex items-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}>
{running ? "progress_activity" : "play_arrow"}
</span>
{running ? "Generating..." : "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">{curlSnippet}</pre>
</div>
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
{/* Audio player */}
{audioUrl ? (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Response {latency && <span className="font-normal normal-case">&#9889; {latency}ms</span>}
</span>
<a href={audioUrl} download="speech.mp3" className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[14px]">download</span>
Download
</a>
</div>
<audio controls src={audioUrl} className="w-full" />
{/* JSON Response (if format is json) */}
{jsonResponse && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">JSON Response</span>
</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">
{JSON.stringify({
format: jsonResponse.format,
audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
}, null, 2)}
</pre>
</div>
)}
</div>
) : (
<div>
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Response</span>
<pre className="mt-1.5 bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre opacity-50">{DEFAULT_TTS_RESPONSE_EXAMPLE}</pre>
</div>
2026-04-09 23:17:53 -04:00
)}
</div>
</Card>
{/* Country Picker Modal */}
{modalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: "rgba(0,0,0,0.6)", backdropFilter: "blur(2px)" }}
onClick={() => setModalOpen(false)}
>
<div
className="border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[80vh]"
style={{ backgroundColor: "var(--color-bg)", isolation: "isolate" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 rounded-t-xl">
<h3 className="text-sm font-semibold">Select Language</h3>
<button onClick={() => setModalOpen(false)} className="text-text-muted hover:text-primary transition-colors">
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
{/* Search */}
<div className="px-4 py-2.5 border-b border-border shrink-0">
<input
autoFocus
value={modalSearch}
onChange={(e) => setModalSearch(e.target.value)}
placeholder="Search language..."
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
{/* Language list */}
<div className="overflow-y-auto flex-1 p-2">
{modalError && <p className="text-xs text-red-500 px-2 py-1">{modalError}</p>}
{modalLoading ? (
<p className="text-xs text-text-muted px-2 py-3">Loading...</p>
) : (
<div className="flex flex-col gap-0.5">
{filteredLanguages.map((c) => (
<button
key={c.code}
onClick={() => handlePickLanguage(c)}
className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-left hover:bg-sidebar transition-colors ${
selectedLang === c.code ? "bg-primary/10 text-primary" : ""
}`}
>
<span className="text-sm">{c.name}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-text-muted">{c.voices.length} voices</span>
{selectedLang === c.code && (
<span className="material-symbols-outlined text-[16px] text-primary">check</span>
)}
</div>
</button>
))}
{filteredLanguages.length === 0 && (
<p className="text-xs text-text-muted px-2 py-3">No languages found.</p>
)}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}
// MediaProviderDetailPage
2026-04-04 04:07:07 -04:00
export default function MediaProviderDetailPage() {
const { kind, id } = useParams();
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
if (!kindConfig) return notFound();
const provider = AI_PROVIDERS[id];
if (!provider) return notFound();
const kinds = provider.serviceKinds ?? ["llm"];
if (!kinds.includes(kind)) return notFound();
return (
<div className="flex flex-col gap-8">
{/* Back */}
<div>
<Link
href={`/dashboard/media-providers/${kind}`}
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
{kindConfig.label}
</Link>
{/* Header */}
<div className="flex items-center gap-4">
2026-04-09 23:17:53 -04:00
<div className="size-12 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${provider.color}15` }}>
<ProviderIcon
src={`/providers/${provider.id}.png`}
alt={provider.name}
size={48}
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
fallbackColor={provider.color}
/>
2026-04-04 04:07:07 -04:00
</div>
<div>
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{kinds.map((k) => (
<Badge key={k} variant={k === kind ? "primary" : "default"} size="sm">
{k.toUpperCase()}
</Badge>
))}
</div>
</div>
</div>
</div>
2026-04-09 23:17:53 -04:00
{/* Connections */}
{provider.noAuth ? (
<Card>
<div className="flex items-center gap-3">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div>
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">This provider is ready to use.</p>
</div>
</div>
</Card>
) : (
<ConnectionsCard providerId={id} isOAuth={false} />
)}
2026-04-04 04:07:07 -04:00
2026-04-09 23:17:53 -04:00
{/* Models - only for non-tts kinds */}
{kind !== "tts" && <ModelsCard providerId={id} kindFilter={kind} />}
2026-04-04 04:07:07 -04:00
2026-04-09 23:17:53 -04:00
{/* Example — per kind */}
{kind === "embedding" && <EmbeddingExampleCard providerId={id} />}
{kind === "tts" && <TtsExampleCard providerId={id} />}
2026-04-04 04:07:07 -04:00
</div>
);
}