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 NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost/collab
# UltiAI (chemin proxy OpenWebUI — même origine) # UltiAI (chemin proxy OpenWebUI — même origine)
NEXT_PUBLIC_AI_PUBLIC_PATH=/ai 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 ### Docs internes
- `components/gmail/README.md` — arborescence composants - `components/gmail/README.md` — arborescence composants
- `lib/stores/README.md` — architecture stores - `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" "use client"
import Link from "next/link"
import { Sparkles } from "lucide-react" import { Sparkles } from "lucide-react"
import { AiChatIframe } from "@/components/ai/ai-chat-iframe" import { AiChatIframe } from "@/components/ai/ai-chat-iframe"
import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries" import { useAiConfig, useAiQuota } from "@/lib/api/hooks/use-ai-queries"
import { Button } from "@/components/ui/button"
export default function ChatPage() { export default function ChatPage() {
const { data: config, isLoading } = useAiConfig() const { data: config, isLoading, isError } = useAiConfig()
const { data: quota } = useAiQuota(Boolean(config?.enabled)) const { data: quota } = useAiQuota(Boolean(config?.enabled))
if (isLoading) { if (isLoading) {
@ -16,17 +18,34 @@ export default function ChatPage() {
) )
} }
if (!config?.enabled) { if (isError) {
return ( 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" /> <Sparkles className="h-8 w-8 text-muted-foreground" />
<p className="text-sm 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> </p>
</div> </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 ( return (
<div className="flex h-dvh flex-col"> <div className="flex h-dvh flex-col">
<header className="flex items-center justify-between border-b px-4 py-2"> <header className="flex items-center justify-between border-b px-4 py-2">

View File

@ -1,24 +1,107 @@
"use client" "use client"
import { useMemo, useState } from "react"
import { Plus, RefreshCw, Trash2 } from "lucide-react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" 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 { 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 { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" 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() { export function AiAssistantSection() {
const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant) const aiAssistant = useOrgSettingsStore((s) => s.aiAssistant)
const setAiAssistant = useOrgSettingsStore((s) => s.setAiAssistant) 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 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 ( return (
<OrgSettingsSection <OrgSettingsSection
title="UltiAI" title="UltiAI"
description="Assistant IA intégré (OpenWebUI) avec gateway LLM, tools et sync Nextcloud." description="Assistant IA intégré (OpenWebUI) avec gateway LLM, tools et sync Nextcloud."
policySection="ai_assistant" policySection={["ai_assistant", "plugins"]}
> >
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -26,14 +109,34 @@ export function AiAssistantSection() {
<div> <div>
<CardTitle className="text-sm font-medium">Assistant IA</CardTitle> <CardTitle className="text-sm font-medium">Assistant IA</CardTitle>
<CardDescription> <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> </CardDescription>
</div> </div>
<Switch <Switch
checked={enabled} checked={orgEnabled}
onCheckedChange={(v) => setAiAssistant({ enabled: v })} disabled={enabledLocked}
onCheckedChange={setUltiAIEnabled}
/> />
</div> </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> </CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2"> <CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2"> <div className="space-y-2 sm:col-span-2">
@ -42,7 +145,9 @@ export function AiAssistantSection() {
value={aiAssistant.public_path} value={aiAssistant.public_path}
onChange={(e) => setAiAssistant({ public_path: e.target.value })} onChange={(e) => setAiAssistant({ public_path: e.target.value })}
placeholder="/ai" placeholder="/ai"
disabled={publicPathLocked}
/> />
<DeployLockedHint section="ai_assistant" field="public_path" />
</div> </div>
<div className="space-y-2 sm:col-span-2"> <div className="space-y-2 sm:col-span-2">
<Label>URL interne OpenWebUI</Label> <Label>URL interne OpenWebUI</Label>
@ -50,7 +155,9 @@ export function AiAssistantSection() {
value={aiAssistant.openwebui_internal_url} value={aiAssistant.openwebui_internal_url}
onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })} onChange={(e) => setAiAssistant({ openwebui_internal_url: e.target.value })}
placeholder="http://openwebui:8080" placeholder="http://openwebui:8080"
disabled={openwebuiLocked}
/> />
<DeployLockedHint section="ai_assistant" field="openwebui_internal_url" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Modèle par défaut</Label> <Label>Modèle par défaut</Label>
@ -94,6 +201,148 @@ export function AiAssistantSection() {
</div> </div>
</CardContent> </CardContent>
</Card> </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> </OrgSettingsSection>
) )
} }

View File

@ -5,6 +5,7 @@ import { Plus, Trash2 } from "lucide-react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { ApiLLMProvider } from "@/lib/contacts/discovery-types" import type { ApiLLMProvider } from "@/lib/contacts/discovery-types"
import { isOrgLLMProviderKeyConfigured } from "@/lib/api/hooks/use-admin-llm"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -30,6 +31,7 @@ function emptyProvider(): ApiLLMProvider {
export function LlmSection() { export function LlmSection() {
const llm = useOrgSettingsStore((s) => s.llm) const llm = useOrgSettingsStore((s) => s.llm)
const setLlm = useOrgSettingsStore((s) => s.setLlm) const setLlm = useOrgSettingsStore((s) => s.setLlm)
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const [draft, setDraft] = useState(llm) const [draft, setDraft] = useState(llm)
useEffect(() => { useEffect(() => {
@ -66,7 +68,7 @@ export function LlmSection() {
return ( return (
<OrgSettingsSection <OrgSettingsSection
title="Fournisseurs LLM" 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" policySection="llm"
beforeSave={() => setLlm(draft)} beforeSave={() => setLlm(draft)}
> >
@ -118,59 +120,76 @@ export function LlmSection() {
<SelectContent> <SelectContent>
{draft.providers.map((p) => ( {draft.providers.map((p) => (
<SelectItem key={p.id} value={p.id}> <SelectItem key={p.id} value={p.id}>
{p.name || p.id} {p.name || p.base_url || p.id}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </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"> <div className="grid gap-4 lg:grid-cols-2">
{draft.providers.map((provider, index) => ( {draft.providers.map((provider, index) => {
<div key={provider.id} className="space-y-3 rounded-lg border p-4"> const keyConfigured = isOrgLLMProviderKeyConfigured(secrets, provider.id)
<div className="flex items-center justify-between"> return (
<span className="text-sm font-medium"> <div key={provider.id} className="space-y-3 rounded-lg border p-4">
{provider.name || `Fournisseur ${index + 1}`} <div className="flex items-center justify-between">
</span> <span className="text-sm font-medium">
<Button variant="ghost" size="icon" onClick={() => removeProvider(index)}> {provider.name || provider.base_url || `Fournisseur ${index + 1}`}
<Trash2 className="size-4" /> </span>
</Button> <Button variant="ghost" size="icon" onClick={() => removeProvider(index)}>
<Trash2 className="size-4" />
</Button>
</div>
<div>
<Label className="text-xs">Nom</Label>
<Input
className="mt-1 h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
placeholder="OpenAI"
/>
</div>
<div>
<Label className="text-xs">URL de base</Label>
<Input
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>
<Label className="text-xs">Clé API</Label>
<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>
<Input
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>
<div> )
<Label className="text-xs">Nom</Label> })}
<Input
className="mt-1 h-9"
value={provider.name}
onChange={(e) => updateProvider(index, { name: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">URL de base</Label>
<Input
className="mt-1 h-9"
value={provider.base_url}
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Clé API</Label>
<Input
className="mt-1 h-9"
type="password"
value={provider.api_key ?? ""}
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Modèle par défaut</Label>
<Input
className="mt-1 h-9"
value={provider.default_model}
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
/>
</div>
</div>
))}
</div> </div>
</OrgSettingsSection> </OrgSettingsSection>
) )

View File

@ -31,6 +31,13 @@ export function PluginsSection() {
<Badge variant="outline">v{plugin.version}</Badge> <Badge variant="outline">v{plugin.version}</Badge>
</div> </div>
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p> <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} {locked ? <DeployLockedHint section="plugins" field={plugin.id} /> : null}
</div> </div>
<Switch <Switch

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef } from "react" import { useEffect, useMemo, useRef } from "react"
import type { AiChatContext } from "@/lib/ai/chat-context" import type { AiChatContext } from "@/lib/ai/chat-context"
import { buildEmbedSearchParams } 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" import { useTheme } from "next-themes"
type AiChatIframeProps = { type AiChatIframeProps = {
@ -14,33 +15,33 @@ type AiChatIframeProps = {
export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) { export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const embedOrigin = useMemo(() => resolveAiEmbedOrigin(publicPath), [publicPath])
const src = useMemo(() => { const src = useMemo(() => {
const base = publicPath.replace(/\/$/, "")
const qs = buildEmbedSearchParams(context) const qs = buildEmbedSearchParams(context)
return qs ? `${base}/?${qs}` : `${base}/` return buildAiEmbedUrl(publicPath, qs)
}, [publicPath, context]) }, [publicPath, context])
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current const iframe = iframeRef.current
if (!iframe?.contentWindow) return if (!iframe?.contentWindow || !embedOrigin) return
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
{ type: "ULTI_THEME", theme: resolvedTheme === "dark" ? "dark" : "light" }, { type: "ULTI_THEME", theme: resolvedTheme === "dark" ? "dark" : "light" },
window.location.origin embedOrigin
) )
}, [resolvedTheme]) }, [resolvedTheme, embedOrigin])
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current const iframe = iframeRef.current
if (!iframe?.contentWindow) return if (!iframe?.contentWindow || !embedOrigin) return
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
{ {
type: "ULTI_CONTEXT_UPDATE", type: "ULTI_CONTEXT_UPDATE",
context, context,
systemPrompt: context.systemPromptExtra, systemPrompt: context.systemPromptExtra,
}, },
window.location.origin embedOrigin
) )
}, [context]) }, [context, embedOrigin])
return ( return (
<iframe <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" } from "@/components/ui/card"
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form" import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
import { EditMailAccountForm } from "@/components/gmail/settings/edit-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 { SignatureLibraryCard } from "@/components/gmail/settings/signature-library-card"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries" import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { import {
@ -107,6 +108,8 @@ export function AccountsSettingsSection() {
/> />
<div className="space-y-6"> <div className="space-y-6">
<HostedMailSetupCard />
<AddMailAccountForm <AddMailAccountForm
pending={createAccount.isPending} pending={createAccount.isPending}
onSubmit={(payload) => createAccount.mutate(payload)} onSubmit={(payload) => createAccount.mutate(payload)}

View File

@ -138,7 +138,10 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
filePolicies: mergeFilePolicies(policy.file_policies), filePolicies: mergeFilePolicies(policy.file_policies),
llm: { llm: {
...policy.llm, ...policy.llm,
providers: policy.llm.providers ?? [], providers: (policy.llm.providers ?? []).map((provider) => ({
...provider,
api_key: provider.api_key ?? "",
})),
}, },
search: { search: {
...policy.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"], enabled_tools: policy.ai_assistant?.enabled_tools ?? ["mail", "drive", "contacts", "search"],
chat_sync_enabled: policy.ai_assistant?.chat_sync_enabled ?? true, chat_sync_enabled: policy.ai_assistant?.chat_sync_enabled ?? true,
chat_nc_path: policy.ai_assistant?.chat_nc_path ?? "/.ultimail/ai/chats", 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: { agenda: {
default_theme_mode: policy.agenda?.default_theme_mode ?? "system", 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"], enabled_tools: ["mail", "drive", "contacts", "search"],
chat_sync_enabled: true, chat_sync_enabled: true,
chat_nc_path: "/.ultimail/ai/chats", chat_nc_path: "/.ultimail/ai/chats",
models: [],
} }
const DEFAULT_AGENDA: AgendaOrgPolicySettings = { const DEFAULT_AGENDA: AgendaOrgPolicySettings = {
@ -377,9 +378,16 @@ export const useOrgSettingsStore = create<
})), })),
setPlugins: (plugins) => set({ plugins }), setPlugins: (plugins) => set({ plugins }),
togglePlugin: (id, enabled) => togglePlugin: (id, enabled) =>
set((s) => ({ set((s) => {
plugins: s.plugins.map((p) => (p.id === id ? { ...p, enabled } : p)), 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 }), setIntegrations: (integrations) => set({ integrations }),
toggleIntegration: (id, enabled) => toggleIntegration: (id, enabled) =>
set((s) => ({ set((s) => ({

View File

@ -188,6 +188,12 @@ export type RichTextSettings = {
hocuspocus_url: string hocuspocus_url: string
} }
export type AiModelCatalogEntry = {
model_id: string
label: string
enabled: boolean
}
export type AiAssistantSettings = { export type AiAssistantSettings = {
enabled: boolean enabled: boolean
openwebui_internal_url: string openwebui_internal_url: string
@ -197,6 +203,7 @@ export type AiAssistantSettings = {
enabled_tools: string[] enabled_tools: string[]
chat_sync_enabled: boolean chat_sync_enabled: boolean
chat_nc_path: string chat_nc_path: string
models: AiModelCatalogEntry[]
} }
export type { 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[] enabled_tools: string[]
chat_sync_enabled: boolean chat_sync_enabled: boolean
chat_nc_path: string chat_nc_path: string
models?: ApiOrgAiModelCatalogEntry[]
}
export type ApiOrgAiModelCatalogEntry = {
model_id: string
label: string
enabled: boolean
} }
export type ApiOrgAgenda = { 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 default_model: string
enabled_tools: string[] enabled_tools: string[]
chat_sync_enabled: boolean chat_sync_enabled: boolean
models?: { model_id: string; label: string; enabled: boolean }[]
restrict_models?: boolean
} }
export type AiQuota = { export type AiQuota = {
@ -32,7 +34,7 @@ export type AiSessionResponse = {
export function useAiConfig() { export function useAiConfig() {
return useQuery({ return useQuery({
queryKey: ["ai", "config"], queryKey: ["ai", "config"],
queryFn: () => apiClient<AiConfig>("/ai/config"), queryFn: () => apiClient.get<AiConfig>("/ai/config"),
staleTime: 60_000, staleTime: 60_000,
}) })
} }
@ -40,7 +42,7 @@ export function useAiConfig() {
export function useAiQuota(enabled = true) { export function useAiQuota(enabled = true) {
return useQuery({ return useQuery({
queryKey: ["ai", "quota"], queryKey: ["ai", "quota"],
queryFn: () => apiClient<AiQuota>("/ai/quota"), queryFn: () => apiClient.get<AiQuota>("/ai/quota"),
enabled, enabled,
staleTime: 30_000, staleTime: 30_000,
}) })
@ -49,19 +51,16 @@ export function useAiQuota(enabled = true) {
export function useCreateAiSession() { export function useCreateAiSession() {
return useMutation({ return useMutation({
mutationFn: (context: AiChatContext) => mutationFn: (context: AiChatContext) =>
apiClient<AiSessionResponse>("/ai/sessions", { apiClient.post<AiSessionResponse>("/ai/sessions", {
method: "POST", app: context.app,
body: JSON.stringify({ temporary: context.temporary ?? true,
app: context.app, message_id: context.messageId,
temporary: context.temporary ?? true, account_id: context.accountId,
message_id: context.messageId, drive_path: context.drivePath,
account_id: context.accountId, file_id: context.fileId,
drive_path: context.drivePath, contact_id: context.contactId,
file_id: context.fileId, subject: context.subject,
contact_id: context.contactId, snippet: context.snippet,
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", "conversation", "Mode Conversation", "fil discussion thread regrouper messages"),
entry("display", "infinite-scroll", "Scroll infini", "défilement pagination liste messages bureau desktop"), 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", "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", "identities", "Identités d'envoi", "alias from expéditeur adresse envoi"),
entry("accounts", "imap", "IMAP", "réception serveur entrant synchronisation"), entry("accounts", "imap", "IMAP", "réception serveur entrant synchronisation"),
entry("accounts", "smtp", "SMTP", "envoi serveur sortant"), entry("accounts", "smtp", "SMTP", "envoi serveur sortant"),

View File

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

File diff suppressed because one or more lines are too long