mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(ui): mobile layout and dark mode FOUC stabilization
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
- **feat(dashboard):** Complete the reconciled v3.7.0 dashboard task set: MCP cache tools and count, video endpoint visibility, provider taxonomy, upstream proxy visibility, provider count badges, costs overview, eval suite management, Custom CLI builder, ACP-focused Agents copy, Translator stream transformer, logs convergence, learned rate-limit health cards, docs expansion, and active request payload inspection.
|
||||
- **feat(mcp):** Register `omniroute_cache_stats` and `omniroute_cache_flush` across MCP schemas, server registration, handlers, docs, and tests.
|
||||
- **feat(providers):** Complete the v3.7.0 provider onboarding wave with self-hosted/local providers (`lm-studio`, `vllm`, `lemonade`, `llamafile`, `triton`, `docker-model-runner`, `xinference`, `oobabooga`), OpenAI-compatible gateways (`glhf`, `cablyai`, `thebai`, `fenayai`, `empower`, `poe`), enterprise providers (`datarobot`, `azure-openai`, `azure-ai`, `bedrock`, `watsonx`, `oci`, `sap`), specialty providers (`clarifai`, `modal`, `reka`, `nous-research`, `nlpcloud`, `petals`, `vertex-partner`), `amazon-q`, GitLab/GitLab Duo, and Chutes.ai.
|
||||
- **feat(providers):** Add Cloudflare Workers AI integration and UI support for robust backend execution.
|
||||
- **feat(telemetry):** Implement proactive public IP capture from client headers (`x-forwarded-for`, `x-real-ip`, etc.) within `safeLogEvents` for accurate database observability.
|
||||
- **feat(audio):** Add AWS Polly as an audio speech provider with SigV4 request signing, static engine catalog, provider validation, managed-provider UI coverage, and sanitization for AWS secret/session fields.
|
||||
- **feat(search):** Add You.com search provider support with dashboard discovery, validation, livecrawl option handling, and search handler normalization.
|
||||
- **feat(video):** Add RunwayML task-based video generation support, task polling, provider catalog metadata, validation, and dashboard/model-list coverage.
|
||||
@@ -55,6 +57,8 @@
|
||||
- **fix(combo):** Fallback to next model on all-accounts-rate-limited (HTTP 503/429) to maintain high availability. (#1523)
|
||||
- **fix(api):** Harden batch and file endpoints for auth and recovery to prevent schema state collisions.
|
||||
- **fix(ui):** Add missing UI wiring for "Add Memory" and "Import" buttons on the `/dashboard/memory` page. (#1506)
|
||||
- **fix(ui):** Prevent Dark Mode FOUC (Flash of Unstyled Content) by injecting a synchronous theme initialization script into the root `layout.tsx`.
|
||||
- **fix(ui):** Fix mobile layout text overflow in provider and combo cards, and enable touch-friendly reordering arrows across all combo strategies.
|
||||
- **fix(core):** Add periodic runtime log rotation checks to prevent disk exhaustion in long-running instances. (#1504 — thanks @ether-btc)
|
||||
- **fix(build):** Resolve missing `process` module in webpack client build for pino-abstract-transport. (#1509 — thanks @hartmark)
|
||||
- **fix(ui):** Add dark mode support for native dropdown `<option>` elements on Linux/Windows, resolving invisible text in settings and combo builders (#1488)
|
||||
|
||||
@@ -483,6 +483,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "codex-auto-review", name: "Codex Auto Review" },
|
||||
{ id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.5", name: "GPT 5.5", targetFormat: "openai-responses" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
|
||||
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
|
||||
@@ -601,6 +602,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
|
||||
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano" },
|
||||
{ id: "gpt-5.5", name: "GPT-5.5" },
|
||||
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
||||
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
@@ -1219,6 +1221,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "openai/gpt-5.4", name: "GPT-5.4" },
|
||||
{ id: "openai/gpt-5.5", name: "GPT-5.5" },
|
||||
{ id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "z-ai/glm-5", name: "GLM-5" },
|
||||
{ id: "x-ai/grok-4.1-fast", name: "Grok 4.1 Fast" },
|
||||
|
||||
@@ -1534,8 +1534,8 @@ function ComboCard({
|
||||
isDragged ? "opacity-60" : ""
|
||||
} ${isSelected ? "border-primary/30 bg-primary/[0.04]" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 md:gap-0">
|
||||
<div className="flex items-center gap-2 md:gap-3 flex-1 min-w-0 w-full">
|
||||
<button
|
||||
type="button"
|
||||
draggable={!dragDisabled}
|
||||
@@ -1644,13 +1644,18 @@ function ComboCard({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!isDisabled}
|
||||
onChange={onToggle}
|
||||
title={isDisabled ? t("enableCombo") : t("disableCombo")}
|
||||
/>
|
||||
<div className="flex items-center justify-between md:justify-end gap-1.5 shrink-0 ml-0 md:ml-2 w-full md:w-auto mt-2 md:mt-0 pt-2 md:pt-0 border-t border-black/5 dark:border-white/5 md:border-t-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!isDisabled}
|
||||
onChange={onToggle}
|
||||
title={isDisabled ? t("enableCombo") : t("disableCombo")}
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted md:hidden">
|
||||
{isDisabled ? "Disabled" : "Active"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 transition-opacity">
|
||||
<button
|
||||
onClick={onTest}
|
||||
@@ -3195,31 +3200,29 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority arrows (priority mode) */}
|
||||
{strategy === "priority" && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
|
||||
title={t("moveUp")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
arrow_upward
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === models.length - 1}
|
||||
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
|
||||
title={t("moveDown")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
arrow_downward
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Reorder arrows (Mobile friendly) */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
|
||||
title={t("moveUp")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
arrow_upward
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === models.length - 1}
|
||||
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
|
||||
title={t("moveDown")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">
|
||||
arrow_downward
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
|
||||
327
src/app/(dashboard)/dashboard/playground/ChatPlayground.tsx
Normal file
327
src/app/(dashboard)/dashboard/playground/ChatPlayground.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
|
||||
interface Message {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ChatPlaygroundProps {
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
selectedConnection: string;
|
||||
models: any[];
|
||||
providers: any[];
|
||||
providerConnections: any[];
|
||||
onProviderChange: (p: string) => void;
|
||||
onModelChange: (m: string) => void;
|
||||
onConnectionChange: (c: string) => void;
|
||||
noAccountsString: string;
|
||||
autoAccountsString: string;
|
||||
}
|
||||
|
||||
export default function ChatPlayground({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
selectedConnection,
|
||||
models,
|
||||
providers,
|
||||
providerConnections,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onConnectionChange,
|
||||
noAccountsString,
|
||||
autoAccountsString,
|
||||
}: ChatPlaygroundProps) {
|
||||
const t = useTranslations("playground");
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [responseStatus, setResponseStatus] = useState<number | null>(null);
|
||||
const [responseDuration, setResponseDuration] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const filteredModels = models
|
||||
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
|
||||
.map((m) => ({ value: m.id, label: m.id }));
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !selectedModel) return;
|
||||
|
||||
const userMessage: Message = { role: "user", content: input };
|
||||
const currentMessages = [...messages, userMessage];
|
||||
setMessages(currentMessages);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (selectedConnection) {
|
||||
fetchHeaders["X-OmniRoute-Connection"] = selectedConnection;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: fetchHeaders,
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages: currentMessages,
|
||||
stream: true,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
setResponseStatus(res.status);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
setError(errorData.error?.message || errorData.error || `Error ${res.status}`);
|
||||
setLoading(false);
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read SSE stream
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let assistantResponse = "";
|
||||
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ") && line !== "data: [DONE]") {
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(6));
|
||||
const delta = parsed.choices[0]?.delta?.content || "";
|
||||
assistantResponse += delta;
|
||||
setMessages((prev) => {
|
||||
const newMsgs = [...prev];
|
||||
newMsgs[newMsgs.length - 1].content = assistantResponse;
|
||||
return newMsgs;
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore parse errors for partial chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") {
|
||||
setError("Request cancelled");
|
||||
} else {
|
||||
setError(err.message || "Network error");
|
||||
}
|
||||
}
|
||||
|
||||
setResponseDuration(Date.now() - startTime);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setMessages([{ role: "system", content: "You are a helpful assistant." }]);
|
||||
setError(null);
|
||||
setResponseStatus(null);
|
||||
setResponseDuration(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const noModels = filteredModels.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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("provider")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onChange={(e: any) => onProviderChange(e.target.value)}
|
||||
options={providers}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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) => onModelChange(e.target.value)}
|
||||
options={filteredModels}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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) => onConnectionChange(e.target.value)}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label:
|
||||
providerConnections.length > 0
|
||||
? autoAccountsString
|
||||
: noAccountsString,
|
||||
},
|
||||
...providerConnections.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.email || c.name || c.id,
|
||||
})),
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
</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">
|
||||
chat
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Conversational Chat</h3>
|
||||
{responseStatus !== null && (
|
||||
<Badge variant={responseStatus < 400 ? "success" : "error"} size="sm">
|
||||
{responseStatus}
|
||||
</Badge>
|
||||
)}
|
||||
{responseDuration !== null && (
|
||||
<span className="text-xs text-text-muted">{responseDuration}ms</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 transition-colors"
|
||||
title="Clear chat"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border rounded-lg bg-surface overflow-hidden h-[500px]">
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex flex-col max-w-[85%] ${
|
||||
msg.role === "user" ? "ml-auto items-end" : "mr-auto items-start"
|
||||
}`}
|
||||
>
|
||||
<span className="text-[10px] text-text-muted uppercase mb-1 px-1">
|
||||
{msg.role}
|
||||
</span>
|
||||
<div
|
||||
className={`px-4 py-2 rounded-2xl text-sm whitespace-pre-wrap ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||
: msg.role === "system"
|
||||
? "bg-black/5 dark:bg-white/5 border border-border text-text-muted w-full"
|
||||
: "bg-bg-alt border border-border text-text-main rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && messages[messages.length - 1]?.role === "user" && (
|
||||
<div className="flex flex-col max-w-[85%] mr-auto items-start">
|
||||
<span className="text-[10px] text-text-muted uppercase mb-1 px-1">
|
||||
assistant
|
||||
</span>
|
||||
<div className="px-4 py-2 rounded-2xl text-sm bg-bg-alt border border-border rounded-tl-sm text-text-muted flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px] animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
Generating...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-center p-2 text-sm text-red-500 bg-red-500/10 rounded border border-red-500/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-3 border-t border-border bg-bg-alt flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message... (Shift+Enter for new line)"
|
||||
className="flex-1 min-h-[44px] max-h-[120px] bg-surface border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-y"
|
||||
rows={1}
|
||||
disabled={loading || noModels}
|
||||
/>
|
||||
{loading ? (
|
||||
<Button icon="stop" variant="secondary" onClick={handleCancel}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || noModels}
|
||||
className="px-4 shrink-0"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
const SearchPlayground = dynamic(() => import("./SearchPlayground"), {
|
||||
ssr: false,
|
||||
});
|
||||
const ChatPlayground = dynamic(() => import("./ChatPlayground"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
@@ -187,6 +190,7 @@ export default function PlaygroundPage() {
|
||||
// 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") },
|
||||
@@ -223,6 +227,7 @@ export default function PlaygroundPage() {
|
||||
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";
|
||||
@@ -504,7 +509,7 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
|
||||
{/* Provider — hidden in search mode */}
|
||||
{!isSearchEndpoint && (
|
||||
{!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")}
|
||||
@@ -519,7 +524,7 @@ export default function PlaygroundPage() {
|
||||
)}
|
||||
|
||||
{/* Model — hidden in search mode */}
|
||||
{!isSearchEndpoint && (
|
||||
{!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")}
|
||||
@@ -534,7 +539,7 @@ export default function PlaygroundPage() {
|
||||
)}
|
||||
|
||||
{/* Account/Key — always shown when provider is selected */}
|
||||
{!isSearchEndpoint && (
|
||||
{!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")}
|
||||
@@ -561,7 +566,7 @@ export default function PlaygroundPage() {
|
||||
)}
|
||||
|
||||
{/* Send Button — hidden in search mode (SearchPlayground has its own) */}
|
||||
{!isSearchEndpoint && (
|
||||
{!isSearchEndpoint && !isConversationalEndpoint && (
|
||||
<div className="shrink-0">
|
||||
{loading ? (
|
||||
<Button icon="stop" variant="secondary" onClick={handleCancel}>
|
||||
@@ -584,9 +589,27 @@ export default function PlaygroundPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Search mode — isolated sub-component */}
|
||||
{/* 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 */}
|
||||
|
||||
@@ -2684,7 +2684,6 @@ export default function ProviderDetailPage() {
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => setShowAddApiKeyModal(true)}
|
||||
disabled={connections.length > 0}
|
||||
>
|
||||
{t("add")}
|
||||
</Button>
|
||||
@@ -2729,9 +2728,6 @@ export default function ProviderDetailPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{connections.length > 0 && (
|
||||
<p className="text-sm text-text-muted">{t("singleConnectionPerCompatible")}</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1106,16 +1106,16 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
className="size-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{/* (#529) ProviderIcon: Lobehub icons → PNG fallback → generic icon */}
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold flex items-center gap-1.5 truncate">
|
||||
{provider.name}
|
||||
<span
|
||||
className={`size-2 rounded-full ${dotColors[authType] || dotColors.oauth} shrink-0`}
|
||||
@@ -1149,7 +1149,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{stats.total > 0 && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
@@ -1234,9 +1234,9 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
className="size-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{/* (#529) ProviderIcon with static override for compatible providers */}
|
||||
@@ -1253,8 +1253,8 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold flex items-center gap-1.5 truncate">
|
||||
{provider.name}
|
||||
<span
|
||||
className={`size-2 rounded-full ${dotColors[authType] || dotColors.apikey} shrink-0`}
|
||||
@@ -1303,7 +1303,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{stats.total > 0 && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function ProxyRegistryManager() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.host.trim()) {
|
||||
if (!(form.name || "").trim() || !(form.host || "").trim()) {
|
||||
setError(t("errorNameHostRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -244,17 +244,17 @@ export default function ProxyRegistryManager() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const normalizedUsername = form.username.trim();
|
||||
const normalizedPassword = form.password.trim();
|
||||
const normalizedUsername = (form.username || "").trim();
|
||||
const normalizedPassword = (form.password || "").trim();
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
...(editingId ? { id: editingId } : {}),
|
||||
name: form.name.trim(),
|
||||
name: (form.name || "").trim(),
|
||||
type: form.type,
|
||||
host: form.host.trim(),
|
||||
host: (form.host || "").trim(),
|
||||
port: Number(form.port || 8080),
|
||||
region: form.region.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
region: (form.region || "").trim() || null,
|
||||
notes: (form.notes || "").trim() || null,
|
||||
status: form.status,
|
||||
};
|
||||
if (!editingId || normalizedUsername.length > 0) {
|
||||
|
||||
@@ -664,6 +664,14 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.models || data.data || [],
|
||||
},
|
||||
"cloudflare-ai": {
|
||||
url: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/models/search",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.result || [],
|
||||
},
|
||||
synthetic: {
|
||||
url: "https://api.synthetic.new/openai/v1/models",
|
||||
method: "GET",
|
||||
@@ -1757,6 +1765,19 @@ export async function GET(
|
||||
|
||||
// Build request URL
|
||||
let url = config.url;
|
||||
if (provider === "cloudflare-ai") {
|
||||
const pData = asRecord(connection.providerSpecificData);
|
||||
const accountId =
|
||||
(typeof pData.accountId === "string" && pData.accountId) ||
|
||||
process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cloudflare Workers AI requires an Account ID in provider settings." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
url = url.replace("{accountId}", accountId);
|
||||
}
|
||||
if (config.authQuery) {
|
||||
url += `${url.includes("?") ? "&" : "?"}${config.authQuery}=${token}`;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,22 @@ export default async function RootLayout({ children }) {
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
const stored = localStorage.getItem('theme');
|
||||
const parsed = stored ? JSON.parse(stored) : null;
|
||||
const theme = parsed?.state?.theme || 'system';
|
||||
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`} suppressHydrationWarning>
|
||||
<a
|
||||
|
||||
@@ -284,6 +284,13 @@ export function safeLogEvents({
|
||||
tlsFingerprintUsed = false,
|
||||
}) {
|
||||
try {
|
||||
const rawIp =
|
||||
clientRawRequest?.headers?.["x-forwarded-for"] ||
|
||||
clientRawRequest?.headers?.["x-real-ip"] ||
|
||||
clientRawRequest?.headers?.["cf-connecting-ip"] ||
|
||||
null;
|
||||
const publicIp = rawIp ? rawIp.split(",")[0].trim() : null;
|
||||
|
||||
logProxyEvent({
|
||||
status: result.success
|
||||
? "success"
|
||||
@@ -295,6 +302,7 @@ export function safeLogEvents({
|
||||
levelId: proxyInfo?.levelId || null,
|
||||
provider,
|
||||
targetUrl: `${provider}/${model}`,
|
||||
publicIp,
|
||||
latencyMs: proxyLatency,
|
||||
error: result.success ? null : result.error || null,
|
||||
connectionId: credentials.connectionId,
|
||||
|
||||
Reference in New Issue
Block a user