feat(ai-assistant): enhance AI assistant configuration and integration
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
e28ff6e384
commit
7ee1a66942
@ -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
|
||||
|
||||
18
CLAUDE.md
18
CLAUDE.md
@ -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`.
|
||||
|
||||
@ -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'est pas activé. Activez le plugin dans l'administration.
|
||||
Impossible de contacter l'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'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">
|
||||
|
||||
@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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'oubliez pas d'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
|
||||
|
||||
@ -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
|
||||
|
||||
226
components/gmail/settings/hosted-mail-setup-card.tsx
Normal file
226
components/gmail/settings/hosted-mail-setup-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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
23
lib/ai/embed-url.ts
Normal 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}/`
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
24
lib/api/hooks/use-admin-llm.ts
Normal file
24
lib/api/hooks/use-admin-llm.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user