@@ -1474,81 +1289,6 @@ export default function APIPageClient({ machineId }) {
);
}
-/** Reusable endpoint row component */
-function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
- return (
-
- {label}
-
-
- {actions}
-
- );
-}
-
-/** Reusable status alert */
-function StatusAlert({ status, className = "" }) {
- // Render URLs in message as clickable links
- const renderMessage = (msg) => {
- const parts = msg.split(/(https?:\/\/[^\s]+)/g);
- return parts.map((part, i) =>
- /^https?:\/\//.test(part)
- ?
{part}
- : part
- );
- };
-
- return (
-
- {renderMessage(status.message)}
-
- );
-}
-
-/** Inline tooltip, Claude Code CLI style */
-function Tooltip({ text }) {
- return (
-
- help
-
- {text}
-
-
- );
-}
-
-/** Security warning banner with optional action link */
-function SecurityWarning({ message, action }) {
- return (
-
- );
-}
APIPageClient.propTypes = {
machineId: PropTypes.string.isRequired,
diff --git a/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js b/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js
new file mode 100644
index 00000000..29d3d653
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js
@@ -0,0 +1,22 @@
+"use client";
+
+import { Input } from "@/shared/components";
+
+/** Reusable endpoint row component */
+export default function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
+ return (
+
+ {label}
+
+
+ {actions}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/endpoint/components/SecurityWarning.js b/src/app/(dashboard)/dashboard/endpoint/components/SecurityWarning.js
new file mode 100644
index 00000000..87565038
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/endpoint/components/SecurityWarning.js
@@ -0,0 +1,23 @@
+"use client";
+
+/** Security warning banner with optional action link */
+export default function SecurityWarning({ message, action }) {
+ return (
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/endpoint/components/StatusAlert.js b/src/app/(dashboard)/dashboard/endpoint/components/StatusAlert.js
new file mode 100644
index 00000000..c0609abc
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/endpoint/components/StatusAlert.js
@@ -0,0 +1,23 @@
+"use client";
+
+/** Reusable status alert */
+export default function StatusAlert({ status, className = "" }) {
+ const renderMessage = (msg) => {
+ const parts = msg.split(/(https?:\/\/[^\s]+)/g);
+ return parts.map((part, i) =>
+ /^https?:\/\//.test(part)
+ ?
{part}
+ : part
+ );
+ };
+
+ return (
+
+ {renderMessage(status.message)}
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js b/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js
new file mode 100644
index 00000000..c9726ea0
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js
@@ -0,0 +1,13 @@
+"use client";
+
+/** Inline tooltip, Claude Code CLI style */
+export default function Tooltip({ text }) {
+ return (
+
+ help
+
+ {text}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js b/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js
new file mode 100644
index 00000000..ac10b76b
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js
@@ -0,0 +1,32 @@
+export const WENYAN_LOCALES = ["zh-CN", "zh-TW"];
+
+export const TUNNEL_BENEFITS = [
+ { icon: "public", title: "Access Anywhere", desc: "Use your API from any network" },
+ { icon: "group", title: "Share Endpoint", desc: "Share URL with team members" },
+ { icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" },
+ { icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" },
+];
+
+export const TUNNEL_PING_INTERVAL_MS = 2000;
+export const TUNNEL_PING_MAX_MS = 300000;
+export const STATUS_POLL_FAST_MS = 5000;
+export const STATUS_POLL_SLOW_MS = 30000;
+export const REACHABLE_MISS_THRESHOLD = 5;
+export const CLIENT_PING_FAST_MS = 10000;
+export const CLIENT_PING_SLOW_MS = 60000;
+export const CLIENT_PING_TIMEOUT_MS = 5000;
+
+export const CAVEMAN_LEVELS = [
+ { id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
+ { id: "full", label: "Full", desc: "Drop articles, fragments OK" },
+ { id: "ultra", label: "Ultra", desc: "Telegraphic, max compression" },
+ { id: "wenyan-lite", label: "文 Lite", desc: "Classical Chinese, light compression", wenyan: true },
+ { id: "wenyan", label: "文 Full", desc: "Maximum 文言文, 80-90% reduction", wenyan: true },
+ { id: "wenyan-ultra", label: "文 Ultra", desc: "Extreme classical compression", wenyan: true },
+];
+
+export const PONYTAIL_LEVELS = [
+ { id: "lite", label: "Lite", desc: "Build asked, name lazier option" },
+ { id: "full", label: "Full", desc: "Ladder enforced: stdlib/native first" },
+ { id: "ultra", label: "Ultra", desc: "YAGNI extremist, deletion first" },
+];
diff --git a/src/app/(dashboard)/dashboard/endpoint/endpointPing.js b/src/app/(dashboard)/dashboard/endpoint/endpointPing.js
new file mode 100644
index 00000000..5c522cdc
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/endpoint/endpointPing.js
@@ -0,0 +1,29 @@
+import { CLIENT_PING_TIMEOUT_MS } from "./endpointConstants";
+
+// Browser-side health probe: must reach origin (not just CF/TS edge).
+// cors mode → res.ok=false for 5xx (e.g. Cloudflare 530 when origin dead).
+// /api/health route sets Access-Control-Allow-Origin: * → CORS works through tunnel.
+export async function clientPingUrl(url) {
+ if (!url) return false;
+ try {
+ const res = await fetch(`${url}/api/health`, {
+ mode: "cors",
+ cache: "no-store",
+ signal: AbortSignal.timeout(CLIENT_PING_TIMEOUT_MS),
+ });
+ return res.ok;
+ } catch { return false; }
+}
+
+// Race multiple URLs: resolve true as soon as any one passes ping.
+export async function clientPingAny(...urls) {
+ const checks = urls.filter(Boolean).map(clientPingUrl);
+ if (!checks.length) return false;
+ return new Promise((resolve) => {
+ let pending = checks.length;
+ checks.forEach((p) => p.then((ok) => {
+ if (ok) resolve(true);
+ else if (--pending === 0) resolve(false);
+ }));
+ });
+}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js
new file mode 100644
index 00000000..5580d759
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js
@@ -0,0 +1,254 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Card } from "@/shared/components";
+import { getProviderAlias, isCustomEmbeddingProvider } from "@/shared/constants/providers";
+import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
+import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
+import { Row } from "./exampleShared";
+
+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 }
+}`;
+
+export function EmbeddingExampleCard({ providerId, customAlias }) {
+ const isCustom = isCustomEmbeddingProvider(providerId);
+ const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId);
+ const embeddingModels = isCustom ? [] : getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "embedding");
+
+ const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? "");
+ const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
+ const [dimensions, setDimensions] = useState("");
+ 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}` : "";
+
+ // Build request body — include dimensions only if user provided a positive number
+ const buildBody = () => {
+ const body = { model: modelFull, input: input.trim() };
+ const dim = Number(dimensions);
+ if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim;
+ return body;
+ };
+
+ const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
+ -H "Content-Type: application/json" \\
+ -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
+ -d '${JSON.stringify(buildBody())}'`;
+
+ 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(buildBody()),
+ });
+ 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 (
+
+ Example
+
+
+ {/* Model — text input for custom node, dropdown otherwise */}
+
+ {isCustom ? (
+ setSelectedModel(e.target.value)}
+ placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
+ 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"
+ />
+ ) : (
+
+ )}
+
+
+ {/* Endpoint */}
+
+
+ useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
+ className="w-full min-w-0 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 && (
+
+ )}
+
+
+
+ {/* API Key */}
+
+ 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"
+ />
+
+
+ {/* Input */}
+
+
+ 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 && (
+
+ )}
+
+
+
+ {/* Dimensions (optional) — truncate embedding vector length */}
+
+ setDimensions(e.target.value)}
+ placeholder="optional, e.g. 512, 1024 (leave empty for default)"
+ className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
+ />
+
+
+ {/* Curl + Run */}
+
+
+
Request
+
+
+
+
+
+
{curlSnippet}
+
+
+ {/* Error */}
+ {error &&
{error}
}
+
+ {/* Response — default example or real result */}
+
+
+
+ Response {result && ⚡ {result.latencyMs}ms}
+
+ {result && (
+
+ )}
+
+
+ {formatResultJson(result?.data)}
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js
new file mode 100644
index 00000000..815528d7
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js
@@ -0,0 +1,539 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Card } from "@/shared/components";
+import { MEDIA_PROVIDER_KINDS, getProviderAlias, resolveProviderId } from "@/shared/constants/providers";
+import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
+import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
+import { Row, KIND_EXAMPLE_CONFIG } from "./exampleShared";
+
+const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png";
+const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png";
+
+function getImageEditDefaults(providerId, modelId) {
+ if (providerId !== "cloudflare-ai") return {};
+ if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") {
+ return { image: CLOUDFLARE_TEST_IMAGE_URL };
+ }
+ if (modelId === "@cf/runwayml/stable-diffusion-v1-5-inpainting") {
+ return { image: CLOUDFLARE_TEST_IMAGE_URL, mask_image: CLOUDFLARE_TEST_MASK_URL };
+ }
+ return {};
+}
+
+function toImagePreviewSrc(value) {
+ const trimmed = typeof value === "string" ? value.trim() : "";
+ if (!trimmed) return "";
+ if (/^(data:image\/|https?:\/\/)/i.test(trimmed)) return trimmed;
+ return `data:image/png;base64,${trimmed}`;
+}
+
+export function GenericExampleCard({ providerId, kind }) {
+ const providerAlias = getProviderAlias(providerId);
+ const resolvedId = resolveProviderId(providerAlias);
+ const safeProviderAlias = resolvedId === providerId ? providerAlias : providerId;
+ const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
+ const exConfig = KIND_EXAMPLE_CONFIG[kind];
+ const safeExConfig = exConfig || {};
+
+ // Get models for this kind (e.g., type="image")
+ const kindModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === kind);
+ // Kinds that need a model identifier in the request (image/video/music)
+ const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]);
+ const needsModel = KIND_NEEDS_MODEL.has(kind);
+ const allowManualModel = needsModel && kindModels.length === 0;
+ const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
+ const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
+ const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
+ const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
+
+ const [input, setInput] = useState(safeExConfig.defaultInput || "");
+ const [refImage, setRefImage] = useState("");
+ const [maskImage, setMaskImage] = useState("");
+ const [extraValues, setExtraValues] = useState(() =>
+ (safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
+ );
+ const [apiKey, setApiKey] = useState("");
+ const [useTunnel, setUseTunnel] = useState(false);
+ const [localEndpoint, setLocalEndpoint] = useState("");
+ const [tunnelEndpoint, setTunnelEndpoint] = useState("");
+ const [result, setResult] = useState(null);
+ const [progress, setProgress] = useState(null); // { stage, bytesReceived }
+ const [partialImage, setPartialImage] = useState(null);
+ const [imageOutputFormat, setImageOutputFormat] = useState("json"); // json | binary
+ const [binaryImageUrl, setBinaryImageUrl] = useState("");
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState("");
+ const [connections, setConnections] = useState([]);
+ const [pinnedConnectionId, setPinnedConnectionId] = 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(() => {});
+ // Load active connections of this provider for pinning
+ fetch("/api/providers/client")
+ .then((r) => r.json())
+ .then((d) => {
+ const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false);
+ setConnections(conns);
+ })
+ .catch(() => {});
+ }, [providerId]);
+
+ // Safe to early-return now that all hooks are declared
+ if (!kindConfig || !exConfig) return null;
+
+ const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
+ const apiPath = kindConfig.endpoint.path;
+ // webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present.
+ const modelFull = !needsModel
+ ? safeProviderAlias
+ : (selectedModel ? `${safeProviderAlias}/${selectedModel}` : (allowManualModel ? "" : safeProviderAlias));
+ const imageEditDefaults = getImageEditDefaults(providerId, selectedModel);
+ const effectiveRefImage = refImage.trim() || imageEditDefaults.image || "";
+ const effectiveMaskImage = maskImage.trim() || imageEditDefaults.mask_image || "";
+ const refImagePreviewSrc = toImagePreviewSrc(effectiveRefImage);
+ const maskImagePreviewSrc = toImagePreviewSrc(effectiveMaskImage);
+
+ // Build request body with optional extra fields (only non-empty values)
+ const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
+ if (v === "" || v === null || v === undefined) return acc;
+ if (typeof v === "number" && Number.isNaN(v)) return acc;
+ acc[k] = v;
+ return acc;
+ }, {});
+ const requestBody = {
+ model: modelFull,
+ [exConfig.bodyKey]: input,
+ ...exConfig.extraBody,
+ ...extraBodyFromFields,
+ ...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}),
+ ...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}),
+ };
+
+ // Streaming supported for codex image (Plus/Pro accounts) — disabled when binary output requested
+ const wantBinary = kind === "image" && imageOutputFormat === "binary";
+ const useStreaming = kind === "image" && providerId === "codex" && !wantBinary;
+ const apiPathWithQuery = `${apiPath}${wantBinary ? "?response_format=binary" : ""}`;
+ const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`;
+ const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPathWithQuery} \\
+ ${headersPreview.replace(/\\\n /g, "\\\n ")} \\
+ -d '${JSON.stringify(requestBody)}'${wantBinary ? " \\\n --output image.png" : ""}`;
+
+ const handleRun = async () => {
+ if (!input.trim() || !modelFull) return;
+ setRunning(true);
+ setError("");
+ setResult(null);
+ setProgress(null);
+ setPartialImage(null);
+ if (binaryImageUrl) { try { URL.revokeObjectURL(binaryImageUrl); } catch {} setBinaryImageUrl(""); }
+ const start = Date.now();
+ try {
+ const headers = { "Content-Type": "application/json" };
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
+ if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId;
+ if (useStreaming) headers["Accept"] = "text/event-stream";
+ const body = { ...requestBody, model: modelFull };
+ const res = await fetch(`/api${apiPathWithQuery}`, {
+ method: kindConfig.endpoint.method,
+ headers,
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setError(data?.error?.message || data?.error || `HTTP ${res.status}`);
+ return;
+ }
+ const ctype = res.headers.get("content-type") || "";
+ // Binary image response — convert to blob URL
+ if (ctype.startsWith("image/")) {
+ const blob = await res.blob();
+ const objUrl = URL.createObjectURL(blob);
+ setBinaryImageUrl(objUrl);
+ setResult({ data: { binary: true, mime: ctype, size: blob.size }, latencyMs: Date.now() - start });
+ return;
+ }
+ const isSse = ctype.includes("text/event-stream");
+ if (isSse && res.body) {
+ // Parse SSE: progress / partial_image / done / error
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buf = "";
+ let finalData = null;
+ let streamErr = null;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ let sep;
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
+ const block = buf.slice(0, sep);
+ buf = buf.slice(sep + 2);
+ let evt = null, dataStr = "";
+ for (const line of block.split("\n")) {
+ if (line.startsWith("event:")) evt = line.slice(6).trim();
+ else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
+ }
+ if (!evt) continue;
+ try {
+ const payload = dataStr ? JSON.parse(dataStr) : {};
+ if (evt === "progress") setProgress(payload);
+ else if (evt === "partial_image") setPartialImage(payload);
+ else if (evt === "done") finalData = payload;
+ else if (evt === "error") streamErr = payload?.message || "Stream error";
+ } catch {}
+ }
+ }
+ const latencyMs = Date.now() - start;
+ if (streamErr) { setError(streamErr); return; }
+ if (finalData) setResult({ data: finalData, latencyMs });
+ } else {
+ const data = await res.json();
+ const latencyMs = Date.now() - start;
+ setResult({ data, latencyMs });
+ }
+ } catch (e) {
+ setError(e.message || "Network error");
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ // Mask large b64_json strings in JSON view to keep it readable
+ const maskB64 = (obj) => {
+ if (!obj || typeof obj !== "object") return obj;
+ if (Array.isArray(obj)) return obj.map(maskB64);
+ const out = {};
+ for (const [k, v] of Object.entries(obj)) {
+ out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
+ ? `<${v.length} chars base64>`
+ : maskB64(v);
+ }
+ return out;
+ };
+ const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : "";
+
+ return (
+
+ Example
+
+ {/* Model selector — dropdown if presets exist, else manual input for media kinds */}
+ {kindModels.length > 0 ? (
+
+
+
+ ) : allowManualModel ? (
+
+ setSelectedModel(e.target.value)}
+ placeholder="Enter model id (provider-specific)"
+ 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"
+ />
+
+ ) : null}
+
+ {/* Endpoint */}
+
+
+
+ {endpoint}{apiPath}
+
+ {tunnelEndpoint && (
+
+ )}
+
+
+
+ {/* API Key */}
+
+
+ {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
+
+
+
+ {/* Connection picker - only show when 2+ connections (or any with email) */}
+ {connections.length > 0 && (
+
+
+
+ )}
+
+ {/* Input */}
+
+
+ setInput(e.target.value)}
+ placeholder={exConfig.inputPlaceholder}
+ 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 && (
+
+ )}
+
+
+
+ {/* Reference image (only for edit-capable image models) */}
+ {supportsEdit && (
+
+
+
+ setRefImage(e.target.value)}
+ placeholder={imageEditDefaults.image || "https://example.com/source.png"}
+ 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"
+ />
+ {refImage && (
+
+ )}
+
+ {refImagePreviewSrc && (
+

{ e.currentTarget.style.display = "none"; }}
+ onLoad={(e) => { e.currentTarget.style.display = "block"; }}
+ />
+ )}
+
+
+ )}
+
+ {supportsMask && (
+
+
+
+ setMaskImage(e.target.value)}
+ placeholder={imageEditDefaults.mask_image || "https://example.com/mask.png"}
+ 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"
+ />
+ {maskImage && (
+
+ )}
+
+ {maskImagePreviewSrc && (
+

{ e.currentTarget.style.display = "none"; }}
+ onLoad={(e) => { e.currentTarget.style.display = "block"; }}
+ />
+ )}
+
+
+ )}
+
+ {/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */}
+ {(exConfig.extraFields || [])
+ .filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key)))
+ .map((f) => (
+
+ {f.type === "select" ? (
+
+ ) : f.type === "text" ? (
+ setExtraValues((s) => ({ ...s, [f.key]: 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"
+ />
+ ) : (
+ setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(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"
+ />
+ )}
+
+ ))}
+
+ {/* Output Format toggle (image only) — last */}
+ {kind === "image" && (
+
+
+
+ )}
+
+ {/* Curl + Run */}
+
+
+
Request
+
+
+
+
+
+
{curlSnippet}
+
+
+ {/* Streaming progress */}
+ {(running || progress) && useStreaming && (
+
+
+ {running ? "progress_activity" : "check_circle"}
+
+
+ {progress?.stage || "starting"}
+ {!running && progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""}
+
+
+ )}
+
+ {/* Partial image preview (codex stream) */}
+ {partialImage?.b64_json && !result && (
+
+
Partial preview
+

+
+ )}
+
+ {/* Error */}
+ {error &&
{error}
}
+
+ {/* Response */}
+
+
+
+ Response {result && ⚡ {result.latencyMs}ms}
+
+ {result && (
+
+ )}
+
+
+ {result ? resultJson : exConfig.defaultResponse}
+
+ {kind === "image" && (binaryImageUrl || result?.data?.data?.[0]) && (
+
+
+

+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js
new file mode 100644
index 00000000..18f2a4f6
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js
@@ -0,0 +1,290 @@
+"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 (
+
+ Example
+
+ {/* Model */}
+ {sttModels.length > 0 ? (
+
+
+
+ ) : (
+
+ 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"
+ />
+
+ )}
+
+ {/* Endpoint */}
+
+
+
+ {endpoint}/v1/audio/transcriptions
+
+ {tunnelEndpoint && (
+
+ )}
+
+
+
+ {/* API Key */}
+
+
+ {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
+
+
+
+ {/* Audio file */}
+
+
+ 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 && (
+
+ {audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB
+
+ )}
+
+
+
+ {/* Language (if model supports) */}
+ {allowedParams.includes("language") && (
+
+ 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"
+ />
+
+ )}
+
+ {/* Prompt (if model supports) */}
+ {allowedParams.includes("prompt") && (
+
+ 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"
+ />
+
+ )}
+
+ {/* Temperature (if model supports) */}
+ {allowedParams.includes("temperature") && (
+
+ 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"
+ />
+
+ )}
+
+ {/* Response format (if model supports) */}
+ {allowedParams.includes("response_format") && (
+
+
+
+ )}
+
+ {/* Curl + Run */}
+
+
+
Request
+
+
+
+
+
+
{curlSnippet}
+
+
+ {error &&
{error}
}
+
+ {/* Response */}
+
+
+
+ Response {result && latency && ⚡ {latency}ms}
+
+ {result && (
+
+ )}
+
+
+ {resultStr}
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js
new file mode 100644
index 00000000..e0191903
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js
@@ -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, getModelKind } 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 (
+ <>
+
+ Example
+
+
+ {/* Endpoint + API Key as read-only text */}
+
+
+
+ {endpoint}/v1/audio/speech
+
+ {tunnelEndpoint && (
+
+ )}
+
+
+
+
+ {apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
+
+
+
+ {/* Model selector — prefer PROVIDER_MODELS[kind=tts], else providerModels via modelKey */}
+ {config.hasModelSelector && (config.modelKey || getModelsByProviderId(providerId).some(m => getModelKind(m) === "tts")) && (
+
+
+
+ )}
+
+ {/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
+ {config.hasLanguageHint && (
+
+
+
+ )}
+
+ {/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
+ {config.hasBrowseButton && (
+
+
+
+
+
+
+ )}
+
+ {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
+ {countryVoices.length > 0 && (
+
+
+ {countryVoices.map((v) => (
+
+ ))}
+
+
+ )}
+
+ {/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
+ {config.hasVoiceIdInput && (
+
+
+
+ {
+ 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 && (
+
+ )}
+
+
+
+ )}
+
+ {/* Google TTS: Language dropdown */}
+ {config.hasLanguageDropdown && (
+
+
+
+ )}
+
+ {/* Input */}
+
+
+ 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 && (
+
+ )}
+
+
+
+ {/* Output Format */}
+
+
+
+
+ {/* Curl + Run */}
+
+
+
Request
+
+
+
+
+
+
{curlSnippet}
+
+
+ {error &&
{error}
}
+
+ {/* Audio player */}
+ {audioUrl ? (
+
+
+
+
+ {/* JSON Response (if format is json) */}
+ {jsonResponse && (
+
+
+ JSON Response
+
+
+ {JSON.stringify({
+ format: jsonResponse.format,
+ audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
+ }, null, 2)}
+
+
+ )}
+
+ ) : (
+
+
Response
+
{DEFAULT_TTS_RESPONSE_EXAMPLE}
+
+ )}
+
+
+
+ {/* Country Picker Modal */}
+ {modalOpen && (
+
setModalOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
Select Language
+
+
+
+ {/* Search */}
+
+ 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"
+ />
+
+
+ {/* Language list */}
+
+ {modalError &&
{modalError}
}
+ {modalLoading ? (
+
Loading...
+ ) : (
+
+ {filteredLanguages.map((c) => (
+
+ ))}
+ {filteredLanguages.length === 0 && (
+
No languages found.
+ )}
+
+ )}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js
new file mode 100644
index 00000000..2fb4cb5e
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js
@@ -0,0 +1,76 @@
+"use client";
+
+export function Row({ label, children }) {
+ return (
+
+ );
+}
+
+export const KIND_EXAMPLE_CONFIG = {
+ webSearch: {
+ inputLabel: "Query",
+ inputPlaceholder: "What is the latest news about AI?",
+ defaultInput: "What is the latest news about AI?",
+ bodyKey: "query",
+ defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`,
+ extraFields: [
+ { key: "search_type", label: "Type", type: "select", default: "web", options: ["web", "news"] },
+ { key: "max_results", label: "Max results", type: "number", default: 5, min: 1, max: 100 },
+ { key: "country", label: "Country", type: "text", default: "" },
+ { key: "language", label: "Language", type: "text", default: "" },
+ ],
+ },
+ webFetch: {
+ inputLabel: "URL",
+ inputPlaceholder: "https://example.com",
+ defaultInput: "https://example.com",
+ bodyKey: "url",
+ defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`,
+ extraFields: [
+ { key: "format", label: "Format", type: "select", default: "markdown", options: ["markdown", "text", "html"] },
+ { key: "max_characters", label: "Max chars", type: "number", default: 0, min: 0 },
+ ],
+ },
+ image: {
+ inputLabel: "Prompt",
+ inputPlaceholder: "A cute cat wearing a hat",
+ defaultInput: "A cute cat wearing a hat",
+ bodyKey: "prompt",
+ defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`,
+ extraFields: [
+ { key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
+ { key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] },
+ { key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] },
+ { key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] },
+ { key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
+ { key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] },
+ { key: "image_detail", label: "Image Detail", type: "select", default: "high", options: ["auto", "low", "high", "original"] },
+ { key: "output_format", label: "Codec", type: "select", default: "png", options: ["png", "jpeg", "webp"] },
+ ],
+ },
+ imageToText: {
+ inputLabel: "Image URL",
+ inputPlaceholder: "https://example.com/image.png",
+ defaultInput: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg",
+ bodyKey: "url",
+ extraBody: { prompt: "Describe this image in detail" },
+ defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`,
+ },
+ video: {
+ inputLabel: "Prompt",
+ inputPlaceholder: "A serene lake at sunset",
+ defaultInput: "A serene lake at sunset",
+ bodyKey: "prompt",
+ defaultResponse: `{\n "data": [\n { "url": "..." }\n ]\n}`,
+ },
+ music: {
+ inputLabel: "Prompt",
+ inputPlaceholder: "A calm piano melody",
+ defaultInput: "A calm piano melody",
+ bodyKey: "prompt",
+ defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`,
+ },
+};
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js
index 5382f6f4..c331267b 100644
--- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js
@@ -5,1707 +5,14 @@ import Link from "next/link";
import { useState, useEffect } from "react";
import { Card, Badge, Button, AddCustomEmbeddingModal, NoAuthProxyCard, ProviderInfoCard } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
-import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias, isCustomEmbeddingProvider, resolveProviderId } from "@/shared/constants/providers";
-import { getModelsByProviderId } from "@/shared/constants/models";
-import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
+import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, isCustomEmbeddingProvider } from "@/shared/constants/providers";
import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard";
import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard";
-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";
-
-// Shared row layout — defined outside components to avoid re-mount on re-render
-function Row({ label, children }) {
- return (
-
- );
-}
-
-const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
-// Example JSON response (response_format=json):
-{
- "format": "mp3",
- "audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
-}`;
-
-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 }
-}`;
-
-const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png";
-const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png";
-
-function getImageEditDefaults(providerId, modelId) {
- if (providerId !== "cloudflare-ai") return {};
- if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") {
- return { image: CLOUDFLARE_TEST_IMAGE_URL };
- }
- if (modelId === "@cf/runwayml/stable-diffusion-v1-5-inpainting") {
- return { image: CLOUDFLARE_TEST_IMAGE_URL, mask_image: CLOUDFLARE_TEST_MASK_URL };
- }
- return {};
-}
-
-function toImagePreviewSrc(value) {
- const trimmed = typeof value === "string" ? value.trim() : "";
- if (!trimmed) return "";
- if (/^(data:image\/|https?:\/\/)/i.test(trimmed)) return trimmed;
- return `data:image/png;base64,${trimmed}`;
-}
-
-// Config-driven example defaults per kind
-const KIND_EXAMPLE_CONFIG = {
- webSearch: {
- inputLabel: "Query",
- inputPlaceholder: "What is the latest news about AI?",
- defaultInput: "What is the latest news about AI?",
- bodyKey: "query",
- defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`,
- extraFields: [
- { key: "search_type", label: "Type", type: "select", default: "web", options: ["web", "news"] },
- { key: "max_results", label: "Max results", type: "number", default: 5, min: 1, max: 100 },
- { key: "country", label: "Country", type: "text", default: "" },
- { key: "language", label: "Language", type: "text", default: "" },
- ],
- },
- webFetch: {
- inputLabel: "URL",
- inputPlaceholder: "https://example.com",
- defaultInput: "https://example.com",
- bodyKey: "url",
- defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`,
- extraFields: [
- { key: "format", label: "Format", type: "select", default: "markdown", options: ["markdown", "text", "html"] },
- { key: "max_characters", label: "Max chars", type: "number", default: 0, min: 0 },
- ],
- },
- image: {
- inputLabel: "Prompt",
- inputPlaceholder: "A cute cat wearing a hat",
- defaultInput: "A cute cat wearing a hat",
- bodyKey: "prompt",
- defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`,
- extraFields: [
- { key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
- { key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] },
- { key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] },
- { key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] },
- { key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
- { key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] },
- { key: "image_detail", label: "Image Detail", type: "select", default: "high", options: ["auto", "low", "high", "original"] },
- { key: "output_format", label: "Codec", type: "select", default: "png", options: ["png", "jpeg", "webp"] },
- ],
- },
- imageToText: {
- inputLabel: "Image URL",
- inputPlaceholder: "https://example.com/image.png",
- defaultInput: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg",
- bodyKey: "url",
- extraBody: { prompt: "Describe this image in detail" },
- defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`,
- },
- video: {
- inputLabel: "Prompt",
- inputPlaceholder: "A serene lake at sunset",
- defaultInput: "A serene lake at sunset",
- bodyKey: "prompt",
- defaultResponse: `{\n "data": [\n { "url": "..." }\n ]\n}`,
- },
- music: {
- inputLabel: "Prompt",
- inputPlaceholder: "A calm piano melody",
- defaultInput: "A calm piano melody",
- bodyKey: "prompt",
- defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`,
- },
-};
-
-// EmbeddingExampleCard
-function EmbeddingExampleCard({ providerId, customAlias }) {
- const isCustom = isCustomEmbeddingProvider(providerId);
- const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId);
- const embeddingModels = isCustom ? [] : 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 [dimensions, setDimensions] = useState("");
- 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}` : "";
-
- // Build request body — include dimensions only if user provided a positive number
- const buildBody = () => {
- const body = { model: modelFull, input: input.trim() };
- const dim = Number(dimensions);
- if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim;
- return body;
- };
-
- const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
- -H "Content-Type: application/json" \\
- -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
- -d '${JSON.stringify(buildBody())}'`;
-
- 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(buildBody()),
- });
- 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 (
-
- Example
-
-
- {/* Model — text input for custom node, dropdown otherwise */}
-
- {isCustom ? (
- setSelectedModel(e.target.value)}
- placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
- 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"
- />
- ) : (
-
- )}
-
-
- {/* Endpoint */}
-
-
- useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
- className="w-full min-w-0 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 && (
-
- )}
-
-
-
- {/* API Key */}
-
- 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"
- />
-
-
- {/* Input */}
-
-
- 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 && (
-
- )}
-
-
-
- {/* Dimensions (optional) — truncate embedding vector length */}
-
- setDimensions(e.target.value)}
- placeholder="optional, e.g. 512, 1024 (leave empty for default)"
- className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
- />
-
-
- {/* Curl + Run */}
-
-
-
Request
-
-
-
-
-
-
{curlSnippet}
-
-
- {/* Error */}
- {error &&
{error}
}
-
- {/* Response — default example or real result */}
-
-
-
- Response {result && ⚡ {result.latencyMs}ms}
-
- {result && (
-
- )}
-
-
- {formatResultJson(result?.data)}
-
-
-
-
- );
-}
-
-// ─── 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(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) => 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 }]);
- } 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) => 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 || {});
- }
- } 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 (
- <>
-
- Example
-
-
- {/* Endpoint + API Key as read-only text */}
-
-
-
- {endpoint}/v1/audio/speech
-
- {tunnelEndpoint && (
-
- )}
-
-
-
-
- {apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
-
-
-
- {/* Model selector — prefer ttsConfig.models, else providerModels via modelKey */}
- {config.hasModelSelector && (config.modelKey || AI_PROVIDERS[providerId]?.ttsConfig?.models?.length) && (
-
-
-
- )}
-
- {/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
- {config.hasLanguageHint && (
-
-
-
- )}
-
- {/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
- {config.hasBrowseButton && (
-
-
-
-
-
-
- )}
-
- {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
- {countryVoices.length > 0 && (
-
-
- {countryVoices.map((v) => (
-
- ))}
-
-
- )}
-
- {/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
- {config.hasVoiceIdInput && (
-
-
-
- {
- 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 && (
-
- )}
-
-
-
- )}
-
- {/* Google TTS: Language dropdown */}
- {config.hasLanguageDropdown && (
-
-
-
- )}
-
- {/* Input */}
-
-
- 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 && (
-
- )}
-
-
-
- {/* Output Format */}
-
-
-
-
- {/* Curl + Run */}
-
-
-
Request
-
-
-
-
-
-
{curlSnippet}
-
-
- {error &&
{error}
}
-
- {/* Audio player */}
- {audioUrl ? (
-
-
-
-
- {/* JSON Response (if format is json) */}
- {jsonResponse && (
-
-
- JSON Response
-
-
- {JSON.stringify({
- format: jsonResponse.format,
- audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
- }, null, 2)}
-
-
- )}
-
- ) : (
-
-
Response
-
{DEFAULT_TTS_RESPONSE_EXAMPLE}
-
- )}
-
-
-
- {/* Country Picker Modal */}
- {modalOpen && (
-
setModalOpen(false)}
- >
-
e.stopPropagation()}
- >
- {/* Header */}
-
-
Select Language
-
-
-
- {/* Search */}
-
- 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"
- />
-
-
- {/* Language list */}
-
- {modalError &&
{modalError}
}
- {modalLoading ? (
-
Loading...
- ) : (
-
- {filteredLanguages.map((c) => (
-
- ))}
- {filteredLanguages.length === 0 && (
-
No languages found.
- )}
-
- )}
-
-
-
- )}
- >
- );
-}
-
-// Generic Example Card — config-driven for webSearch, webFetch, image, imageToText, stt, video, music
-function GenericExampleCard({ providerId, kind }) {
- const providerAlias = getProviderAlias(providerId);
- const resolvedId = resolveProviderId(providerAlias);
- const safeProviderAlias = resolvedId === providerId ? providerAlias : providerId;
- const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
- const exConfig = KIND_EXAMPLE_CONFIG[kind];
- const safeExConfig = exConfig || {};
-
- // Get models for this kind (e.g., type="image")
- const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind);
- // Kinds that need a model identifier in the request (image/video/music)
- const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]);
- const needsModel = KIND_NEEDS_MODEL.has(kind);
- const allowManualModel = needsModel && kindModels.length === 0;
- const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
- const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
- const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
- const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
-
- const [input, setInput] = useState(safeExConfig.defaultInput || "");
- const [refImage, setRefImage] = useState("");
- const [maskImage, setMaskImage] = useState("");
- const [extraValues, setExtraValues] = useState(() =>
- (safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
- );
- const [apiKey, setApiKey] = useState("");
- const [useTunnel, setUseTunnel] = useState(false);
- const [localEndpoint, setLocalEndpoint] = useState("");
- const [tunnelEndpoint, setTunnelEndpoint] = useState("");
- const [result, setResult] = useState(null);
- const [progress, setProgress] = useState(null); // { stage, bytesReceived }
- const [partialImage, setPartialImage] = useState(null);
- const [imageOutputFormat, setImageOutputFormat] = useState("json"); // json | binary
- const [binaryImageUrl, setBinaryImageUrl] = useState("");
- const [running, setRunning] = useState(false);
- const [error, setError] = useState("");
- const [connections, setConnections] = useState([]);
- const [pinnedConnectionId, setPinnedConnectionId] = 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(() => {});
- // Load active connections of this provider for pinning
- fetch("/api/providers/client")
- .then((r) => r.json())
- .then((d) => {
- const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false);
- setConnections(conns);
- })
- .catch(() => {});
- }, [providerId]);
-
- // Safe to early-return now that all hooks are declared
- if (!kindConfig || !exConfig) return null;
-
- const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
- const apiPath = kindConfig.endpoint.path;
- // webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present.
- const modelFull = !needsModel
- ? safeProviderAlias
- : (selectedModel ? `${safeProviderAlias}/${selectedModel}` : (allowManualModel ? "" : safeProviderAlias));
- const imageEditDefaults = getImageEditDefaults(providerId, selectedModel);
- const effectiveRefImage = refImage.trim() || imageEditDefaults.image || "";
- const effectiveMaskImage = maskImage.trim() || imageEditDefaults.mask_image || "";
- const refImagePreviewSrc = toImagePreviewSrc(effectiveRefImage);
- const maskImagePreviewSrc = toImagePreviewSrc(effectiveMaskImage);
-
- // Build request body with optional extra fields (only non-empty values)
- const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
- if (v === "" || v === null || v === undefined) return acc;
- if (typeof v === "number" && Number.isNaN(v)) return acc;
- acc[k] = v;
- return acc;
- }, {});
- const requestBody = {
- model: modelFull,
- [exConfig.bodyKey]: input,
- ...exConfig.extraBody,
- ...extraBodyFromFields,
- ...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}),
- ...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}),
- };
-
- // Streaming supported for codex image (Plus/Pro accounts) — disabled when binary output requested
- const wantBinary = kind === "image" && imageOutputFormat === "binary";
- const useStreaming = kind === "image" && providerId === "codex" && !wantBinary;
- const apiPathWithQuery = `${apiPath}${wantBinary ? "?response_format=binary" : ""}`;
- const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`;
- const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPathWithQuery} \\
- ${headersPreview.replace(/\\\n /g, "\\\n ")} \\
- -d '${JSON.stringify(requestBody)}'${wantBinary ? " \\\n --output image.png" : ""}`;
-
- const handleRun = async () => {
- if (!input.trim() || !modelFull) return;
- setRunning(true);
- setError("");
- setResult(null);
- setProgress(null);
- setPartialImage(null);
- if (binaryImageUrl) { try { URL.revokeObjectURL(binaryImageUrl); } catch {} setBinaryImageUrl(""); }
- const start = Date.now();
- try {
- const headers = { "Content-Type": "application/json" };
- if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
- if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId;
- if (useStreaming) headers["Accept"] = "text/event-stream";
- const body = { ...requestBody, model: modelFull };
- const res = await fetch(`/api${apiPathWithQuery}`, {
- method: kindConfig.endpoint.method,
- headers,
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- setError(data?.error?.message || data?.error || `HTTP ${res.status}`);
- return;
- }
- const ctype = res.headers.get("content-type") || "";
- // Binary image response — convert to blob URL
- if (ctype.startsWith("image/")) {
- const blob = await res.blob();
- const objUrl = URL.createObjectURL(blob);
- setBinaryImageUrl(objUrl);
- setResult({ data: { binary: true, mime: ctype, size: blob.size }, latencyMs: Date.now() - start });
- return;
- }
- const isSse = ctype.includes("text/event-stream");
- if (isSse && res.body) {
- // Parse SSE: progress / partial_image / done / error
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let buf = "";
- let finalData = null;
- let streamErr = null;
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += decoder.decode(value, { stream: true });
- let sep;
- while ((sep = buf.indexOf("\n\n")) !== -1) {
- const block = buf.slice(0, sep);
- buf = buf.slice(sep + 2);
- let evt = null, dataStr = "";
- for (const line of block.split("\n")) {
- if (line.startsWith("event:")) evt = line.slice(6).trim();
- else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
- }
- if (!evt) continue;
- try {
- const payload = dataStr ? JSON.parse(dataStr) : {};
- if (evt === "progress") setProgress(payload);
- else if (evt === "partial_image") setPartialImage(payload);
- else if (evt === "done") finalData = payload;
- else if (evt === "error") streamErr = payload?.message || "Stream error";
- } catch {}
- }
- }
- const latencyMs = Date.now() - start;
- if (streamErr) { setError(streamErr); return; }
- if (finalData) setResult({ data: finalData, latencyMs });
- } else {
- const data = await res.json();
- const latencyMs = Date.now() - start;
- setResult({ data, latencyMs });
- }
- } catch (e) {
- setError(e.message || "Network error");
- } finally {
- setRunning(false);
- }
- };
-
- // Mask large b64_json strings in JSON view to keep it readable
- const maskB64 = (obj) => {
- if (!obj || typeof obj !== "object") return obj;
- if (Array.isArray(obj)) return obj.map(maskB64);
- const out = {};
- for (const [k, v] of Object.entries(obj)) {
- out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
- ? `<${v.length} chars base64>`
- : maskB64(v);
- }
- return out;
- };
- const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : "";
-
- return (
-
- Example
-
- {/* Model selector — dropdown if presets exist, else manual input for media kinds */}
- {kindModels.length > 0 ? (
-
-
-
- ) : allowManualModel ? (
-
- setSelectedModel(e.target.value)}
- placeholder="Enter model id (provider-specific)"
- 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"
- />
-
- ) : null}
-
- {/* Endpoint */}
-
-
-
- {endpoint}{apiPath}
-
- {tunnelEndpoint && (
-
- )}
-
-
-
- {/* API Key */}
-
-
- {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
-
-
-
- {/* Connection picker - only show when 2+ connections (or any with email) */}
- {connections.length > 0 && (
-
-
-
- )}
-
- {/* Input */}
-
-
- setInput(e.target.value)}
- placeholder={exConfig.inputPlaceholder}
- 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 && (
-
- )}
-
-
-
- {/* Reference image (only for edit-capable image models) */}
- {supportsEdit && (
-
-
-
- setRefImage(e.target.value)}
- placeholder={imageEditDefaults.image || "https://example.com/source.png"}
- 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"
- />
- {refImage && (
-
- )}
-
- {refImagePreviewSrc && (
-

{ e.currentTarget.style.display = "none"; }}
- onLoad={(e) => { e.currentTarget.style.display = "block"; }}
- />
- )}
-
-
- )}
-
- {supportsMask && (
-
-
-
- setMaskImage(e.target.value)}
- placeholder={imageEditDefaults.mask_image || "https://example.com/mask.png"}
- 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"
- />
- {maskImage && (
-
- )}
-
- {maskImagePreviewSrc && (
-

{ e.currentTarget.style.display = "none"; }}
- onLoad={(e) => { e.currentTarget.style.display = "block"; }}
- />
- )}
-
-
- )}
-
- {/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */}
- {(exConfig.extraFields || [])
- .filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key)))
- .map((f) => (
-
- {f.type === "select" ? (
-
- ) : f.type === "text" ? (
- setExtraValues((s) => ({ ...s, [f.key]: 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"
- />
- ) : (
- setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(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"
- />
- )}
-
- ))}
-
- {/* Output Format toggle (image only) — last */}
- {kind === "image" && (
-
-
-
- )}
-
- {/* Curl + Run */}
-
-
-
Request
-
-
-
-
-
-
{curlSnippet}
-
-
- {/* Streaming progress */}
- {(running || progress) && useStreaming && (
-
-
- {running ? "progress_activity" : "check_circle"}
-
-
- {progress?.stage || "starting"}
- {!running && progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""}
-
-
- )}
-
- {/* Partial image preview (codex stream) */}
- {partialImage?.b64_json && !result && (
-
-
Partial preview
-

-
- )}
-
- {/* Error */}
- {error &&
{error}
}
-
- {/* Response */}
-
-
-
- Response {result && ⚡ {result.latencyMs}ms}
-
- {result && (
-
- )}
-
-
- {result ? resultJson : exConfig.defaultResponse}
-
- {kind === "image" && (binaryImageUrl || result?.data?.data?.[0]) && (
-
-
-

-
- )}
-
-
-
- );
-}
-
-// ─── STT Example Card ────────────────────────────────────────────────────────
-function SttExampleCard({ providerId }) {
- const providerAlias = getProviderAlias(providerId);
- const builtinSttModels = getModelsByProviderId(providerId).filter((m) => m.type === "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) => m.type === "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 (
-
- Example
-
- {/* Model */}
- {sttModels.length > 0 ? (
-
-
-
- ) : (
-
- 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"
- />
-
- )}
-
- {/* Endpoint */}
-
-
-
- {endpoint}/v1/audio/transcriptions
-
- {tunnelEndpoint && (
-
- )}
-
-
-
- {/* API Key */}
-
-
- {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured}
-
-
-
- {/* Audio file */}
-
-
- 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 && (
-
- {audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB
-
- )}
-
-
-
- {/* Language (if model supports) */}
- {allowedParams.includes("language") && (
-
- 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"
- />
-
- )}
-
- {/* Prompt (if model supports) */}
- {allowedParams.includes("prompt") && (
-
- 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"
- />
-
- )}
-
- {/* Temperature (if model supports) */}
- {allowedParams.includes("temperature") && (
-
- 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"
- />
-
- )}
-
- {/* Response format (if model supports) */}
- {allowedParams.includes("response_format") && (
-
-
-
- )}
-
- {/* Curl + Run */}
-
-
-
Request
-
-
-
-
-
-
{curlSnippet}
-
-
- {error &&
{error}
}
-
- {/* Response */}
-
-
-
- Response {result && latency && ⚡ {latency}ms}
-
- {result && (
-
- )}
-
-
- {resultStr}
-
-
-
-
- );
-}
+import { KIND_EXAMPLE_CONFIG } from "./components/exampleShared";
+import { EmbeddingExampleCard } from "./components/EmbeddingExampleCard";
+import { TtsExampleCard } from "./components/TtsExampleCard";
+import { GenericExampleCard } from "./components/GenericExampleCard";
+import { SttExampleCard } from "./components/SttExampleCard";
// MediaProviderDetailPage
export default function MediaProviderDetailPage() {
diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js
index 2851bc0c..4df8cbef 100644
--- a/src/app/(dashboard)/dashboard/profile/page.js
+++ b/src/app/(dashboard)/dashboard/profile/page.js
@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
-import { useRouter } from "next/navigation";
import { Card, Button, Toggle, Input } from "@/shared/components";
import Modal, { ConfirmModal } from "@/shared/components/Modal";
import LanguageSwitcher from "@/shared/components/LanguageSwitcher";
@@ -21,7 +20,6 @@ function getLocaleFromCookie() {
}
export default function ProfilePage() {
- const router = useRouter();
const { theme, setTheme, isDark } = useTheme();
const [locale, setLocale] = useState("en");
const [langOpen, setLangOpen] = useState(false);
@@ -569,8 +567,7 @@ export default function ProfilePage() {
try {
const res = await fetch("/api/auth/logout", { method: "POST" });
if (res.ok) {
- router.push("/login");
- router.refresh();
+ window.location.assign("/login");
}
} catch (err) {
console.error("Failed to logout:", err);
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js
index 33f1f05a..bfc12a13 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js
@@ -3,6 +3,7 @@
import { useState } from "react";
import PropTypes from "prop-types";
import { Button } from "@/shared/components";
+import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) {
const borderColor = testStatus === "ok"
? "border-green-500/40"
@@ -70,7 +71,7 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias,
);
}
-export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) {
+export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic }) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false);
@@ -95,44 +96,24 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
}
};
- const providerAliases = Object.entries(modelAliases).filter(
- ([, model]) => model.startsWith(`${providerStorageAlias}/`)
- );
-
- const allModels = providerAliases.map(([alias, fullModel]) => ({
- modelId: fullModel.replace(`${providerStorageAlias}/`, ""),
- fullModel,
- alias,
- }));
-
- const generateDefaultAlias = (modelId) => {
- const parts = modelId.split("/");
- return parts[parts.length - 1];
- };
-
- const resolveAlias = (modelId) => {
- const fullModel = `${providerStorageAlias}/${modelId}`;
- // Skip if this exact model already has an alias
- if (Object.values(modelAliases).includes(fullModel)) return null;
- const baseAlias = generateDefaultAlias(modelId);
- if (!modelAliases[baseAlias]) return baseAlias;
- const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`;
- if (!modelAliases[prefixedAlias]) return prefixedAlias;
- return null;
- };
+ const allModels = getProviderCustomModelRows({
+ customModels,
+ modelAliases,
+ providerAlias: providerStorageAlias,
+ type: "llm",
+ });
const handleAdd = async () => {
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
- const resolvedAlias = resolveAlias(modelId);
- if (!resolvedAlias) {
- alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases.");
+ if (allModels.some((model) => model.id === modelId)) {
+ alert("Model already exists for this provider.");
return;
}
setAdding(true);
try {
- await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
+ await onAddCustomModel(modelId);
setNewModel("");
} catch (error) {
console.log("Error adding model:", error);
@@ -163,9 +144,8 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
for (const model of models) {
const modelId = model.id || model.name || model.model;
if (!modelId) continue;
- const resolvedAlias = resolveAlias(modelId);
- if (!resolvedAlias) continue;
- await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
+ if (allModels.some((entry) => entry.id === modelId)) continue;
+ await onAddCustomModel(modelId);
importedCount += 1;
}
if (importedCount === 0) {
@@ -215,17 +195,17 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
{allModels.length > 0 && (
- {allModels.map(({ modelId, fullModel, alias }) => (
+ {allModels.map(({ id, alias, source }) => (
onDeleteAlias(alias)}
- onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined}
- testStatus={modelTestResults[modelId]}
- isTesting={testingModelId === modelId}
+ onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
+ onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
+ testStatus={modelTestResults[id]}
+ isTesting={testingModelId === id}
/>
))}
@@ -238,10 +218,12 @@ CompatibleModelsSection.propTypes = {
providerStorageAlias: PropTypes.string.isRequired,
providerDisplayAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired,
+ customModels: PropTypes.arrayOf(PropTypes.object),
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
- onSetAlias: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired,
+ onAddCustomModel: PropTypes.func.isRequired,
+ onDeleteCustomModel: PropTypes.func.isRequired,
connections: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
isActive: PropTypes.bool,
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
index e6e7b611..15096050 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
@@ -1,11 +1,12 @@
"use client";
import { useState, useEffect, useRef } from "react";
+import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
import PropTypes from "prop-types";
-import { Badge, Toggle } from "@/shared/components";
+import { Badge, Toggle, Tooltip } from "@/shared/components";
import CooldownTimer from "./CooldownTimer";
-export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null }) {
+export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false);
const proxyDropdownRef = useRef(null);
@@ -70,10 +71,15 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
const isCookieConnection = rowAuthType === "cookie";
const authIcon = isCookieConnection ? "cookie" : isOAuthConnection ? "lock" : "key";
const authLabel = isOAuthConnection ? "OAuth" : isCookieConnection ? "Cookie" : "API Key";
- const isEmail = (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
- const displayName = isOAuthConnection
- ? (isEmail(connection.email) ? connection.email : (isEmail(connection.name) ? connection.name : (connection.name || connection.email || connection.displayName || "OAuth Account")))
- : (connection.name || connection.email || connection.displayName || "API Key");
+ const displayName = connection.name?.trim()
+ || connection.email?.trim()
+ || connection.displayName?.trim()
+ || (isOAuthConnection ? "OAuth Account" : isCookieConnection ? "Cookie Account" : "API Key");
+ const secondaryDisplayName = connection.name?.trim() && connection.email?.trim() && connection.name.trim() !== connection.email.trim()
+ ? connection.email.trim()
+ : connection.name?.trim() && connection.displayName?.trim() && connection.name.trim() !== connection.displayName.trim()
+ ? connection.displayName.trim()
+ : null;
// Use useState + useEffect for impure Date.now() to avoid calling during render
const [isCooldown, setIsCooldown] = useState(false);
@@ -107,12 +113,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
? "active" // Cooldown expired u2192 treat as active
: connection.testStatus;
- const getStatusVariant = () => {
- if (connection.isActive === false) return "default";
- if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
- if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
- return "default";
- };
+ const getStatusVariant = () => getConnectionStatusVariant(connection.isActive, effectiveStatus);
const getOneByOneVariant = () => {
if (!oneByOneStatus) return "default";
@@ -156,6 +157,9 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
{displayName}
+ {secondaryDisplayName && (
+
{secondaryDisplayName}
+ )}
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
@@ -239,6 +243,17 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
)}
)}
+ {autoPing && (
+
+
+
+ )}