diff --git a/.env.example b/.env.example index 979aab3..9aa4320 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index aeb0f25..51c9818 100644 --- a/CLAUDE.md +++ b/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`. diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 96ad825..6153e1e 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -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 ( -
+

- 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é.

) } + if (!config?.enabled) { + return ( +
+ +

+ UltiAI n'est pas activé. Activez le plugin{" "} + UltiAI dans Administration → Plugins (puis enregistrez), ou définissez{" "} + AI_ASSISTANT_ENABLED=true dans le + déploiement backend. +

+ +
+ ) + } + return (
diff --git a/components/admin/settings/sections/ai-assistant-section.tsx b/components/admin/settings/sections/ai-assistant-section.tsx index 68f9cb2..1193d08 100644 --- a/components/admin/settings/sections/ai-assistant-section.tsx +++ b/components/admin/settings/sections/ai-assistant-section.tsx @@ -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([]) + 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) { + 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 ( @@ -26,14 +109,34 @@ export function AiAssistantSection() {
Assistant IA - Chat standalone et panneaux contextuels mail/drive/contacts. + Active le plugin UltiAI pour toute l'organisation. Le service OpenWebUI doit + aussi être déployé.
setAiAssistant({ enabled: v })} + checked={orgEnabled} + disabled={enabledLocked} + onCheckedChange={setUltiAIEnabled} />
+ {enabledLocked ? ( + + ) : null} +
+ + Politique org. {orgEnabled ? "activée" : "désactivée"} + + + Runtime Compose {runtimeEnabled ? "actif" : "inactif"} + +
+ {!orgEnabled && !runtimeEnabled ? ( +

+ Activez le plugin UltiAI dans Administration → Plugins, ou définissez{" "} + AI_ASSISTANT_ENABLED=true dans le + déploiement, puis redémarrez le backend et OpenWebUI. +

+ ) : null}
@@ -42,7 +145,9 @@ export function AiAssistantSection() { value={aiAssistant.public_path} onChange={(e) => setAiAssistant({ public_path: e.target.value })} placeholder="/ai" + disabled={publicPathLocked} /> +
@@ -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} /> +
@@ -94,6 +201,148 @@ export function AiAssistantSection() {
+ + + + Modèles autorisés + + 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. + + + + {llm.providers.length === 0 ? ( +

+ Configurez d'abord un fournisseur LLM dans Administration → Fournisseurs LLM. +

+ ) : ( +
+
+ + +
+ +
+ )} + + {discoverModels.isError ? ( +

+ {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."} +

+ ) : null} + + {discoveredModels.length ? ( +
+ +
+ {discoveredModels.map((modelId) => { + const alreadyAdded = aiAssistant.models.some( + (entry) => entry.model_id === modelId, + ) + return ( + + ) + })} +
+
+ ) : null} + +
+ + +
+ + {aiAssistant.models.length === 0 ? ( +

+ Aucune restriction — tous les modèles LLM configurés restent disponibles. +

+ ) : ( +
+ {aiAssistant.models.map((entry, index) => ( +
+
+ + updateModel(index, { model_id: e.target.value })} + placeholder="gpt-4o-mini" + /> +
+
+ + updateModel(index, { label: e.target.value })} + placeholder="GPT-4o Mini" + /> +
+ + +
+ ))} +
+ )} +
+
) } diff --git a/components/admin/settings/sections/llm-section.tsx b/components/admin/settings/sections/llm-section.tsx index 61ce443..1ef439e 100644 --- a/components/admin/settings/sections/llm-section.tsx +++ b/components/admin/settings/sections/llm-section.tsx @@ -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 ( setLlm(draft)} > @@ -118,59 +120,76 @@ export function LlmSection() { {draft.providers.map((p) => ( - {p.name || p.id} + {p.name || p.base_url || p.id} ))} - ) : null} + ) : ( +

+ Aucun fournisseur configuré. Ajoutez-en un puis enregistrez. +

+ )}
- {draft.providers.map((provider, index) => ( -
-
- - {provider.name || `Fournisseur ${index + 1}`} - - + {draft.providers.map((provider, index) => { + const keyConfigured = isOrgLLMProviderKeyConfigured(secrets, provider.id) + return ( +
+
+ + {provider.name || provider.base_url || `Fournisseur ${index + 1}`} + + +
+
+ + updateProvider(index, { name: e.target.value })} + placeholder="OpenAI" + /> +
+
+ + updateProvider(index, { base_url: e.target.value })} + placeholder="https://api.openai.com/v1" + /> +
+
+ + updateProvider(index, { api_key: e.target.value })} + placeholder={ + keyConfigured ? "•••••••• (laisser vide pour conserver)" : "sk-…" + } + /> + {keyConfigured && !(provider.api_key ?? "").trim() ? ( +

Clé API enregistrée

+ ) : null} +
+
+ + updateProvider(index, { default_model: e.target.value })} + placeholder="gpt-4o-mini" + /> +
-
- - updateProvider(index, { name: e.target.value })} - /> -
-
- - updateProvider(index, { base_url: e.target.value })} - /> -
-
- - updateProvider(index, { api_key: e.target.value })} - /> -
-
- - updateProvider(index, { default_model: e.target.value })} - /> -
-
- ))} + ) + })}
) diff --git a/components/admin/settings/sections/plugins-section.tsx b/components/admin/settings/sections/plugins-section.tsx index 7bf922e..6027f41 100644 --- a/components/admin/settings/sections/plugins-section.tsx +++ b/components/admin/settings/sections/plugins-section.tsx @@ -31,6 +31,13 @@ export function PluginsSection() { v{plugin.version}

{plugin.description}

+ {plugin.id === "ai-assistant" && !plugin.enabled ? ( +

+ N'oubliez pas d'enregistrer après activation. OpenWebUI doit être + déployé (AI_ASSISTANT_ENABLED=true + ). +

+ ) : null} {locked ? : null}
(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 (