diff --git a/packages/frontend/src/app/(authenticated)/(dashboard)/api-keys/page.tsx b/packages/frontend/src/app/(authenticated)/(dashboard)/api-keys/page.tsx
index fa669ec..3a35978 100644
--- a/packages/frontend/src/app/(authenticated)/(dashboard)/api-keys/page.tsx
+++ b/packages/frontend/src/app/(authenticated)/(dashboard)/api-keys/page.tsx
@@ -14,6 +14,7 @@ import {
PowerOff,
AlertTriangle,
} from "lucide-react";
+import { Search } from "lucide-react";
import { useState, useMemo, useCallback } from "react";
import { toast } from "sonner";
@@ -119,13 +120,21 @@ export default function ApiKeysPage() {
id: string;
name: string | null;
} | null>(null);
+ const [search, setSearch] = useState("");
const sortedKeys = useMemo(() => {
if (!data) return [];
- return [...data.apiKeys].toSorted(
+ const sorted = [...data.apiKeys].toSorted(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
- }, [data]);
+ if (!search.trim()) return sorted;
+ const q = search.toLowerCase();
+ return sorted.filter(
+ (k) =>
+ (k.name ?? "").toLowerCase().includes(q) ||
+ (k.start ?? k.prefix ?? "").toLowerCase().includes(q),
+ );
+ }, [data, search]);
const resetCreateDialog = useCallback(() => {
setKeyName("");
@@ -204,7 +213,7 @@ export default function ApiKeysPage() {
<>
-
+
Manage API Keys
@@ -212,6 +221,17 @@ export default function ApiKeysPage() {
+
+
+
+ setSearch(e.target.value)}
+ className="pl-9 h-9 text-sm"
+ />
+
+
@@ -342,9 +362,10 @@ export default function ApiKeysPage() {
+
-
+
{isLoading ? (
@@ -411,16 +432,16 @@ export default function ApiKeysPage() {
) : (
-
+
- Name
- Key Preview
- Status
- Created
- Last Used
- Expires
- Actions
+ Name
+ Key Preview
+ Status
+ Created
+ Last Used
+ Expires
+ Actions
@@ -433,10 +454,10 @@ export default function ApiKeysPage() {
- {apiKey.name ?? "Unnamed"}
+ {apiKey.name ?? "Unnamed"}
-
+
{apiKey.start ?? apiKey.prefix ?? "\u2014"}...
@@ -452,13 +473,13 @@ export default function ApiKeysPage() {
{STATUS_LABELS[status]}
-
+
{formatRelative(apiKey.createdAt)}
-
+
{formatRelative(apiKey.lastRequest)}
-
+
{formatExpiry(apiKey.expiresAt)}
diff --git a/packages/frontend/src/app/(authenticated)/(dashboard)/connections/layout.tsx b/packages/frontend/src/app/(authenticated)/(dashboard)/connections/layout.tsx
index 4c6794b..9d41235 100644
--- a/packages/frontend/src/app/(authenticated)/(dashboard)/connections/layout.tsx
+++ b/packages/frontend/src/app/(authenticated)/(dashboard)/connections/layout.tsx
@@ -2,9 +2,9 @@ import type { Metadata } from "next";
import { SiteHeader } from "@/components/site-header";
export const metadata: Metadata = {
- title: "Connections | Enfinyte Router",
+ title: "Providers | Enfinyte Router",
description:
- "Configure and manage your AI provider connections for the Enfinyte Router.",
+ "Configure and manage your AI providers for the Enfinyte Router.",
};
export default function ConnectionsLayout({
@@ -14,7 +14,7 @@ export default function ConnectionsLayout({
}>) {
return (
<>
-
+
{children}
>
);
diff --git a/packages/frontend/src/app/(authenticated)/(dashboard)/connections/page.tsx b/packages/frontend/src/app/(authenticated)/(dashboard)/connections/page.tsx
index 0da212f..7f70718 100644
--- a/packages/frontend/src/app/(authenticated)/(dashboard)/connections/page.tsx
+++ b/packages/frontend/src/app/(authenticated)/(dashboard)/connections/page.tsx
@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
-import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, Power, PowerOff } from "lucide-react";
+import { ChevronDown, ChevronUp, Eye, EyeOff, Loader2, Power, PowerOff, Search } from "lucide-react";
import { PROVIDERS } from "@/lib/providers";
import { useGetAllSecrets, useAddSecret, useToggleProvider } from "@/lib/api/secrets";
import { Button } from "@/components/ui/button";
@@ -24,6 +24,7 @@ export default function ConnectionsPage() {
const [expandedCards, setExpandedCards] = useState>({});
const [showSecrets, setShowSecrets] = useState>>({});
const [savingProviderId, setSavingProviderId] = useState(null);
+ const [search, setSearch] = useState("");
const credentials = useMemo(() => {
const result: Record> = {};
@@ -112,16 +113,38 @@ export default function ConnectionsPage() {
);
};
+ const filteredProviders = useMemo(() => {
+ if (!search.trim()) return PROVIDERS;
+ const q = search.toLowerCase();
+ return PROVIDERS.filter(
+ (p) =>
+ p.name.toLowerCase().includes(q) ||
+ p.description.toLowerCase().includes(q) ||
+ p.models.some((m) => m.toLowerCase().includes(q)),
+ );
+ }, [search]);
+
return (
<>
-
-
-
-
Manage Providers
-
- Configure and manage your AI provider connections. Toggle providers on or off to
- control routing.
-
+
+
+
+
+
Manage Providers
+
+ Configure and manage your AI provider connections. Toggle providers on or off to
+ control routing.
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-9 h-9 text-sm"
+ />
+
@@ -138,7 +161,16 @@ export default function ConnectionsPage() {
))
- : PROVIDERS.map((provider) => {
+ : filteredProviders.length === 0 ? (
+
+
+
Provider not available
+
+ Contact the devs to request support for this provider.
+
+
+ )
+ : filteredProviders.map((provider) => {
const secretData = secretsData?.providers?.[provider.id];
const isConfigured = !!secretData && secretData.fields?.length > 0;
const isEnabled = secretData?.enabled ?? false;
@@ -151,7 +183,7 @@ export default function ConnectionsPage() {
toggleCardExpansion(provider.id)}
- className="flex w-full cursor-pointer items-center gap-4 px-5 py-4 text-left focus:outline-none"
+ className="flex w-full cursor-pointer items-center gap-3 sm:gap-4 px-3 sm:px-5 py-4 text-left focus:outline-none overflow-hidden"
>
{provider.icon}
@@ -197,7 +229,7 @@ export default function ConnectionsPage() {
-
+
{provider.models.slice(0, 3).map((model) => (
{isExpanded && (
-
+
{!isConfigured && (
Set up credentials to start routing through {provider.name}.
@@ -287,7 +319,7 @@ export default function ConnectionsPage() {
onChange={(e) =>
handleInputChange(provider.id, field.key, e.target.value)
}
- className="bg-background border-border pr-10 font-mono text-xs h-9 placeholder:font-sans"
+ className="bg-background border-border pr-10 font-mono text-xs h-9 placeholder:font-sans w-full min-w-0"
/>
{field.type === "password" && (
Last 7 days
setInterval(value as AnalyticsInterval)}>
-
+
diff --git a/packages/frontend/src/app/(authenticated)/(dashboard)/playground/layout.tsx b/packages/frontend/src/app/(authenticated)/(dashboard)/playground/layout.tsx
index 3a9a339..136a0e6 100644
--- a/packages/frontend/src/app/(authenticated)/(dashboard)/playground/layout.tsx
+++ b/packages/frontend/src/app/(authenticated)/(dashboard)/playground/layout.tsx
@@ -13,9 +13,9 @@ export default function PlaygroundLayout({
children: React.ReactNode;
}>) {
return (
- <>
+
{children}
- >
+
);
}
diff --git a/packages/frontend/src/app/(authenticated)/(dashboard)/playground/page.tsx b/packages/frontend/src/app/(authenticated)/(dashboard)/playground/page.tsx
index 9351e41..f193100 100644
--- a/packages/frontend/src/app/(authenticated)/(dashboard)/playground/page.tsx
+++ b/packages/frontend/src/app/(authenticated)/(dashboard)/playground/page.tsx
@@ -2,7 +2,7 @@ import { ChatPlayground } from "@/components/playground/chat-playground";
export default function PlaygroundPage() {
return (
-
+
);
diff --git a/packages/frontend/src/app/(authenticated)/(dashboard)/settings/page.tsx b/packages/frontend/src/app/(authenticated)/(dashboard)/settings/page.tsx
index e97131b..a7c62fc 100644
--- a/packages/frontend/src/app/(authenticated)/(dashboard)/settings/page.tsx
+++ b/packages/frontend/src/app/(authenticated)/(dashboard)/settings/page.tsx
@@ -7,7 +7,7 @@ import { toast } from "sonner";
import { useGetUser } from "@/lib/api/user";
import { useGetModels } from "@/lib/api/models";
import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, Crosshair, Check, Pencil, ScanSearch } from "lucide-react";
+import { Loader2, Crosshair, Check, Pencil, ScanSearch, Search } from "lucide-react";
import { PROVIDERS } from "@/lib/providers";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
@@ -27,14 +27,6 @@ const ANALYSIS_TARGET_OPTIONS = [
},
] as const;
-interface ModelOption {
- provider: string;
- providerName: string;
- providerIcon: React.ReactNode;
- model: string;
- value: string;
-}
-
export default function SettingsPage() {
const queryClient = useQueryClient();
@@ -45,22 +37,33 @@ export default function SettingsPage() {
const [selectedFallback, setSelectedFallback] = useState
(null);
const [isSavingFallback, setIsSavingFallback] = useState(false);
+ const [fallbackSearch, setFallbackSearch] = useState("");
+
const [isEditingAnalysisTarget, setIsEditingAnalysisTarget] = useState(false);
const [selectedAnalysisTarget, setSelectedAnalysisTarget] = useState(null);
const [isSavingAnalysisTarget, setIsSavingAnalysisTarget] = useState(false);
- const fallbackModelOptions = useMemo(() => {
+ const currentFallbackDisplay = useMemo(() => {
+ const pair = user?.fallbackProviderModelPair;
+ if (!pair) return null;
+ const slashIndex = pair.indexOf("/");
+ if (slashIndex === -1) return { provider: pair, model: pair };
+ const providerId = pair.slice(0, slashIndex);
+ const model = pair.slice(slashIndex + 1);
+ const provider = PROVIDERS.find((p) => p.id === providerId);
+ return { provider: provider?.name ?? providerId, model, value: pair, icon: provider?.icon };
+ }, [user?.fallbackProviderModelPair]);
+
+ const fallbackModelOptions = useMemo(() => {
if (!modelsData?.models) return [];
- const options: ModelOption[] = [];
+ const options: { provider: string; providerName: string; providerIcon: React.ReactNode; model: string; value: string }[] = [];
for (const [providerId, models] of Object.entries(modelsData.models)) {
const providerConfig = PROVIDERS.find((p) => p.id === providerId);
- const providerName = providerConfig?.name ?? providerId;
- const providerIcon = providerConfig?.icon ?? null;
for (const model of models) {
options.push({
provider: providerId,
- providerName,
- providerIcon,
+ providerName: providerConfig?.name ?? providerId,
+ providerIcon: providerConfig?.icon ?? null,
model,
value: `${providerId}/${model}`,
});
@@ -69,27 +72,27 @@ export default function SettingsPage() {
return options;
}, [modelsData]);
+ const filteredFallbackOptions = useMemo(() => {
+ if (!fallbackSearch.trim()) return fallbackModelOptions;
+ const q = fallbackSearch.toLowerCase();
+ return fallbackModelOptions.filter(
+ (o) =>
+ o.model.toLowerCase().includes(q) ||
+ o.providerName.toLowerCase().includes(q) ||
+ o.value.toLowerCase().includes(q),
+ );
+ }, [fallbackModelOptions, fallbackSearch]);
+
const fallbackGroupedOptions = useMemo(() => {
- const groups: Record = {};
- for (const option of fallbackModelOptions) {
+ const groups: Record = {};
+ for (const option of filteredFallbackOptions) {
if (!groups[option.provider]) {
groups[option.provider] = [];
}
groups[option.provider]!.push(option);
}
return groups;
- }, [fallbackModelOptions]);
-
- const currentFallbackDisplay = useMemo(() => {
- const pair = user?.fallbackProviderModelPair;
- if (!pair) return null;
- const slashIndex = pair.indexOf("/");
- if (slashIndex === -1) return { provider: pair, model: pair };
- const providerId = pair.slice(0, slashIndex);
- const model = pair.slice(slashIndex + 1);
- const provider = PROVIDERS.find((p) => p.id === providerId);
- return { provider: provider?.name ?? providerId, model, value: pair, icon: provider?.icon };
- }, [user?.fallbackProviderModelPair]);
+ }, [filteredFallbackOptions]);
const handleSaveFallback = useCallback(async () => {
if (!selectedFallback) return;
@@ -132,7 +135,7 @@ export default function SettingsPage() {
return (
-
+
@@ -145,7 +148,7 @@ export default function SettingsPage() {
-
+
{isLoading ? (
@@ -183,6 +186,17 @@ export default function SettingsPage() {
) : (
+
+
+ setFallbackSearch(e.target.value)}
+ placeholder="Search models..."
+ className="w-full rounded-lg border border-border bg-background pl-9 pr-3 py-2.5 text-sm outline-none placeholder:text-muted-foreground focus:border-ring/50 focus:ring-1 focus:ring-ring/20 transition-all"
+ />
+
+
{Object.entries(fallbackGroupedOptions).map(([providerId, options]) => {
const first = options[0];
if (!first) return null;
@@ -200,7 +214,7 @@ export default function SettingsPage() {
({options.length} models)
-
+
{options.map((option) => {
const isSelected = selectedFallback === option.value;
return (
@@ -233,6 +247,14 @@ export default function SettingsPage() {
);
})}
+ {fallbackModelOptions.length > 0 && filteredFallbackOptions.length === 0 && (
+
+
+ No models match “{fallbackSearch}”
+
+
+ )}
+
{fallbackModelOptions.length === 0 && (
No connected providers available. Connect a provider first.
@@ -246,6 +268,7 @@ export default function SettingsPage() {
onClick={() => {
setIsEditingFallback(false);
setSelectedFallback(null);
+ setFallbackSearch("");
}}
className="cursor-pointer"
>
@@ -279,14 +302,14 @@ export default function SettingsPage() {
-
+
{isLoading ? (
) : !isEditingAnalysisTarget ? (
-
+
{currentAnalysisTarget.label}
@@ -300,7 +323,7 @@ export default function SettingsPage() {
setSelectedAnalysisTarget(user?.analysisTarget ?? "per_prompt");
setIsEditingAnalysisTarget(true);
}}
- className="gap-1.5 cursor-pointer shrink-0 ml-4"
+ className="gap-1.5 cursor-pointer shrink-0 self-start"
>
Change
diff --git a/packages/frontend/src/components/Providers.tsx b/packages/frontend/src/components/Providers.tsx
index d0c2238..cee5392 100644
--- a/packages/frontend/src/components/Providers.tsx
+++ b/packages/frontend/src/components/Providers.tsx
@@ -4,7 +4,7 @@ import { useState } from "react";
import { QueryProvider } from "./query-provider";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
-import { Toaster } from "sonner";
+import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "./ui/tooltip";
export default function Providers({
diff --git a/packages/frontend/src/components/app-sidebar.tsx b/packages/frontend/src/components/app-sidebar.tsx
index 0d03a12..0d62cf6 100644
--- a/packages/frontend/src/components/app-sidebar.tsx
+++ b/packages/frontend/src/components/app-sidebar.tsx
@@ -38,7 +38,7 @@ const data = {
icon: IconMessageChatbot,
},
{
- title: "Connections",
+ title: "Providers",
url: "/connections",
icon: IconPlug,
},
diff --git a/packages/frontend/src/components/model-search-combobox.tsx b/packages/frontend/src/components/model-search-combobox.tsx
new file mode 100644
index 0000000..5829ca8
--- /dev/null
+++ b/packages/frontend/src/components/model-search-combobox.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import { Button } from "@/components/ui/button";
+import { Check, ChevronsUpDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { PROVIDERS } from "@/lib/providers";
+
+interface ModelOption {
+ provider: string;
+ providerName: string;
+ model: string;
+ value: string;
+}
+
+interface ModelSearchComboboxProps {
+ models: Record | undefined;
+ value: string | null;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+ placeholder?: string;
+ className?: string;
+}
+
+export function ModelSearchCombobox({
+ models,
+ value,
+ onChange,
+ disabled,
+ placeholder = "Select model...",
+ className,
+}: ModelSearchComboboxProps) {
+ const [open, setOpen] = useState(false);
+
+ const modelOptions = useMemo(() => {
+ if (!models) return [];
+ const options: ModelOption[] = [];
+ for (const [providerId, modelList] of Object.entries(models)) {
+ const provider = PROVIDERS.find((p) => p.id === providerId);
+ const providerName = provider?.name ?? providerId;
+ for (const model of modelList) {
+ options.push({
+ provider: providerId,
+ providerName,
+ model,
+ value: `${providerId}/${model}`,
+ });
+ }
+ }
+ return options;
+ }, [models]);
+
+ const groupedOptions = useMemo(() => {
+ const groups: Record = {};
+ for (const option of modelOptions) {
+ if (!groups[option.provider]) {
+ groups[option.provider] = [];
+ }
+ groups[option.provider]!.push(option);
+ }
+ return groups;
+ }, [modelOptions]);
+
+ const selected = useMemo(
+ () => modelOptions.find((o) => o.value === value),
+ [modelOptions, value],
+ );
+
+ return (
+
+
+
+
+ {selected ? selected.model : placeholder}
+
+
+
+
+
+
+
+
+
+ No models found.
+
+ {Object.entries(groupedOptions).map(([providerId, options]) => {
+ const first = options[0];
+ if (!first) return null;
+ return (
+
+ {options.map((option) => (
+ {
+ onChange(v);
+ setOpen(false);
+ }}
+ className="text-xs cursor-pointer"
+ >
+ {option.model}
+
+
+ ))}
+
+ );
+ })}
+ {modelOptions.length === 0 && (
+
+ No models available. Connect a provider first.
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/frontend/src/components/nav-main.tsx b/packages/frontend/src/components/nav-main.tsx
index f9859bd..3324e2c 100644
--- a/packages/frontend/src/components/nav-main.tsx
+++ b/packages/frontend/src/components/nav-main.tsx
@@ -7,6 +7,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
+ useSidebar,
} from "@/components/ui/sidebar";
import Link from "next/link";
@@ -19,24 +20,32 @@ export function NavMain({
icon?: Icon;
}[];
}) {
+ const { isMobile, setOpenMobile } = useSidebar();
+
+ const closeSidebar = () => {
+ if (isMobile) setOpenMobile(false);
+ };
+
return (
-
-
- Connect a New Provider
-
+
+
+
+ Connect a New Provider
+
+
{items.map((item) => (
-
+
{item.icon && }
{item.title}
diff --git a/packages/frontend/src/components/playground/chat-playground.tsx b/packages/frontend/src/components/playground/chat-playground.tsx
index 871c0ef..2d8753f 100644
--- a/packages/frontend/src/components/playground/chat-playground.tsx
+++ b/packages/frontend/src/components/playground/chat-playground.tsx
@@ -3,9 +3,10 @@
import { RotateCcw, PanelRightOpen, PanelRightClose } from "lucide-react";
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
-import { useLocalStorage } from "usehooks-ts";
+import { useLocalStorage, useMediaQuery } from "usehooks-ts";
import { Button } from "@/components/ui/button";
+import { Sheet, SheetContent } from "@/components/ui/sheet";
import {
sendStreamingMessage,
type PlaygroundMessage,
@@ -25,7 +26,8 @@ export function ChatPlayground() {
const [apiKey, setApiKey] = useLocalStorage("enfinyte-playground-api-key", "");
const [isStreaming, setIsStreaming] = useState(false);
const [showSettings, setShowSettings] = useState(false);
- const [settings, setSettings] = useState({});
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+ const [settings, setSettings] = useLocalStorage("enfinyte-playground-settings", {});
const abortControllerRef = useRef(null);
const lastResponseIdRef = useRef(undefined);
@@ -233,7 +235,7 @@ export function ChatPlayground() {
return (
-
+
@@ -277,10 +279,23 @@ export function ChatPlayground() {
/>
- {showSettings && (
-
-
+ {isDesktop ? (
+
+ ) : (
+
+
+
+
+
)}
);
diff --git a/packages/frontend/src/components/playground/message-bubble.tsx b/packages/frontend/src/components/playground/message-bubble.tsx
index 2f2ed7a..2a7bd0d 100644
--- a/packages/frontend/src/components/playground/message-bubble.tsx
+++ b/packages/frontend/src/components/playground/message-bubble.tsx
@@ -257,7 +257,7 @@ export function MessageBubble({ message, onRegenerate }: MessageBubbleProps) {
if (isUser) {
return (
-
+
@@ -270,7 +270,7 @@ export function MessageBubble({ message, onRegenerate }: MessageBubbleProps) {
}
return (
-
+
{message.isStreaming && (
diff --git a/packages/frontend/src/components/playground/message-input.tsx b/packages/frontend/src/components/playground/message-input.tsx
index e01e262..a6a340a 100644
--- a/packages/frontend/src/components/playground/message-input.tsx
+++ b/packages/frontend/src/components/playground/message-input.tsx
@@ -51,7 +51,7 @@ export function MessageInput({
);
return (
-
+
{selectedModel ? selectedModel.model : "Select a model..."}
@@ -183,7 +183,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
-
+
@@ -237,7 +237,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
onChange(`auto/${p}`);
}}
>
-
+
@@ -263,7 +263,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
onChange(`${i}/${intentPolicy}`);
}}
>
-
+
@@ -282,7 +282,7 @@ export function ModelSelector({ value, onChange }: ModelSelectorProps) {
onChange(`${intent}/${p}`);
}}
>
-
+
diff --git a/packages/frontend/src/components/playground/settings-panel.tsx b/packages/frontend/src/components/playground/settings-panel.tsx
index 335cb71..19ad4d7 100644
--- a/packages/frontend/src/components/playground/settings-panel.tsx
+++ b/packages/frontend/src/components/playground/settings-panel.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useState, useEffect, useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
@@ -9,8 +11,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { ModelSearchCombobox } from "@/components/model-search-combobox";
import type { PlaygroundSettings } from "@/lib/api/playground";
-import { Settings2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Settings2, RotateCcw, Loader2, ChevronDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+import { useGetUser } from "@/lib/api/user";
+import { useGetModels } from "@/lib/api/models";
+import { authClient } from "@/lib/auth-client";
interface SettingsPanelProps {
settings: PlaygroundSettings;
@@ -59,122 +68,267 @@ function SliderRow({
);
}
+const ANALYSIS_TARGET_OPTIONS = [
+ { value: "per_prompt", label: "Per message" },
+ { value: "per_system_prompt", label: "Per system prompt" },
+] as const;
+
export function SettingsPanel({ settings, onChange }: SettingsPanelProps) {
+ const queryClient = useQueryClient();
+ const { data: user } = useGetUser();
+ const { data: modelsData } = useGetModels();
+
+ const [draft, setDraft] = useState(settings);
+ const [isSavingRouter, setIsSavingRouter] = useState(false);
+ const [generationOpen, setGenerationOpen] = useState(false);
+ const [routerOpen, setRouterOpen] = useState(false);
+ const [draftFallback, setDraftFallback] = useState(null);
+ const [draftAnalysisTarget, setDraftAnalysisTarget] = useState(null);
+
+ useEffect(() => {
+ setDraft(settings);
+ }, [settings]);
+
+ useEffect(() => {
+ setDraftFallback(user?.fallbackProviderModelPair ?? null);
+ setDraftAnalysisTarget(user?.analysisTarget ?? "per_prompt");
+ }, [user?.fallbackProviderModelPair, user?.analysisTarget]);
+
const update = (partial: Partial) => {
- onChange({ ...settings, ...partial });
+ setDraft((prev) => ({ ...prev, ...partial }));
};
+ const isDirty = JSON.stringify(draft) !== JSON.stringify(settings);
+ const isRouterDirty =
+ draftFallback !== (user?.fallbackProviderModelPair ?? null) ||
+ draftAnalysisTarget !== (user?.analysisTarget ?? "per_prompt");
+
+ const handleApplyRouterConfig = useCallback(async () => {
+ const updates: Record = {};
+ if (draftFallback && draftFallback !== (user?.fallbackProviderModelPair ?? null)) {
+ updates.fallbackProviderModelPair = draftFallback;
+ }
+ if (draftAnalysisTarget && draftAnalysisTarget !== (user?.analysisTarget ?? "per_prompt")) {
+ updates.analysisTarget = draftAnalysisTarget;
+ }
+ if (Object.keys(updates).length === 0) return;
+ try {
+ setIsSavingRouter(true);
+ await authClient.updateUser(updates);
+ await queryClient.invalidateQueries({ queryKey: ["user"] });
+ toast.success("Router config updated");
+ } catch {
+ toast.error("Failed to update router config");
+ } finally {
+ setIsSavingRouter(false);
+ }
+ }, [draftFallback, draftAnalysisTarget, user, queryClient]);
+
return (
-
+
Settings
+
+ {
+ setDraft({});
+ onChange({});
+ toast.success("Settings reset to defaults");
+ }}
+ >
+
+
+
-
-
-
- System Instructions
-
-
+
+ {/* Generation Settings */}
+
+
setGenerationOpen((v) => !v)}
+ className="flex w-full items-center justify-between px-4 py-3 cursor-pointer hover:bg-accent/50 transition-colors"
+ >
+ Generation
+
+
-
update({ temperature: v })}
- min={0}
- max={2}
- step={0.1}
- defaultValue={1}
- />
-
- update({ top_p: v })}
- min={0}
- max={1}
- step={0.05}
- defaultValue={1}
- />
-
-
-
- Max Output Tokens
-
-
{
- const v = e.target.value ? parseInt(e.target.value, 10) : null;
- update({ max_output_tokens: v });
- }}
- placeholder="Default"
- className="h-8 text-xs"
- />
+
+
+
+
+
+ System Instructions
+
+
+
+
update({ temperature: v })}
+ min={0}
+ max={2}
+ step={0.1}
+ defaultValue={1}
+ />
+
+ update({ top_p: v })}
+ min={0}
+ max={1}
+ step={0.05}
+ defaultValue={1}
+ />
+
+
+
+ Max Output Tokens
+
+ {
+ const v = e.target.value ? parseInt(e.target.value, 10) : null;
+ update({ max_output_tokens: v });
+ }}
+ placeholder="Default"
+ className="h-8 text-xs"
+ />
+
+
+
+ Reasoning Effort
+ {
+ const val = v as PlaygroundSettings["reasoning_effort"];
+ update({ reasoning_effort: val === "none" ? null : val });
+ }}
+ >
+
+
+
+
+ None (default)
+ Low
+ Medium
+ High
+
+
+
+
+ update({ presence_penalty: v })}
+ min={-2}
+ max={2}
+ step={0.1}
+ defaultValue={0}
+ />
+
+ update({ frequency_penalty: v })}
+ min={-2}
+ max={2}
+ step={0.1}
+ defaultValue={0}
+ />
+
+ {
+ onChange(draft);
+ toast.success("Generation settings applied");
+ }}
+ >
+ Apply
+
+
+
+
-
-
- Reasoning Effort
-
-
{
- const val = v as PlaygroundSettings["reasoning_effort"];
- update({ reasoning_effort: val === "none" ? null : val });
- }}
+ {/* Router Config */}
+
+ setRouterOpen((v) => !v)}
+ className="flex w-full items-center justify-between px-4 py-3 cursor-pointer hover:bg-accent/50 transition-colors"
>
-
-
-
-
-
- None (default)
-
-
- Low
-
-
- Medium
-
-
- High
-
-
-
-
+ Router Config
+
+
- update({ presence_penalty: v })}
- min={-2}
- max={2}
- step={0.1}
- defaultValue={0}
- />
-
- update({ frequency_penalty: v })}
- min={-2}
- max={2}
- step={0.1}
- defaultValue={0}
- />
+
+
+
+
+ Default Model
+
+
+
+
+ Analysis Target
+
+
+
+
+
+ {ANALYSIS_TARGET_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+ {isSavingRouter && }
+ Apply
+
+
+
+
+
);
diff --git a/packages/frontend/src/components/site-header.tsx b/packages/frontend/src/components/site-header.tsx
index 256f4c7..4fd758a 100644
--- a/packages/frontend/src/components/site-header.tsx
+++ b/packages/frontend/src/components/site-header.tsx
@@ -11,7 +11,7 @@ export function SiteHeader({ title }: { title?: string }) {
-
{title}
+
{title}
) {
{
,
info: ,