mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
refactor(app): DRY pass — split large files, extract shared utils
S1: delete page.new.js (1724L abandoned) + remove dead getAntigravityProjectId
S2: split large files by natural seams
- usage.js → usage/{github,google,claude,codex,kiro,minimax,misc,shared}.js
- media-providers page → components/{Embedding,Tts,Generic,Stt}ExampleCard.js
- EndpointPageClient → endpointConstants.js + endpointPing.js + components/
- tokenRefresh.js → tokenRefresh/{dedup,providers}.js
- ProviderLimits/index.js: 16 pure fn + 9 constants → utils.js
- oauth/providers.js: 7 pure helpers → providerHelpers.js
S3: shared utils
- getModelKind(m, fallback) → shared/constants/models.js (replaces 20× m.kind||m.type)
- getStatusVariant → shared/utils/connectionStatus.js (dedup ConnectionRow/ConnectionsCard)
- sseChunk → open-sse/utils/sse.js (dedup grok-web/perplexity-web)
- fetchWithTimeout → usage/shared.js (replace 4× AbortController pattern in google.js)
fix: enableObservability2 field name in requestDetailsRepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,62 +5,21 @@ import PropTypes from "prop-types";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime";
|
||||
|
||||
// Locales that unlock wenyan (classical Chinese) caveman levels
|
||||
const WENYAN_LOCALES = ["zh-CN", "zh-TW"];
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
const TUNNEL_PING_INTERVAL_MS = 2000;
|
||||
const TUNNEL_PING_MAX_MS = 300000;
|
||||
const STATUS_POLL_FAST_MS = 5000;
|
||||
const STATUS_POLL_SLOW_MS = 30000;
|
||||
const REACHABLE_MISS_THRESHOLD = 5;
|
||||
const CLIENT_PING_FAST_MS = 10000;
|
||||
const CLIENT_PING_SLOW_MS = 60000;
|
||||
const CLIENT_PING_TIMEOUT_MS = 5000;
|
||||
|
||||
// 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.
|
||||
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.
|
||||
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);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
import {
|
||||
WENYAN_LOCALES,
|
||||
TUNNEL_BENEFITS,
|
||||
TUNNEL_PING_INTERVAL_MS,
|
||||
TUNNEL_PING_MAX_MS,
|
||||
STATUS_POLL_FAST_MS,
|
||||
REACHABLE_MISS_THRESHOLD,
|
||||
CLIENT_PING_FAST_MS,
|
||||
CAVEMAN_LEVELS,
|
||||
} from "./endpointConstants";
|
||||
import { clientPingUrl, clientPingAny } from "./endpointPing";
|
||||
import EndpointRow from "./components/EndpointRow";
|
||||
import StatusAlert from "./components/StatusAlert";
|
||||
import Tooltip from "./components/Tooltip";
|
||||
import SecurityWarning from "./components/SecurityWarning";
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -1474,81 +1433,6 @@ export default function APIPageClient({ machineId }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable endpoint row component */
|
||||
function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>{label}</span>
|
||||
<Input value={url} readOnly className="flex-1 font-mono text-sm" />
|
||||
<button
|
||||
onClick={() => onCopy(url, copyId)}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
|
||||
</button>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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)
|
||||
? <a key={i} href={part} target="_blank" rel="noreferrer" className="underline font-medium">{part}</a>
|
||||
: part
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-2 rounded text-sm ${className} ${status.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
status.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
status.type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{renderMessage(status.message)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline tooltip, Claude Code CLI style */
|
||||
function Tooltip({ text }) {
|
||||
return (
|
||||
<span className="relative group inline-flex items-center">
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted cursor-help">help</span>
|
||||
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Security warning banner with optional action link */
|
||||
function SecurityWarning({ message, action }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[16px] shrink-0 mt-0.5">warning</span>
|
||||
<p className="text-xs flex-1">{message}</p>
|
||||
{action && (
|
||||
<a
|
||||
href={action.href}
|
||||
className="text-xs font-medium underline shrink-0 hover:opacity-80"
|
||||
onClick={action.href.startsWith("#") ? (e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(action.href.slice(1))?.scrollIntoView({ behavior: "smooth" });
|
||||
} : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
APIPageClient.propTypes = {
|
||||
machineId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>{label}</span>
|
||||
<Input value={url} readOnly className="flex-1 font-mono text-sm" />
|
||||
<button
|
||||
onClick={() => onCopy(url, copyId)}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
|
||||
</button>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/** Security warning banner with optional action link */
|
||||
export default function SecurityWarning({ message, action }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[16px] shrink-0 mt-0.5">warning</span>
|
||||
<p className="text-xs flex-1">{message}</p>
|
||||
{action && (
|
||||
<a
|
||||
href={action.href}
|
||||
className="text-xs font-medium underline shrink-0 hover:opacity-80"
|
||||
onClick={action.href.startsWith("#") ? (e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(action.href.slice(1))?.scrollIntoView({ behavior: "smooth" });
|
||||
} : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
? <a key={i} href={part} target="_blank" rel="noreferrer" className="underline font-medium">{part}</a>
|
||||
: part
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-2 rounded text-sm ${className} ${status.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
status.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
status.type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{renderMessage(status.message)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
/** Inline tooltip, Claude Code CLI style */
|
||||
export default function Tooltip({ text }) {
|
||||
return (
|
||||
<span className="relative group inline-flex items-center">
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted cursor-help">help</span>
|
||||
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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 },
|
||||
];
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
}
|
||||
+254
@@ -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 (
|
||||
<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 w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<input
|
||||
value={endpoint}
|
||||
onChange={(e) => 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 && (
|
||||
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !input.trim() || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "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-wrap break-all">{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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {result && <span className="font-normal normal-case">⚡ {result.latencyMs}ms</span>}
|
||||
</span>
|
||||
{result && (
|
||||
<button
|
||||
onClick={() => copyRes(resultJson)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||
{copiedRes ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
|
||||
{formatResultJson(result?.data)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+539
@@ -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 } 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 (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model selector — dropdown if presets exist, else manual input for media kinds */}
|
||||
{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>
|
||||
) : allowManualModel ? (
|
||||
<Row label="Model">
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{/* Endpoint */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||
{endpoint}{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={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 && (
|
||||
<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>
|
||||
{refImagePreviewSrc && (
|
||||
<img
|
||||
src={refImagePreviewSrc}
|
||||
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>
|
||||
)}
|
||||
|
||||
{supportsMask && (
|
||||
<Row label="Mask (URL)">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={maskImage}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMaskImage("")}
|
||||
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>
|
||||
{maskImagePreviewSrc && (
|
||||
<img
|
||||
src={maskImagePreviewSrc}
|
||||
alt="Mask"
|
||||
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 — 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) => (
|
||||
<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>
|
||||
) : f.type === "text" ? (
|
||||
<input
|
||||
type="text"
|
||||
value={extraValues[f.key] ?? ""}
|
||||
placeholder={f.placeholder}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !input.trim() || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "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-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{/* Streaming progress */}
|
||||
{(running || progress) && useStreaming && (
|
||||
<div className="flex flex-col gap-2 px-3 py-2 rounded-lg bg-sidebar border border-border sm:flex-row sm:items-center sm:gap-3">
|
||||
<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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {result && <span className="font-normal normal-case">⚡ {result.latencyMs}ms</span>}
|
||||
</span>
|
||||
{result && (
|
||||
<button
|
||||
onClick={() => copyRes(resultJson)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||
{copiedRes ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
|
||||
{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="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">download</span>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+290
@@ -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 (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model */}
|
||||
{sttModels.length > 0 ? (
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{sttModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
) : (
|
||||
<Row label="Model">
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="Enter model id"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Endpoint */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||
{endpoint}/v1/audio/transcriptions
|
||||
</span>
|
||||
{tunnelEndpoint && (
|
||||
<button
|
||||
onClick={() => setUseTunnel((v) => !v)}
|
||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||
Tunnel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* API Key */}
|
||||
<Row label="API Key">
|
||||
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
||||
{apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Audio file */}
|
||||
<Row label="Audio File">
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/mp4,.m4a,.mp3,.wav,.ogg,.flac,.webm,.opus"
|
||||
onChange={(e) => setAudioFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer"
|
||||
/>
|
||||
{audioFile && (
|
||||
<span className="text-xs text-text-muted font-mono">
|
||||
{audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Language (if model supports) */}
|
||||
{allowedParams.includes("language") && (
|
||||
<Row label="Language">
|
||||
<input
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
placeholder="e.g. en, vi, ja (auto-detect if empty)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Prompt (if model supports) */}
|
||||
{allowedParams.includes("prompt") && (
|
||||
<Row label="Prompt">
|
||||
<input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="optional context to improve accuracy"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Temperature (if model supports) */}
|
||||
{allowedParams.includes("temperature") && (
|
||||
<Row label="Temperature">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(e.target.value)}
|
||||
placeholder="0 - 1 (default 0)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Response format (if model supports) */}
|
||||
{allowedParams.includes("response_format") && (
|
||||
<Row label="Response Format">
|
||||
<select
|
||||
value={responseFormat}
|
||||
onChange={(e) => setResponseFormat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="json">json</option>
|
||||
<option value="text">text</option>
|
||||
<option value="srt">srt</option>
|
||||
<option value="verbose_json">verbose_json</option>
|
||||
<option value="vtt">vtt</option>
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !audioFile || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "Transcribing..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Response */}
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {result && latency && <span className="font-normal normal-case">⚡ {latency}ms</span>}
|
||||
</span>
|
||||
{result && (
|
||||
<button
|
||||
onClick={() => copyRes(resultStr)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||
{copiedRes ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
|
||||
{resultStr}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
|
||||
import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
|
||||
import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js";
|
||||
import { Row } from "./exampleShared";
|
||||
|
||||
const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
|
||||
// Example JSON response (response_format=json):
|
||||
{
|
||||
"format": "mp3",
|
||||
"audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
|
||||
}`;
|
||||
|
||||
export function TtsExampleCard({ providerId }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"];
|
||||
|
||||
// Voice state
|
||||
const [selectedVoice, setSelectedVoice] = useState(config.defaultVoiceId || "");
|
||||
const [selectedVoiceName, setSelectedVoiceName] = useState("");
|
||||
const [voiceId, setVoiceId] = useState(config.defaultVoiceId || ""); // editable voice id (elevenlabs/config providers)
|
||||
// Voices shown below Voice row after language selected
|
||||
const [countryVoices, setCountryVoices] = useState([]);
|
||||
const [selectedLang, setSelectedLang] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState(() => {
|
||||
const cfgModels = AI_PROVIDERS[providerId]?.ttsConfig?.models;
|
||||
if (cfgModels?.length) return cfgModels[0].id;
|
||||
if (config.hasModelSelector && config.modelKey) {
|
||||
const models = getModelsByProviderId(config.modelKey);
|
||||
return models?.[0]?.id || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// Form state
|
||||
const [input, setInput] = useState("Hello, this is a text to speech test.");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
||||
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
|
||||
const [audioUrl, setAudioUrl] = useState("");
|
||||
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [latency, setLatency] = useState(null);
|
||||
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
|
||||
|
||||
// Country picker modal state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [languages, setLanguages] = useState([]);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [modalSearch, setModalSearch] = useState("");
|
||||
const [modalError, setModalError] = useState("");
|
||||
const [byLang, setByLang] = useState({});
|
||||
// Language hint (e.g. Gemini): controls the spoken language without affecting voice selection
|
||||
const [languageHint, setLanguageHint] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEndpoint(window.location.origin);
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||
.catch(() => {});
|
||||
fetch("/api/tunnel/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
||||
.catch(() => {});
|
||||
|
||||
// Pre-select default voice based on provider config
|
||||
if (config.voiceSource === "hardcoded") {
|
||||
const defaultModel = config.hasModelSelector && config.modelKey
|
||||
? (getModelsByProviderId(config.modelKey)?.[0]?.id || "")
|
||||
: "";
|
||||
// Use per-model voices if available, else flat list
|
||||
const voices = (config.voicesPerModel && defaultModel)
|
||||
? (getTtsVoicesForModel(providerId, defaultModel) || [])
|
||||
: getModelsByProviderId(config.voiceKey || providerId).filter((m) => getModelKind(m) === "tts");
|
||||
if (voices.length) {
|
||||
if (config.hasBrowseButton) {
|
||||
// Google TTS: pre-select "en" (English) as default, show as single voice chip
|
||||
const defaultVoice = voices.find((v) => v.id === "en") || voices[0];
|
||||
setSelectedLang(defaultVoice.id);
|
||||
setSelectedVoice(defaultVoice.id);
|
||||
setSelectedVoiceName(defaultVoice.name);
|
||||
setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]);
|
||||
} else {
|
||||
// OpenAI/OpenRouter: set voice chips directly (no language picker)
|
||||
setCountryVoices(voices);
|
||||
setSelectedVoice(voices[0].id);
|
||||
setSelectedVoiceName(voices[0].name || voices[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language
|
||||
// config (nvidia, hyperbolic, deepgram, huggingface, cartesia, playht, coqui, tortoise, inworld, qwen):
|
||||
// use ttsConfig.models for model selector; voice is empty by default (backend uses provider default)
|
||||
}, [providerId]);
|
||||
|
||||
// Update voices when model changes (voicesPerModel providers)
|
||||
useEffect(() => {
|
||||
if (!config.voicesPerModel || !selectedModel) return;
|
||||
const voices = getTtsVoicesForModel(providerId, selectedModel) || [];
|
||||
setCountryVoices(voices);
|
||||
if (voices.length) {
|
||||
setSelectedVoice(voices[0].id);
|
||||
setSelectedVoiceName(voices[0].name || voices[0].id);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Open modal — load language list
|
||||
const openModal = async () => {
|
||||
setModalOpen(true);
|
||||
setModalSearch("");
|
||||
setModalError("");
|
||||
if (languages.length) return; // already loaded
|
||||
setModalLoading(true);
|
||||
try {
|
||||
if (config.voiceSource === "hardcoded") {
|
||||
// Build languages/byLang from static providerModels data
|
||||
const voiceKey = config.voiceKey || providerId;
|
||||
const voices = getModelsByProviderId(voiceKey).filter((m) => getModelKind(m) === "tts");
|
||||
const byLangMap = {};
|
||||
for (const v of voices) {
|
||||
if (!byLangMap[v.id]) byLangMap[v.id] = { code: v.id, name: v.name, voices: [{ id: v.id, name: v.name }] };
|
||||
}
|
||||
setByLang(byLangMap);
|
||||
setLanguages(Object.values(byLangMap).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} else {
|
||||
// Use provider-specific apiEndpoint if available, else default to edge-tts voices API
|
||||
const url = config.apiEndpoint
|
||||
? config.apiEndpoint
|
||||
: `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`;
|
||||
const r = await fetch(url);
|
||||
const d = await r.json();
|
||||
if (d.error) { setModalError(d.error); return; }
|
||||
setLanguages(d.languages || []);
|
||||
setByLang(d.byLang || {});
|
||||
}
|
||||
} catch (e) {
|
||||
setModalError(e.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Click language → close modal → show voices below
|
||||
const handlePickLanguage = (lang) => {
|
||||
setModalOpen(false);
|
||||
setSelectedLang(lang.code);
|
||||
const voices = byLang[lang.code]?.voices || [];
|
||||
setCountryVoices(voices);
|
||||
// Auto-select first voice
|
||||
if (voices.length) {
|
||||
setSelectedVoice(voices[0].id);
|
||||
setSelectedVoiceName(voices[0].name);
|
||||
if (config.hasVoiceIdInput) setVoiceId(voices[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLanguages = modalSearch
|
||||
? languages.filter((c) =>
|
||||
c.name.toLowerCase().includes(modalSearch.toLowerCase()) ||
|
||||
c.code.toLowerCase().includes(modalSearch.toLowerCase())
|
||||
)
|
||||
: languages;
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
// For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice
|
||||
const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice;
|
||||
const modelFull = (() => {
|
||||
if (config.hasModelSelector && selectedModel && activeVoiceId) return `${providerAlias}/${selectedModel}/${activeVoiceId}`;
|
||||
if (config.hasModelSelector && selectedModel) return `${providerAlias}/${selectedModel}`;
|
||||
if (activeVoiceId) return `${providerAlias}/${activeVoiceId}`;
|
||||
return "";
|
||||
})();
|
||||
|
||||
const ttsBody = (() => {
|
||||
const b = { model: modelFull, input };
|
||||
if (config.hasLanguageHint && languageHint) b.language = languageHint;
|
||||
return b;
|
||||
})();
|
||||
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
||||
-d '${JSON.stringify(ttsBody)}' \\
|
||||
${responseFormat === "json" ? "" : "--output speech.mp3"}`;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!input.trim() || !modelFull) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setAudioUrl("");
|
||||
setJsonResponse(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ ...ttsBody, input: input.trim() }),
|
||||
});
|
||||
setLatency(Date.now() - start);
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d?.error?.message || d?.error || `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseFormat === "json") {
|
||||
const data = await res.json();
|
||||
setJsonResponse(data); // Store full JSON response
|
||||
const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
|
||||
setAudioUrl(URL.createObjectURL(audioBlob));
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
setAudioUrl(URL.createObjectURL(blob));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || "Network error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Endpoint + API Key as read-only text */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||
{endpoint}/v1/audio/speech
|
||||
</span>
|
||||
{tunnelEndpoint && (
|
||||
<button
|
||||
onClick={() => setUseTunnel((v) => !v)}
|
||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||
Tunnel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="API Key">
|
||||
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
||||
{apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Model selector — prefer PROVIDER_MODELS[kind=tts], else providerModels via modelKey */}
|
||||
{config.hasModelSelector && (config.modelKey || getModelsByProviderId(providerId).some(m => getModelKind(m) === "tts")) && (
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{(() => {
|
||||
const ttsModels = getModelsByProviderId(providerId).filter(m => getModelKind(m) === "tts");
|
||||
return (ttsModels.length ? ttsModels : getModelsByProviderId(config.modelKey) || []).map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
|
||||
{config.hasLanguageHint && (
|
||||
<Row label="Language">
|
||||
<select
|
||||
value={languageHint}
|
||||
onChange={(e) => setLanguageHint(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Auto-detect</option>
|
||||
{GOOGLE_TTS_LANGUAGES.map((l) => (
|
||||
<option key={l.id} value={l.name}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
|
||||
{config.hasBrowseButton && (
|
||||
<Row label="Language">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background font-mono truncate text-left hover:border-primary/40 transition-colors"
|
||||
>
|
||||
{selectedLang
|
||||
? <span className="text-text-main">{languages.find((l) => l.code === selectedLang)?.name || selectedLang}</span>
|
||||
: <span className="text-text-muted">No language selected</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="flex w-full items-center justify-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors sm:w-auto sm:shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">language</span>
|
||||
Select language
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
|
||||
{countryVoices.length > 0 && (
|
||||
<Row label="Voice">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{countryVoices.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => {
|
||||
setSelectedVoice(v.id);
|
||||
setSelectedVoiceName(v.name);
|
||||
if (config.hasVoiceIdInput) setVoiceId(v.id);
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
|
||||
selectedVoice === v.id
|
||||
? "bg-primary/15 border-primary/40 text-primary font-medium"
|
||||
: "border-border text-text-muted hover:text-primary hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
|
||||
{v.free_users_allowed === true && (
|
||||
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
|
||||
)}
|
||||
{v.free_users_allowed === false && (
|
||||
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-amber-500/15 text-amber-600 border border-amber-500/20">Paid</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
|
||||
{config.hasVoiceIdInput && (
|
||||
<Row label="Voice ID">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={voiceId}
|
||||
onChange={(e) => {
|
||||
setVoiceId(e.target.value);
|
||||
setSelectedVoice(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17"
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
{voiceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setVoiceId(""); setSelectedVoice(""); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Google TTS: Language dropdown */}
|
||||
{config.hasLanguageDropdown && (
|
||||
<Row label="Language">
|
||||
<select
|
||||
value={selectedVoice}
|
||||
onChange={(e) => {
|
||||
const m = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "tts").find((m) => m.id === e.target.value);
|
||||
setSelectedVoice(e.target.value);
|
||||
setSelectedVoiceName(m?.name || e.target.value);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "tts").map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<Row label="Input">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{input && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInput("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Output Format */}
|
||||
<Row label="Output Format">
|
||||
<select
|
||||
value={responseFormat}
|
||||
onChange={(e) => setResponseFormat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="mp3">MP3 (Binary)</option>
|
||||
<option value="json">JSON (Base64)</option>
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !input.trim() || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "Generating..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Audio player */}
|
||||
{audioUrl ? (
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {latency && <span className="font-normal normal-case">⚡ {latency}ms</span>}
|
||||
</span>
|
||||
<a href={audioUrl} download="speech.mp3" className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-[14px]">download</span>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<audio controls src={audioUrl} className="w-full" />
|
||||
|
||||
{/* JSON Response (if format is json) */}
|
||||
{jsonResponse && (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">JSON Response</span>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify({
|
||||
format: jsonResponse.format,
|
||||
audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Response</span>
|
||||
<pre className="mt-1.5 bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-50">{DEFAULT_TTS_RESPONSE_EXAMPLE}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Country Picker Modal */}
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center sm:items-center"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.6)", backdropFilter: "blur(2px)" }}
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[80vh]"
|
||||
style={{ backgroundColor: "var(--color-bg)", isolation: "isolate" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 rounded-t-xl">
|
||||
<h3 className="text-sm font-semibold">Select Language</h3>
|
||||
<button onClick={() => setModalOpen(false)} className="text-text-muted hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-2.5 border-b border-border shrink-0">
|
||||
<input
|
||||
autoFocus
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder="Search language..."
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Language list */}
|
||||
<div className="overflow-y-auto flex-1 p-2">
|
||||
{modalError && <p className="text-xs text-red-500 px-2 py-1">{modalError}</p>}
|
||||
{modalLoading ? (
|
||||
<p className="text-xs text-text-muted px-2 py-3">Loading...</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filteredLanguages.map((c) => (
|
||||
<button
|
||||
key={c.code}
|
||||
onClick={() => handlePickLanguage(c)}
|
||||
className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-left hover:bg-sidebar transition-colors ${
|
||||
selectedLang === c.code ? "bg-primary/10 text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{c.name}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-text-muted">{c.voices.length} voices</span>
|
||||
{selectedLang === c.code && (
|
||||
<span className="material-symbols-outlined text-[16px] text-primary">check</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredLanguages.length === 0 && (
|
||||
<p className="text-xs text-text-muted px-2 py-3">No languages found.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
export function Row({ label, children }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="w-full text-xs font-medium text-text-muted sm:w-20 sm:shrink-0">{label}</span>
|
||||
<div className="w-full min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"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 CooldownTimer from "./CooldownTimer";
|
||||
@@ -107,12 +108,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";
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
||||
@@ -914,7 +914,7 @@ export default function ProviderDetailPage() {
|
||||
const allModels = [
|
||||
...models,
|
||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
||||
].filter((m) => { const k = m.kind || m.type; return !k || k === "llm"; });
|
||||
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; });
|
||||
const disabledSet = new Set(disabledModelIds);
|
||||
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
||||
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
|
||||
@@ -1449,7 +1449,7 @@ export default function ProviderDetailPage() {
|
||||
const allIds = [
|
||||
...models,
|
||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
||||
].filter((m) => { const k = m.kind || m.type; return !k || k === "llm"; }).map((m) => m.id);
|
||||
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id);
|
||||
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Badge, Button, Modal, Select, Toggle, EditConnectionModal, ConfirmModal } from "@/shared/components";
|
||||
|
||||
@@ -86,12 +87,7 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov
|
||||
|
||||
const effectiveStatus = connection.testStatus === "unavailable" && !isCooldown ? "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 displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Button, Modal } from "@/shared/components";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
@@ -206,14 +206,14 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
|
||||
const builtInModels = kindFilter
|
||||
? allBuiltIn.filter((m) => {
|
||||
if (m.kinds) return m.kinds.includes(kindFilter);
|
||||
return (m.kind || m.type || "llm") === kindFilter;
|
||||
return getModelKind(m, "llm") === kindFilter;
|
||||
})
|
||||
: allBuiltIn;
|
||||
|
||||
// Custom models for this provider + kind, dedupe vs built-in
|
||||
const myCustomModels = customModels.filter(
|
||||
(m) => m.providerAlias === providerAlias
|
||||
&& (m.kind || m.type || "llm") === effectiveType
|
||||
&& getModelKind(m, "llm") === effectiveType
|
||||
&& !builtInModels.some((b) => b.id === m.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -4,222 +4,39 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import QuotaTable from "./QuotaTable";
|
||||
import Toggle from "@/shared/components/Toggle";
|
||||
import { parseQuotaData, calculatePercentage } from "./utils";
|
||||
import {
|
||||
parseQuotaData,
|
||||
calculatePercentage,
|
||||
getConnectionLabel,
|
||||
getConnectionQuotaRemaining,
|
||||
sortVisibleConnections,
|
||||
buildLoadingState,
|
||||
filterQuotaStateByConnections,
|
||||
getConnectionsEmptyMessage,
|
||||
getPageSizeLabel,
|
||||
getConnectionsPaginationSummary,
|
||||
getSafePagination,
|
||||
getSafeTotals,
|
||||
shouldResetPage,
|
||||
getPaginationPageValue,
|
||||
getProviderOptions,
|
||||
reconcileConnectionsPage,
|
||||
getQuotaCache,
|
||||
setQuotaCache,
|
||||
QUOTA_CACHE_KEY,
|
||||
REFRESH_INTERVAL_MS,
|
||||
DEPLETED_QUOTA_THRESHOLD,
|
||||
AUTO_REFRESH_STORAGE_KEY,
|
||||
CONNECTIONS_PAGE_SIZE,
|
||||
ACCOUNT_PAGE_SIZE_OPTIONS,
|
||||
ACCOUNT_PAGE_SIZE_MAX,
|
||||
ACCOUNT_FILTER_OPTIONS,
|
||||
QUOTA_SORT_OPTIONS,
|
||||
} from "./utils";
|
||||
import Card from "@/shared/components/Card";
|
||||
import { EditConnectionModal } from "@/shared/components";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
function getConnectionLabel(connection) {
|
||||
const isEmail = (value) =>
|
||||
typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
if (isEmail(connection.email)) return connection.email;
|
||||
if (isEmail(connection.name)) return connection.name;
|
||||
return connection.name;
|
||||
}
|
||||
|
||||
function getConnectionQuotaRemaining(connection, quotaData) {
|
||||
const quota = quotaData[connection.id]?.quotas?.[0];
|
||||
if (!quota) return Number.POSITIVE_INFINITY;
|
||||
if (typeof quota.remaining === "number") return quota.remaining;
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function sortVisibleConnections(
|
||||
connections,
|
||||
quotaData,
|
||||
expiringFirst,
|
||||
providerFilter,
|
||||
quotaSortMode,
|
||||
) {
|
||||
if (providerFilter === "codex" && quotaSortMode !== "default") {
|
||||
return [...connections].sort((a, b) => {
|
||||
const remainingA = getConnectionQuotaRemaining(a, quotaData);
|
||||
const remainingB = getConnectionQuotaRemaining(b, quotaData);
|
||||
const remainingDiff =
|
||||
quotaSortMode === "remaining-asc"
|
||||
? remainingA - remainingB
|
||||
: remainingB - remainingA;
|
||||
|
||||
if (remainingDiff !== 0) return remainingDiff;
|
||||
return (getConnectionLabel(a) || "").localeCompare(
|
||||
getConnectionLabel(b) || "",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!expiringFirst) return connections;
|
||||
|
||||
const getEarliestResetTime = (connection) => {
|
||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||
.map((quota) =>
|
||||
quota.resetAt
|
||||
? new Date(quota.resetAt).getTime()
|
||||
: Number.POSITIVE_INFINITY,
|
||||
)
|
||||
.filter((time) => Number.isFinite(time));
|
||||
return resetTimes.length > 0
|
||||
? Math.min(...resetTimes)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
return [...connections].sort((a, b) => {
|
||||
const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b);
|
||||
if (expiryDiff !== 0) return expiryDiff;
|
||||
return (
|
||||
(a.provider || "").localeCompare(b.provider || "") ||
|
||||
(getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildLoadingState(connections) {
|
||||
const nextLoadingState = {};
|
||||
connections.forEach((connection) => {
|
||||
nextLoadingState[connection.id] = true;
|
||||
});
|
||||
return nextLoadingState;
|
||||
}
|
||||
|
||||
function filterQuotaStateByConnections(state, connections) {
|
||||
const visibleIds = new Set(connections.map((connection) => connection.id));
|
||||
return Object.fromEntries(
|
||||
Object.entries(state).filter(([id]) => visibleIds.has(id)),
|
||||
);
|
||||
}
|
||||
|
||||
function getConnectionsPageRange(pagination) {
|
||||
if (!pagination.total) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
const start = (pagination.page - 1) * pagination.pageSize + 1;
|
||||
const end = Math.min(pagination.page * pagination.pageSize, pagination.total);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) {
|
||||
if (!totals.eligibleConnections) {
|
||||
return {
|
||||
icon: "cloud_off",
|
||||
title: "No Providers Connected",
|
||||
description:
|
||||
"Connect to providers with OAuth to track your API quota limits and usage.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!totals.providerFilteredConnections) {
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts Match Current Filters",
|
||||
description:
|
||||
providerFilter === "all"
|
||||
? "Try changing the account status filter to see more quota trackers."
|
||||
: `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts On This Page",
|
||||
description:
|
||||
"Try moving to another page or refreshing the current filters.",
|
||||
};
|
||||
}
|
||||
|
||||
function sortRequestFromExpiringFirst(expiringFirst) {
|
||||
return expiringFirst ? "expiring" : "priority";
|
||||
}
|
||||
|
||||
function getPageSizeLabel(pageSize, isCustomPageSize) {
|
||||
return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`;
|
||||
}
|
||||
|
||||
function getConnectionsPaginationSummary(pagination) {
|
||||
const { start, end } = getConnectionsPageRange(pagination);
|
||||
return `Showing ${start}-${end} of ${pagination.total}`;
|
||||
}
|
||||
|
||||
function getSafePagination(pagination, fallbackPageSize) {
|
||||
return (
|
||||
pagination || {
|
||||
page: 1,
|
||||
pageSize: fallbackPageSize,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getSafeTotals(totals, fallbackTotal = 0) {
|
||||
return (
|
||||
totals || {
|
||||
eligibleConnections: fallbackTotal,
|
||||
providerFilteredConnections: fallbackTotal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function shouldResetPage(previousValue, nextValue) {
|
||||
return previousValue !== nextValue;
|
||||
}
|
||||
|
||||
function getPaginationPageValue(dataPagination, fallbackPage) {
|
||||
return dataPagination?.page || fallbackPage;
|
||||
}
|
||||
|
||||
function getProviderOptions(dataProviderOptions) {
|
||||
return dataProviderOptions || [];
|
||||
}
|
||||
|
||||
async function reconcileConnectionsPage(fetchConnections, targetPage) {
|
||||
const nextConnections = await fetchConnections(targetPage);
|
||||
return nextConnections;
|
||||
}
|
||||
|
||||
const QUOTA_CACHE_KEY = "quotaCacheData";
|
||||
|
||||
function getQuotaCache() {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const cached = window.localStorage.getItem(QUOTA_CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : {};
|
||||
} catch (error) {
|
||||
console.error("Error reading quota cache:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setQuotaCache(connectionId, quotaEntry) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const cache = getQuotaCache();
|
||||
cache[connectionId] = {
|
||||
...quotaEntry,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch (error) {
|
||||
console.error("Error writing quota cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
|
||||
const DEPLETED_QUOTA_THRESHOLD = 5; // percent
|
||||
const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||
const ACCOUNT_FILTER_OPTIONS = [
|
||||
{ value: "all", label: "All accounts" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "inactive", label: "Turned off" },
|
||||
];
|
||||
const QUOTA_SORT_OPTIONS = [
|
||||
{ value: "default", label: "Default quota order" },
|
||||
{ value: "remaining-asc", label: "% quota: low to high" },
|
||||
{ value: "remaining-desc", label: "% quota: high to low" },
|
||||
];
|
||||
const CONNECTIONS_PAGE_SIZE = 20;
|
||||
const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
const ACCOUNT_PAGE_SIZE_MAX = 500;
|
||||
|
||||
export default function ProviderLimits() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [quotaData, setQuotaData] = useState({});
|
||||
|
||||
@@ -1,5 +1,212 @@
|
||||
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
export const QUOTA_CACHE_KEY = "quotaCacheData";
|
||||
export const REFRESH_INTERVAL_MS = 60000;
|
||||
export const DEPLETED_QUOTA_THRESHOLD = 5;
|
||||
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||
export const CONNECTIONS_PAGE_SIZE = 20;
|
||||
export const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
export const ACCOUNT_PAGE_SIZE_MAX = 500;
|
||||
export const ACCOUNT_FILTER_OPTIONS = [
|
||||
{ value: "all", label: "All accounts" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "inactive", label: "Turned off" },
|
||||
];
|
||||
export const QUOTA_SORT_OPTIONS = [
|
||||
{ value: "default", label: "Default quota order" },
|
||||
{ value: "remaining-asc", label: "% quota: low to high" },
|
||||
{ value: "remaining-desc", label: "% quota: high to low" },
|
||||
];
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
export function getConnectionLabel(connection) {
|
||||
const isEmail = (value) =>
|
||||
typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
if (isEmail(connection.email)) return connection.email;
|
||||
if (isEmail(connection.name)) return connection.name;
|
||||
return connection.name;
|
||||
}
|
||||
|
||||
export function getConnectionQuotaRemaining(connection, quotaData) {
|
||||
const quota = quotaData[connection.id]?.quotas?.[0];
|
||||
if (!quota) return Number.POSITIVE_INFINITY;
|
||||
if (typeof quota.remaining === "number") return quota.remaining;
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function sortVisibleConnections(
|
||||
connections,
|
||||
quotaData,
|
||||
expiringFirst,
|
||||
providerFilter,
|
||||
quotaSortMode,
|
||||
) {
|
||||
if (providerFilter === "codex" && quotaSortMode !== "default") {
|
||||
return [...connections].sort((a, b) => {
|
||||
const remainingA = getConnectionQuotaRemaining(a, quotaData);
|
||||
const remainingB = getConnectionQuotaRemaining(b, quotaData);
|
||||
const remainingDiff =
|
||||
quotaSortMode === "remaining-asc"
|
||||
? remainingA - remainingB
|
||||
: remainingB - remainingA;
|
||||
if (remainingDiff !== 0) return remainingDiff;
|
||||
return (getConnectionLabel(a) || "").localeCompare(
|
||||
getConnectionLabel(b) || "",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!expiringFirst) return connections;
|
||||
|
||||
const getEarliestResetTime = (connection) => {
|
||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||
.map((quota) =>
|
||||
quota.resetAt
|
||||
? new Date(quota.resetAt).getTime()
|
||||
: Number.POSITIVE_INFINITY,
|
||||
)
|
||||
.filter((time) => Number.isFinite(time));
|
||||
return resetTimes.length > 0
|
||||
? Math.min(...resetTimes)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
return [...connections].sort((a, b) => {
|
||||
const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b);
|
||||
if (expiryDiff !== 0) return expiryDiff;
|
||||
return (
|
||||
(a.provider || "").localeCompare(b.provider || "") ||
|
||||
(getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLoadingState(connections) {
|
||||
const nextLoadingState = {};
|
||||
connections.forEach((connection) => {
|
||||
nextLoadingState[connection.id] = true;
|
||||
});
|
||||
return nextLoadingState;
|
||||
}
|
||||
|
||||
export function filterQuotaStateByConnections(state, connections) {
|
||||
const visibleIds = new Set(connections.map((connection) => connection.id));
|
||||
return Object.fromEntries(
|
||||
Object.entries(state).filter(([id]) => visibleIds.has(id)),
|
||||
);
|
||||
}
|
||||
|
||||
export function getConnectionsPageRange(pagination) {
|
||||
if (!pagination.total) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
const start = (pagination.page - 1) * pagination.pageSize + 1;
|
||||
const end = Math.min(pagination.page * pagination.pageSize, pagination.total);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) {
|
||||
if (!totals.eligibleConnections) {
|
||||
return {
|
||||
icon: "cloud_off",
|
||||
title: "No Providers Connected",
|
||||
description:
|
||||
"Connect to providers with OAuth to track your API quota limits and usage.",
|
||||
};
|
||||
}
|
||||
if (!totals.providerFilteredConnections) {
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts Match Current Filters",
|
||||
description:
|
||||
providerFilter === "all"
|
||||
? "Try changing the account status filter to see more quota trackers."
|
||||
: `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts On This Page",
|
||||
description:
|
||||
"Try moving to another page or refreshing the current filters.",
|
||||
};
|
||||
}
|
||||
|
||||
export function sortRequestFromExpiringFirst(expiringFirst) {
|
||||
return expiringFirst ? "expiring" : "priority";
|
||||
}
|
||||
|
||||
export function getPageSizeLabel(pageSize, isCustomPageSize) {
|
||||
return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`;
|
||||
}
|
||||
|
||||
export function getConnectionsPaginationSummary(pagination) {
|
||||
const { start, end } = getConnectionsPageRange(pagination);
|
||||
return `Showing ${start}-${end} of ${pagination.total}`;
|
||||
}
|
||||
|
||||
export function getSafePagination(pagination, fallbackPageSize) {
|
||||
return (
|
||||
pagination || {
|
||||
page: 1,
|
||||
pageSize: fallbackPageSize,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getSafeTotals(totals, fallbackTotal = 0) {
|
||||
return (
|
||||
totals || {
|
||||
eligibleConnections: fallbackTotal,
|
||||
providerFilteredConnections: fallbackTotal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldResetPage(previousValue, nextValue) {
|
||||
return previousValue !== nextValue;
|
||||
}
|
||||
|
||||
export function getPaginationPageValue(dataPagination, fallbackPage) {
|
||||
return dataPagination?.page || fallbackPage;
|
||||
}
|
||||
|
||||
export function getProviderOptions(dataProviderOptions) {
|
||||
return dataProviderOptions || [];
|
||||
}
|
||||
|
||||
export async function reconcileConnectionsPage(fetchConnections, targetPage) {
|
||||
return await fetchConnections(targetPage);
|
||||
}
|
||||
|
||||
export function getQuotaCache() {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const cached = window.localStorage.getItem(QUOTA_CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : {};
|
||||
} catch (error) {
|
||||
console.error("Error reading quota cache:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function setQuotaCache(connectionId, quotaEntry) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const cache = getQuotaCache();
|
||||
cache[connectionId] = {
|
||||
...quotaEntry,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch (error) {
|
||||
console.error("Error writing quota cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
|
||||
* @param {string|Date} date - ISO date string or Date object
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
||||
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
||||
import { getModelKind } from "@/shared/constants/models";
|
||||
|
||||
const KIND_ENDPOINT = {
|
||||
llm: "/v1/chat/completions",
|
||||
@@ -52,10 +53,10 @@ function lookup(fullId, requestedKind) {
|
||||
// PROVIDER_MODELS lookup (by alias key, fallback to providerId)
|
||||
const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
|
||||
const m = requestedKind
|
||||
? list.find((x) => x.id === modelId && (x.kind || x.type || "llm") === requestedKind)
|
||||
? list.find((x) => x.id === modelId && getModelKind(x, "llm") === requestedKind)
|
||||
: list.find((x) => x.id === modelId);
|
||||
if (m) {
|
||||
const kind = m.kind || m.type || "llm";
|
||||
const kind = getModelKind(m, "llm");
|
||||
return buildInfo({ alias, providerId, model: m, kind, providerInfo });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
getProviderAlias,
|
||||
@@ -316,7 +316,7 @@ export async function buildModelsList(kindFilter) {
|
||||
|
||||
const customModelIds = customModels
|
||||
.filter((m) => {
|
||||
if (!m?.id || ((m.kind || m.type) && (m.kind || m.type) !== "llm")) return false;
|
||||
if (!m?.id || (getModelKind(m) && getModelKind(m) !== "llm")) return false;
|
||||
const alias = m.providerAlias;
|
||||
return alias === staticAlias || alias === outputAlias || alias === providerId;
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user