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" + /> +
+ +
-
+
{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" && (
+ {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 ( + + + + + + + + + + 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 +
+ +
-
-
- -