/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronsUpDown, Check, CpuIcon, LayersIcon } from 'lucide-react'
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import {
modelGroupSelectorLayoutClasses,
scrollSelectedOptionIntoView,
} from './model-group-selector/layout'
interface ModelOption {
label: string
value: string
category?: string
description?: string
}
interface GroupOption {
label: string
value: string
ratio?: number
desc?: string
description?: string
}
interface ModelSelectorProps {
selectedModel: string
models: ModelOption[]
onModelChange: (value: string) => void
className?: string
disabled?: boolean
}
interface GroupSelectorProps {
selectedGroup: string
groups: GroupOption[]
onGroupChange: (value: string) => void
className?: string
disabled?: boolean
}
const ModelTriggerButton = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef & {
currentLabel: string
triggerClassName?: string
isDisabled?: boolean
}
>(({ currentLabel, triggerClassName, isDisabled, ...props }, ref) => (
))
ModelTriggerButton.displayName = 'ModelTriggerButton'
const GroupTriggerButton = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef & {
currentLabel: string
triggerClassName?: string
isDisabled?: boolean
}
>(({ currentLabel, triggerClassName, isDisabled, ...props }, ref) => (
))
GroupTriggerButton.displayName = 'GroupTriggerButton'
/**
* Model Selector Component
* Styled following Scira's form-component design patterns
*/
export const ModelSelector: React.FC = React.memo(
({ selectedModel, models, onModelChange, className, disabled = false }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const isMobile = useIsMobile()
const currentModel = useMemo(
() => models.find((m) => m.value === selectedModel),
[models, selectedModel]
)
// Group models by category
const groupedModels = useMemo(
() =>
models.reduce(
(acc, model) => {
const category = model.category || t('Other')
if (!acc[category]) {
acc[category] = []
}
acc[category].push(model)
return acc
},
{} as Record
),
[models, t]
)
// Filter models by search query
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return groupedModels
const query = searchQuery.toLowerCase()
const filtered: Record = {}
Object.entries(groupedModels).forEach(([category, categoryModels]) => {
const matches = categoryModels.filter(
(m) =>
m.label.toLowerCase().includes(query) ||
m.value.toLowerCase().includes(query) ||
m.description?.toLowerCase().includes(query)
)
if (matches.length > 0) {
filtered[category] = matches
}
})
return filtered
}, [groupedModels, searchQuery])
const handleModelChange = useCallback(
(value: string) => {
onModelChange(value)
setOpen(false)
setSearchQuery('')
},
[onModelChange]
)
// Shared command content
const renderModelCommandContent = () => (
1}
shouldFilter={false}
>
{!isMobile && (
)}
{t('No model found.')}
{Object.keys(filteredModels).length === 0 ? (