mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
1461 lines
63 KiB
JavaScript
1461 lines
63 KiB
JavaScript
"use client";
|
|
|
|
import { useParams, notFound, useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { useState, useEffect } from "react";
|
|
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
|
|
import ProviderIcon from "@/shared/components/ProviderIcon";
|
|
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias, isCustomEmbeddingProvider } from "@/shared/constants/providers";
|
|
import { getModelsByProviderId } from "@/shared/constants/models";
|
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
|
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";
|
|
|
|
// Shared row layout — defined outside components to avoid re-mount on re-render
|
|
function Row({ label, children }) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-text-muted w-20 shrink-0">{label}</span>
|
|
<div className="flex-1">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
|
|
// Example JSON response (response_format=json):
|
|
{
|
|
"format": "mp3",
|
|
"audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
|
|
}`;
|
|
|
|
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 }
|
|
}`;
|
|
|
|
// 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}`,
|
|
},
|
|
webFetch: {
|
|
inputLabel: "URL",
|
|
inputPlaceholder: "https://example.com",
|
|
defaultInput: "https://example.com",
|
|
bodyKey: "url",
|
|
defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`,
|
|
},
|
|
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}`,
|
|
},
|
|
stt: {
|
|
inputLabel: "Audio URL",
|
|
inputPlaceholder: "https://example.com/audio.mp3",
|
|
defaultInput: "",
|
|
bodyKey: "url",
|
|
defaultResponse: `{\n "text": "Hello world...",\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 (
|
|
<Card>
|
|
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
|
|
|
<div className="flex flex-col gap-2.5">
|
|
{/* Model — text input for custom node, dropdown otherwise */}
|
|
<Row label="Model">
|
|
{isCustom ? (
|
|
<input
|
|
value={selectedModel}
|
|
onChange={(e) => 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"
|
|
/>
|
|
) : (
|
|
<select
|
|
value={selectedModel}
|
|
onChange={(e) => setSelectedModel(e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
>
|
|
{embeddingModels.map((m) => (
|
|
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</Row>
|
|
|
|
{/* Endpoint */}
|
|
<Row label="Endpoint">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
value={endpoint}
|
|
onChange={(e) => useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
|
|
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
|
placeholder="http://localhost:3000"
|
|
/>
|
|
{/* Tunnel toggle — only show if tunnel URL is available */}
|
|
{tunnelEndpoint && (
|
|
<button
|
|
onClick={() => setUseTunnel((v) => !v)}
|
|
title={useTunnel ? "Using tunnel" : "Using local"}
|
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
|
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
|
Tunnel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Row>
|
|
|
|
{/* API Key */}
|
|
<Row label="API Key">
|
|
<input
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder="sk-..."
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
|
/>
|
|
</Row>
|
|
|
|
{/* Input */}
|
|
<Row label="Input">
|
|
<div className="relative">
|
|
<input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
/>
|
|
{input && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setInput("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Row>
|
|
|
|
{/* Dimensions (optional) — truncate embedding vector length */}
|
|
<Row label="Dimensions">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={dimensions}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</Row>
|
|
|
|
{/* Curl + Run */}
|
|
<div className="mt-1">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => copyCurl(curlSnippet)}
|
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
|
{copiedCurl ? "Copied" : "Copy"}
|
|
</button>
|
|
<button
|
|
onClick={handleRun}
|
|
disabled={running || !input.trim() || !modelFull}
|
|
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
|
play_arrow
|
|
</span>
|
|
{running ? "Running..." : "Run"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
|
|
|
{/* Response — default example or real result */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
|
Response {result && <span className="font-normal normal-case">⚡ {result.latencyMs}ms</span>}
|
|
</span>
|
|
{result && (
|
|
<button
|
|
onClick={() => copyRes(resultJson)}
|
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
|
{copiedRes ? "Copied" : "Copy"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre opacity-70">
|
|
{formatResultJson(result?.data)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── TTS Example Card ────────────────────────────────────────────────────────
|
|
function TtsExampleCard({ providerId }) {
|
|
const providerAlias = getProviderAlias(providerId);
|
|
const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"];
|
|
|
|
// Voice state
|
|
const [selectedVoice, setSelectedVoice] = useState("");
|
|
const [selectedVoiceName, setSelectedVoiceName] = useState("");
|
|
const [voiceId, setVoiceId] = useState(""); // editable voice id (elevenlabs)
|
|
// Voices shown below Voice row after language selected
|
|
const [countryVoices, setCountryVoices] = useState([]);
|
|
const [selectedLang, setSelectedLang] = useState("");
|
|
const [selectedModel, setSelectedModel] = useState(() => {
|
|
if (config.hasModelSelector && config.modelKey) {
|
|
const models = getModelsByProviderId(config.modelKey);
|
|
return models?.[0]?.id || "";
|
|
}
|
|
return "";
|
|
});
|
|
|
|
// Form state
|
|
const [input, setInput] = useState("Hello, this is a text to speech test.");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [useTunnel, setUseTunnel] = useState(false);
|
|
const [localEndpoint, setLocalEndpoint] = useState("");
|
|
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
|
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
|
|
const [audioUrl, setAudioUrl] = useState("");
|
|
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
|
|
const [running, setRunning] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [latency, setLatency] = useState(null);
|
|
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
|
|
|
|
// Country picker modal state
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [languages, setLanguages] = useState([]);
|
|
const [modalLoading, setModalLoading] = useState(false);
|
|
const [modalSearch, setModalSearch] = useState("");
|
|
const [modalError, setModalError] = useState("");
|
|
const [byLang, setByLang] = useState({});
|
|
|
|
useEffect(() => {
|
|
setLocalEndpoint(window.location.origin);
|
|
fetch("/api/keys")
|
|
.then((r) => r.json())
|
|
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
|
.catch(() => {});
|
|
fetch("/api/tunnel/status")
|
|
.then((r) => r.json())
|
|
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
|
.catch(() => {});
|
|
|
|
// Pre-select default voice based on provider config
|
|
if (config.voiceSource === "hardcoded") {
|
|
const 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
|
|
}, [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: use voiceId (editable) instead of selectedVoice
|
|
const activeVoiceId = config.hasVoiceIdInput ? voiceId : selectedVoice;
|
|
const modelFull = config.hasModelSelector && activeVoiceId && selectedModel
|
|
? `${providerAlias}/${selectedModel}/${activeVoiceId}`
|
|
: activeVoiceId ? `${providerAlias}/${activeVoiceId}` : "";
|
|
|
|
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
|
|
-H "Content-Type: application/json" \\
|
|
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
|
-d '{"model": "${modelFull}", "input": "${input}"}' \\
|
|
${responseFormat === "json" ? "" : "--output speech.mp3"}`;
|
|
|
|
const handleRun = async () => {
|
|
if (!input.trim() || !modelFull) return;
|
|
setRunning(true);
|
|
setError("");
|
|
setAudioUrl("");
|
|
setJsonResponse(null);
|
|
const start = Date.now();
|
|
try {
|
|
const headers = { "Content-Type": "application/json" };
|
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ model: modelFull, input: input.trim() }),
|
|
});
|
|
setLatency(Date.now() - start);
|
|
if (!res.ok) {
|
|
const d = await res.json().catch(() => ({}));
|
|
setError(d?.error?.message || d?.error || `HTTP ${res.status}`);
|
|
return;
|
|
}
|
|
|
|
if (responseFormat === "json") {
|
|
const data = await res.json();
|
|
setJsonResponse(data); // Store full JSON response
|
|
const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
|
|
setAudioUrl(URL.createObjectURL(audioBlob));
|
|
} else {
|
|
const blob = await res.blob();
|
|
setAudioUrl(URL.createObjectURL(blob));
|
|
}
|
|
} catch (e) {
|
|
setError(e.message || "Network error");
|
|
} finally {
|
|
setRunning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Card>
|
|
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
|
|
|
<div className="flex flex-col gap-2.5">
|
|
{/* Endpoint + API Key as read-only text */}
|
|
<Row label="Endpoint">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
|
{endpoint}/v1/audio/speech
|
|
</span>
|
|
{tunnelEndpoint && (
|
|
<button
|
|
onClick={() => setUseTunnel((v) => !v)}
|
|
title={useTunnel ? "Using tunnel" : "Using local"}
|
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
|
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
|
Tunnel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Row>
|
|
<Row label="API Key">
|
|
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
|
{apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
|
</span>
|
|
</Row>
|
|
|
|
{/* Model selector (OpenAI, ElevenLabs) */}
|
|
{config.hasModelSelector && config.modelKey && (
|
|
<Row label="Model">
|
|
<select
|
|
value={selectedModel}
|
|
onChange={(e) => setSelectedModel(e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
>
|
|
{(getModelsByProviderId(config.modelKey) || []).map((m) => (
|
|
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
|
))}
|
|
</select>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
|
|
{config.hasBrowseButton && (
|
|
<Row label="Language">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={openModal}
|
|
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background font-mono truncate text-left hover:border-primary/40 transition-colors"
|
|
>
|
|
{selectedLang
|
|
? <span className="text-text-main">{languages.find((l) => l.code === selectedLang)?.name || selectedLang}</span>
|
|
: <span className="text-text-muted">No language selected</span>}
|
|
</button>
|
|
<button
|
|
onClick={openModal}
|
|
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors shrink-0"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">language</span>
|
|
Select language
|
|
</button>
|
|
</div>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
|
|
{countryVoices.length > 0 && (
|
|
<Row label="Voice">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{countryVoices.map((v) => (
|
|
<button
|
|
key={v.id}
|
|
onClick={() => {
|
|
setSelectedVoice(v.id);
|
|
setSelectedVoiceName(v.name);
|
|
if (config.hasVoiceIdInput) setVoiceId(v.id);
|
|
}}
|
|
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
|
|
selectedVoice === v.id
|
|
? "bg-primary/15 border-primary/40 text-primary font-medium"
|
|
: "border-border text-text-muted hover:text-primary hover:border-primary/40"
|
|
}`}
|
|
>
|
|
{v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
|
|
{v.free_users_allowed === true && (
|
|
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
|
|
)}
|
|
{v.free_users_allowed === false && (
|
|
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-amber-500/15 text-amber-600 border border-amber-500/20">Paid</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
|
|
{config.hasVoiceIdInput && (
|
|
<Row label="Voice ID">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="relative">
|
|
<input
|
|
value={voiceId}
|
|
onChange={(e) => {
|
|
setVoiceId(e.target.value);
|
|
setSelectedVoice(e.target.value);
|
|
}}
|
|
placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17"
|
|
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
|
/>
|
|
{voiceId && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setVoiceId(""); setSelectedVoice(""); }}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Google TTS: Language dropdown */}
|
|
{config.hasLanguageDropdown && (
|
|
<Row label="Language">
|
|
<select
|
|
value={selectedVoice}
|
|
onChange={(e) => {
|
|
const m = getModelsByProviderId(providerId).filter((m) => m.type === "tts").find((m) => m.id === e.target.value);
|
|
setSelectedVoice(e.target.value);
|
|
setSelectedVoiceName(m?.name || e.target.value);
|
|
}}
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
>
|
|
{getModelsByProviderId(providerId).filter((m) => m.type === "tts").map((m) => (
|
|
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
|
))}
|
|
</select>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Input */}
|
|
<Row label="Input">
|
|
<div className="relative">
|
|
<input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
/>
|
|
{input && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setInput("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Row>
|
|
|
|
{/* Output Format */}
|
|
<Row label="Output Format">
|
|
<select
|
|
value={responseFormat}
|
|
onChange={(e) => setResponseFormat(e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
>
|
|
<option value="mp3">MP3 (Binary)</option>
|
|
<option value="json">JSON (Base64)</option>
|
|
</select>
|
|
</Row>
|
|
|
|
{/* Curl + Run */}
|
|
<div className="mt-1">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => copyCurl(curlSnippet)}
|
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
|
{copiedCurl ? "Copied" : "Copy"}
|
|
</button>
|
|
<button
|
|
onClick={handleRun}
|
|
disabled={running || !input.trim() || !modelFull}
|
|
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
|
play_arrow
|
|
</span>
|
|
{running ? "Generating..." : "Run"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
|
|
</div>
|
|
|
|
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
|
|
|
{/* Audio player */}
|
|
{audioUrl ? (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
|
Response {latency && <span className="font-normal normal-case">⚡ {latency}ms</span>}
|
|
</span>
|
|
<a href={audioUrl} download="speech.mp3" className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
|
|
<span className="material-symbols-outlined text-[14px]">download</span>
|
|
Download
|
|
</a>
|
|
</div>
|
|
<audio controls src={audioUrl} className="w-full" />
|
|
|
|
{/* JSON Response (if format is json) */}
|
|
{jsonResponse && (
|
|
<div className="mt-3">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">JSON Response</span>
|
|
</div>
|
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">
|
|
{JSON.stringify({
|
|
format: jsonResponse.format,
|
|
audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
|
|
}, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Response</span>
|
|
<pre className="mt-1.5 bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre opacity-50">{DEFAULT_TTS_RESPONSE_EXAMPLE}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Country Picker Modal */}
|
|
{modalOpen && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
style={{ backgroundColor: "rgba(0,0,0,0.6)", backdropFilter: "blur(2px)" }}
|
|
onClick={() => setModalOpen(false)}
|
|
>
|
|
<div
|
|
className="border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[80vh]"
|
|
style={{ backgroundColor: "var(--color-bg)", isolation: "isolate" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 rounded-t-xl">
|
|
<h3 className="text-sm font-semibold">Select Language</h3>
|
|
<button onClick={() => setModalOpen(false)} className="text-text-muted hover:text-primary transition-colors">
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="px-4 py-2.5 border-b border-border shrink-0">
|
|
<input
|
|
autoFocus
|
|
value={modalSearch}
|
|
onChange={(e) => setModalSearch(e.target.value)}
|
|
placeholder="Search language..."
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
|
|
{/* Language list */}
|
|
<div className="overflow-y-auto flex-1 p-2">
|
|
{modalError && <p className="text-xs text-red-500 px-2 py-1">{modalError}</p>}
|
|
{modalLoading ? (
|
|
<p className="text-xs text-text-muted px-2 py-3">Loading...</p>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5">
|
|
{filteredLanguages.map((c) => (
|
|
<button
|
|
key={c.code}
|
|
onClick={() => handlePickLanguage(c)}
|
|
className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-left hover:bg-sidebar transition-colors ${
|
|
selectedLang === c.code ? "bg-primary/10 text-primary" : ""
|
|
}`}
|
|
>
|
|
<span className="text-sm">{c.name}</span>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="text-xs text-text-muted">{c.voices.length} voices</span>
|
|
{selectedLang === c.code && (
|
|
<span className="material-symbols-outlined text-[16px] text-primary">check</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
{filteredLanguages.length === 0 && (
|
|
<p className="text-xs text-text-muted px-2 py-3">No languages found.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Generic Example Card — config-driven for webSearch, webFetch, image, imageToText, stt, video, music
|
|
function GenericExampleCard({ providerId, kind }) {
|
|
const providerAlias = getProviderAlias(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);
|
|
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
|
|
const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
|
|
const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
|
|
|
|
const [input, setInput] = useState(safeExConfig.defaultInput || "");
|
|
const [refImage, setRefImage] = 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;
|
|
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
|
|
|
// 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 && refImage.trim() ? { image: refImage.trim() } : {}),
|
|
};
|
|
|
|
// 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 (
|
|
<Card>
|
|
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
|
<div className="flex flex-col gap-2.5">
|
|
{/* Model selector - only show if models available */}
|
|
{kindModels.length > 0 && (
|
|
<Row label="Model">
|
|
<select
|
|
value={selectedModel}
|
|
onChange={(e) => setSelectedModel(e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
>
|
|
{kindModels.map((m) => (
|
|
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
|
))}
|
|
</select>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Endpoint */}
|
|
<Row label="Endpoint">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
|
{endpoint}{apiPath}
|
|
</span>
|
|
{tunnelEndpoint && (
|
|
<button
|
|
onClick={() => setUseTunnel((v) => !v)}
|
|
title={useTunnel ? "Using tunnel" : "Using local"}
|
|
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
|
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
|
Tunnel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Row>
|
|
|
|
{/* API Key */}
|
|
<Row label="API Key">
|
|
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
|
{apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
|
</span>
|
|
</Row>
|
|
|
|
{/* Connection picker - only show when 2+ connections (or any with email) */}
|
|
{connections.length > 0 && (
|
|
<Row label="Connection">
|
|
<select
|
|
value={pinnedConnectionId}
|
|
onChange={(e) => setPinnedConnectionId(e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
>
|
|
<option value="">Auto (by priority)</option>
|
|
{connections.map((c) => {
|
|
const plan = c.providerSpecificData?.chatgptPlanType;
|
|
const label = c.email || c.name || c.id.slice(0, 8);
|
|
return (
|
|
<option key={c.id} value={c.id}>
|
|
{label}{plan ? ` [${plan}]` : ""}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Input */}
|
|
<Row label={exConfig.inputLabel}>
|
|
<div className="relative">
|
|
<input
|
|
value={input}
|
|
onChange={(e) => 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 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setInput("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Row>
|
|
|
|
{/* Reference image (only for edit-capable image models) */}
|
|
{supportsEdit && (
|
|
<Row label="Ref Image (URL)">
|
|
<div className="flex flex-col gap-2">
|
|
<div className="relative">
|
|
<input
|
|
value={refImage}
|
|
onChange={(e) => setRefImage(e.target.value)}
|
|
placeholder="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 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setRefImage("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{refImage.trim() && (
|
|
<img
|
|
src={refImage.trim()}
|
|
alt="Reference"
|
|
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
|
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
|
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Extra fields (filtered by model.params; if undefined → none shown) */}
|
|
{(exConfig.extraFields || [])
|
|
.filter((f) => Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))
|
|
.map((f) => (
|
|
<Row key={f.key} label={f.label}>
|
|
{f.type === "select" ? (
|
|
<select
|
|
value={extraValues[f.key] ?? ""}
|
|
onChange={(e) => 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"
|
|
>
|
|
{(f.options || []).map((opt) => (
|
|
<option key={opt} value={opt}>{opt === "" ? "(default)" : opt}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type="number"
|
|
value={extraValues[f.key] ?? ""}
|
|
min={f.min}
|
|
max={f.max}
|
|
onChange={(e) => 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"
|
|
/>
|
|
)}
|
|
</Row>
|
|
))}
|
|
|
|
{/* Output Format toggle (image only) — last */}
|
|
{kind === "image" && (
|
|
<Row label="Output Format">
|
|
<select
|
|
value={imageOutputFormat}
|
|
onChange={(e) => setImageOutputFormat(e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
|
>
|
|
<option value="json">JSON (Base64)</option>
|
|
<option value="binary">Binary File</option>
|
|
</select>
|
|
</Row>
|
|
)}
|
|
|
|
{/* Curl + Run */}
|
|
<div className="mt-1">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => copyCurl(curlSnippet)}
|
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
|
{copiedCurl ? "Copied" : "Copy"}
|
|
</button>
|
|
<button
|
|
onClick={handleRun}
|
|
disabled={running || !input.trim() || !modelFull}
|
|
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
|
play_arrow
|
|
</span>
|
|
{running ? "Running..." : "Run"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">{curlSnippet}</pre>
|
|
</div>
|
|
|
|
{/* Streaming progress */}
|
|
{(running || progress) && useStreaming && (
|
|
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-sidebar border border-border">
|
|
<span className="material-symbols-outlined text-[16px] text-primary" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
|
{running ? "progress_activity" : "check_circle"}
|
|
</span>
|
|
<span className="text-xs text-text-muted">
|
|
{progress?.stage || "starting"}
|
|
{!running && progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Partial image preview (codex stream) */}
|
|
{partialImage?.b64_json && !result && (
|
|
<div>
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Partial preview</span>
|
|
<img
|
|
src={`data:image/png;base64,${partialImage.b64_json}`}
|
|
alt="Partial"
|
|
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
|
|
|
{/* Response */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
|
Response {result && <span className="font-normal normal-case">⚡ {result.latencyMs}ms</span>}
|
|
</span>
|
|
{result && (
|
|
<button
|
|
onClick={() => copyRes(resultJson)}
|
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
|
{copiedRes ? "Copied" : "Copy"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre opacity-70">
|
|
{result ? resultJson : exConfig.defaultResponse}
|
|
</pre>
|
|
{kind === "image" && (binaryImageUrl || result?.data?.data?.[0]) && (
|
|
<div className="mt-2">
|
|
<div className="flex items-center justify-end mb-1.5">
|
|
<a
|
|
href={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url || "")}
|
|
download="image.png"
|
|
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">download</span>
|
|
Download
|
|
</a>
|
|
</div>
|
|
<img
|
|
src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)}
|
|
alt="Generated"
|
|
className="max-w-full rounded-lg border border-border"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// MediaProviderDetailPage
|
|
export default function MediaProviderDetailPage() {
|
|
const { kind, id } = useParams();
|
|
const router = useRouter();
|
|
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
|
|
const isCustom = isCustomEmbeddingProvider(id) && kind === "embedding";
|
|
|
|
const handleDeleteCustom = async () => {
|
|
if (!confirm("Delete this Custom Embedding node?")) return;
|
|
try {
|
|
const res = await fetch(`/api/provider-nodes/${id}`, { method: "DELETE" });
|
|
if (res.ok) router.push(`/dashboard/media-providers/${kind}`);
|
|
} catch (error) {
|
|
console.log("Error deleting custom embedding node:", error);
|
|
}
|
|
};
|
|
|
|
const [customNode, setCustomNode] = useState(null);
|
|
const [customLoading, setCustomLoading] = useState(isCustom);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
|
|
// Fetch custom node info from API for custom embedding nodes
|
|
useEffect(() => {
|
|
if (!isCustom) return;
|
|
let cancelled = false;
|
|
fetch("/api/provider-nodes", { cache: "no-store" })
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
if (cancelled) return;
|
|
setCustomNode((d.nodes || []).find((n) => n.id === id) || null);
|
|
setCustomLoading(false);
|
|
})
|
|
.catch(() => { if (!cancelled) setCustomLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [id, isCustom]);
|
|
|
|
if (!kindConfig) return notFound();
|
|
|
|
const builtInProvider = AI_PROVIDERS[id];
|
|
|
|
// For custom embedding nodes, build a synthetic provider object
|
|
const provider = isCustom
|
|
? (customNode ? { id, name: customNode.name || "Custom Embedding", color: "#6366F1", textIcon: "CE" } : null)
|
|
: builtInProvider;
|
|
|
|
if (!isCustom && !builtInProvider) return notFound();
|
|
if (isCustom && !customLoading && !customNode) return notFound();
|
|
if (isCustom && customLoading) {
|
|
return <div className="text-text-muted text-sm py-12 text-center">Loading...</div>;
|
|
}
|
|
|
|
const kinds = isCustom ? ["embedding"] : (provider.serviceKinds ?? ["llm"]);
|
|
if (!isCustom && !kinds.includes(kind)) return notFound();
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8">
|
|
{/* Back */}
|
|
<div>
|
|
<Link
|
|
href={`/dashboard/media-providers/${kind}`}
|
|
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
|
|
>
|
|
<span className="material-symbols-outlined text-lg">arrow_back</span>
|
|
{kindConfig.label}
|
|
</Link>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<div className="size-12 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${provider.color}15` }}>
|
|
<ProviderIcon
|
|
src={`/providers/${provider.id}.png`}
|
|
alt={provider.name}
|
|
size={48}
|
|
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
|
|
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
|
fallbackColor={provider.color}
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<h1 className="text-3xl font-semibold tracking-tight">{provider.name}</h1>
|
|
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
|
{isCustom && <Badge variant="default" size="sm">Custom · {customNode?.prefix}</Badge>}
|
|
{kinds.map((k) => (
|
|
<Badge key={k} variant={k === kind ? "primary" : "default"} size="sm">
|
|
{k.toUpperCase()}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{isCustom && (
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="secondary" icon="edit" onClick={() => setShowEditModal(true)}>
|
|
Edit
|
|
</Button>
|
|
<Button size="sm" variant="secondary" icon="delete" onClick={handleDeleteCustom}>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Kind-specific notice (e.g. codex/image requires Plus) */}
|
|
{!isCustom && provider.kindNotice?.[kind] && (
|
|
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400">
|
|
<span className="material-symbols-outlined text-[20px] mt-0.5">warning</span>
|
|
<p className="text-sm">{provider.kindNotice[kind]}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connections */}
|
|
{!isCustom && provider.noAuth ? (
|
|
<Card>
|
|
<div className="flex items-center gap-3">
|
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
|
|
<span className="material-symbols-outlined text-[20px]">lock_open</span>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium">No authentication required</p>
|
|
<p className="text-xs text-text-muted">This provider is ready to use.</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
) : (
|
|
<ConnectionsCard providerId={id} isOAuth={false} />
|
|
)}
|
|
|
|
{/* Models - only for non-tts kinds; custom uses prefix as alias */}
|
|
{kind !== "tts" && (
|
|
<ModelsCard
|
|
providerId={id}
|
|
kindFilter={kind}
|
|
providerAliasOverride={isCustom ? customNode?.prefix : undefined}
|
|
/>
|
|
)}
|
|
|
|
{/* Example — per kind */}
|
|
{kind === "embedding" && (
|
|
<EmbeddingExampleCard providerId={id} customAlias={customNode?.prefix} />
|
|
)}
|
|
{kind === "tts" && <TtsExampleCard providerId={id} />}
|
|
{!isCustom && KIND_EXAMPLE_CONFIG[kind] && <GenericExampleCard providerId={id} kind={kind} />}
|
|
|
|
{isCustom && (
|
|
<AddCustomEmbeddingModal
|
|
isOpen={showEditModal}
|
|
node={customNode}
|
|
onClose={() => setShowEditModal(false)}
|
|
onSaved={(updated) => {
|
|
setCustomNode(updated);
|
|
setShowEditModal(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|