diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx new file mode 100644 index 0000000000..fb35ed294e --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx @@ -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 = { + 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 = { + 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 { + 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 ( +
+

+ {t("imagesGenerated", { count: images.length })} +

+
+ {images.map((img, i) => { + const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null); + if (!src) return null; + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {img.revised_prompt + + download + {t("save")} + +
+ ); + })} +
+
+ ); +} + +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([]); + const [providers, setProviders] = useState([]); + const [allConnections, setAllConnections] = useState([]); + 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(null); + const [imageData, setImageData] = useState(null); + const [transcriptionText, setTranscriptionText] = useState(null); + const [loading, setLoading] = useState(false); + const [responseStatus, setResponseStatus] = useState(null); + const [responseDuration, setResponseDuration] = useState(null); + const abortRef = useRef(null); + + const [uploadedFile, setUploadedFile] = useState(null); + const [uploadedImages, setUploadedImages] = useState([]); + + 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(); + 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(); + 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).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) => { + const file = e.target.files?.[0] ?? null; + setUploadedFile(file); + }; + + const handleImageFileChange = async (e: React.ChangeEvent) => { + 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, imageBase64s: string[]): Record => { + if (!imageBase64s.length) return parsed; + const messages = [...((parsed.messages as Array>) || [])]; + 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; + for (const [k, v] of Object.entries(extra)) { + if (k !== "file") form.append(k, String(v)); + } + } catch { + /* ignore parse errors */ + } + const fetchHeaders: Record = {}; + 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; + if (supportsVision && uploadedImages.length > 0) { + parsed = buildChatBodyWithImages(parsed, uploadedImages); + } + const fetchHeaders: Record = { "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; + 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 ( +
+ {/* Info Banner */} +
+ + science + +
+

{t("title")}

+

{t("description")}

+
+
+ + {/* Controls */} + +
+
+ + ) => handleProviderChange(e.target.value)} + options={providers} + className="w-full" + /> +
+ )} + + {!isSearchEndpoint && ( +
+ + ) => 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" + /> +
+ )} + + {!isSearchEndpoint && ( +
+ {loading ? ( + + ) : ( + + )} +
+ )} +
+
+ + {/* File Upload Zone */} + {(isTranscriptionEndpoint || supportsVision) && ( + +
+
+ + attach_file + +

+ {isTranscriptionEndpoint ? t("audioFile") : t("attachImages")} +

+ {isTranscriptionEndpoint && ( + + {t("multipartFormData")} + + )} + {supportsVision && ( + + {t("upToImages")} + + )} +
+ {isTranscriptionEndpoint && ( +
+ + {uploadedFile && ( +

+ + check_circle + + {uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB) +

+ )} + {!uploadedFile && ( +

+ info + {t("selectAudioFile")} +

+ )} +
+ )} + {supportsVision && ( +
+ 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 && ( +
+ {uploadedImages.map((src, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Attached + +
+ ))} + +
+ )} +
+ )} +
+
+ )} + + {/* Split Editor View */} +
+ +
+
+
+ + upload + +

{t("request")}

+ + POST {ENDPOINT_PATHS[selectedEndpoint]} + +
+
+ + +
+
+ {isTranscriptionEndpoint && ( +

+ + info + + {t("transcriptionHint")} +

+ )} +
+ setRequestBody(value || "")} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 12, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "on", + automaticLayout: true, + formatOnPaste: true, + }} + /> +
+
+
+ + +
+
+
+ + download + +

{t("response")}

