ultisuite-client/components/admin/settings/sections/ai-assistant-section.tsx
R3D347HR4Y 7ee1a66942
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(ai-assistant): enhance AI assistant configuration and integration
- Added support for managing AI models within the AI assistant settings.
- Introduced new hosted mail setup component for streamlined email configuration.
- Updated environment variables for local development and proxy settings.
- Enhanced error handling and user feedback in the chat page for API connectivity issues.
- Improved routing for AI-related API calls in the Next.js configuration.
- Added documentation for local development and agent management in CLAUDE.md.
2026-06-13 20:38:15 +02:00

349 lines
14 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { Plus, RefreshCw, Trash2 } from "lucide-react"
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 { 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"
function emptyModelEntry(): AiModelCatalogEntry {
return {
model_id: "",
label: "",
enabled: true,
}
}
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 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 [discoverProviderId, setDiscoverProviderId] = useState(llm.default_provider_id)
const [discoveredModels, setDiscoveredModels] = useState<string[]>([])
const discoverProvider = useMemo(
() => llm.providers.find((p) => p.id === discoverProviderId) ?? llm.providers[0],
[discoverProviderId, llm.providers],
)
const discoverModels = useDiscoverOrgLLMModels()
async function handleDiscoverModels() {
if (!discoverProvider?.id) return
setDiscoveredModels([])
try {
const result = await discoverModels.mutateAsync(discoverProvider.id)
setDiscoveredModels(result.models ?? [])
} catch {
setDiscoveredModels([])
}
}
function updateModel(index: number, patch: Partial<AiModelCatalogEntry>) {
const models = aiAssistant.models.map((entry, i) =>
i === index ? { ...entry, ...patch } : entry,
)
setAiAssistant({ models })
}
function removeModel(index: number) {
setAiAssistant({ models: aiAssistant.models.filter((_, i) => i !== index) })
}
function addManualModel() {
setAiAssistant({ models: [...aiAssistant.models, emptyModelEntry()] })
}
function addDiscoveredModel(modelId: string) {
if (aiAssistant.models.some((entry) => entry.model_id === modelId)) return
setAiAssistant({
models: [
...aiAssistant.models,
{ model_id: modelId, label: modelId, enabled: true },
],
})
}
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) avec gateway LLM, tools et sync Nextcloud."
policySection={["ai_assistant", "plugins"]}
>
<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">
<Label>Modèle par défaut</Label>
<Input
value={aiAssistant.default_model}
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
placeholder="gpt-4o"
/>
</div>
<div className="space-y-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>
<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">
{llm.providers.length === 0 ? (
<p className="text-sm text-muted-foreground">
Configurez d&apos;abord un fournisseur LLM dans Administration Fournisseurs LLM.
</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>
{llm.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}
{discoveredModels.length ? (
<div className="space-y-2">
<Label className="text-xs">Modèles disponibles sur l&apos;endpoint</Label>
<div className="flex flex-wrap gap-2">
{discoveredModels.map((modelId) => {
const alreadyAdded = aiAssistant.models.some(
(entry) => entry.model_id === modelId,
)
return (
<Button
key={modelId}
type="button"
size="sm"
variant={alreadyAdded ? "secondary" : "outline"}
disabled={alreadyAdded}
onClick={() => addDiscoveredModel(modelId)}
>
{alreadyAdded ? "Ajouté" : `+ ${modelId}`}
</Button>
)
})}
</div>
</div>
) : null}
<div className="flex items-center justify-between">
<Label>Catalogue organisation</Label>
<Button type="button" variant="outline" size="sm" onClick={addManualModel}>
<Plus className="mr-2 size-4" />
Ajouter manuellement
</Button>
</div>
{aiAssistant.models.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucune restriction tous les modèles LLM configurés restent disponibles.
</p>
) : (
<div className="space-y-2">
{aiAssistant.models.map((entry, index) => (
<div
key={`${entry.model_id}-${index}`}
className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[1fr_1fr_auto_auto]"
>
<div className="space-y-1">
<Label className="text-xs">ID modèle</Label>
<Input
className="h-9"
value={entry.model_id}
onChange={(e) => updateModel(index, { model_id: e.target.value })}
placeholder="gpt-4o-mini"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Surnom utilisateur</Label>
<Input
className="h-9"
value={entry.label}
onChange={(e) => updateModel(index, { label: e.target.value })}
placeholder="GPT-4o Mini"
/>
</div>
<label className="flex items-center gap-2 self-end pb-1 text-sm">
<Switch
checked={entry.enabled}
onCheckedChange={(enabled) => updateModel(index, { enabled })}
/>
Autorisé
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="self-end"
onClick={() => removeModel(index)}
aria-label="Supprimer le modèle"
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</OrgSettingsSection>
)
}