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