refactor(app): DRY pass — split large files, extract shared utils
S1: delete page.new.js (1724L abandoned) + remove dead getAntigravityProjectId
S2: split large files by natural seams
- usage.js → usage/{github,google,claude,codex,kiro,minimax,misc,shared}.js
- media-providers page → components/{Embedding,Tts,Generic,Stt}ExampleCard.js
- EndpointPageClient → endpointConstants.js + endpointPing.js + components/
- tokenRefresh.js → tokenRefresh/{dedup,providers}.js
- ProviderLimits/index.js: 16 pure fn + 9 constants → utils.js
- oauth/providers.js: 7 pure helpers → providerHelpers.js
S3: shared utils
- getModelKind(m, fallback) → shared/constants/models.js (replaces 20× m.kind||m.type)
- getStatusVariant → shared/utils/connectionStatus.js (dedup ConnectionRow/ConnectionsCard)
- sseChunk → open-sse/utils/sse.js (dedup grok-web/perplexity-web)
- fetchWithTimeout → usage/shared.js (replace 4× AbortController pattern in google.js)
fix: enableObservability2 field name in requestDetailsRepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d3f61aac2f
commit
fbf973f2e7
44 files changed
+4211
-5875
No files matched your search
+566
@@ -0,0 +1,566 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
|
||||
import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
|
||||
import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js";
|
||||
import { Row } from "./exampleShared";
|
||||
|
||||
const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
|
||||
// Example JSON response (response_format=json):
|
||||
{
|
||||
"format": "mp3",
|
||||
"audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
|
||||
}`;
|
||||
|
||||
export function TtsExampleCard({ providerId }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"];
|
||||
|
||||
// Voice state
|
||||
const [selectedVoice, setSelectedVoice] = useState(config.defaultVoiceId || "");
|
||||
const [selectedVoiceName, setSelectedVoiceName] = useState("");
|
||||
const [voiceId, setVoiceId] = useState(config.defaultVoiceId || ""); // editable voice id (elevenlabs/config providers)
|
||||
// Voices shown below Voice row after language selected
|
||||
const [countryVoices, setCountryVoices] = useState([]);
|
||||
const [selectedLang, setSelectedLang] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState(() => {
|
||||
const cfgModels = AI_PROVIDERS[providerId]?.ttsConfig?.models;
|
||||
if (cfgModels?.length) return cfgModels[0].id;
|
||||
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({});
|
||||
// Language hint (e.g. Gemini): controls the spoken language without affecting voice selection
|
||||
const [languageHint, setLanguageHint] = 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 defaultModel = config.hasModelSelector && config.modelKey
|
||||
? (getModelsByProviderId(config.modelKey)?.[0]?.id || "")
|
||||
: "";
|
||||
// Use per-model voices if available, else flat list
|
||||
const voices = (config.voicesPerModel && defaultModel)
|
||||
? (getTtsVoicesForModel(providerId, defaultModel) || [])
|
||||
: getModelsByProviderId(config.voiceKey || providerId).filter((m) => getModelKind(m) === "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 }]);
|
||||
} else {
|
||||
// OpenAI/OpenRouter: set voice chips directly (no language picker)
|
||||
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
|
||||
// config (nvidia, hyperbolic, deepgram, huggingface, cartesia, playht, coqui, tortoise, inworld, qwen):
|
||||
// use ttsConfig.models for model selector; voice is empty by default (backend uses provider default)
|
||||
}, [providerId]);
|
||||
|
||||
// Update voices when model changes (voicesPerModel providers)
|
||||
useEffect(() => {
|
||||
if (!config.voicesPerModel || !selectedModel) return;
|
||||
const voices = getTtsVoicesForModel(providerId, selectedModel) || [];
|
||||
setCountryVoices(voices);
|
||||
if (voices.length) {
|
||||
setSelectedVoice(voices[0].id);
|
||||
setSelectedVoiceName(voices[0].name || voices[0].id);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// 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) => getModelKind(m) === "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 || {});
|
||||
}
|
||||
} 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/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice
|
||||
const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice;
|
||||
const modelFull = (() => {
|
||||
if (config.hasModelSelector && selectedModel && activeVoiceId) return `${providerAlias}/${selectedModel}/${activeVoiceId}`;
|
||||
if (config.hasModelSelector && selectedModel) return `${providerAlias}/${selectedModel}`;
|
||||
if (activeVoiceId) return `${providerAlias}/${activeVoiceId}`;
|
||||
return "";
|
||||
})();
|
||||
|
||||
const ttsBody = (() => {
|
||||
const b = { model: modelFull, input };
|
||||
if (config.hasLanguageHint && languageHint) b.language = languageHint;
|
||||
return b;
|
||||
})();
|
||||
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 '${JSON.stringify(ttsBody)}' \\
|
||||
${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({ ...ttsBody, 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 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/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 — prefer PROVIDER_MODELS[kind=tts], else providerModels via modelKey */}
|
||||
{config.hasModelSelector && (config.modelKey || getModelsByProviderId(providerId).some(m => getModelKind(m) === "tts")) && (
|
||||
<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"
|
||||
>
|
||||
{(() => {
|
||||
const ttsModels = getModelsByProviderId(providerId).filter(m => getModelKind(m) === "tts");
|
||||
return (ttsModels.length ? ttsModels : getModelsByProviderId(config.modelKey) || []).map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
|
||||
{config.hasLanguageHint && (
|
||||
<Row label="Language">
|
||||
<select
|
||||
value={languageHint}
|
||||
onChange={(e) => setLanguageHint(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="">Auto-detect</option>
|
||||
{GOOGLE_TTS_LANGUAGES.map((l) => (
|
||||
<option key={l.id} value={l.name}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
|
||||
{config.hasBrowseButton && (
|
||||
<Row label="Language">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="w-full min-w-0 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 w-full items-center justify-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 sm:w-auto sm:shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">language</span>
|
||||
Select language
|
||||
</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) => getModelKind(m) === "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) => getModelKind(m) === "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 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 || !input.trim() || !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 ? "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-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Audio player */}
|
||||
{audioUrl ? (
|
||||
<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 {latency && <span className="font-normal normal-case">⚡ {latency}ms</span>}
|
||||
</span>
|
||||
<a href={audioUrl} download="speech.mp3" className="inline-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 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">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-wrap break-all opacity-50">{DEFAULT_TTS_RESPONSE_EXAMPLE}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Country Picker Modal */}
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center sm:items-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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user