ultisuite-client/components/admin/settings/sections/ai-authorized-model-picker.tsx
R3D347HR4Y 9e9fd208ad
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(admin-settings): enhance admin settings with new components and layout improvements
- Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel.
- Implemented dynamic loading for admin settings sections to optimize performance.
- Enhanced the layout of various admin settings sections for better user experience.
- Updated the AiAssistantSection to include LLM provider management and improved model selection.
- Refactored authentication settings to streamline configuration and improve accessibility.
2026-06-15 00:22:20 +02:00

205 lines
6.5 KiB
TypeScript

"use client"
import { useMemo, useRef, useState } from "react"
import { X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
import { cn } from "@/lib/utils"
const VISIBLE_SUGGESTIONS = 5
const SUGGESTION_ROW_HEIGHT_REM = 2.25
function modelKey(entry: AiModelCatalogEntry) {
return entry.model_id
}
function chipLabel(entry: AiModelCatalogEntry) {
const label = entry.label.trim()
return label && label !== entry.model_id ? label : entry.model_id
}
export function AiAuthorizedModelPicker({
models,
onChange,
availableModelIds,
disabled,
emptyHint,
}: {
models: AiModelCatalogEntry[]
onChange: (models: AiModelCatalogEntry[]) => void
availableModelIds: string[]
disabled?: boolean
emptyHint?: string
}) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const blurTimer = useRef<number | null>(null)
const taken = useMemo(() => new Set(models.map((m) => m.model_id)), [models])
const suggestions = useMemo(() => {
const q = query.trim().toLowerCase()
const pool = availableModelIds.filter((id) => !taken.has(id))
const matches = q ? pool.filter((id) => id.toLowerCase().includes(q)) : pool
return matches.sort((a, b) => a.localeCompare(b))
}, [availableModelIds, query, taken])
const showSuggestions = focused && !disabled && suggestions.length > 0
function addModel(modelId: string) {
const id = modelId.trim()
if (!id || taken.has(id)) return
onChange([...models, { model_id: id, label: id, enabled: true }])
setQuery("")
setActiveIndex(0)
}
function removeModel(modelId: string) {
onChange(models.filter((entry) => entry.model_id !== modelId))
}
function updateLabel(modelId: string, label: string) {
onChange(
models.map((entry) =>
entry.model_id === modelId ? { ...entry, label } : entry,
),
)
}
function tryAddFromQuery() {
const id = query.trim()
if (!id) return false
if (suggestions[activeIndex]) {
addModel(suggestions[activeIndex]!)
return true
}
if (!taken.has(id)) {
addModel(id)
return true
}
return false
}
return (
<div className="space-y-2">
<div className="relative">
<Input
value={query}
disabled={disabled}
placeholder="Rechercher ou saisir un modèle…"
className="h-9"
onChange={(e) => {
setQuery(e.target.value)
setActiveIndex(0)
}}
onFocus={() => {
if (blurTimer.current) window.clearTimeout(blurTimer.current)
setFocused(true)
}}
onBlur={() => {
blurTimer.current = window.setTimeout(() => setFocused(false), 120)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
tryAddFromQuery()
return
}
if (!showSuggestions || suggestions.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => (i + 1) % suggestions.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
} else if (e.key === "Escape") {
setFocused(false)
}
}}
/>
{showSuggestions ? (
<ul
className="absolute z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-md"
style={{ maxHeight: `${VISIBLE_SUGGESTIONS * SUGGESTION_ROW_HEIGHT_REM}rem` }}
>
{suggestions.map((modelId, index) => (
<li key={modelId}>
<button
type="button"
className={cn(
"block w-full px-3 py-1.5 text-left text-xs hover:bg-muted",
index === activeIndex && "bg-muted",
)}
onMouseDown={(e) => {
e.preventDefault()
addModel(modelId)
}}
>
<span className="block truncate font-mono">{modelId}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
{models.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{models.map((entry) => (
<span
key={modelKey(entry)}
className="inline-flex max-w-full items-center gap-1 rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[11px] text-foreground"
>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="max-w-[14rem] truncate text-left hover:underline"
title={entry.model_id}
>
{chipLabel(entry)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 space-y-2 p-3">
<div className="space-y-1">
<Label className="text-xs">ID modèle</Label>
<p className="truncate font-mono text-xs text-muted-foreground">
{entry.model_id}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">Surnom utilisateur</Label>
<Input
className="h-8 text-xs"
value={entry.label}
onChange={(e) => updateLabel(entry.model_id, e.target.value)}
placeholder={entry.model_id}
/>
</div>
</PopoverContent>
</Popover>
<button
type="button"
className="shrink-0 rounded-full p-0.5 hover:bg-background/80"
aria-label={`Retirer ${entry.model_id}`}
onClick={() => removeModel(entry.model_id)}
>
<X className="size-3" />
</button>
</span>
))}
</div>
) : emptyHint ? (
<p className="text-sm text-muted-foreground">{emptyHint}</p>
) : null}
</div>
)
}