fix(ui): mobile layout and dark mode FOUC stabilization

This commit is contained in:
diegosouzapw
2026-04-24 14:42:50 -03:00
parent 7d34b6a7e1
commit 7ed15c742f
11 changed files with 461 additions and 60 deletions

View File

@@ -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)

View File

@@ -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" },

View File

@@ -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

View 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>
);
}

View File

@@ -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 */}

View File

@@ -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>
)}

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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}`;
}

View File

@@ -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

View File

@@ -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,