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.
360 lines
13 KiB
TypeScript
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 }
|