feat(ai-assistant): enhance AI assistant configuration and integration
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.
This commit is contained in:
R3D347HR4Y 2026-06-13 20:38:15 +02:00
parent e28ff6e384
commit 7ee1a66942
20 changed files with 757 additions and 84 deletions

View File

@ -23,3 +23,5 @@ NEXT_PUBLIC_ONLYOFFICE_URL=http://localhost/office
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab
# UltiAI (chemin proxy OpenWebUI — même origine)
NEXT_PUBLIC_AI_PUBLIC_PATH=/ai
# Dev Next.js (:3000) : charger l'iframe depuis nginx (:80) pour cookies session + proxy OpenWebUI
NEXT_PUBLIC_AI_ORIGIN=http://localhost

View File

@ -194,3 +194,21 @@ Cela permet de brancher n'importe quel service (Slack, Discord, n8n, Make, custo
### Docs internes
- `components/gmail/README.md` — arborescence composants
- `lib/stores/README.md` — architecture stores
### Environnement local & agents
Stack dev typique : **nginx (:80)**`ultid` (Docker) + frontend Next.js (`pnpm dev`, proxy `/api/v1` via `ULTI_PROXY_ORIGIN`).
**Les agents doivent redémarrer les services eux-mêmes** quand un changement l'exige — ne pas demander au développeur de le faire.
| Changement | Action (depuis `ulti-backend/`) |
|----------|----------------------------------|
| Code Go (`internal/`, `cmd/`, migrations embarquées) | `./deploy/compose-up.sh up -d --build ultid` |
| Variable `.env` lue au démarrage d'`ultid` | `./deploy/compose-up.sh up -d --build ultid` (régénère `.env.resolved`) |
| Module Compose optionnel (OpenWebUI, Nextcloud, …) | `./deploy/compose-up.sh up -d` (overlay activé selon `.env`) |
| Redémarrage simple sans rebuild | `./deploy/compose-up.sh restart ultid` |
| Frontend Next.js (ce repo) | `pnpm dev` se recharge seul ; rebuild seulement si config Next/env change |
Après restart backend, vérifier : `curl -s http://127.0.0.1:80/api/v1/ai/config` (JSON, pas 502).
Repo backend : `../ulti-backend` — voir aussi `ulti-backend/CLAUDE.md`.

View File

