ultisuite-client/components/llm/llm-providers-editor.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

360 lines
13 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { ExternalLink, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { FieldGroup } from "@/components/admin/settings/field-group"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import type { ApiLLMProvider, ApiLLMProviderType } from "@/lib/contacts/discovery-types"
import {
emptyLlmProvider,
inferLlmProviderType,
isLlmProviderConfigured,
llmCatalogEntry,
LLM_PROVIDER_CATALOG,
normalizeLlmProvider,
} from "@/lib/llm/llm-provider-catalog"
import { cn } from "@/lib/utils"
type LlmProviderSecrets = Record<string, { configured?: boolean } | undefined>
export type LlmProvidersEditorProps = {
providers: ApiLLMProvider[]
defaultProviderId: string
onProvidersChange: (providers: ApiLLMProvider[]) => void
onDefaultProviderIdChange: (id: string) => void
className?: string
columns?: 1 | 2
providerSecrets?: LlmProviderSecrets
renderDefaultModelInput?: (props: {
provider: ApiLLMProvider
index: number
onChange: (default_model: string) => void
}) => React.ReactNode
}
function providerOptions(
providers: ApiLLMProvider[],
providerSecrets?: LlmProviderSecrets,
) {
return providers.map((provider) => ({
provider: normalizeLlmProvider(provider),
configured: isLlmProviderConfigured(provider, {
apiKeyConfigured: providerSecrets?.[provider.id]?.configured,
}),
}))
}
export function LlmProvidersEditor({
providers,
defaultProviderId,
onProvidersChange,
onDefaultProviderIdChange,
className,
columns = 2,
providerSecrets,
renderDefaultModelInput,
}: LlmProvidersEditorProps) {
const options = providerOptions(providers, providerSecrets)
const [editingProviderId, setEditingProviderId] = useState<string | null>(null)
useEffect(() => {
if (editingProviderId && !providers.some((provider) => provider.id === editingProviderId)) {
setEditingProviderId(null)
}
}, [editingProviderId, providers])
function commit(nextProviders: ApiLLMProvider[]) {
onProvidersChange(nextProviders.map(normalizeLlmProvider))
}
function updateProvider(index: number, patch: Partial<ApiLLMProvider>) {
const next = [...providers]
next[index] = { ...next[index], ...patch }
commit(next)
}
function setProviderType(index: number, type: ApiLLMProviderType) {
const current = providers[index]
const entry = llmCatalogEntry(type)
const next = {
...emptyLlmProvider(type),
id: current?.id ?? emptyLlmProvider(type).id,
api_key: current?.api_key ?? "",
}
const updated = [...providers]
updated[index] = next
commit(updated)
}
function addProvider() {
const provider = emptyLlmProvider("openai")
commit([...providers, provider])
setEditingProviderId(provider.id)
if (!defaultProviderId) {
onDefaultProviderIdChange(provider.id)
}
}
function removeProvider(index: number) {
const removed = providers[index]
if (editingProviderId === removed?.id) {
setEditingProviderId(null)
}
const nextProviders = providers.filter((_, i) => i !== index)
const remaining = providerOptions(nextProviders, providerSecrets)
let nextDefaultId = defaultProviderId
if (nextDefaultId === removed?.id) {
nextDefaultId =
remaining.find((entry) => entry.configured)?.provider.id ??
nextProviders[0]?.id ??
""
}
onProvidersChange(nextProviders.map(normalizeLlmProvider))
onDefaultProviderIdChange(nextDefaultId)
}
return (
<div className={cn("w-full space-y-4", className)}>
{providers.length > 0 ? (
<FieldGroup>
<Label className="text-xs">Fournisseur par défaut</Label>
<Select
value={defaultProviderId || providers[0]?.id || "__none__"}
onValueChange={(id) =>
onDefaultProviderIdChange(id === "__none__" ? "" : id)
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir…" />
</SelectTrigger>
<SelectContent>
{options.map(({ provider, configured }) => {
const type = inferLlmProviderType(provider)
const entry = llmCatalogEntry(type)
return (
<SelectItem
key={provider.id}
value={provider.id}
disabled={!configured}
>
<TechBrandSelectLabel
brand={type}
icon={entry.icon}
suffix={!configured ? " (incomplet)" : undefined}
>
{provider.name || entry.label}
</TechBrandSelectLabel>
</SelectItem>
)
})}
</SelectContent>
</Select>
{options.some((entry) => !entry.configured) ? (
<p className="text-xs text-muted-foreground">
Les fournisseurs incomplets restent visibles mais ne peuvent pas être sélectionnés
par défaut.
</p>
) : null}
</FieldGroup>
) : null}
<AutomationTabMasonry columns={columns}>
{providers.map((provider, index) => {
const normalized = normalizeLlmProvider(provider)
const type = inferLlmProviderType(normalized)
const entry = llmCatalogEntry(type)
const apiKeyConfigured = providerSecrets?.[provider.id]?.configured ?? false
const configured = isLlmProviderConfigured(provider, { apiKeyConfigured })
const isEditing = editingProviderId === provider.id
const displayName =
normalized.name || entry.label || `Fournisseur ${index + 1}`
if (!isEditing) {
return (
<div
key={provider.id}
className="flex w-full items-center justify-between gap-3 rounded-lg border border-border px-4 py-3"
>
<TechBrandSelectLabel
brand={type}
icon={entry.icon}
className="min-w-0 text-sm font-medium"
>
{displayName}
</TechBrandSelectLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditingProviderId(provider.id)}
>
Modifier
</Button>
</div>
)
}
return (
<div key={provider.id} className="w-full rounded-lg border border-border py-4">
<div className="flex items-start justify-between gap-2 px-4">
<div className="min-w-0">
<TechBrandSelectLabel
brand={type}
icon={entry.icon}
className="text-sm font-medium"
>
{displayName}
</TechBrandSelectLabel>
{!configured ? (
<p className="mt-1 text-xs text-muted-foreground">Configuration incomplète</p>
) : apiKeyConfigured && !(provider.api_key ?? "").trim() ? (
<p className="mt-1 text-xs text-muted-foreground">
Clé API enregistrée sur le serveur
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEditingProviderId(null)}
>
Fermer
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer le fournisseur"
onClick={() => removeProvider(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
<div className="mt-4 space-y-4 border-t px-4 pt-4">
<FieldGroup>
<Label className="text-xs">Fournisseur</Label>
<Select
value={type}
onValueChange={(value) =>
setProviderType(index, value as ApiLLMProviderType)
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue>
<TechBrandSelectLabel brand={type} icon={entry.icon}>
{entry.label}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{LLM_PROVIDER_CATALOG.map((item) => (
<SelectItem key={item.type} value={item.type}>
<TechBrandSelectLabel brand={item.type} icon={item.icon}>
{item.label}
</TechBrandSelectLabel>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{entry.description}</p>
{entry.docsUrl ? (
<a
href={entry.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:underline"
>
Documentation
<ExternalLink className="size-3" />
</a>
) : null}
</FieldGroup>
<FieldGroup>
<Label className="text-xs">Nom affiché</Label>
<Input
className="h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
placeholder={entry.label}
/>
</FieldGroup>
<FieldGroup>
<Label className="text-xs">URL de base</Label>
<Input
className="h-9"
value={provider.base_url}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
placeholder={entry.baseURLPlaceholder ?? entry.defaultBaseURL}
/>
</FieldGroup>
{type !== "ollama" ? (
<FieldGroup>
<Label className="text-xs">Clé API</Label>
<Input
className="h-9"
type="password"
autoComplete="off"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder={
apiKeyConfigured && !(provider.api_key ?? "").trim()
? "•••••••• (laisser vide pour conserver)"
: type === "openai"
? "sk-…"
: undefined
}
/>
</FieldGroup>
) : null}
<FieldGroup>
<Label className="text-xs">Modèle par défaut</Label>
{renderDefaultModelInput ? (
renderDefaultModelInput({
provider,
index,
onChange: (default_model) => updateProvider(index, { default_model }),
})
) : (
<Input
className="h-9"
value={provider.default_model}
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
placeholder={entry.defaultModel || "gpt-4o-mini"}
/>
)}
</FieldGroup>
</div>
</div>
)
})}
</AutomationTabMasonry>
<Button type="button" variant="outline" size="sm" onClick={addProvider}>
<Plus className="mr-2 size-4" />
Ajouter un fournisseur
</Button>
</div>
)
}
export { emptyLlmProvider }