ultisuite-client/components/admin/settings/sections/ai-assistant-section.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

380 lines
16 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { RefreshCw } from "lucide-react"
import { AiAuthorizedModelPicker } from "@/components/admin/settings/sections/ai-authorized-model-picker"
import { UltiAiToolsCard } from "@/components/admin/settings/sections/ultiai-tools-card"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { DeployLockedHint } from "@/components/admin/settings/deploy-locked-hint"
import { useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { AdminOrgLlmPolicyCard } from "@/components/admin/settings/sections/admin-org-llm-providers-panel"
import { LlmProvidersEditor } from "@/components/llm/llm-providers-editor"
import { normalizeLlmProvider } from "@/lib/llm/llm-provider-catalog"
import { AutomationTabMasonry } from "@/components/gmail/settings/automation/automation-tab-masonry"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { AiModelCatalogEntry } from "@/lib/admin-settings/org-settings-types"
import { useDiscoverOrgLLMModels } from "@/lib/api/hooks/use-admin-llm"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export function AiAssistantSection() {
const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant)
const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant)
const setPlugins = useOrgSettingsStore((s) => s.setPlugins)
const plugins = useOrgSettingsStore((s) => s.plugins)
const llm = useOrgSettingsStore((s) => s.llm)
const setLlm = useOrgSettingsStore((s) => s.setLlm)
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const effective = useOrgSettingsStore((s) => s.meta?.effective.ai_assistant)
const enabledLocked = useDeployFieldLocked("ai_assistant", "enabled")
const publicPathLocked = useDeployFieldLocked("ai_assistant", "public_path")
const openwebuiLocked = useDeployFieldLocked("ai_assistant", "openwebui_internal_url")
const pluginEnabled = plugins.find((p) => p.id === "ai-assistant")?.enabled ?? false
const runtimeEnabled = effective?.enabled ?? false
const orgEnabled = aiAssistant.enabled || pluginEnabled
const [llmDraft, setLlmDraft] = useState(llm)
useEffect(() => {
setLlmDraft({
...llm,
providers: (llm.providers ?? []).map(normalizeLlmProvider),
})
}, [llm])
const [discoverProviderId, setDiscoverProviderId] = useState(llm.default_provider_id)
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
const discoverProvider = useMemo(
() => llmDraft.providers.find((p) => p.id === discoverProviderId) ?? llmDraft.providers[0],
[discoverProviderId, llmDraft.providers],
)
const discoverModels = useDiscoverOrgLLMModels()
const defaultModelOptions = useMemo(() => {
const ids = new Set<string>()
if (aiAssistant.models.length > 0) {
for (const entry of aiAssistant.models) {
if (entry.enabled && entry.model_id.trim()) ids.add(entry.model_id.trim())
}
} else {
for (const provider of llmDraft.providers) {
if (provider.default_model?.trim()) ids.add(provider.default_model.trim())
}
for (const modelId of discoveredModels) {
if (modelId.trim()) ids.add(modelId.trim())
}
}
if (aiAssistant.default_model.trim()) ids.add(aiAssistant.default_model.trim())
return Array.from(ids).sort((a, b) => a.localeCompare(b))
}, [aiAssistant.models, aiAssistant.default_model, llmDraft.providers, discoveredModels])
useEffect(() => {
if (!discoverProvider?.id) return
let cancelled = false
void discoverModels
.mutateAsync(discoverProvider.id)
.then((result) => {
if (!cancelled) setDiscoveredModels(result.models ?? [])
})
.catch(() => {
if (!cancelled) setDiscoveredModels([])
})
return () => {
cancelled = true
}
}, [discoverProvider?.id])
async function handleDiscoverModels() {
if (!discoverProvider?.id) return
setDiscoveredModels([])
try {
const result = await discoverModels.mutateAsync(discoverProvider.id)
setDiscoveredModels(result.models ?? [])
} catch {
setDiscoveredModels([])
}
}
const orgLlmProviderSecrets = secrets?.llm_providers as
| Record<string, { configured?: boolean }>
| undefined
function setAuthorizedModels(models: AiModelCatalogEntry[]) {
setAiAssistant({ models })
}
function setUltiAIEnabled(enabled: boolean) {
setAiAssistant({ enabled })
setPlugins(
plugins.map((plugin) =>
plugin.id === "ai-assistant" ? { ...plugin, enabled } : plugin,
),
)
}
return (
<OrgSettingsSection
title="UltiAI"
description="Assistant IA intégré (OpenWebUI), fournisseurs LLM, gateway, tools et sync Nextcloud."
policySection={["ai_assistant", "plugins", "llm"]}
beforeSave={() => setLlm(llmDraft)}
>
<AutomationTabMasonry columns={2}>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-sm font-medium">Assistant IA</CardTitle>
<CardDescription>
Active le plugin UltiAI pour toute l&apos;organisation. Le service OpenWebUI doit
aussi être déployé.
</CardDescription>
</div>
<Switch
checked={orgEnabled}
disabled={enabledLocked}
onCheckedChange={setUltiAIEnabled}
/>
</div>
{enabledLocked ? (
<DeployLockedHint section="ai_assistant" field="enabled" />
) : null}
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant={orgEnabled ? "default" : "secondary"}>
Politique org. {orgEnabled ? "activée" : "désactivée"}
</Badge>
<Badge variant={runtimeEnabled ? "default" : "outline"}>
Runtime Compose {runtimeEnabled ? "actif" : "inactif"}
</Badge>
</div>
{!orgEnabled && !runtimeEnabled ? (
<p className="text-xs text-muted-foreground">
Activez le plugin UltiAI dans Administration Plugins, ou définissez{" "}
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
déploiement, puis redémarrez le backend et OpenWebUI.
</p>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label>Chemin public (proxy)</Label>
<Input
value={aiAssistant.public_path}
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
placeholder="/ai"
disabled={publicPathLocked}
/>
<DeployLockedHint section="ai_assistant" field="public_path" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label>URL interne OpenWebUI</Label>
<Input
value={aiAssistant.openwebui_internal_url}
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
placeholder="http://openwebui:8080"
disabled={openwebuiLocked}
/>
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Modèle par défaut</Label>
{defaultModelOptions.length > 0 ? (
<Select
value={aiAssistant.default_model || "__auto__"}
onValueChange={(value) =>
setAiAssistant({
default_model: value === "__auto__" ? "" : value,
})
}
>
<SelectTrigger className="h-9 w-full min-w-0">
<SelectValue placeholder="Choisir un modèle…" />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="__auto__">
Automatique (fournisseur LLM par défaut)
</SelectItem>
{defaultModelOptions.map((modelId) => {
const catalogLabel = aiAssistant.models.find(
(entry) => entry.model_id === modelId,
)?.label
return (
<SelectItem key={modelId} value={modelId}>
{catalogLabel?.trim() ? `${catalogLabel} (${modelId})` : modelId}
</SelectItem>
)
})}
</SelectContent>
</Select>
) : (
<Input
value={aiAssistant.default_model}
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
placeholder="gpt-4o-mini"
/>
)}
<p className="text-xs text-muted-foreground">
Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un
fournisseur LLM ou découvrez les modèles ci-dessous.
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Chemin historique NC</Label>
<Input
value={aiAssistant.chat_nc_path}
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
placeholder="/.ultimail/ai/chats"
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Embed temporaire par défaut</Label>
<p className="text-xs text-muted-foreground">
Les panneaux mail/drive/contacts ne sauvegardent pas l&apos;historique.
</p>
</div>
<Switch
checked={aiAssistant.embed_default_temporary}
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
/>
</div>
<div className="flex items-center justify-between gap-4 sm:col-span-2">
<div>
<Label>Sync historique Nextcloud</Label>
<p className="text-xs text-muted-foreground">
Pipeline OpenWebUI fichiers .ultichat.json sur le drive utilisateur.
</p>
</div>
<Switch
checked={aiAssistant.chat_sync_enabled}
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
/>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-4 sm:col-span-2">
<AdminOrgLlmPolicyCard draft={llmDraft} setDraft={setLlmDraft} />
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Fournisseurs LLM</CardTitle>
<CardDescription>
Modèles IA organisationnels pour UltiAI, le tri, l&apos;enrichissement contacts et
les automatisations.
</CardDescription>
</CardHeader>
<CardContent>
<LlmProvidersEditor
columns={1}
providers={llmDraft.providers}
defaultProviderId={llmDraft.default_provider_id}
providerSecrets={orgLlmProviderSecrets}
onProvidersChange={(providers) =>
setLlmDraft((prev) => ({ ...prev, providers }))
}
onDefaultProviderIdChange={(default_provider_id) =>
setLlmDraft((prev) => ({ ...prev, default_provider_id }))
}
/>
</CardContent>
</Card>
</div>
<UltiAiToolsCard
enabledTools={aiAssistant.enabled_tools}
onChange={(enabled_tools) => setAiAssistant({ enabled_tools })}
webSearchSettingsHref="/admin/settings/search"
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Modèles autorisés</CardTitle>
<CardDescription>
Liste vide = tous les modèles des fournisseurs LLM org. Sinon, seuls les modèles
autorisés sont visibles pour les utilisateurs. Le surnom remplace le nom technique.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{llmDraft.providers.length === 0 ? (
<p className="text-sm text-muted-foreground">
Configurez d&apos;abord un fournisseur LLM dans la section ci-dessus.
</p>
) : (
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
<div className="min-w-[220px] flex-1 space-y-2">
<Label className="text-xs">Découvrir depuis le fournisseur</Label>
<Select
value={discoverProvider?.id ?? ""}
onValueChange={setDiscoverProviderId}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Choisir un fournisseur…" />
</SelectTrigger>
<SelectContent>
{llmDraft.providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name || provider.base_url}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={!discoverProvider?.id || discoverModels.isPending}
onClick={() => void handleDiscoverModels()}
>
<RefreshCw
className={`mr-2 size-4 ${discoverModels.isPending ? "animate-spin" : ""}`}
/>
Découvrir les modèles
</Button>
</div>
)}
{discoverModels.isError ? (
<p className="text-sm text-destructive">
{discoverModels.error instanceof Error
? discoverModels.error.message
: "Impossible de lister les modèles sur ce fournisseur. Enregistrez d'abord le fournisseur LLM avec une clé API valide."}
</p>
) : null}
{llmDraft.providers.length > 0 ? (
<div className="space-y-2">
<Label>Catalogue organisation</Label>
<AiAuthorizedModelPicker
models={aiAssistant.models}
onChange={setAuthorizedModels}
availableModelIds={discoveredModels}
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
/>
{discoveredModels.length === 0 ? (
<p className="text-xs text-muted-foreground">
Découvrez les modèles depuis un fournisseur pour remplir l&apos;autocomplétion,
ou saisissez un ID manuellement puis Entrée.
</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
</AutomationTabMasonry>
</OrgSettingsSection>
)
}