Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
205 lines
6.5 KiB
TypeScript
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>
|
|
)
|
|
}
|