+ {responseStatus !== null && ( + = 200 && responseStatus < 300 ? "success" : "error" + } + size="sm" + > + {responseStatus} + + )} + {responseDuration !== null && ( + {responseDuration}ms + )} + {loading && ( + + progress_activity + + )} +
+
+ +
+
+
+ {audioUrl ? ( + + ) : imageData ? ( + + ) : transcriptionText !== null ? ( +
+

+ {t("transcription")} +

+
+ {transcriptionText} +
+ +
+ ) : ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/playground/page.tsx b/src/app/(dashboard)/dashboard/playground/page.tsx index 7ead418ed7..e755c212d7 100644 --- a/src/app/(dashboard)/dashboard/playground/page.tsx +++ b/src/app/(dashboard)/dashboard/playground/page.tsx @@ -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 = { - 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 = { - 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 { - 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 ( -
-

- {t("imagesGenerated", { count: images.length })} -

-
- {images.map((img, i) => { - const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null); - if (!src) return null; - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {img.revised_prompt - - download - {t("save")} - -
- ); - })} -
-
- ); -} +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([]); - const [providers, setProviders] = useState([]); - const [allConnections, setAllConnections] = useState([]); - 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(null); - const [imageData, setImageData] = useState(null); - const [transcriptionText, setTranscriptionText] = useState(null); - const [loading, setLoading] = useState(false); - const [responseStatus, setResponseStatus] = useState(null); - const [responseDuration, setResponseDuration] = useState(null); - const abortRef = useRef(null); - - // File upload state - const [uploadedFile, setUploadedFile] = useState(null); - const [uploadedImages, setUploadedImages] = useState([]); // 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(); - 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(); - 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) => { - const file = e.target.files?.[0] ?? null; - setUploadedFile(file); - }; - - /** Handle image file select for vision models */ - const handleImageFileChange = async (e: React.ChangeEvent) => { - 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 = {}; - 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 = { "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 ( -
- {/* Info Banner */} -
- - science - -
-

{t("title")}

-

{t("description")}

-
-
- - {/* Controls */} - -
- {/* Endpoint — always first */} -
- - handleProviderChange(e.target.value)} - options={providers} - className="w-full" - /> -
- )} - - {/* Model — hidden in search mode */} - {!isSearchEndpoint && !isConversationalEndpoint && ( -
- - 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" - /> -
- )} - - {/* Send Button — hidden in search mode (SearchPlayground has its own) */} - {!isSearchEndpoint && !isConversationalEndpoint && ( -
- {loading ? ( - - ) : ( - - )} -
- )} -
-
- - {/* Isolated sub-components */} - {isSearchEndpoint ? ( - - ) : isConversationalEndpoint ? ( - 0 - ? t("autoAccounts", { count: providerConnections.length }) - : "" - } - /> - ) : ( - <> - {/* File Upload Zone — shown for transcription and vision models */} - {(isTranscriptionEndpoint || supportsVision) && ( - -
-
- - attach_file - -

- {isTranscriptionEndpoint ? t("audioFile") : t("attachImages")} -

- {isTranscriptionEndpoint && ( - - {t("multipartFormData")} - - )} - {supportsVision && ( - - {t("upToImages")} - - )} -
- {isTranscriptionEndpoint && ( -
- - {uploadedFile && ( -

- - check_circle - - {uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB) -

- )} - {!uploadedFile && ( -

- info - {t("selectAudioFile")} -

- )} -
- )} - {supportsVision && ( -
- - {uploadedImages.length > 0 && ( -
- {uploadedImages.map((src, i) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`Attached - -
- ))} - -
- )} -
- )} -
-
- )} - - {/* Split Editor View */} -
- {/* Request Panel */} - -
-
-
- - upload - -

{t("request")}

- - POST {ENDPOINT_PATHS[selectedEndpoint]} - -
-
- - -
-
- {isTranscriptionEndpoint && ( -

- - info - - {t("transcriptionHint")} -

- )} -
- setRequestBody(value || "")} - theme="vs-dark" - options={{ - minimap: { enabled: false }, - fontSize: 12, - lineNumbers: "on", - scrollBeyondLastLine: false, - wordWrap: "on", - automaticLayout: true, - formatOnPaste: true, - }} - /> -
-
-
- - {/* Response Panel */} - -
-
-
- - download - -

{t("response")}

- {responseStatus !== null && ( - = 200 && responseStatus < 300 ? "success" : "error" - } - size="sm" - > - {responseStatus} - - )} - {responseDuration !== null && ( - {responseDuration}ms - )} - {loading && ( - - progress_activity - - )} -
-
- -
-
-
- {audioUrl ? ( - - ) : imageData ? ( - - ) : transcriptionText !== null ? ( -
-

- {t("transcription")} -

-
- {transcriptionText} -
- -
- ) : ( - - )} -
-
-
-
- - )} -
+ + + ); }