Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced legacy components with new `SettingsCard`, `SettingsField`, and `SettingsToggleRow` for a unified design. - Enhanced `AdminListControls` to support compact mode and improved pagination controls. - Updated various sections including `AiAssistantSection`, `AuthenticationSection`, and `DriveMountOAuthSection` to utilize new components, streamlining the settings interface. - Improved accessibility and user experience across admin settings with clearer labels and hints. - Deprecated old components while maintaining backward compatibility for existing admin sections.
359 lines
14 KiB
TypeScript
359 lines
14 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 {
|
|
SettingsCard,
|
|
SettingsField,
|
|
SettingsGrid,
|
|
SettingsHint,
|
|
SettingsToggleRow,
|
|
} from "@/components/settings/settings-kit"
|
|
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 { Switch } from "@/components/ui/switch"
|
|
import { Button } from "@/components/ui/button"
|
|
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}>
|
|
<SettingsCard
|
|
title="Assistant IA"
|
|
description="Active le plugin UltiAI pour toute l'organisation. Le service OpenWebUI doit aussi être déployé."
|
|
action={
|
|
<Switch
|
|
checked={orgEnabled}
|
|
disabled={enabledLocked}
|
|
onCheckedChange={setUltiAIEnabled}
|
|
/>
|
|
}
|
|
badges={
|
|
<>
|
|
<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>
|
|
</>
|
|
}
|
|
hint={
|
|
<>
|
|
{enabledLocked ? <DeployLockedHint section="ai_assistant" field="enabled" /> : null}
|
|
{!orgEnabled && !runtimeEnabled ? (
|
|
<SettingsHint>
|
|
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.
|
|
</SettingsHint>
|
|
) : null}
|
|
</>
|
|
}
|
|
>
|
|
<SettingsField
|
|
label="Chemin public (proxy)"
|
|
hint={<DeployLockedHint section="ai_assistant" field="public_path" />}
|
|
>
|
|
<Input
|
|
className="h-9"
|
|
value={aiAssistant.public_path}
|
|
onChange={(e) => setAiAssistant({ public_path: e.target.value })}
|
|
placeholder="/ai"
|
|
disabled={publicPathLocked}
|
|
/>
|
|
</SettingsField>
|
|
<SettingsField
|
|
label="URL interne OpenWebUI"
|
|
hint={<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />}
|
|
>
|
|
<Input
|
|
className="h-9"
|
|
value={aiAssistant.openwebui_internal_url}
|
|
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
|
|
placeholder="http://openwebui:8080"
|
|
disabled={openwebuiLocked}
|
|
/>
|
|
</SettingsField>
|
|
<SettingsField
|
|
label="Modèle par défaut"
|
|
hint="Modèle pré-sélectionné dans UltiAI pour tous les utilisateurs. Configurez un fournisseur LLM ou découvrez les modèles ci-dessous."
|
|
>
|
|
{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
|
|
className="h-9"
|
|
value={aiAssistant.default_model}
|
|
onChange={(e) => setAiAssistant({ default_model: e.target.value })}
|
|
placeholder="gpt-4o-mini"
|
|
/>
|
|
)}
|
|
</SettingsField>
|
|
<SettingsField label="Chemin historique NC">
|
|
<Input
|
|
className="h-9"
|
|
value={aiAssistant.chat_nc_path}
|
|
onChange={(e) => setAiAssistant({ chat_nc_path: e.target.value })}
|
|
placeholder="/.ultimail/ai/chats"
|
|
/>
|
|
</SettingsField>
|
|
<SettingsToggleRow
|
|
title="Embed temporaire par défaut"
|
|
description="Les panneaux mail/drive/contacts ne sauvegardent pas l'historique."
|
|
checked={aiAssistant.embed_default_temporary}
|
|
onCheckedChange={(v) => setAiAssistant({ embed_default_temporary: v })}
|
|
/>
|
|
<SettingsToggleRow
|
|
title="Sync historique Nextcloud"
|
|
description="Pipeline OpenWebUI → fichiers .ultichat.json sur le drive utilisateur."
|
|
checked={aiAssistant.chat_sync_enabled}
|
|
onCheckedChange={(v) => setAiAssistant({ chat_sync_enabled: v })}
|
|
/>
|
|
</SettingsCard>
|
|
|
|
<div className="flex flex-col gap-4 sm:col-span-2">
|
|
<AdminOrgLlmPolicyCard draft={llmDraft} setDraft={setLlmDraft} />
|
|
|
|
<SettingsCard
|
|
title="Fournisseurs LLM"
|
|
description="Modèles IA organisationnels pour UltiAI, le tri, l'enrichissement contacts et les automatisations."
|
|
>
|
|
<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 }))
|
|
}
|
|
/>
|
|
</SettingsCard>
|
|
</div>
|
|
|
|
<UltiAiToolsCard
|
|
enabledTools={aiAssistant.enabled_tools}
|
|
onChange={(enabled_tools) => setAiAssistant({ enabled_tools })}
|
|
webSearchSettingsHref="/admin/settings/search"
|
|
/>
|
|
|
|
<SettingsCard
|
|
title="Modèles autorisés"
|
|
description="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."
|
|
>
|
|
{llmDraft.providers.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Configurez d'abord un fournisseur LLM dans la section ci-dessus.
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-mail-border bg-mail-surface-muted/40 p-3">
|
|
<SettingsField label="Découvrir depuis le fournisseur" className="min-w-[220px] flex-1">
|
|
<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>
|
|
</SettingsField>
|
|
<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 ? (
|
|
<SettingsHint tone="danger">
|
|
{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."}
|
|
</SettingsHint>
|
|
) : null}
|
|
|
|
{llmDraft.providers.length > 0 ? (
|
|
<SettingsField
|
|
label="Catalogue organisation"
|
|
hint={
|
|
discoveredModels.length === 0
|
|
? "Découvrez les modèles depuis un fournisseur pour remplir l'autocomplétion, ou saisissez un ID manuellement puis Entrée."
|
|
: undefined
|
|
}
|
|
>
|
|
<AiAuthorizedModelPicker
|
|
models={aiAssistant.models}
|
|
onChange={setAuthorizedModels}
|
|
availableModelIds={discoveredModels}
|
|
emptyHint="Aucune restriction — tous les modèles LLM configurés restent disponibles."
|
|
/>
|
|
</SettingsField>
|
|
) : null}
|
|
</SettingsCard>
|
|
</AutomationTabMasonry>
|
|
</OrgSettingsSection>
|
|
)
|
|
}
|