2025-12-23 01:16:25 -08:00
"use client" ;
2026-06-10 21:50:10 +05:30
import { useAtom , useAtomValue } from "jotai" ;
2026-06-13 13:21:21 +05:30
import { Check , ChevronDown , Search , Settings2 } from "lucide-react" ;
2026-06-13 12:14:17 +05:30
import { useRouter } from "next/navigation" ;
import type { UIEvent } from "react" ;
import { useCallback , useMemo , useState } from "react" ;
2026-06-10 21:50:10 +05:30
import { updateModelRolesMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms" ;
2026-04-13 16:10:29 -07:00
import {
2026-06-10 21:50:10 +05:30
globalModelConnectionsAtom ,
modelConnectionsAtom ,
modelRolesAtom ,
} from "@/atoms/model-connections/model-connections-query.atoms" ;
2025-12-23 01:16:25 -08:00
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import {
2026-04-13 16:10:29 -07:00
Drawer ,
DrawerContent ,
2026-06-13 03:28:54 +05:30
DrawerHandle ,
2026-04-13 16:10:29 -07:00
DrawerHeader ,
DrawerTitle ,
DrawerTrigger ,
} from "@/components/ui/drawer" ;
2026-06-10 21:50:10 +05:30
import { Input } from "@/components/ui/input" ;
2025-12-23 01:16:25 -08:00
import { Popover , PopoverContent , PopoverTrigger } from "@/components/ui/popover" ;
2026-01-25 15:23:45 +05:30
import { Spinner } from "@/components/ui/spinner" ;
2026-06-10 21:50:10 +05:30
import type { ConnectionRead , ModelRead } from "@/contracts/types/model-connections.types" ;
2026-04-13 16:10:29 -07:00
import { useIsMobile } from "@/hooks/use-mobile" ;
2026-06-13 13:21:21 +05:30
import { AUTO_PROVIDER_ICON_KEY , getProviderIcon } from "@/lib/provider-icons" ;
2026-02-10 17:20:42 +05:30
import { cn } from "@/lib/utils" ;
2026-06-13 12:14:17 +05:30
import { providerDisplay } from "../settings/model-connections/provider-metadata" ;
2025-12-23 01:16:25 -08:00
2026-06-10 21:50:10 +05:30
interface ModelSelectorProps {
2026-06-13 12:14:17 +05:30
searchSpaceId : number ;
2026-06-10 21:50:10 +05:30
className? : string ;
2026-04-13 16:10:29 -07:00
}
2026-06-10 21:50:10 +05:30
type ChatModel = ModelRead & {
connectionId : number ;
connectionLabel : string ;
2026-06-13 12:14:17 +05:30
connectionScope : string ;
2026-04-13 16:10:29 -07:00
provider : string ;
2026-06-10 21:50:10 +05:30
} ;
2026-04-13 16:10:29 -07:00
2026-06-10 21:50:10 +05:30
function connectionLabel ( connection : ConnectionRead ) {
2026-06-13 12:14:17 +05:30
if ( connection . scope === "GLOBAL" ) return "Global" ;
return providerDisplay ( connection . provider ) . name ;
2026-04-13 16:10:29 -07:00
}
2026-06-10 21:50:10 +05:30
function flattenChatModels ( connections : ConnectionRead [ ] ) {
return connections . flatMap ( ( connection ) = >
connection . models
2026-06-12 02:17:51 +05:30
. filter ( ( model ) = > model . enabled && Boolean ( model . supports_chat ) )
2026-06-10 21:50:10 +05:30
. map ( ( model ) = > ( {
. . . model ,
connectionId : connection.id ,
connectionLabel : connectionLabel ( connection ) ,
2026-06-13 12:14:17 +05:30
connectionScope : connection.scope ,
2026-06-12 02:17:51 +05:30
provider : connection.provider ,
2026-06-10 21:50:10 +05:30
} ) )
2026-05-01 04:39:33 +05:30
) ;
2026-06-10 21:50:10 +05:30
}
2026-05-01 04:39:33 +05:30
2026-06-13 12:14:17 +05:30
function isFreeGlobalModel ( model : ChatModel ) {
return model . connectionScope === "GLOBAL" && model . billing_tier ? . toLowerCase ( ) === "free" ;
}
2026-06-13 18:18:03 +05:30
function modelName ( model : ChatModel ) {
const name = model . display_name || model . model_id ;
if ( model . connectionScope === "GLOBAL" ) {
return name . replace ( /\s+\(free\)$/i , "" ) ;
}
return name ;
}
2026-06-10 21:50:10 +05:30
function groupedModels ( models : ChatModel [ ] ) {
return models . reduce < Record < string , ChatModel [ ] > > ( ( groups , model ) = > {
const key = model . connectionLabel ;
if ( ! groups [ key ] ) groups [ key ] = [ ] ;
groups [ key ] . push ( model ) ;
return groups ;
} , { } ) ;
2025-12-23 01:16:25 -08:00
}
2026-06-13 21:59:35 +05:30
export function ModelSelector ( { searchSpaceId , className } : ModelSelectorProps ) {
2026-06-13 12:14:17 +05:30
const router = useRouter ( ) ;
2026-06-10 21:50:10 +05:30
const isMobile = useIsMobile ( ) ;
const [ open , setOpen ] = useState ( false ) ;
const [ search , setSearch ] = useState ( "" ) ;
2026-06-13 12:14:17 +05:30
const [ scrollPos , setScrollPos ] = useState < "top" | "middle" | "bottom" > ( "top" ) ;
2026-06-10 21:50:10 +05:30
const [ { data : globalConnections = [ ] , isLoading : globalLoading } ] = useAtom (
globalModelConnectionsAtom
) ;
const [ { data : connections = [ ] , isLoading : connectionsLoading } ] = useAtom ( modelConnectionsAtom ) ;
const [ { data : roles } ] = useAtom ( modelRolesAtom ) ;
const updateRoles = useAtomValue ( updateModelRolesMutationAtom ) ;
const chatModels = useMemo ( ( ) = > {
const normalized = search . trim ( ) . toLowerCase ( ) ;
const models = flattenChatModels ( [ . . . globalConnections , . . . connections ] ) ;
if ( ! normalized ) return models ;
return models . filter ( ( model ) = >
[ modelName ( model ) , model . model_id , model . connectionLabel ]
. join ( " " )
. toLowerCase ( )
. includes ( normalized )
2026-04-07 20:47:17 +02:00
) ;
2026-06-10 21:50:10 +05:30
} , [ globalConnections , connections , search ] ) ;
2026-04-13 16:10:29 -07:00
2026-06-10 21:50:10 +05:30
const selected = chatModels . find ( ( model ) = > model . id === roles ? . chat_model_id ) ;
const groups = groupedModels ( chatModels ) ;
const loading = globalLoading || connectionsLoading ;
2026-04-15 23:46:29 -07:00
2026-06-10 21:50:10 +05:30
function selectModel ( modelId : number ) {
updateRoles . mutate ( { chat_model_id : modelId } ) ;
setOpen ( false ) ;
}
2026-02-10 17:20:42 +05:30
2026-06-13 03:28:54 +05:30
function manageModelConnections() {
setOpen ( false ) ;
2026-06-13 12:14:17 +05:30
router . push ( ` /dashboard/ ${ searchSpaceId } /search-space-settings/models ` ) ;
2026-06-13 03:28:54 +05:30
}
2026-06-13 12:14:17 +05:30
const handleScroll = useCallback ( ( event : UIEvent < HTMLDivElement > ) = > {
const el = event . currentTarget ;
const atTop = el . scrollTop <= 2 ;
const atBottom = el . scrollHeight - el . scrollTop - el . clientHeight <= 2 ;
setScrollPos ( atTop ? "top" : atBottom ? "bottom" : "middle" ) ;
} , [ ] ) ;
2026-06-10 21:50:10 +05:30
const content = (
2026-06-13 12:14:17 +05:30
< div className = "flex h-[320px] select-none flex-col overflow-hidden" >
2026-06-13 03:28:54 +05:30
< div className = "p-2" >
2026-06-10 21:50:10 +05:30
< div className = "relative" >
2026-06-13 03:28:54 +05:30
< Search className = "absolute left-0.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" / >
2026-06-10 21:50:10 +05:30
< Input
value = { search }
onChange = { ( event ) = > setSearch ( event . target . value ) }
2026-06-13 03:28:54 +05:30
placeholder = "Search chat models"
className = "h-8 border-0 bg-transparent pl-6 text-sm shadow-none"
2026-06-10 21:50:10 +05:30
/ >
2026-04-13 16:10:29 -07:00
< / div >
< / div >
2026-06-13 12:14:17 +05:30
< div
className = "min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-1.5"
onScroll = { handleScroll }
style = { {
maskImage : ` linear-gradient(to bottom, ${ scrollPos === "top" ? "black" : "transparent" } , black 16px, black calc(100% - 16px), ${ scrollPos === "bottom" ? "black" : "transparent" } ) ` ,
WebkitMaskImage : ` linear-gradient(to bottom, ${ scrollPos === "top" ? "black" : "transparent" } , black 16px, black calc(100% - 16px), ${ scrollPos === "bottom" ? "black" : "transparent" } ) ` ,
} }
>
2026-06-10 21:50:10 +05:30
< button
type = "button"
2026-06-13 03:28:54 +05:30
className = "flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
2026-06-10 21:50:10 +05:30
onClick = { ( ) = > selectModel ( 0 ) }
>
2026-06-13 13:21:21 +05:30
< div className = "min-w-0 flex-1" >
< div className = "flex min-w-0 items-center gap-2 font-medium" >
{ getProviderIcon ( AUTO_PROVIDER_ICON_KEY , { className : "size-4 shrink-0" } ) }
< span className = "truncate" > Auto < / span >
2026-05-14 12:53:52 +05:30
< / div >
2026-04-13 16:10:29 -07:00
< / div >
2026-06-10 21:50:10 +05:30
{ ( roles ? . chat_model_id ? ? 0 ) === 0 ? < Check className = "h-4 w-4" / > : null }
< / button >
{ loading ? (
< div className = "flex items-center justify-center py-8" >
< Spinner / >
< / div >
) : Object . keys ( groups ) . length === 0 ? (
< div className = "px-3 py-8 text-center text-sm text-muted-foreground" >
No enabled chat models . Add or enable models in Settings .
< / div >
) : (
Object . entries ( groups ) . map ( ( [ connection , models ] ) = > (
< div key = { connection } className = "mt-3" >
2026-06-13 12:14:17 +05:30
< div className = "px-2 py-1 text-sm font-semibold text-muted-foreground" >
2026-06-10 21:50:10 +05:30
{ connection }
2026-04-13 16:10:29 -07:00
< / div >
2026-06-10 21:50:10 +05:30
{ models . map ( ( model ) = > (
< button
type = "button"
key = { model . id }
2026-06-13 03:28:54 +05:30
className = "flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
2026-06-10 21:50:10 +05:30
onClick = { ( ) = > selectModel ( model . id ) }
2026-04-13 16:10:29 -07:00
>
2026-06-13 12:14:17 +05:30
< div className = "min-w-0 flex-1" >
< div className = "flex min-w-0 items-center gap-2 font-medium" >
2026-06-10 21:50:10 +05:30
{ getProviderIcon ( model . provider , { className : "size-4 shrink-0" } ) }
< span className = "truncate" > { modelName ( model ) } < / span >
< / div >
2026-06-13 18:44:06 +05:30
{ / * { m o d e l . m a x _ i n p u t _ t o k e n s ? (
2026-06-12 02:17:51 +05:30
< div className = "text-xs text-muted-foreground" >
{ model . max_input_tokens . toLocaleString ( ) } context
< / div >
2026-06-13 18:44:06 +05:30
) : null } * / }
2026-06-10 21:50:10 +05:30
< / div >
2026-06-13 12:14:17 +05:30
< div className = "ml-3 flex shrink-0 items-center gap-2" >
{ isFreeGlobalModel ( model ) ? (
< Badge
variant = "secondary"
className = "h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10"
>
Free
2026-06-10 21:50:10 +05:30
< / Badge >
) : null }
2026-06-13 12:14:17 +05:30
{ / *
Re - enable this once the chat composer supports image input .
For now , surfacing ` supports_image_input ` in the chat model
selector is misleading because users cannot attach images .
{ ! model . supports_image_input ? (
< Badge variant = "outline" className = "gap-1" >
< ImageOff className = "h-3 w-3" / > No image
< / Badge >
) : null }
* / }
2026-06-10 21:50:10 +05:30
{ roles ? . chat_model_id === model . id ? < Check className = "h-4 w-4" / > : null }
< / div >
< / button >
) ) }
< / div >
) )
) }
2026-04-13 16:10:29 -07:00
< / div >
2026-06-13 03:28:54 +05:30
< div className = "p-2" >
< Button
variant = "ghost"
className = "w-full justify-start rounded-md bg-foreground/5 hover:bg-foreground/10 hover:text-foreground"
onClick = { manageModelConnections }
>
< Settings2 className = "mr-2 h-4 w-4" / > Manage models
2026-06-10 21:50:10 +05:30
< / Button >
< / div >
< / div >
) ;
2026-04-13 16:10:29 -07:00
2026-06-10 21:50:10 +05:30
const trigger = (
2026-04-13 16:10:29 -07:00
< Button
2026-06-10 21:50:10 +05:30
type = "button"
2026-04-13 16:10:29 -07:00
variant = "ghost"
size = "sm"
2026-06-13 03:28:54 +05:30
className = { cn (
"h-8 min-w-0 gap-2 rounded-md px-3 text-muted-foreground transition-colors" ,
2026-06-13 12:14:17 +05:30
"select-none" ,
2026-06-13 03:28:54 +05:30
"hover:bg-foreground/10 hover:text-foreground" ,
"data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground" ,
className
) }
2026-04-13 16:10:29 -07:00
>
2026-06-13 21:59:35 +05:30
{ selected
? getProviderIcon ( selected . provider , { className : "size-4 shrink-0" } )
: getProviderIcon ( AUTO_PROVIDER_ICON_KEY , { className : "size-4 shrink-0" } ) }
2026-06-13 03:28:54 +05:30
< span className = "min-w-0 flex-1 truncate text-sm" >
2026-06-10 21:50:10 +05:30
{ selected ? modelName ( selected ) : "Auto" }
< / span >
2026-06-13 03:28:54 +05:30
< ChevronDown className = "h-3.5 w-3.5 shrink-0" / >
2026-04-13 16:10:29 -07:00
< / Button >
) ;
if ( isMobile ) {
return (
2026-06-10 21:50:10 +05:30
< Drawer open = { open } onOpenChange = { setOpen } >
< DrawerTrigger asChild > { trigger } < / DrawerTrigger >
2026-06-13 03:28:54 +05:30
< DrawerContent className = "max-h-[85vh]" >
< DrawerHandle / >
2026-06-10 21:50:10 +05:30
< DrawerHeader >
< DrawerTitle > Select Chat Model < / DrawerTitle >
2026-04-13 16:10:29 -07:00
< / DrawerHeader >
2026-06-10 21:50:10 +05:30
{ content }
2026-04-13 16:10:29 -07:00
< / DrawerContent >
< / Drawer >
) ;
}
return (
2026-06-10 21:50:10 +05:30
< Popover open = { open } onOpenChange = { setOpen } >
< PopoverTrigger asChild > { trigger } < / PopoverTrigger >
2026-06-13 12:14:17 +05:30
< PopoverContent align = "start" className = "w-[340px] p-0" >
2026-06-10 21:50:10 +05:30
{ content }
2025-12-23 01:16:25 -08:00
< / PopoverContent >
< / Popover >
) ;
}