@ -1,11 +1,13 @@
"use client"
import Link from "next/link"
import { Sparkles } from "lucide-react"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
import { Button } from "@/components/ui/button"
export default function ChatPage() {
const { data: config, isLoading } = useAiConfig()
const { data: config, isLoading, isError } = useAiConfig()
const { data: quota } = useAiQuota(Boolean(config?.enabled))
if (isLoading) {
@ -16,17 +18,34 @@ export default function ChatPage() {
)
}
if (!config?.enabled) {
if (isError) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-2 px-6 text-center">
<div className="flex h-dvh flex-col items-center justify-center gap-3 px-6 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
UltiAI n&apos;est pas activé. Activez le plugin dans l&apos;administration.
Impossible de contacter l&apos;API UltiAI. Vérifiez que le backend est démarré.
</p>
</div>
)
}
if (!config?.enabled) {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 px-6 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground" />
<p className="max-w-md text-sm text-muted-foreground">
UltiAI n&apos;est pas activé. Activez le plugin{" "}
<strong>UltiAI</strong> dans Administration Plugins (puis enregistrez), ou définissez{" "}
<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code> dans le
déploiement backend.
</p>
<Button asChild variant="outline" size="sm">
<Link href="/admin/settings/plugins">Ouvrir les plugins</Link>
</Button>
</div>
)
}
return (
<div className="flex h-dvh flex-col">
<header className="flex items-center justify-between border-b px-4 py-2">

View File

@ -1,24 +1,107 @@
"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 enabled = effective?.enabled ?? aiAssistant.enabled
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"
policySection={["ai_assistant", "plugins"]}
>
<Card>
<CardHeader className="pb-3">
@ -26,14 +109,34 @@ export function AiAssistantSection() {
<div>
<CardTitle className="text-sm font-medium">Assistant IA</CardTitle>
<CardDescription>
Chat standalone et panneaux contextuels mail/drive/contacts.
Active le plugin UltiAI pour toute l&apos;organisation. Le service OpenWebUI doit
aussi être déployé.
</CardDescription>
</div>
<Switch
checked={enabled}
onCheckedChange={(v) => setAiAssistant({ enabled: v })}
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">
@ -42,7 +145,9 @@ export function AiAssistantSection() {
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>
@ -50,7 +155,9 @@ export function AiAssistantSection() {
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>
@ -94,6 +201,148 @@ export function AiAssistantSection() {
</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>
)
}

View File

@ -5,6 +5,7 @@ import { Plus, Trash2 } from "lucide-react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { ApiLLMProvider } from "@/lib/contacts/discovery-types"
import { isOrgLLMProviderKeyConfigured } from "@/lib/api/hooks/use-admin-llm"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -30,6 +31,7 @@ function emptyProvider(): ApiLLMProvider {
export function LlmSection() {
const llm = useOrgSettingsStore((s) => s.llm)
const setLlm = useOrgSettingsStore((s) => s.setLlm)
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const [draft, setDraft] = useState(llm)
useEffect(() => {
@ -66,7 +68,7 @@ export function LlmSection() {
return (
<OrgSettingsSection
title="Fournisseurs LLM"
description="Modèles IA organisationnels pour le tri, l'enrichissement contacts et les automatisations."
description="Modèles IA organisationnels pour le tri, l'enrichissement contacts, UltiAI et les automatisations."
policySection="llm"
beforeSave={() => setLlm(draft)}
>
@ -118,19 +120,25 @@ export function LlmSection() {
<SelectContent>
{draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
{p.name || p.base_url || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
) : (
<p className="text-sm text-muted-foreground">
Aucun fournisseur configuré. Ajoutez-en un puis enregistrez.
</p>
)}
<div className="grid gap-4 lg:grid-cols-2">
{draft.providers.map((provider, index) => (
{draft.providers.map((provider, index) => {
const keyConfigured = isOrgLLMProviderKeyConfigured(secrets, provider.id)
return (
<div key={provider.id} className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{provider.name || `Fournisseur ${index + 1}`}
{provider.name || provider.base_url || `Fournisseur ${index + 1}`}
</span>
<Button variant="ghost" size="icon" onClick={() => removeProvider(index)}>
<Trash2 className="size-4" />
@ -142,6 +150,7 @@ export function LlmSection() {
className="mt-1 h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
placeholder="OpenAI"
/>
</div>
<div>
@ -150,6 +159,7 @@ export function LlmSection() {
className="mt-1 h-9"
value={provider.base_url}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div>
@ -157,9 +167,16 @@ export function LlmSection() {
<Input
className="mt-1 h-9"
type="password"
autoComplete="off"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
placeholder={
keyConfigured ? "•••••••• (laisser vide pour conserver)" : "sk-…"
}
/>
{keyConfigured && !(provider.api_key ?? "").trim() ? (
<p className="mt-1 text-xs text-muted-foreground">Clé API enregistrée</p>
) : null}
</div>
<div>
<Label className="text-xs">Modèle par défaut</Label>
@ -167,10 +184,12 @@ export function LlmSection() {
className="mt-1 h-9"
value={provider.default_model}
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
placeholder="gpt-4o-mini"
/>
</div>
</div>
))}
)
})}
</div>
</OrgSettingsSection>
)

View File

@ -31,6 +31,13 @@ export function PluginsSection() {
<Badge variant="outline">v{plugin.version}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
{plugin.id === "ai-assistant" && !plugin.enabled ? (
<p className="mt-1 text-xs text-muted-foreground">
N&apos;oubliez pas d&apos;enregistrer après activation. OpenWebUI doit être
déployé (<code className="rounded bg-muted px-1">AI_ASSISTANT_ENABLED=true</code>
).
</p>
) : null}
{locked ? <DeployLockedHint section="plugins" field={plugin.id} /> : null}
</div>
<Switch

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef } from "react"
import type { AiChatContext } from "@/lib/ai/chat-context"
import { buildEmbedSearchParams } from "@/lib/ai/chat-context"
import { buildAiEmbedUrl, resolveAiEmbedOrigin } from "@/lib/ai/embed-url"
import { useTheme } from "next-themes"
type AiChatIframeProps = {
@ -14,33 +15,33 @@ type AiChatIframeProps = {
export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
const embedOrigin = useMemo(() => resolveAiEmbedOrigin(publicPath), [publicPath])
const src = useMemo(() => {
const base = publicPath.replace(/\/$/, "")
const qs = buildEmbedSearchParams(context)
return qs ? `${base}/?${qs}` : `${base}/`
return buildAiEmbedUrl(publicPath, qs)
}, [publicPath, context])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe?.contentWindow) return
if (!iframe?.contentWindow || !embedOrigin) return
iframe.contentWindow.postMessage(
{ type: "ULTI_THEME", theme: resolvedTheme === "dark" ? "dark" : "light" },
window.location.origin
embedOrigin
)
}, [resolvedTheme])
}, [resolvedTheme, embedOrigin])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe?.contentWindow) return
if (!iframe?.contentWindow || !embedOrigin) return
iframe.contentWindow.postMessage(
{
type: "ULTI_CONTEXT_UPDATE",
context,
systemPrompt: context.systemPromptExtra,
},
window.location.origin
embedOrigin
)
}, [context])
}, [context, embedOrigin])
return (
<iframe

View File

@ -0,0 +1,226 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Loader2, MailCheck, Server } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ApiRequestError } from "@/lib/api/client"
import {
useCheckMailAddress,
useHostedMailStatus,
useSetupHostedMailbox,
} from "@/lib/api/hooks/use-hosted-mail"
import { useAuthStore } from "@/lib/api/auth-store"
function suggestLocalPart(platformEmail: string | undefined, domain: string): string {
if (!platformEmail?.includes("@")) return ""
const [local, emailDomain] = platformEmail.split("@")
if (!local) return ""
if (emailDomain?.toLowerCase() === domain.toLowerCase()) {
return local.toLowerCase()
}
return local.toLowerCase().replace(/[^a-z0-9._+-]/g, "")
}
function setupErrorMessage(error: unknown): string {
if (error instanceof ApiRequestError) {
switch (error.code) {
case "address_taken":
return "Cette adresse est déjà utilisée."
case "domain_not_active":
return "Le domaine mail n'est pas encore actif."
}
return error.message
}
if (error instanceof Error) return error.message
return "Échec de la configuration."
}
export function HostedMailSetupCard() {
const platformEmail = useAuthStore((s) => s.user?.email)
const { data: status, isPending } = useHostedMailStatus()
const setup = useSetupHostedMailbox()
const domain = status?.platform_domain ?? ""
const alreadyConnected = Boolean(
status?.hosted_mail_account_id || status?.mailbox?.mail_account_id
)
const connectedEmail =
status?.hosted_mail_account_email ?? status?.mailbox?.email ?? ""
const [localPart, setLocalPart] = useState("")
const [displayName, setDisplayName] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
useEffect(() => {
if (!domain || localPart) return
const suggested = suggestLocalPart(platformEmail, domain)
if (suggested) setLocalPart(suggested)
}, [domain, localPart, platformEmail])
const fullEmail = useMemo(() => {
const local = localPart.trim().toLowerCase()
return local && domain ? `${local}@${domain}` : ""
}, [localPart, domain])
const addressCheck = useCheckMailAddress(localPart.trim(), domain)
const addressTaken =
addressCheck.data?.available === false &&
addressCheck.data.reason !== "hosted_mail_disabled"
const passwordsMatch = password === confirmPassword
const passwordOk = password.length >= 8
const canSubmit =
!setup.isPending &&
localPart.trim().length > 0 &&
passwordOk &&
passwordsMatch &&
addressCheck.data?.available !== false
if (isPending || !status?.enabled) return null
const endpoints = status.endpoints
async function handleSubmit() {
if (!canSubmit) return
try {
await setup.mutateAsync({
local_part: localPart.trim().toLowerCase(),
password,
display_name: displayName.trim() || undefined,
})
setPassword("")
setConfirmPassword("")
} catch {
/* message below */
}
}
return (
<Card className="border-primary/20 bg-primary/[0.03]">
<CardHeader>
<div className="flex items-start gap-3">
<Server className="mt-0.5 size-5 shrink-0 text-primary" />
<div>
<CardTitle className="text-base">Mail hébergé Ultimail (Stalwart)</CardTitle>
<CardDescription>
Créez ou connectez votre boîte @{domain} IMAP/SMTP préconfigurés, sans saisie
manuelle des serveurs.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{endpoints ? (
<p className="text-xs text-muted-foreground">
Serveurs : IMAP {endpoints.imap_host}:{endpoints.imap_port}
{endpoints.imap_tls ? " (TLS)" : ""} · SMTP {endpoints.smtp_host}:{endpoints.smtp_port}
{endpoints.smtp_tls ? " (TLS)" : ""}
</p>
) : null}
{alreadyConnected ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-background px-3 py-2">
<MailCheck className="mt-0.5 size-4 shrink-0 text-green-600 dark:text-green-500" />
<div>
<p className="text-sm font-medium">Boîte hébergée connectée</p>
<p className="text-sm text-muted-foreground">{connectedEmail}</p>
</div>
</div>
) : (
<div className="space-y-4 max-w-lg">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="hosted-local-part">Adresse mail</Label>
<div className="flex items-center gap-2">
<Input
id="hosted-local-part"
value={localPart}
autoComplete="username"
placeholder="prenom.nom"
onChange={(e) => setLocalPart(e.target.value)}
/>
<span className="shrink-0 text-sm text-muted-foreground">@{domain}</span>
</div>
{fullEmail ? (
<p className="text-xs text-muted-foreground">{fullEmail}</p>
) : null}
{addressCheck.isFetching ? (
<p className="text-xs text-muted-foreground">Vérification de la disponibilité</p>
) : addressTaken ? (
<p className="text-xs text-destructive">Adresse déjà prise.</p>
) : addressCheck.data?.available === true && localPart.trim() ? (
<p className="text-xs text-green-600 dark:text-green-500">Adresse disponible.</p>
) : null}
</div>
<div className="space-y-1.5">
<Label htmlFor="hosted-display-name">Nom affiché</Label>
<Input
id="hosted-display-name"
value={displayName}
placeholder="Optionnel"
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hosted-password">Mot de passe boîte</Label>
<Input
id="hosted-password"
type="password"
value={password}
autoComplete="new-password"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="hosted-password-confirm">Confirmer le mot de passe</Label>
<Input
id="hosted-password-confirm"
type="password"
value={confirmPassword}
autoComplete="new-password"
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{password && !passwordOk ? (
<p className="text-xs text-destructive">8 caractères minimum.</p>
) : null}
{confirmPassword && !passwordsMatch ? (
<p className="text-xs text-destructive">Les mots de passe ne correspondent pas.</p>
) : null}
</div>
</div>
{setup.isError ? (
<p className="text-sm text-destructive">{setupErrorMessage(setup.error)}</p>
) : null}
{setup.isSuccess ? (
<p className="text-sm text-green-600 dark:text-green-500">
Boîte configurée synchronisation IMAP en cours.
</p>
) : null}
<Button type="button" disabled={!canSubmit} onClick={() => void handleSubmit()}>
{setup.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Configuration
</>
) : (
"Activer ma boîte hébergée"
)}
</Button>
</div>
)}
</CardContent>
</Card>
)
}

View File

@ -28,6 +28,7 @@ import {
} from "@/components/ui/card"
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
import { EditMailAccountForm } from "@/components/gmail/settings/edit-mail-account-form"
import { HostedMailSetupCard } from "@/components/gmail/settings/hosted-mail-setup-card"
import { SignatureLibraryCard } from "@/components/gmail/settings/signature-library-card"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import {
@ -107,6 +108,8 @@ export function AccountsSettingsSection() {
/>
<div className="space-y-6">
<HostedMailSetupCard />
<AddMailAccountForm
pending={createAccount.isPending}
onSubmit={(payload) => createAccount.mutate(payload)}

View File

@ -138,7 +138,10 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
filePolicies: mergeFilePolicies(policy.file_policies),
llm: {
...policy.llm,
providers: policy.llm.providers ?? [],
providers: (policy.llm.providers ?? []).map((provider) => ({
...provider,
api_key: provider.api_key ?? "",
})),
},
search: {
...policy.search,
@ -166,6 +169,11 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
enabled_tools: policy.ai_assistant?.enabled_tools ?? ["mail", "drive", "contacts", "search"],
chat_sync_enabled: policy.ai_assistant?.chat_sync_enabled ?? true,
chat_nc_path: policy.ai_assistant?.chat_nc_path ?? "/.ultimail/ai/chats",
models: (policy.ai_assistant?.models ?? []).map((entry) => ({
model_id: entry.model_id ?? "",
label: entry.label ?? "",
enabled: entry.enabled ?? true,
})),
},
agenda: {
default_theme_mode: policy.agenda?.default_theme_mode ?? "system",

View File

@ -142,6 +142,7 @@ const DEFAULT_AI_ASSISTANT: AiAssistantSettings = {
enabled_tools: ["mail", "drive", "contacts", "search"],
chat_sync_enabled: true,
chat_nc_path: "/.ultimail/ai/chats",
models: [],
}
const DEFAULT_AGENDA: AgendaOrgPolicySettings = {
@ -377,9 +378,16 @@ export const useOrgSettingsStore = create<
})),
setPlugins: (plugins) => set({ plugins }),
togglePlugin: (id, enabled) =>
set((s) => ({
plugins: s.plugins.map((p) => (p.id === id ? { ...p, enabled } : p)),
})),
set((s) => {
const plugins = s.plugins.map((p) => (p.id === id ? { ...p, enabled } : p))
if (id === "ai-assistant") {
return {
plugins,
aiAssistant: { ...s.aiAssistant, enabled },
}
}
return { plugins }
}),
setIntegrations: (integrations) => set({ integrations }),
toggleIntegration: (id, enabled) =>
set((s) => ({

View File

@ -188,6 +188,12 @@ export type RichTextSettings = {
hocuspocus_url: string
}
export type AiModelCatalogEntry = {
model_id: string
label: string
enabled: boolean
}
export type AiAssistantSettings = {
enabled: boolean
openwebui_internal_url: string
@ -197,6 +203,7 @@ export type AiAssistantSettings = {
enabled_tools: string[]
chat_sync_enabled: boolean
chat_nc_path: string
models: AiModelCatalogEntry[]
}
export type {

23
lib/ai/embed-url.ts Normal file
View File

@ -0,0 +1,23 @@
/** Public path for OpenWebUI (default /ai). */
export function resolveAiEmbedBase(publicPath = "/ai"): string {
const path = (publicPath || "/ai").replace(/\/$/, "") || "/ai"
const normalized = path.startsWith("/") ? path : `/${path}`
const origin = process.env.NEXT_PUBLIC_AI_ORIGIN?.trim().replace(/\/$/, "")
return origin ? `${origin}${normalized}` : normalized
}
export function resolveAiEmbedOrigin(publicPath = "/ai"): string {
const base = resolveAiEmbedBase(publicPath)
if (base.startsWith("http://") || base.startsWith("https://")) {
return new URL(base).origin
}
if (typeof window !== "undefined") {
return window.location.origin
}
return ""
}
export function buildAiEmbedUrl(publicPath: string, searchParams: string): string {
const base = resolveAiEmbedBase(publicPath)
return searchParams ? `${base}/?${searchParams}` : `${base}/`
}

View File

@ -174,6 +174,13 @@ export type ApiOrgAiAssistant = {
enabled_tools: string[]
chat_sync_enabled: boolean
chat_nc_path: string
models?: ApiOrgAiModelCatalogEntry[]
}
export type ApiOrgAiModelCatalogEntry = {
model_id: string
label: string
enabled: boolean
}
export type ApiOrgAgenda = {

View File

@ -0,0 +1,24 @@
"use client"
import { useMutation } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type { ApiLLMModelsResponse } from "@/lib/contacts/discovery-types"
export function useDiscoverOrgLLMModels() {
return useMutation({
mutationFn: (providerId: string) =>
apiClient.post<ApiLLMModelsResponse>("/admin/org/llm/discover-models", {
provider_id: providerId,
}),
})
}
export function isOrgLLMProviderKeyConfigured(
secrets: Record<string, unknown> | undefined,
providerId: string,
): boolean {
const llmProviders = secrets?.llm_providers
if (!llmProviders || typeof llmProviders !== "object") return false
const entry = (llmProviders as Record<string, { configured?: boolean }>)[providerId]
return entry?.configured === true
}

View File

@ -11,6 +11,8 @@ export type AiConfig = {
default_model: string
enabled_tools: string[]
chat_sync_enabled: boolean
models?: { model_id: string; label: string; enabled: boolean }[]
restrict_models?: boolean
}
export type AiQuota = {
@ -32,7 +34,7 @@ export type AiSessionResponse = {
export function useAiConfig() {
return useQuery({
queryKey: ["ai", "config"],
queryFn: () => apiClient<AiConfig>("/ai/config"),
queryFn: () => apiClient.get<AiConfig>("/ai/config"),
staleTime: 60_000,
})
}
@ -40,7 +42,7 @@ export function useAiConfig() {
export function useAiQuota(enabled = true) {
return useQuery({
queryKey: ["ai", "quota"],
queryFn: () => apiClient<AiQuota>("/ai/quota"),
queryFn: () => apiClient.get<AiQuota>("/ai/quota"),
enabled,
staleTime: 30_000,
})
@ -49,9 +51,7 @@ export function useAiQuota(enabled = true) {
export function useCreateAiSession() {
return useMutation({
mutationFn: (context: AiChatContext) =>
apiClient<AiSessionResponse>("/ai/sessions", {
method: "POST",
body: JSON.stringify({
apiClient.post<AiSessionResponse>("/ai/sessions", {
app: context.app,
temporary: context.temporary ?? true,
message_id: context.messageId,
@ -62,6 +62,5 @@ export function useCreateAiSession() {
subject: context.subject,
snippet: context.snippet,
}),
}),
})
}

View File

@ -544,3 +544,47 @@ export function useCheckMailAddress(local: string, domain: string) {
),
})
}
export type HostedMailEndpoints = {
imap_host: string
imap_port: number
imap_tls: boolean
smtp_host: string
smtp_port: number
smtp_tls: boolean
}
export type HostedMailStatus = {
enabled: boolean
platform_domain?: string
domain_status?: string
endpoints?: HostedMailEndpoints
mailbox?: {
id: string
email: string
mail_account_id?: string
status: string
}
hosted_mail_account_id?: string
hosted_mail_account_email?: string
}
export function useHostedMailStatus() {
return useQuery({
queryKey: ["mail", "hosted", "status"],
queryFn: () => apiClient.get<HostedMailStatus>("/mail/hosted/status"),
})
}
export function useSetupHostedMailbox() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: { local_part: string; password: string; display_name?: string }) =>
apiClient.post<{ email: string; mail_account_id: string }>("/mail/hosted/setup", body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mail", "hosted", "status"] })
void queryClient.invalidateQueries({ queryKey: ["accounts"] })
void queryClient.invalidateQueries({ queryKey: ["identities"] })
},
})
}

View File

@ -49,6 +49,7 @@ export const MAIL_SETTINGS_SEARCH_INDEX: MailSettingsSearchEntry[] = [
entry("display", "conversation", "Mode Conversation", "fil discussion thread regrouper messages"),
entry("display", "infinite-scroll", "Scroll infini", "défilement pagination liste messages bureau desktop"),
entry("accounts", "add-account", "Ajouter un compte mail", "imap smtp oauth connecter serveur"),
entry("accounts", "hosted-mail", "Mail hébergé Stalwart", "ultimail boîte ultisuite imap smtp stalwart hébergé"),
entry("accounts", "identities", "Identités d'envoi", "alias from expéditeur adresse envoi"),
entry("accounts", "imap", "IMAP", "réception serveur entrant synchronisation"),
entry("accounts", "smtp", "SMTP", "envoi serveur sortant"),

View File

@ -22,6 +22,14 @@ const nextConfig = {
source: "/api/v1/:path*",
destination: `${ultiProxyOrigin}/api/v1/:path*`,
},
{
source: "/ai/:path*",
destination: `${ultiProxyOrigin}/ai/:path*`,
},
{
source: "/ai",
destination: `${ultiProxyOrigin}/ai`,
},
]
},
typescript: {

File diff suppressed because one or more lines are too long