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
|
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
|
||||||
|
|||||||
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
|
### 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`.
|
||||||
|
|||||||
@ -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'est pas activé. Activez le plugin dans l'administration.
|
Impossible de contacter l'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'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">
|
||||||
|
|||||||
@ -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'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'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>
|
</OrgSettingsSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,19 +120,25 @@ 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) => {
|
||||||
|
const keyConfigured = isOrgLLMProviderKeyConfigured(secrets, provider.id)
|
||||||
|
return (
|
||||||
<div key={provider.id} className="space-y-3 rounded-lg border p-4">
|
<div key={provider.id} className="space-y-3 rounded-lg border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{provider.name || `Fournisseur ${index + 1}`}
|
{provider.name || provider.base_url || `Fournisseur ${index + 1}`}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" size="icon" onClick={() => removeProvider(index)}>
|
<Button variant="ghost" size="icon" onClick={() => removeProvider(index)}>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
@ -142,6 +150,7 @@ export function LlmSection() {
|
|||||||
className="mt-1 h-9"
|
className="mt-1 h-9"
|
||||||
value={provider.name}
|
value={provider.name}
|
||||||
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
onChange={(e) => updateProvider(index, { name: e.target.value })}
|
||||||
|
placeholder="OpenAI"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -150,6 +159,7 @@ export function LlmSection() {
|
|||||||
className="mt-1 h-9"
|
className="mt-1 h-9"
|
||||||
value={provider.base_url}
|
value={provider.base_url}
|
||||||
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
onChange={(e) => updateProvider(index, { base_url: e.target.value })}
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -157,9 +167,16 @@ export function LlmSection() {
|
|||||||
<Input
|
<Input
|
||||||
className="mt-1 h-9"
|
className="mt-1 h-9"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
value={provider.api_key ?? ""}
|
value={provider.api_key ?? ""}
|
||||||
onChange={(e) => updateProvider(index, { api_key: e.target.value })}
|
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>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">Modèle par défaut</Label>
|
<Label className="text-xs">Modèle par défaut</Label>
|
||||||
@ -167,10 +184,12 @@ export function LlmSection() {
|
|||||||
className="mt-1 h-9"
|
className="mt-1 h-9"
|
||||||
value={provider.default_model}
|
value={provider.default_model}
|
||||||
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
|
onChange={(e) => updateProvider(index, { default_model: e.target.value })}
|
||||||
|
placeholder="gpt-4o-mini"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</OrgSettingsSection>
|
</OrgSettingsSection>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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'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}
|
{locked ? <DeployLockedHint section="plugins" field={plugin.id} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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"
|
} 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)}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
|||||||
@ -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
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[]
|
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 = {
|
||||||
|
|||||||
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
|
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,9 +51,7 @@ 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",
|
|
||||||
body: JSON.stringify({
|
|
||||||
app: context.app,
|
app: context.app,
|
||||||
temporary: context.temporary ?? true,
|
temporary: context.temporary ?? true,
|
||||||
message_id: context.messageId,
|
message_id: context.messageId,
|
||||||
@ -62,6 +62,5 @@ export function useCreateAiSession() {
|
|||||||
subject: context.subject,
|
subject: context.subject,
|
||||||
snippet: context.snippet,
|
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", "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"),
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user