feat(playground): migrate Monaco editor to ApiTab (D14 zero regression)

Cuts the full content of page.tsx (889 LOC) to tabs/ApiTab.tsx with zero
logic changes: 10 endpoints, multimodal upload (vision images + audio),
SSE streaming, model/provider select, Monaco request/response editors,
image generation inline render, speech playback. page.tsx replaced with
Suspense-wrapped <PlaygroundStudio /> shell.
This commit is contained in:
diegosouzapw
2026-05-28 09:24:55 -03:00
parent f24406dcf4
commit 9a36d9ecf5
2 changed files with 854 additions and 882 deletions

View File

@@ -0,0 +1,846 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx
// D14: Preserves 100% of the original page.tsx Monaco multi-endpoint editor.
// Content migrated from src/app/(dashboard)/dashboard/playground/page.tsx (889 LOC).
// Zero logic changes — just moved into this tab component.
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Card, Button, Select, Badge } from "@/shared/components";
import { ALIAS_TO_ID } from "@/shared/constants/providers";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import dynamic from "next/dynamic";
// Monaco editor lazy-loaded (ssr: false) to avoid SSR issues (F10 requirement)
const Editor = dynamic(() => import("@/shared/components/MonacoEditor"), { ssr: false });
interface ModelInfo {
id: string;
object: string;
owned_by: string;
}
interface ProviderOption {
value: string;
label: string;
}
interface ConnectionOption {
id: string;
name: string;
email?: string;
provider: string;
authType: string;
}
const DEFAULT_BODIES: Record<string, object> = {
chat: {
model: "",
messages: [{ role: "user", content: "Hello! Say hi in one sentence." }],
max_tokens: 100,
stream: false,
},
responses: {
model: "",
input: "Hello! Say hi in one sentence.",
stream: false,
},
images: {
model: "",
prompt: "A beautiful sunset over mountains",
n: 1,
size: "1024x1024",
},
embeddings: {
model: "",
input: "Hello world",
encoding_format: "float",
},
speech: {
model: "openai/tts-1",
input: "Hello, this is a test of the text-to-speech endpoint.",
voice: "alloy",
response_format: "mp3",
},
transcription: {
model: "deepgram/nova-3",
language: "en",
},
video: {
model: "comfyui/animatediff",
prompt: "A timelapse of a sunset over the ocean",
n: 1,
},
music: {
model: "comfyui/stable-audio",
prompt: "Calm ambient piano music with soft reverb",
duration: 10,
},
rerank: {
model: "cohere/rerank-english-v3.0",
query: "What is the capital of France?",
documents: [
"Paris is the capital of France.",
"London is the capital of England.",
"Berlin is the capital of Germany.",
],
top_n: 2,
},
search: {
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
};
const ENDPOINT_PATHS: Record<string, string> = {
chat: "/v1/chat/completions",
responses: "/v1/responses",
images: "/v1/images/generations",
embeddings: "/v1/embeddings",
speech: "/v1/audio/speech",
transcription: "/v1/audio/transcriptions",
video: "/v1/videos/generations",
music: "/v1/music/generations",
rerank: "/v1/rerank",
search: "/v1/search",
};
const VISION_MODELS = [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4-vision",
"claude-3",
"claude-sonnet",
"claude-opus",
"claude-haiku",
"gemini",
"llava",
"bakllava",
"pixtral",
"qwen-vl",
"qvq",
"mistral-pixtral",
"kimi",
];
function isVisionModel(modelId: string): boolean {
const lower = modelId.toLowerCase();
return VISION_MODELS.some((k) => lower.includes(k));
}
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function ImageResultsInline({ data }: { data: unknown }) {
const t = useTranslations("playground");
const typed = data as { data?: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> };
const images = typed?.data || [];
if (images.length === 0) return null;
return (
<div className="p-4 space-y-3">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
{t("imagesGenerated", { count: images.length })}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{images.map((img, i) => {
const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null);
if (!src) return null;
return (
<div key={i} className="relative group rounded-lg overflow-hidden border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={img.revised_prompt || t("generatedImage", { index: i + 1 })}
className="w-full"
/>
<a
href={src}
download={`image-${i + 1}.png`}
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
>
<span className="material-symbols-outlined text-[13px]">download</span>
{t("save")}
</a>
</div>
);
})}
</div>
</div>
);
}
interface ApiTabProps {
// configState is received but ApiTab manages its own JSON state (D14)
configState?: unknown;
}
/**
* ApiTab — D14: Preserves 100% of the Monaco multi-endpoint editor.
* Receives configState prop but manages its own internal JSON state
* (the raw API tab has its own raw JSON editor, independent of the config pane).
*/
export default function ApiTab(_props: ApiTabProps) {
const t = useTranslations("playground");
const endpointOptions = useMemo(
() => [
{ value: "chat", label: t("endpointOptions.chat") },
{ value: "responses", label: t("endpointOptions.responses") },
{ value: "images", label: t("endpointOptions.images") },
{ value: "embeddings", label: t("endpointOptions.embeddings") },
{ value: "speech", label: t("endpointOptions.speech") },
{ value: "transcription", label: t("endpointOptions.transcription") },
{ value: "video", label: t("endpointOptions.video") },
{ value: "music", label: t("endpointOptions.music") },
{ value: "rerank", label: t("endpointOptions.rerank") },
{ value: "search", label: t("endpointOptions.search") },
],
[t]
);
const [models, setModels] = useState<ModelInfo[]>([]);
const [providers, setProviders] = useState<ProviderOption[]>([]);
const [allConnections, setAllConnections] = useState<ConnectionOption[]>([]);
const [selectedProvider, setSelectedProvider] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [selectedConnection, setSelectedConnection] = useState("");
const [selectedEndpoint, setSelectedEndpoint] = useState("chat");
const [requestBody, setRequestBody] = useState("");
const [responseBody, setResponseBody] = useState("");
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [imageData, setImageData] = useState<unknown>(null);
const [transcriptionText, setTranscriptionText] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [responseStatus, setResponseStatus] = useState<number | null>(null);
const [responseDuration, setResponseDuration] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
const isSearchEndpoint = selectedEndpoint === "search";
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
const isChatEndpoint = selectedEndpoint === "chat";
const isImageEndpoint = selectedEndpoint === "images";
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
useEffect(() => {
return () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
};
}, [audioUrl]);
const providerConnections = allConnections.filter((c) => {
if (!selectedProvider) return false;
const resolvedProvider = ALIAS_TO_ID[selectedProvider] || selectedProvider;
return c.provider === resolvedProvider || c.provider === selectedProvider;
});
useEffect(() => {
fetch("/v1/models")
.then((res) => res.json())
.then((data: { data?: ModelInfo[] }) => {
const modelList = (data?.data || []) as ModelInfo[];
setModels(modelList);
const providerSet = new Set<string>();
modelList.forEach((m) => {
if (typeof m?.id !== "string") return;
const parts = m.id.split("/");
if (parts.length >= 2) providerSet.add(parts[0]);
});
const providerOpts = Array.from(providerSet)
.sort()
.map((p) => ({ value: p, label: p }));
setProviders(providerOpts);
if (providerOpts.length > 0) {
setSelectedProvider(providerOpts[0].value);
}
})
.catch((err: unknown) => {
console.error("[ApiTab] Failed to load models:", err);
});
fetch("/api/providers/client")
.then((res) => res.json())
.then((data: { connections?: ConnectionOption[] }) => {
const conns: ConnectionOption[] = [];
for (const conn of data?.connections || []) {
conns.push({
id: conn.id,
name: conn.name || conn.id,
email: conn.email,
provider: conn.provider,
authType: conn.authType || "apiKey",
});
}
setAllConnections(conns);
})
.catch(() => {});
}, []);
const filteredModels = (() => {
const seen = new Set<string>();
const out: Array<{ value: string; label: string }> = [];
for (const m of models) {
if (typeof m?.id !== "string") continue;
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push({ value: m.id, label: m.id });
}
return out;
})();
const generateDefaultBody = (endpoint: string, model: string) => {
const template = { ...DEFAULT_BODIES[endpoint] };
if ("model" in template) {
(template as Record<string, unknown>).model = model;
}
return JSON.stringify(template, null, 2);
};
const handleProviderChange = (newProvider: string) => {
setSelectedProvider(newProvider);
setSelectedConnection("");
const providerModels = models
.filter(
(m) => typeof m?.id === "string" && (!newProvider || m.id.startsWith(newProvider + "/"))
)
.map((m) => m.id);
const firstModel = providerModels[0] || "";
setSelectedModel(firstModel);
setRequestBody(generateDefaultBody(selectedEndpoint, firstModel));
clearResults();
};
const handleModelChange = (newModel: string) => {
setSelectedModel(newModel);
setRequestBody(generateDefaultBody(selectedEndpoint, newModel));
clearResults();
};
const handleEndpointChange = (newEndpoint: string) => {
setSelectedEndpoint(newEndpoint);
setRequestBody(generateDefaultBody(newEndpoint, selectedModel));
setUploadedFile(null);
setUploadedImages([]);
clearResults();
};
const clearResults = () => {
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
setAudioUrl(null);
setImageData(null);
setTranscriptionText(null);
};
const handleAudioFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setUploadedFile(file);
};
const handleImageFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const base64s = await Promise.all(files.map(fileToBase64));
setUploadedImages((prev) => [...prev, ...base64s].slice(0, 4));
};
const buildChatBodyWithImages = (parsed: Record<string, unknown>, imageBase64s: string[]): Record<string, unknown> => {
if (!imageBase64s.length) return parsed;
const messages = [...((parsed.messages as Array<Record<string, unknown>>) || [])];
if (messages.length === 0) return parsed;
const lastMsg = messages[messages.length - 1];
const currentContent = typeof lastMsg.content === "string" ? lastMsg.content : "";
messages[messages.length - 1] = {
...lastMsg,
content: [
{ type: "text", text: currentContent },
...imageBase64s.map((b64) => ({
type: "image_url",
image_url: { url: b64 },
})),
],
};
return { ...parsed, messages };
};
const handleSend = useCallback(async () => {
if (!requestBody.trim() && !isTranscriptionEndpoint) return;
setLoading(true);
clearResults();
const controller = new AbortController();
abortRef.current = controller;
const startTime = Date.now();
try {
const path = ENDPOINT_PATHS[selectedEndpoint];
let res: Response;
if (isTranscriptionEndpoint) {
const form = new FormData();
if (uploadedFile) {
form.append("file", uploadedFile);
}
try {
const extra = JSON.parse(requestBody || "{}") as Record<string, unknown>;
for (const [k, v] of Object.entries(extra)) {
if (k !== "file") form.append(k, String(v));
}
} catch {
/* ignore parse errors */
}
const fetchHeaders: Record<string, string> = {};
if (selectedConnection) {
fetchHeaders["X-OmniRoute-Connection"] = selectedConnection;
}
res = await fetch(`/api${path}`, {
method: "POST",
headers: fetchHeaders,
body: form,
signal: controller.signal,
});
} else {
let parsed = JSON.parse(requestBody) as Record<string, unknown>;
if (supportsVision && uploadedImages.length > 0) {
parsed = buildChatBodyWithImages(parsed, uploadedImages);
}
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
if (selectedConnection) {
fetchHeaders["X-OmniRoute-Connection"] = selectedConnection;
}
res = await fetch(`/api${path}`, {
method: "POST",
headers: fetchHeaders,
body: JSON.stringify(parsed),
signal: controller.signal,
});
}
setResponseStatus(res.status);
setResponseDuration(Date.now() - startTime);
const contentType = res.headers.get("content-type") || "";
if (contentType.startsWith("audio/")) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setResponseBody(`// Audio response (${contentType})\n// Click play below to listen.`);
} else if (contentType.includes("text/event-stream")) {
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
accumulated += decoder.decode(value, { stream: true });
setResponseBody(accumulated);
}
}
} else {
const data = await res.json() as Record<string, unknown>;
setResponseBody(JSON.stringify(data, null, 2));
if (isImageEndpoint && data?.data && Array.isArray(data.data) && res.ok) {
setImageData(data);
}
if (isTranscriptionEndpoint && typeof (data as { text?: string })?.text === "string") {
setTranscriptionText((data as { text?: string }).text || "(empty result — check provider credentials)");
}
}
} catch (err: unknown) {
const e = err as { name?: string; message?: string };
if (e.name === "AbortError") {
setResponseBody(JSON.stringify({ cancelled: true }, null, 2));
} else {
setResponseBody(JSON.stringify({ error: e.message }, null, 2));
}
setResponseDuration(Date.now() - startTime);
}
setLoading(false);
}, [requestBody, isTranscriptionEndpoint, selectedEndpoint, uploadedFile, selectedConnection, supportsVision, uploadedImages, isImageEndpoint, clearResults]);
const handleCancel = () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
/* silent */
}
};
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
return (
<div className="space-y-5 p-4 overflow-y-auto">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
science
</span>
<div>
<p className="font-medium text-text-main mb-0.5">{t("title")}</p>
<p>{t("description")}</p>
</div>
</div>
{/* Controls */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("endpoint")}
</label>
<Select
value={selectedEndpoint}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleEndpointChange(e.target.value)}
options={endpointOptions}
className="w-full"
/>
</div>
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("provider")}
</label>
<Select
value={selectedProvider}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
)}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("model")}
</label>
<Select
value={selectedModel}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
)}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("accountKey")}
</label>
<Select
value={selectedConnection}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSelectedConnection(e.target.value)}
options={[
{
value: "",
label:
providerConnections.length > 0
? t("autoAccounts", { count: providerConnections.length })
: t("noAccounts"),
},
...providerConnections.map((c) => ({
value: c.id,
label: pickDisplayValue([c.name, c.email], emailsVisible, c.id),
})),
]}
className="w-full"
/>
</div>
)}
{!isSearchEndpoint && (
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
{t("cancel")}
</Button>
) : (
<Button
icon="send"
onClick={() => void handleSend()}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
{t("send")}
</Button>
)}
</div>
)}
</div>
</Card>
{/* File Upload Zone */}
{(isTranscriptionEndpoint || supportsVision) && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? t("audioFile") : t("attachImages")}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
{t("multipartFormData")}
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
{t("upToImages")}
</Badge>
)}
</div>
{isTranscriptionEndpoint && (
<div>
<input
type="file"
accept="audio/*,video/*"
onChange={handleAudioFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedFile && (
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-green-500">
check_circle
</span>
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
</p>
)}
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
{t("selectAudioFile")}
</p>
)}
</div>
)}
{supportsVision && (
<div>
<input
type="file"
accept="image/*"
multiple
onChange={(e) => void handleImageFileChange(e)}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedImages.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{uploadedImages.map((src, i) => (
<div
key={i}
className="relative group size-16 rounded overflow-hidden border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Attached ${i + 1}`}
className="w-full h-full object-cover"
/>
<button
onClick={() =>
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
}
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
<button
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
>
{t("clearAll")}
</button>
</div>
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">{t("request")}</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => void handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={t("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as Record<string, unknown>).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
{isTranscriptionEndpoint && (
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
{t("transcriptionHint")}
</p>
)}
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
</div>
</Card>
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">{t("response")}</h3>
{responseStatus !== null && (
<Badge
variant={
responseStatus >= 200 && responseStatus < 300 ? "success" : "error"
}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => void handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={t("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
{t("downloadAudio")}
</a>
</div>
) : imageData ? (
<ImageResultsInline data={imageData} />
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
{t("transcription")}
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
</div>
<button
onClick={() => void handleCopy(transcriptionText)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
{t("copyText")}
</button>
</div>
) : (
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -1,889 +1,15 @@
"use client";
// src/app/(dashboard)/dashboard/playground/page.tsx
// Server component shell — delegates to PlaygroundStudio client component.
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Card, Button, Select, Badge } from "@/shared/components";
import { ALIAS_TO_ID } from "@/shared/constants/providers";
import { pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import dynamic from "next/dynamic";
import Editor from "@/shared/components/MonacoEditor";
import { Suspense } from "react";
import { PlaygroundStudio } from "./PlaygroundStudio";
const SearchPlayground = dynamic(() => import("./SearchPlayground"), {
ssr: false,
});
const ChatPlayground = dynamic(() => import("./ChatPlayground"), {
ssr: false,
});
interface ModelInfo {
id: string;
object: string;
owned_by: string;
}
interface ProviderOption {
value: string;
label: string;
}
interface ConnectionOption {
id: string;
name: string;
email?: string;
provider: string;
authType: string;
}
// Endpoint options will be generated dynamically with translations
const DEFAULT_BODIES: Record<string, object> = {
chat: {
model: "",
messages: [{ role: "user", content: "Hello! Say hi in one sentence." }],
max_tokens: 100,
stream: false,
},
responses: {
model: "",
input: "Hello! Say hi in one sentence.",
stream: false,
},
images: {
model: "",
prompt: "A beautiful sunset over mountains",
n: 1,
size: "1024x1024",
},
embeddings: {
model: "",
input: "Hello world",
encoding_format: "float",
},
speech: {
model: "openai/tts-1",
input: "Hello, this is a test of the text-to-speech endpoint.",
voice: "alloy",
response_format: "mp3",
},
transcription: {
// Note: this endpoint requires multipart/form-data — use the file upload below
model: "deepgram/nova-3",
language: "en",
},
video: {
model: "comfyui/animatediff",
prompt: "A timelapse of a sunset over the ocean",
n: 1,
},
music: {
model: "comfyui/stable-audio",
prompt: "Calm ambient piano music with soft reverb",
duration: 10,
},
rerank: {
model: "cohere/rerank-english-v3.0",
query: "What is the capital of France?",
documents: [
"Paris is the capital of France.",
"London is the capital of England.",
"Berlin is the capital of Germany.",
],
top_n: 2,
},
search: {
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
};
const ENDPOINT_PATHS: Record<string, string> = {
chat: "/v1/chat/completions",
responses: "/v1/responses",
images: "/v1/images/generations",
embeddings: "/v1/embeddings",
speech: "/v1/audio/speech",
transcription: "/v1/audio/transcriptions",
video: "/v1/videos/generations",
music: "/v1/music/generations",
rerank: "/v1/rerank",
search: "/v1/search",
};
// Models known to support vision (image input)
const VISION_MODELS = [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4-vision",
"claude-3",
"claude-sonnet",
"claude-opus",
"claude-haiku",
"gemini",
"llava",
"bakllava",
"pixtral",
"qwen-vl",
"qvq",
"mistral-pixtral",
"kimi",
];
function isVisionModel(modelId: string): boolean {
const lower = modelId.toLowerCase();
return VISION_MODELS.some((k) => lower.includes(k));
}
/** Convert a File to base64 data URI */
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/** Render image results from OpenAI-compatible format */
function ImageResultsInline({ data }: { data: any }) {
const t = useTranslations("playground");
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
data?.data || [];
if (images.length === 0) return null;
return (
<div className="p-4 space-y-3">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
{t("imagesGenerated", { count: images.length })}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{images.map((img, i) => {
const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null);
if (!src) return null;
return (
<div key={i} className="relative group rounded-lg overflow-hidden border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={img.revised_prompt || t("generatedImage", { index: i + 1 })}
className="w-full"
/>
<a
href={src}
download={`image-${i + 1}.png`}
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
>
<span className="material-symbols-outlined text-[13px]">download</span>
{t("save")}
</a>
</div>
);
})}
</div>
</div>
);
}
export const dynamic = "force-dynamic";
export default function PlaygroundPage() {
const t = useTranslations("playground");
// Get translated endpoint options
const endpointOptions = useMemo(
() => [
{ value: "conversational", label: "Chat (Conversational)" },
{ value: "chat", label: t("endpointOptions.chat") },
{ value: "responses", label: t("endpointOptions.responses") },
{ value: "images", label: t("endpointOptions.images") },
{ value: "embeddings", label: t("endpointOptions.embeddings") },
{ value: "speech", label: t("endpointOptions.speech") },
{ value: "transcription", label: t("endpointOptions.transcription") },
{ value: "video", label: t("endpointOptions.video") },
{ value: "music", label: t("endpointOptions.music") },
{ value: "rerank", label: t("endpointOptions.rerank") },
{ value: "search", label: t("endpointOptions.search") },
],
[t]
);
const [models, setModels] = useState<ModelInfo[]>([]);
const [providers, setProviders] = useState<ProviderOption[]>([]);
const [allConnections, setAllConnections] = useState<ConnectionOption[]>([]);
const [selectedProvider, setSelectedProvider] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [selectedConnection, setSelectedConnection] = useState("");
const [selectedEndpoint, setSelectedEndpoint] = useState("chat");
const [requestBody, setRequestBody] = useState("");
const [responseBody, setResponseBody] = useState("");
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [imageData, setImageData] = useState<any>(null);
const [transcriptionText, setTranscriptionText] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [responseStatus, setResponseStatus] = useState<number | null>(null);
const [responseDuration, setResponseDuration] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
// File upload state
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedImages, setUploadedImages] = useState<string[]>([]); // base64 URIs for vision
const isSearchEndpoint = selectedEndpoint === "search";
const isConversationalEndpoint = selectedEndpoint === "conversational";
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
const isChatEndpoint = selectedEndpoint === "chat";
const isImageEndpoint = selectedEndpoint === "images";
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
useEffect(() => {
return () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
};
}, [audioUrl]);
// Load connections for a given provider — filtered from allConnections
const providerConnections = allConnections.filter((c) => {
if (!selectedProvider) return false;
const resolvedProvider = ALIAS_TO_ID[selectedProvider] || selectedProvider;
return c.provider === resolvedProvider || c.provider === selectedProvider;
});
// Fetch models and ALL connections at startup
useEffect(() => {
// Fetch models
fetch("/v1/models")
.then((res) => res.json())
.then((data) => {
const modelList = (data?.data || []) as ModelInfo[];
setModels(modelList);
const providerSet = new Set<string>();
modelList.forEach((m) => {
if (typeof m?.id !== "string") return;
const parts = m.id.split("/");
if (parts.length >= 2) providerSet.add(parts[0]);
});
const providerOpts = Array.from(providerSet)
.sort()
.map((p) => ({ value: p, label: p }));
setProviders(providerOpts);
if (providerOpts.length > 0) {
setSelectedProvider(providerOpts[0].value);
}
})
.catch((err) => {
console.error("[playground] Failed to load models:", err);
});
// Fetch ALL connections (once)
fetch("/api/providers/client")
.then((res) => res.json())
.then((data) => {
const conns: ConnectionOption[] = [];
for (const conn of data?.connections || []) {
conns.push({
id: conn.id,
name: conn.name || conn.id,
email: conn.email,
provider: conn.provider,
authType: conn.authType || "apiKey",
});
}
setAllConnections(conns);
})
.catch(() => {});
}, []);
const filteredModels = (() => {
const seen = new Set<string>();
const out: Array<{ value: string; label: string }> = [];
for (const m of models) {
if (typeof m?.id !== "string") continue;
if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue;
if (seen.has(m.id)) continue;
seen.add(m.id);
out.push({ value: m.id, label: m.id });
}
return out;
})();
const generateDefaultBody = (endpoint: string, model: string) => {
const template = { ...DEFAULT_BODIES[endpoint] };
if ("model" in template) {
(template as any).model = model;
}
return JSON.stringify(template, null, 2);
};
const handleProviderChange = (newProvider: string) => {
setSelectedProvider(newProvider);
setSelectedConnection("");
const providerModels = models
.filter(
(m) => typeof m?.id === "string" && (!newProvider || m.id.startsWith(newProvider + "/"))
)
.map((m) => m.id);
const firstModel = providerModels[0] || "";
setSelectedModel(firstModel);
setRequestBody(generateDefaultBody(selectedEndpoint, firstModel));
clearResults();
};
const handleModelChange = (newModel: string) => {
setSelectedModel(newModel);
setRequestBody(generateDefaultBody(selectedEndpoint, newModel));
clearResults();
};
const handleEndpointChange = (newEndpoint: string) => {
setSelectedEndpoint(newEndpoint);
setRequestBody(generateDefaultBody(newEndpoint, selectedModel));
setUploadedFile(null);
setUploadedImages([]);
clearResults();
};
const clearResults = () => {
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
setAudioUrl(null);
setImageData(null);
setTranscriptionText(null);
};
/** Handle audio file select for transcription endpoint */
const handleAudioFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setUploadedFile(file);
};
/** Handle image file select for vision models */
const handleImageFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const base64s = await Promise.all(files.map(fileToBase64));
setUploadedImages((prev) => [...prev, ...base64s].slice(0, 4)); // max 4 images
};
/** Inject uploaded images into chat messages body */
const buildChatBodyWithImages = (parsed: any, imageBase64s: string[]): any => {
if (!imageBase64s.length) return parsed;
const messages = [...(parsed.messages || [])];
if (messages.length === 0) return parsed;
const lastMsg = messages[messages.length - 1];
const currentContent = typeof lastMsg.content === "string" ? lastMsg.content : "";
messages[messages.length - 1] = {
...lastMsg,
content: [
{ type: "text", text: currentContent },
...imageBase64s.map((b64) => ({
type: "image_url",
image_url: { url: b64 },
})),
],
};
return { ...parsed, messages };
};
const handleSend = async () => {
if (!requestBody.trim() && !isTranscriptionEndpoint) return;
setLoading(true);
clearResults();
const controller = new AbortController();
abortRef.current = controller;
const startTime = Date.now();
try {
const path = ENDPOINT_PATHS[selectedEndpoint];
let res: Response;
if (isTranscriptionEndpoint) {
// Multipart form-data for transcription
const form = new FormData();
if (uploadedFile) {
form.append("file", uploadedFile);
}
// Parse extra params from JSON editor
try {
const extra = JSON.parse(requestBody || "{}");
for (const [k, v] of Object.entries(extra)) {
if (k !== "file") form.append(k, String(v));
}
} catch {
/* ignore parse errors */
}
const fetchHeaders: Record<string, string> = {};
if (selectedConnection) {
fetchHeaders["X-OmniRoute-Connection"] = selectedConnection;
}
res = await fetch(`/api${path}`, {
method: "POST",
headers: fetchHeaders,
body: form,
signal: controller.signal,
});
} else {
let parsed = JSON.parse(requestBody);
// Inject vision images if available
if (supportsVision && uploadedImages.length > 0) {
parsed = buildChatBodyWithImages(parsed, uploadedImages);
}
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
if (selectedConnection) {
fetchHeaders["X-OmniRoute-Connection"] = selectedConnection;
}
res = await fetch(`/api${path}`, {
method: "POST",
headers: fetchHeaders,
body: JSON.stringify(parsed),
signal: controller.signal,
});
}
setResponseStatus(res.status);
setResponseDuration(Date.now() - startTime);
const contentType = res.headers.get("content-type") || "";
if (contentType.startsWith("audio/")) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setResponseBody(`// Audio response (${contentType})\n// Click play below to listen.`);
} else if (contentType.includes("text/event-stream")) {
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
accumulated += decoder.decode(value, { stream: true });
setResponseBody(accumulated);
}
}
} else {
const data = await res.json();
setResponseBody(JSON.stringify(data, null, 2));
// Detect image generation result → render inline
if (isImageEndpoint && data?.data && Array.isArray(data.data) && res.ok) {
setImageData(data);
}
// Detect transcription result → render plain text
if (isTranscriptionEndpoint && typeof data?.text === "string") {
setTranscriptionText(data.text || "(empty result — check provider credentials)");
}
}
} catch (err: any) {
if (err.name === "AbortError") {
setResponseBody(JSON.stringify({ cancelled: true }, null, 2));
} else {
setResponseBody(JSON.stringify({ error: err.message }, null, 2));
}
setResponseDuration(Date.now() - startTime);
}
setLoading(false);
};
const handleCancel = () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
/* silent */
}
};
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
return (
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
science
</span>
<div>
<p className="font-medium text-text-main mb-0.5">{t("title")}</p>
<p>{t("description")}</p>
</div>
</div>
{/* Controls */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
{/* Endpoint — always first */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("endpoint")}
</label>
<Select
value={selectedEndpoint}
onChange={(e: any) => handleEndpointChange(e.target.value)}
options={endpointOptions}
className="w-full"
/>
</div>
{/* Provider — hidden in search mode */}
{!isSearchEndpoint && !isConversationalEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("provider")}
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
)}
{/* Model — hidden in search mode */}
{!isSearchEndpoint && !isConversationalEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("model")}
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
)}
{/* Account/Key — always shown when provider is selected */}
{!isSearchEndpoint && !isConversationalEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("accountKey")}
</label>
<Select
value={selectedConnection}
onChange={(e: any) => setSelectedConnection(e.target.value)}
options={[
{
value: "",
label:
providerConnections.length > 0
? t("autoAccounts", { count: providerConnections.length })
: t("noAccounts"),
},
...providerConnections.map((c) => ({
value: c.id,
label: pickDisplayValue([c.name, c.email], emailsVisible, c.id),
})),
]}
className="w-full"
/>
</div>
)}
{/* Send Button — hidden in search mode (SearchPlayground has its own) */}
{!isSearchEndpoint && !isConversationalEndpoint && (
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
{t("cancel")}
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
{t("send")}
</Button>
)}
</div>
)}
</div>
</Card>
{/* Isolated sub-components */}
{isSearchEndpoint ? (
<SearchPlayground />
) : isConversationalEndpoint ? (
<ChatPlayground
selectedProvider={selectedProvider}
selectedModel={selectedModel}
selectedConnection={selectedConnection}
models={models}
providers={providers}
providerConnections={providerConnections}
onProviderChange={handleProviderChange}
onModelChange={handleModelChange}
onConnectionChange={setSelectedConnection}
noAccountsString={t("noAccounts")}
autoAccountsString={
providerConnections.length > 0
? t("autoAccounts", { count: providerConnections.length })
: ""
}
/>
) : (
<>
{/* File Upload Zone — shown for transcription and vision models */}
{(isTranscriptionEndpoint || supportsVision) && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? t("audioFile") : t("attachImages")}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
{t("multipartFormData")}
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
{t("upToImages")}
</Badge>
)}
</div>
{isTranscriptionEndpoint && (
<div>
<input
type="file"
accept="audio/*,video/*"
onChange={handleAudioFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedFile && (
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-green-500">
check_circle
</span>
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
</p>
)}
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
{t("selectAudioFile")}
</p>
)}
</div>
)}
{supportsVision && (
<div>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedImages.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{uploadedImages.map((src, i) => (
<div
key={i}
className="relative group size-16 rounded overflow-hidden border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Attached ${i + 1}`}
className="w-full h-full object-cover"
/>
<button
onClick={() =>
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
}
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
<button
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
>
{t("clearAll")}
</button>
</div>
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">{t("request")}</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={t("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
{isTranscriptionEndpoint && (
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
{t("transcriptionHint")}
</p>
)}
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">{t("response")}</h3>
{responseStatus !== null && (
<Badge
variant={
responseStatus >= 200 && responseStatus < 300 ? "success" : "error"
}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title={t("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
{t("downloadAudio")}
</a>
</div>
) : imageData ? (
<ImageResultsInline data={imageData} />
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
{t("transcription")}
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
</div>
<button
onClick={() => handleCopy(transcriptionText)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
{t("copyText")}
</button>
</div>
) : (
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
</div>
</>
)}
</div>
<Suspense fallback={null}>
<PlaygroundStudio />
</Suspense>
);
}