feat(model-selector): enhance model selection with connection scope and free model indication

This commit is contained in:
Anish Sarkar 2026-06-13 12:14:17 +05:30
parent 02070201fb
commit 50668775f8

View file

@ -1,8 +1,10 @@
"use client";
import { useAtom, useAtomValue } from "jotai";
import { Check, ChevronDown, Cpu, ImageOff, Search, Settings2, Zap } from "lucide-react";
import { useMemo, useState } from "react";
import { Check, ChevronDown, Cpu, Search, Settings2, Zap } from "lucide-react";
import { useRouter } from "next/navigation";
import type { UIEvent } from "react";
import { useCallback, useMemo, useState } from "react";
import { updateModelRolesMutationAtom } from "@/atoms/model-connections/model-connections-mutation.atoms";
import {
globalModelConnectionsAtom,
@ -23,41 +25,30 @@ import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import type { ConnectionRead, ModelRead } from "@/contracts/types/model-connections.types";
import type {
GlobalImageGenConfig,
GlobalNewLLMConfig,
GlobalVisionLLMConfig,
ImageGenerationConfig,
NewLLMConfigPublic,
VisionLLMConfig,
} from "@/contracts/types/new-llm-config.types";
import { useIsMobile } from "@/hooks/use-mobile";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
import { providerDisplay } from "../settings/model-connections/provider-metadata";
interface ModelSelectorProps {
onEditLLM: (config: NewLLMConfigPublic | GlobalNewLLMConfig, isGlobal: boolean) => void;
onAddNewLLM: (provider?: string) => void;
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
onAddNewImage?: (provider?: string) => void;
onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
onAddNewVision?: (provider?: string) => void;
searchSpaceId: number;
className?: string;
}
type ChatModel = ModelRead & {
connectionId: number;
connectionLabel: string;
connectionScope: string;
provider: string;
};
function modelName(model: ModelRead) {
return model.display_name || model.model_id;
return (model.display_name || model.model_id).replace(/\s+\(free\)$/i, "");
}
function connectionLabel(connection: ConnectionRead) {
if (connection.scope === "GLOBAL") return "Hosted";
return connection.provider;
if (connection.scope === "GLOBAL") return "Global";
return providerDisplay(connection.provider).name;
}
function flattenChatModels(connections: ConnectionRead[]) {
@ -68,11 +59,16 @@ function flattenChatModels(connections: ConnectionRead[]) {
...model,
connectionId: connection.id,
connectionLabel: connectionLabel(connection),
connectionScope: connection.scope,
provider: connection.provider,
}))
);
}
function isFreeGlobalModel(model: ChatModel) {
return model.connectionScope === "GLOBAL" && model.billing_tier?.toLowerCase() === "free";
}
function groupedModels(models: ChatModel[]) {
return models.reduce<Record<string, ChatModel[]>>((groups, model) => {
const key = model.connectionLabel;
@ -83,23 +79,14 @@ function groupedModels(models: ChatModel[]) {
}
export function ModelSelector({
onAddNewLLM,
onEditLLM,
onEditImage,
onAddNewImage,
onEditVision,
onAddNewVision,
searchSpaceId,
className,
}: ModelSelectorProps) {
void onEditLLM;
void onEditImage;
void onAddNewImage;
void onEditVision;
void onAddNewVision;
const router = useRouter();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
const [{ data: globalConnections = [], isLoading: globalLoading }] = useAtom(
globalModelConnectionsAtom
);
@ -130,11 +117,18 @@ export function ModelSelector({
function manageModelConnections() {
setOpen(false);
onAddNewLLM();
router.push(`/dashboard/${searchSpaceId}/search-space-settings/models`);
}
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");
}, []);
const content = (
<div className="flex max-h-[min(520px,80vh)] flex-col overflow-hidden">
<div className="flex h-[320px] select-none flex-col overflow-hidden">
<div className="p-2">
<div className="relative">
<Search className="absolute left-0.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@ -146,7 +140,14 @@ export function ModelSelector({
/>
</div>
</div>
<div className="overflow-y-auto overflow-x-hidden px-1.5 py-1.5">
<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"})`,
}}
>
<button
type="button"
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"
@ -174,7 +175,7 @@ export function ModelSelector({
) : (
Object.entries(groups).map(([connection, models]) => (
<div key={connection} className="mt-3">
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div className="px-2 py-1 text-sm font-semibold text-muted-foreground">
{connection}
</div>
{models.map((model) => (
@ -184,8 +185,8 @@ export function ModelSelector({
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"
onClick={() => selectModel(model.id)}
>
<div className="min-w-0">
<div className="flex items-center gap-2 truncate font-medium">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 font-medium">
{getProviderIcon(model.provider, { className: "size-4 shrink-0" })}
<span className="truncate">{modelName(model)}</span>
</div>
@ -195,12 +196,26 @@ export function ModelSelector({
</div>
) : null}
</div>
<div className="ml-3 flex items-center gap-2">
{!model.supports_image_input ? (
<Badge variant="outline" className="gap-1">
<ImageOff className="h-3 w-3" /> No image
<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
</Badge>
) : null}
{/*
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}
*/}
{roles?.chat_model_id === model.id ? <Check className="h-4 w-4" /> : null}
</div>
</button>
@ -228,6 +243,7 @@ export function ModelSelector({
size="sm"
className={cn(
"h-8 min-w-0 gap-2 rounded-md px-3 text-muted-foreground transition-colors",
"select-none",
"hover:bg-foreground/10 hover:text-foreground",
"data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground",
className
@ -263,7 +279,7 @@ export function ModelSelector({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="start" className="w-[360px] p-0">
<PopoverContent align="start" className="w-[340px] p-0">
{content}
</PopoverContent>
</Popover>