IA Assistant version initiale
This commit is contained in:
1 parent
3e8508d48b
commit
662003fdd3
29 files changed
+4253
-10
No files matched your search
@@ -21,11 +21,13 @@ import MonCompte from './pages/MonCompte.jsx';
|
||||
import Admin from './pages/Admin.jsx';
|
||||
import AdminPlateformes from './pages/AdminPlateformes.jsx';
|
||||
import AdminFiscalite from './pages/AdminFiscalite.jsx';
|
||||
import AdminIA from './pages/AdminIA.jsx';
|
||||
import Aide from './pages/Aide.jsx';
|
||||
import PlatformeProfile from './pages/PlatformeProfile.jsx';
|
||||
import Plateformes from './pages/Plateformes.jsx';
|
||||
import Notifications from './pages/Notifications.jsx';
|
||||
import Communication from './pages/Communication.jsx';
|
||||
import AssistantIA from './pages/AssistantIA.jsx';
|
||||
|
||||
function Protected({ children }) {
|
||||
const { token, loading } = useAuth();
|
||||
@@ -98,6 +100,7 @@ export default function App() {
|
||||
<Route path="frais" element={<FraisOnly><Frais /></FraisOnly>} />
|
||||
<Route path="simul" element={<SimulRemboursements />} />
|
||||
<Route path="taxreport" element={<TaxReport />} />
|
||||
<Route path="assistant-ia" element={<AssistantIA />} />
|
||||
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
|
||||
<Route path="imports" element={<Navigate to="/settings?section=imports" replace />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
@@ -106,6 +109,7 @@ export default function App() {
|
||||
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
|
||||
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
|
||||
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
|
||||
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
|
||||
<Route path="aide" element={<Aide />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="communication" element={<Communication />} />
|
||||
|
||||
@@ -64,6 +64,63 @@ export const api = {
|
||||
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
||||
postForm: (path, formData) =>
|
||||
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
||||
/** Flux SSE (Server-Sent Events) en POST — utilisé par l'assistant IA
|
||||
* (POST /api/ai/chat). EventSource ne permet pas d'en-têtes personnalisés
|
||||
* (Authorization, X-Investisseur-Id) : on lit donc le flux manuellement
|
||||
* via fetch + ReadableStream, en parsant les trames "event:"/"data:"
|
||||
* séparées par une ligne vide, au format écrit par le backend. Générateur
|
||||
* asynchrone : `for await (const {event, data} of api.stream(...))`.
|
||||
*/
|
||||
stream: async function* (path, body) {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = res.statusText || 'Requête échouée';
|
||||
try {
|
||||
const text = await res.text();
|
||||
const parsed = text ? JSON.parse(text) : null;
|
||||
if (parsed && parsed.error) msg = parsed.error;
|
||||
} catch { /* corps non-JSON, on garde statusText */ }
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
|
||||
let sepIdx;
|
||||
while ((sepIdx = buf.indexOf('\n\n')) !== -1) {
|
||||
const frame = buf.slice(0, sepIdx);
|
||||
buf = buf.slice(sepIdx + 2);
|
||||
|
||||
let event = 'message';
|
||||
const dataLines = [];
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length === 0) continue;
|
||||
|
||||
let data;
|
||||
try { data = JSON.parse(dataLines.join('\n')); }
|
||||
catch { data = dataLines.join('\n'); }
|
||||
|
||||
yield { event, data };
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
blob: (path) =>
|
||||
fetch(BASE + path, { headers: authHeaders(), cache: 'no-store' }).then(async res => {
|
||||
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* AiAssistant.jsx — Bulle flottante de l'assistant IA, disponible sur
|
||||
* toutes les pages (sauf /assistant-ia, où le chat est déjà affiché en
|
||||
* grand — voir pages/AssistantIA.jsx). Partage sa logique de chat
|
||||
* (chargement, envoi en streaming, etc.) avec la page dédiée via
|
||||
* components/ai/aiChatShared.jsx ; les deux surfaces affichent la même
|
||||
* conversation, synchronisée par son id (cl_ai_conversation_id en
|
||||
* localStorage ici, paramètre ?conversation= sur la page dédiée).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
IconSparkle, IconNewChat, IconExpand,
|
||||
MessageThread, Composer, useAiChat,
|
||||
} from './ai/aiChatShared.jsx';
|
||||
|
||||
const STORAGE_KEY_OPEN = 'cl_ui_ai_chat_open';
|
||||
const STORAGE_KEY_CONVERSATION = 'cl_ai_conversation_id';
|
||||
// Fermeture automatique du panneau (pas de la conversation, qui reste en historique
|
||||
// côté serveur) après inactivité, pour ne pas laisser la fenêtre ouverte indéfiniment.
|
||||
const INACTIVITY_CLOSE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function IconClose() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AiAssistant() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem(STORAGE_KEY_OPEN) === '1'; } catch { return false; }
|
||||
});
|
||||
const [input, setInput] = useState('');
|
||||
const [initialised, setInitialised] = useState(false);
|
||||
const inactivityTimerRef = useRef(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const lastPathRef = useRef(location.pathname);
|
||||
|
||||
const persistConversationId = useCallback((id) => {
|
||||
try {
|
||||
if (id) localStorage.setItem(STORAGE_KEY_CONVERSATION, String(id));
|
||||
else localStorage.removeItem(STORAGE_KEY_CONVERSATION);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const chat = useAiChat({ onConversationChange: persistConversationId });
|
||||
const { conversationId, messages, sending, selectConversation, startNewConversation, sendMessage, regenerateMessage } = chat;
|
||||
|
||||
/* ── Statut : l'utilisateur courant a-t-il accès à l'assistant ? ──────── */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get('/ai/status')
|
||||
.then((r) => { if (!cancelled) setEnabled(!!r.enabled); })
|
||||
.catch(() => { if (!cancelled) setEnabled(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
/* ── Reprend la conversation en cours (si connue) dès que l'accès est
|
||||
* confirmé — au plus une fois. */
|
||||
useEffect(() => {
|
||||
if (!enabled || initialised) return;
|
||||
setInitialised(true);
|
||||
let storedId = null;
|
||||
try { storedId = localStorage.getItem(STORAGE_KEY_CONVERSATION); } catch { /* ignore */ }
|
||||
if (storedId) selectConversation(Number(storedId));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, initialised]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(STORAGE_KEY_OPEN, open ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [open]);
|
||||
|
||||
/* ── Fermeture automatique en changeant de page ────────────────────────
|
||||
* La conversation reste disponible (persistée côté serveur) ; seul le
|
||||
* panneau se replie, pour ne pas rester affiché par-dessus la nouvelle page. */
|
||||
useEffect(() => {
|
||||
if (location.pathname !== lastPathRef.current) {
|
||||
lastPathRef.current = location.pathname;
|
||||
setOpen(false);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
/* ── Fermeture automatique après inactivité ────────────────────────────
|
||||
* Le minuteur repart à chaque frappe, envoi, ou réception de contenu
|
||||
* (donc jamais pendant qu'une réponse est en cours de génération). */
|
||||
useEffect(() => {
|
||||
if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current);
|
||||
if (!open) return undefined;
|
||||
inactivityTimerRef.current = setTimeout(() => setOpen(false), INACTIVITY_CLOSE_MS);
|
||||
return () => clearTimeout(inactivityTimerRef.current);
|
||||
}, [open, input, messages]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const text = input;
|
||||
setInput('');
|
||||
sendMessage(text);
|
||||
}, [input, sendMessage]);
|
||||
|
||||
const expand = useCallback(() => {
|
||||
navigate(conversationId ? `/assistant-ia?conversation=${conversationId}` : '/assistant-ia');
|
||||
}, [conversationId, navigate]);
|
||||
|
||||
// Masquée sur la page dédiée : le chat y est déjà affiché en grand, pas
|
||||
// besoin d'une seconde interface de chat par-dessus.
|
||||
if (!enabled || location.pathname === '/assistant-ia') return null;
|
||||
|
||||
const panel = open && createPortal(
|
||||
<div className="ai-panel" role="dialog" aria-label="Assistant IA">
|
||||
<div className="ai-panel-header">
|
||||
<IconSparkle />
|
||||
<span className="ai-panel-title">Assistant IA</span>
|
||||
<button className="ai-panel-close" onClick={expand} aria-label="Agrandir" title="Agrandir dans une page dédiée">
|
||||
<IconExpand />
|
||||
</button>
|
||||
<button className="ai-panel-close" onClick={startNewConversation} disabled={sending || messages.length === 0}
|
||||
aria-label="Nouvelle conversation" title="Nouvelle conversation">
|
||||
<IconNewChat />
|
||||
</button>
|
||||
<button className="ai-panel-close" onClick={() => setOpen(false)} aria-label="Fermer">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MessageThread
|
||||
messages={messages}
|
||||
emptyHint="Posez une question sur votre portefeuille — ex. « Quels sont mes intérêts nets perçus cette année ? »"
|
||||
sending={sending}
|
||||
onRegenerate={regenerateMessage}
|
||||
/>
|
||||
|
||||
<Composer value={input} onChange={setInput} onSend={handleSend} disabled={sending} autoFocusKey={open} />
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="ai-fab"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Assistant IA"
|
||||
aria-label="Ouvrir l'assistant IA"
|
||||
>
|
||||
<IconSparkle />
|
||||
</button>
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useUi } from '../context/UiContext.jsx';
|
||||
import Logo from './Logo.jsx';
|
||||
import UserMenu from './UserMenu.jsx';
|
||||
import NotificationBell from './NotificationBell.jsx';
|
||||
import AiAssistant from './AiAssistant.jsx';
|
||||
|
||||
/* ── Icônes nav ─────────────────────────────────────────────── */
|
||||
const ICONS_BASE = '/api/icons-files/';
|
||||
@@ -25,6 +26,7 @@ const IconInvestments = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyli
|
||||
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
|
||||
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
|
||||
const IconFrais = () => <I><circle cx="5.5" cy="5.5" r="2"/><circle cx="12.5" cy="12.5" r="2"/><line x1="13.5" y1="3.5" x2="3.5" y2="13.5"/></I>;
|
||||
const IconAssistantIa = () => <I><path d="M9 2l1.3 4.2L14.5 7.5l-4.2 1.3L9 13l-1.3-4.2L3.5 7.5l4.2-1.3L9 2z"/><path d="M14.5 11.5l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5.5-1.5z"/></I>;
|
||||
|
||||
/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */
|
||||
function NavIcon({ libFilename, Fallback }) {
|
||||
@@ -253,6 +255,7 @@ export default function Layout() {
|
||||
const { hasFrais } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [navIcons, setNavIcons] = useState({});
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [burgerOpen, setBurgerOpen] = useState(false);
|
||||
const burgerRef = useRef(null);
|
||||
|
||||
@@ -283,6 +286,10 @@ export default function Layout() {
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/ai/status').then(r => setAiEnabled(!!r.enabled)).catch(() => setAiEnabled(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
|
||||
<aside className="sidebar">
|
||||
@@ -340,6 +347,11 @@ export default function Layout() {
|
||||
<NavLink to="/taxreport" title="Fiscalité">
|
||||
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
|
||||
</NavLink>
|
||||
{aiEnabled && (
|
||||
<NavLink to="/assistant-ia" title="Assistant IA">
|
||||
<NavIcon libFilename={navIcons.assistantIa} Fallback={IconAssistantIa} /><span className="nav-label">Assistant IA</span>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
</nav>
|
||||
|
||||
@@ -423,6 +435,8 @@ export default function Layout() {
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<AiAssistant />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* aiChatShared.jsx — Logique et rendu du chat de l'assistant IA, partagés
|
||||
* entre la bulle flottante (components/AiAssistant.jsx) et la page dédiée
|
||||
* (pages/AssistantIA.jsx), qui affichent la même conversation en cours
|
||||
* (synchronisée via son id — voir useAiChat) dans deux tailles de fenêtre
|
||||
* différentes.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
let _uid = 0;
|
||||
export const nextId = () => `m${++_uid}`;
|
||||
|
||||
/* ── Icônes réutilisées par le widget et la page ───────────────────────── */
|
||||
export function IconSparkle() {
|
||||
return (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z" />
|
||||
<path d="M19 15l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconSparkleSmall() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconSend() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconTool() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconNewChat() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<line x1="12" y1="7" x2="12" y2="13" /><line x1="9" y1="10" x2="15" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconExpand() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconTrash() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4h6v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconCopy() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconCheck() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function IconRegenerate() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="1 4 1 10 7 10" /><polyline points="23 20 23 14 17 14" />
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_LABELS = {
|
||||
crowdlending_get_investisseur: 'profil investisseur',
|
||||
crowdlending_get_dashboard: 'synthèse du portefeuille',
|
||||
crowdlending_list_investissements:'liste des investissements',
|
||||
crowdlending_get_investissement: "détail d'un investissement",
|
||||
crowdlending_list_remboursements: 'liste des remboursements',
|
||||
crowdlending_list_frais_operations:'liste des frais',
|
||||
crowdlending_list_depots_retraits:'dépôts / retraits',
|
||||
crowdlending_list_objectifs: 'objectifs',
|
||||
crowdlending_get_taxreport: 'synthèse fiscale',
|
||||
};
|
||||
const toolLabel = (name) => TOOL_LABELS[name] || name;
|
||||
|
||||
/** Horodatage compact pour les messages et la liste des conversations :
|
||||
* heure si aujourd'hui, « Hier » si hier, sinon jj/mm (jj/mm/aaaa si année
|
||||
* différente). Partagé entre le fil de discussion (MessageThread) et la
|
||||
* liste des conversations de la page dédiée. */
|
||||
export function relativeDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso.includes('T') || iso.endsWith('Z') ? iso : iso.replace(' ', 'T') + 'Z');
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return 'Hier';
|
||||
const opts = d.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: 'numeric' };
|
||||
return d.toLocaleDateString('fr-FR', opts);
|
||||
}
|
||||
|
||||
/** Badges des outils consultés pour un message assistant donné. */
|
||||
export function ToolBadges({ tools }) {
|
||||
if (!tools || tools.length === 0) return null;
|
||||
return (
|
||||
<div className="ai-tool-badges">
|
||||
{tools.map((t, i) => (
|
||||
<span key={i} className={`ai-tool-badge ${t.status}`}>
|
||||
<IconTool />
|
||||
{toolLabel(t.name)}
|
||||
{t.status === 'calling' && '…'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convertit un message persisté (GET /ai/conversations/:id/messages —
|
||||
* {role, content, tools:string[]}) au format interne du composant
|
||||
* ({id, role, content, tools:[{name,status}], ...}). */
|
||||
function fromPersisted(m) {
|
||||
return {
|
||||
id: nextId(),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
tools: (m.tools || []).map((name) => ({ name, status: 'done' })),
|
||||
pending: false,
|
||||
error: null,
|
||||
timestamp: m.createdAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useAiChat — état et logique du fil de discussion courant : chargement
|
||||
* d'une conversation existante, envoi de message en streaming (SSE),
|
||||
* démarrage d'une nouvelle conversation (déconnexion locale seulement —
|
||||
* rien n'est supprimé côté serveur, la nouvelle conversation n'est créée
|
||||
* qu'à l'envoi du premier message, voir routes/ai.js POST /chat).
|
||||
*
|
||||
* onConversationChange(id, titre) est appelé chaque fois que l'id de
|
||||
* conversation change, quelle qu'en soit la raison (sélection explicite,
|
||||
* "Nouvelle conversation", ou création à la volée par le serveur au premier
|
||||
* message) — le widget s'en sert pour retenir l'id (localStorage), la page
|
||||
* dédiée pour synchroniser l'URL. onTurnComplete() est appelé à la fin de
|
||||
* chaque tour (succès ou échec) — la page dédiée s'en sert pour rafraîchir
|
||||
* la liste des conversations (nouvel élément, ou position remontée par
|
||||
* récence d'activité).
|
||||
*/
|
||||
export function useAiChat({ onConversationChange, onTurnComplete } = {}) {
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const updateMessage = useCallback((id, updater) => {
|
||||
setMessages((prev) => prev.map((m) => (m.id === id ? updater(m) : m)));
|
||||
}, []);
|
||||
|
||||
const applyConversationId = useCallback((id, titre) => {
|
||||
setConversationId(id);
|
||||
onConversationChange?.(id, titre ?? null);
|
||||
}, [onConversationChange]);
|
||||
|
||||
/** Charge le fil d'une conversation existante ; id falsy = vide l'affichage
|
||||
* (ne supprime rien côté serveur). */
|
||||
const selectConversation = useCallback(async (id) => {
|
||||
if (!id) {
|
||||
setMessages([]);
|
||||
applyConversationId(null, null);
|
||||
return;
|
||||
}
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const r = await api.get(`/ai/conversations/${id}/messages`);
|
||||
setMessages((r.messages || []).map(fromPersisted));
|
||||
applyConversationId(id, r.conversation?.titre);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
applyConversationId(null, null);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, [applyConversationId]);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
if (sending) return;
|
||||
setMessages([]);
|
||||
applyConversationId(null, null);
|
||||
}, [sending, applyConversationId]);
|
||||
|
||||
/** Logique d'envoi commune à sendMessage() et regenerateMessage() : ajoute
|
||||
* la paire utilisateur/assistant et consomme le flux SSE de la réponse. */
|
||||
const runTurn = useCallback(async (trimmed) => {
|
||||
const now = new Date().toISOString();
|
||||
const userMsg = { id: nextId(), role: 'user', content: trimmed, timestamp: now };
|
||||
const assistantId = nextId();
|
||||
const assistantMsg = { id: assistantId, role: 'assistant', content: '', tools: [], pending: true, error: null, timestamp: now };
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
for await (const { data } of api.stream('/ai/chat', { conversationId, message: trimmed })) {
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
|
||||
if (data.type === 'conversation') {
|
||||
if (data.id !== conversationId) applyConversationId(data.id, data.titre);
|
||||
} else if (data.type === 'text_delta') {
|
||||
updateMessage(assistantId, (m) => ({ ...m, content: m.content + data.text }));
|
||||
} else if (data.type === 'tool_call') {
|
||||
updateMessage(assistantId, (m) => ({
|
||||
...m,
|
||||
tools: [...m.tools, { name: data.name, status: 'calling' }],
|
||||
}));
|
||||
} else if (data.type === 'tool_result') {
|
||||
updateMessage(assistantId, (m) => ({
|
||||
...m,
|
||||
tools: m.tools.map((t) =>
|
||||
t.name === data.name && t.status === 'calling'
|
||||
? { ...t, status: data.isError ? 'error' : 'done' }
|
||||
: t
|
||||
),
|
||||
}));
|
||||
} else if (data.type === 'done') {
|
||||
updateMessage(assistantId, (m) => ({ ...m, pending: false }));
|
||||
} else if (data.type === 'error') {
|
||||
updateMessage(assistantId, (m) => ({ ...m, pending: false, error: data.message }));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
updateMessage(assistantId, (m) => ({ ...m, pending: false, error: e.message || 'Erreur de connexion' }));
|
||||
} finally {
|
||||
setSending(false);
|
||||
onTurnComplete?.();
|
||||
}
|
||||
}, [conversationId, updateMessage, applyConversationId, onTurnComplete]);
|
||||
|
||||
const sendMessage = useCallback((text) => {
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed || sending) return;
|
||||
return runTurn(trimmed);
|
||||
}, [sending, runTurn]);
|
||||
|
||||
/** Rejoue une demande : reprend le texte du message utilisateur associé
|
||||
* (celui-ci s'il en est un, sinon celui qui précède immédiatement le
|
||||
* message assistant visé) et l'envoie à nouveau comme un nouveau tour —
|
||||
* l'historique existant n'est ni modifié ni supprimé, la nouvelle réponse
|
||||
* s'ajoute simplement à la suite (cohérent avec le modèle d'historique
|
||||
* "append only" côté serveur). */
|
||||
const regenerateMessage = useCallback((messageId) => {
|
||||
if (sending) return;
|
||||
const idx = messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return;
|
||||
const target = messages[idx];
|
||||
const userMessage = target.role === 'user' ? target : messages[idx - 1];
|
||||
if (!userMessage || userMessage.role !== 'user') return;
|
||||
runTurn(userMessage.content);
|
||||
}, [sending, messages, runTurn]);
|
||||
|
||||
return {
|
||||
conversationId, messages, sending, loadingHistory,
|
||||
selectConversation, startNewConversation, sendMessage, regenerateMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fil de messages (bulles + badges d'outils + indicateur de saisie),
|
||||
* auto-scrollé vers le bas à chaque nouveau contenu. Utilisé tel quel
|
||||
* dans le panneau flottant et dans la page dédiée (seule la taille du
|
||||
* conteneur parent diffère, via CSS). */
|
||||
export function MessageThread({ messages, emptyHint, sending, onRegenerate }) {
|
||||
const threadRef = useRef(null);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const handleCopy = useCallback(async (m) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(m.content);
|
||||
} catch {
|
||||
// Repli si l'API Clipboard est indisponible (contexte non sécurisé, etc.).
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = m.content;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCopiedId(m.id);
|
||||
setTimeout(() => setCopiedId((cur) => (cur === m.id ? null : cur)), 1500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="ai-thread" ref={threadRef}>
|
||||
{messages.length === 0 && <div className="ai-empty">{emptyHint}</div>}
|
||||
{messages.map((m) => {
|
||||
const anyToolCalling = (m.tools || []).some((t) => t.status === 'calling');
|
||||
// "Réfléchit" (points animés) tant qu'il n'y a ni texte ni outil en
|
||||
// cours d'appel (le badge d'outil affiche déjà son propre "…") ;
|
||||
// curseur clignotant en fin de texte une fois la réponse démarrée —
|
||||
// pour qu'il y ait toujours un signe visible que ça travaille encore.
|
||||
const showThinking = m.pending && m.content === '' && !anyToolCalling;
|
||||
const showCursor = m.pending && m.content !== '';
|
||||
const canAct = !m.pending && !m.error && !!m.content;
|
||||
return (
|
||||
<div key={m.id} className={`ai-message from-${m.role === 'assistant' ? 'ai' : 'user'}`}>
|
||||
<div className="ai-message-avatar">
|
||||
{m.role === 'assistant' ? <IconSparkleSmall /> : 'Moi'}
|
||||
</div>
|
||||
<div className="ai-message-content">
|
||||
<ToolBadges tools={m.tools} />
|
||||
{m.content}
|
||||
{showCursor && <span className="ai-cursor" aria-hidden="true" />}
|
||||
{showThinking && (
|
||||
<span className="ai-typing" aria-label="L'assistant réfléchit">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
)}
|
||||
{m.error && <div className="ai-message-error">{m.error}</div>}
|
||||
<div className="ai-message-meta">
|
||||
{m.timestamp && <span className="ai-message-time">{relativeDate(m.timestamp)}</span>}
|
||||
{canAct && (
|
||||
<button
|
||||
type="button"
|
||||
className="ai-message-action"
|
||||
onClick={() => handleCopy(m)}
|
||||
aria-label="Copier le message"
|
||||
title="Copier"
|
||||
>
|
||||
{copiedId === m.id ? <IconCheck /> : <IconCopy />}
|
||||
</button>
|
||||
)}
|
||||
{canAct && m.role === 'assistant' && onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
className="ai-message-action"
|
||||
onClick={() => onRegenerate(m.id)}
|
||||
disabled={sending}
|
||||
aria-label="Rejouer cette demande"
|
||||
title="Rejouer cette demande"
|
||||
>
|
||||
<IconRegenerate />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Zone de saisie (textarea + bouton d'envoi), Entrée pour envoyer /
|
||||
* Maj+Entrée pour un saut de ligne. */
|
||||
export function Composer({ value, onChange, onSend, disabled, autoFocusKey }) {
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocusKey !== undefined) setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}, [autoFocusKey]);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-input-form">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="ai-textarea"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Votre question…"
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
className="ai-send-btn"
|
||||
onClick={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label="Envoyer"
|
||||
title="Envoyer (Entrée)"
|
||||
>
|
||||
<IconSend />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
||||
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
|
||||
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
|
||||
function IconDownload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; }
|
||||
function IconSparkle() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>; }
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
@@ -28,6 +29,8 @@ const NAV = [
|
||||
{ id: 'audit-logs', label: 'Audit', icon: <IconShield /> },
|
||||
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
|
||||
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
|
||||
{ id: 'smtp', label: 'Configuration SMTP', icon: <IconMail /> },
|
||||
{ id: 'ia', label: 'Assistant IA', icon: <IconSparkle />, href: '/admin/ia' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -37,12 +40,6 @@ const NAV = [
|
||||
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Notifications',
|
||||
items: [
|
||||
{ id: 'smtp', label: 'SMTP', icon: <IconMail /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Maintenance',
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import ResultBanner from '../components/ResultBanner.jsx';
|
||||
import { fmt, StatusBadge as LogStatusBadge } from './admin/adminHelpers.jsx';
|
||||
|
||||
/* ── Icônes ───────────────────────────────────────────────────── */
|
||||
function IconChevronLeft() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>;
|
||||
}
|
||||
function IconSparkle() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>;
|
||||
}
|
||||
function IconServer() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>;
|
||||
}
|
||||
function IconSettings() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>;
|
||||
}
|
||||
function IconChart() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>;
|
||||
}
|
||||
function IconEye({ open }) {
|
||||
return open
|
||||
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>;
|
||||
}
|
||||
const IconEdit = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
|
||||
const IconTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
|
||||
const IconStar = ({ filled }) => <svg width="14" height="14" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>;
|
||||
const IconZap = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>;
|
||||
|
||||
/* ── Toggle switch (copié du pattern SmtpSection.jsx) ────────────── */
|
||||
function Toggle({ checked, onChange, id }) {
|
||||
return (
|
||||
<label htmlFor={id} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<input id={id} type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display: 'none' }} />
|
||||
<span style={{
|
||||
display: 'inline-block', width: 44, height: 24, borderRadius: 12,
|
||||
background: checked ? 'var(--primary)' : 'var(--border)', position: 'relative',
|
||||
transition: 'background 0.2s', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute', top: 3, left: checked ? 23 : 3, width: 18, height: 18,
|
||||
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ actif }) {
|
||||
return actif
|
||||
? <span style={{ display: 'inline-block', padding: '1px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600, background: 'rgba(34,197,94,.12)', color: '#16a34a', border: '1px solid rgba(34,197,94,.3)' }}>Actif</span>
|
||||
: <span style={{ display: 'inline-block', padding: '1px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600, background: 'rgba(148,163,184,.15)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>Inactif</span>;
|
||||
}
|
||||
|
||||
const TYPE_LABELS = {
|
||||
anthropic: 'Anthropic (natif)',
|
||||
openai_compatible: 'Compatible OpenAI',
|
||||
};
|
||||
|
||||
/* ── Modal création / édition d'un fournisseur ───────────────────── */
|
||||
const EMPTY_PROVIDER = { nom: '', type: 'anthropic', baseUrl: '', modele: '', apiKey: '', actif: true, isDefault: false };
|
||||
|
||||
function ProviderModal({ provider, onClose, onSaved }) {
|
||||
const isNew = !provider?.id;
|
||||
const [form, setForm] = useState(isNew ? EMPTY_PROVIDER : {
|
||||
nom: provider.nom, type: provider.type, baseUrl: provider.baseUrl || '',
|
||||
modele: provider.modele, apiKey: '', actif: provider.actif, isDefault: provider.isDefault,
|
||||
});
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
|
||||
if (!form.modele.trim()) { setErr('Le modèle est requis.'); return; }
|
||||
if (form.type === 'openai_compatible' && !form.baseUrl.trim()) {
|
||||
setErr("L'URL de base est requise pour un déploiement compatible OpenAI (ex : https://openrouter.ai/api/v1)."); return;
|
||||
}
|
||||
if (isNew && !form.apiKey.trim()) { setErr('La clé API est requise.'); return; }
|
||||
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
const payload = {
|
||||
nom: form.nom.trim(),
|
||||
type: form.type,
|
||||
baseUrl: form.baseUrl.trim() || undefined,
|
||||
modele: form.modele.trim(),
|
||||
actif: form.actif,
|
||||
isDefault: form.isDefault,
|
||||
};
|
||||
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
|
||||
if (isNew) { await api.post('/admin/ai/providers', payload); }
|
||||
else { await api.put(`/admin/ai/providers/${provider.id}`, payload); }
|
||||
onSaved(); onClose();
|
||||
} catch (ex) { setErr(ex.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const inp = { width: '100%', boxSizing: 'border-box' };
|
||||
|
||||
return (
|
||||
<Modal open title={isNew ? 'Ajouter un fournisseur IA' : `Modifier — ${provider.nom}`}
|
||||
onClose={onClose} width={520}
|
||||
footer={<>
|
||||
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
|
||||
<button className="primary" form="ai-provider-form" type="submit" disabled={saving}>
|
||||
{saving ? '…' : isNew ? 'Créer' : 'Enregistrer'}
|
||||
</button>
|
||||
</>}>
|
||||
<form id="ai-provider-form" onSubmit={handleSubmit}>
|
||||
{err && (
|
||||
<div style={{ padding: '8px 12px', borderRadius: 6, marginBottom: 14, background: 'rgba(239,68,68,.1)', color: '#dc2626', border: '1px solid rgba(239,68,68,.3)', fontSize: 13 }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label>Nom *</label>
|
||||
<input style={inp} value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Anthropic (production)" />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label>Type de déploiement *</label>
|
||||
<select style={inp} value={form.type} onChange={e => set('type', e.target.value)}>
|
||||
<option value="anthropic">Anthropic (natif)</option>
|
||||
<option value="openai_compatible">Compatible OpenAI — OpenRouter, OpenAI, Mistral, LLM local…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.type === 'openai_compatible' && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label>URL de base *</label>
|
||||
<input style={inp} value={form.baseUrl} onChange={e => set('baseUrl', e.target.value)} placeholder="https://openrouter.ai/api/v1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label>Modèle *</label>
|
||||
<input style={inp} value={form.modele} onChange={e => set('modele', e.target.value)}
|
||||
placeholder={form.type === 'anthropic' ? 'claude-sonnet-4-5-20250929' : 'anthropic/claude-sonnet-4.5'} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label>Clé API {isNew ? '*' : ''}</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
style={{ ...inp, paddingRight: 36 }}
|
||||
value={form.apiKey}
|
||||
onChange={e => set('apiKey', e.target.value)}
|
||||
placeholder={!isNew && provider.hasApiKey ? '•••••••• (laisser vide pour conserver)' : 'sk-ant-…'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowKey(s => !s)} tabIndex={-1}
|
||||
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0, display: 'flex' }}>
|
||||
<IconEye open={showKey} />
|
||||
</button>
|
||||
</div>
|
||||
{!isNew && provider.hasApiKey && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
Une clé est déjà enregistrée. Laissez ce champ vide pour la conserver.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 28, marginTop: 20 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
|
||||
<Toggle id="prov-actif" checked={form.actif} onChange={v => set('actif', v)} /> Actif
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
|
||||
<Toggle id="prov-default" checked={form.isDefault} onChange={v => set('isDefault', v)} /> Fournisseur par défaut
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section : fournisseurs ───────────────────────────────────────── */
|
||||
function ProvidersSection() {
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // provider | { new: true } | null
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const [banner, setBanner] = useState(null);
|
||||
const [testingId, setTestingId] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setProviders(await api.get('/admin/ai/providers')); }
|
||||
catch (e) { setBanner({ ok: false, msg: e.message }); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const delProvider = (p) => {
|
||||
setConfirmDel({
|
||||
message: `Supprimer le fournisseur « ${p.nom} » ? Cette action est irréversible.`,
|
||||
onConfirm: async () => {
|
||||
try { await api.del(`/admin/ai/providers/${p.id}`); await load(); }
|
||||
catch (e) { setBanner({ ok: false, msg: e.message }); }
|
||||
finally { setConfirmDel(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleActif = async (p) => {
|
||||
try { await api.put(`/admin/ai/providers/${p.id}`, { actif: !p.actif }); await load(); }
|
||||
catch (e) { setBanner({ ok: false, msg: e.message }); }
|
||||
};
|
||||
|
||||
const testProvider = async (p) => {
|
||||
setTestingId(p.id);
|
||||
setBanner(null);
|
||||
try {
|
||||
const r = await api.post(`/admin/ai/providers/${p.id}/test`, {});
|
||||
if (r.ok) {
|
||||
setBanner({ ok: true, msg: `${p.nom} — connexion réussie en ${r.dureeMs} ms (réponse du modèle : « ${r.reponse} »).` });
|
||||
} else {
|
||||
setBanner({ ok: false, msg: `${p.nom} — échec : ${r.error}` });
|
||||
}
|
||||
} catch (e) {
|
||||
setBanner({ ok: false, msg: `${p.nom} — échec : ${e.message}` });
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="account-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<h2 style={{ margin: 0 }}>Fournisseurs IA</h2>
|
||||
<button className="primary" onClick={() => setEditing({ new: true })}>+ Ajouter</button>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: 20 }}>
|
||||
Adaptateur natif Anthropic ou compatible OpenAI (OpenRouter, OpenAI, Mistral, LLM local…). Plusieurs fournisseurs peuvent
|
||||
être configurés ; le fournisseur par défaut actif est utilisé pour les conversations.
|
||||
</p>
|
||||
|
||||
{banner && <ResultBanner result={banner} onDismiss={() => setBanner(null)} style={{ marginBottom: 16 }} />}
|
||||
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
{loading ? (
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</p>
|
||||
) : providers.length === 0 ? (
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Aucun fournisseur configuré.</p>
|
||||
) : (
|
||||
<table style={{ margin: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ paddingLeft: 20 }}>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Modèle</th>
|
||||
<th>Clé API</th>
|
||||
<th>Statut</th>
|
||||
<th>Défaut</th>
|
||||
<th style={{ width: 120, paddingRight: 20 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{providers.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ paddingLeft: 20, fontWeight: 600 }}>{p.nom}</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{TYPE_LABELS[p.type] || p.type}</td>
|
||||
<td style={{ fontSize: 13 }}>{p.modele}</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{p.hasApiKey ? '••••••••' : <em>non renseignée</em>}</td>
|
||||
<td>
|
||||
<button onClick={() => toggleActif(p)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }} title="Basculer l'activation">
|
||||
<StatusBadge actif={p.actif} />
|
||||
</button>
|
||||
</td>
|
||||
<td><IconStar filled={p.isDefault} /></td>
|
||||
<td style={{ paddingRight: 20, textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button onClick={() => testProvider(p)} disabled={testingId === p.id || !p.hasApiKey}
|
||||
title={p.hasApiKey ? 'Tester la connexion' : 'Aucune clé API enregistrée'}
|
||||
style={{ background: 'none', border: 'none', cursor: p.hasApiKey ? 'pointer' : 'not-allowed', color: testingId === p.id ? 'var(--primary)' : 'var(--text-muted)', padding: 4, opacity: p.hasApiKey ? 1 : 0.4 }}>
|
||||
<IconZap />
|
||||
</button>
|
||||
<button onClick={() => setEditing(p)} title="Modifier"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 4 }}>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button onClick={() => delProvider(p)} title="Supprimer"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 4 }}>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<ProviderModal
|
||||
provider={editing.new ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setBanner({ ok: true, msg: editing.new ? 'Fournisseur créé.' : 'Fournisseur mis à jour.' }); load(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDel}
|
||||
title="Supprimer le fournisseur"
|
||||
message={confirmDel?.message}
|
||||
onConfirm={confirmDel?.onConfirm}
|
||||
onCancel={() => setConfirmDel(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Ligne de paramètre (copié du pattern SmtpSection.jsx) ───────── */
|
||||
function SettingRow({ label, description, children }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', alignItems: 'center', padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: description ? 4 : 0 }}>{label}</div>
|
||||
{description && <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>{description}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Section : configuration globale ─────────────────────────────── */
|
||||
function ConfigSection() {
|
||||
const [config, setConfig] = useState({ actif: false, quotaQuotidien: null });
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [banner, setBanner] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [cfg, provs] = await Promise.all([api.get('/admin/ai/config'), api.get('/admin/ai/providers')]);
|
||||
setConfig({ actif: cfg.actif, quotaQuotidien: cfg.quotaQuotidien });
|
||||
setProviders(provs);
|
||||
} catch (e) { setBanner({ ok: false, msg: e.message }); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const activeDefault = providers.find(p => p.actif && p.isDefault) || providers.find(p => p.actif);
|
||||
|
||||
const save = async (next) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.patch('/admin/ai/config', next);
|
||||
setBanner({ ok: true, msg: 'Configuration enregistrée.' });
|
||||
await load();
|
||||
} catch (e) { setBanner({ ok: false, msg: e.message }); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const toggleActif = (v) => {
|
||||
if (v && !activeDefault) {
|
||||
setBanner({ ok: false, msg: "Impossible d'activer l'Assistant IA : configurez d'abord au moins un fournisseur actif." });
|
||||
return;
|
||||
}
|
||||
save({ actif: v });
|
||||
};
|
||||
|
||||
if (loading) return <div className="account-section"><p style={{ color: 'var(--text-muted)' }}>Chargement…</p></div>;
|
||||
|
||||
return (
|
||||
<div className="account-section">
|
||||
<h2 style={{ margin: '0 0 4px' }}>Configuration</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: 20 }}>
|
||||
Interrupteur global de l'Assistant IA embarqué. L'accès reste également soumis à l'autorisation individuelle
|
||||
de chaque utilisateur (page Utilisateurs) et à la disponibilité d'un fournisseur actif.
|
||||
</p>
|
||||
|
||||
{banner && <ResultBanner result={banner} onDismiss={() => setBanner(null)} style={{ marginBottom: 16 }} />}
|
||||
|
||||
<div className="card" style={{ padding: '0 24px' }}>
|
||||
<SettingRow
|
||||
label="Activer l'Assistant IA"
|
||||
description={activeDefault
|
||||
? `Fournisseur par défaut actuel : ${activeDefault.nom} (${TYPE_LABELS[activeDefault.type] || activeDefault.type})`
|
||||
: 'Aucun fournisseur actif — configurez-en un dans l\'onglet Fournisseurs avant d\'activer.'}
|
||||
>
|
||||
<Toggle id="ai-config-actif" checked={config.actif} onChange={toggleActif} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="Quota quotidien (requêtes / utilisateur)" description="Laissez vide pour ne fixer aucune limite.">
|
||||
<input
|
||||
type="number" min={1} className="form-input" style={{ width: 140 }}
|
||||
value={config.quotaQuotidien ?? ''}
|
||||
onChange={e => setConfig(c => ({ ...c, quotaQuotidien: e.target.value === '' ? null : Number(e.target.value) }))}
|
||||
onBlur={() => save({ quotaQuotidien: config.quotaQuotidien })}
|
||||
placeholder="Illimité"
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Carte de synthèse chiffrée ──────────────────────────────────── */
|
||||
function StatCard({ label, value, accent }) {
|
||||
return (
|
||||
<div className="card" style={{ flex: '1 1 160px', minWidth: 160, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>{label}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: accent || 'var(--text)' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PERIODS = [
|
||||
{ value: 7, label: '7 jours' },
|
||||
{ value: 30, label: '30 jours' },
|
||||
{ value: 90, label: '90 jours' },
|
||||
{ value: 365, label: '1 an' },
|
||||
];
|
||||
|
||||
/* ── Section : usage (requêtes & tokens consommés) ────────────────── */
|
||||
function UsageSection() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [loadingSum, setLoadingSum] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const [logRows, setLogRows] = useState([]);
|
||||
const [logTotal, setLogTotal] = useState(0);
|
||||
const [logPage, setLogPage] = useState(0);
|
||||
const [logUserId, setLogUserId] = useState('');
|
||||
const [loadingLog, setLoadingLog] = useState(true);
|
||||
const LOG_PER_PAGE = 20;
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
setLoadingSum(true);
|
||||
try { setSummary(await api.get('/admin/ai/usage/summary', { days })); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setLoadingSum(false); }
|
||||
}, [days]);
|
||||
|
||||
const loadLog = useCallback(async () => {
|
||||
setLoadingLog(true);
|
||||
try {
|
||||
const params = { limit: LOG_PER_PAGE, offset: logPage * LOG_PER_PAGE };
|
||||
if (logUserId) params.userId = logUserId;
|
||||
const data = await api.get('/admin/ai/usage/log', params);
|
||||
setLogRows(data.rows);
|
||||
setLogTotal(data.total);
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoadingLog(false); }
|
||||
}, [logPage, logUserId]);
|
||||
|
||||
useEffect(() => { loadSummary(); }, [loadSummary]);
|
||||
useEffect(() => { loadLog(); }, [loadLog]);
|
||||
useEffect(() => { setLogPage(0); }, [logUserId]);
|
||||
|
||||
const logPages = Math.ceil(logTotal / LOG_PER_PAGE);
|
||||
const n = (v) => (v ?? 0).toLocaleString('fr-FR');
|
||||
|
||||
return (
|
||||
<div className="account-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4, flexWrap: 'wrap', gap: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>Usage</h2>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.value}
|
||||
className="btn btn-outline"
|
||||
onClick={() => setDays(p.value)}
|
||||
style={{
|
||||
fontSize: 12, padding: '4px 10px',
|
||||
background: days === p.value ? 'var(--primary)' : undefined,
|
||||
color: days === p.value ? '#fff' : undefined,
|
||||
borderColor: days === p.value ? 'var(--primary)' : undefined,
|
||||
}}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: 20 }}>
|
||||
Nombre de requêtes envoyées à l'Assistant IA et tokens consommés (entrée / sortie), au global, par utilisateur,
|
||||
et requête par requête, sur la période sélectionnée.
|
||||
</p>
|
||||
|
||||
{err && <ResultBanner result={{ ok: false, msg: err }} onDismiss={() => setErr(null)} style={{ marginBottom: 16 }} />}
|
||||
|
||||
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginBottom: 24 }}>
|
||||
<StatCard label="Requêtes" value={loadingSum ? '…' : n(summary?.totals.requests)} />
|
||||
<StatCard label="Tokens entrée" value={loadingSum ? '…' : n(summary?.totals.tokensIn)} />
|
||||
<StatCard label="Tokens sortie" value={loadingSum ? '…' : n(summary?.totals.tokensOut)} />
|
||||
<StatCard label="Tokens (total)" value={loadingSum ? '…' : n(summary?.totals.tokensTotal)} accent="var(--primary)" />
|
||||
<StatCard label="Erreurs" value={loadingSum ? '…' : n(summary?.totals.errors)} accent={(summary?.totals.errors ?? 0) > 0 ? '#dc2626' : undefined} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ margin: '0 0 10px', fontSize: 15 }}>Par utilisateur</h3>
|
||||
<div className="card" style={{ padding: 0, marginBottom: 28 }}>
|
||||
{loadingSum ? (
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</p>
|
||||
) : !summary?.byUser.length ? (
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Aucune requête sur cette période.</p>
|
||||
) : (
|
||||
<table style={{ margin: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ paddingLeft: 20 }}>Utilisateur</th>
|
||||
<th>Requêtes</th>
|
||||
<th>Tokens entrée</th>
|
||||
<th>Tokens sortie</th>
|
||||
<th>Tokens total</th>
|
||||
<th>Erreurs</th>
|
||||
<th style={{ paddingRight: 20 }}>Dernière utilisation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.byUser.map(u => (
|
||||
<tr key={u.userId}>
|
||||
<td style={{ paddingLeft: 20 }}>
|
||||
<div style={{ fontWeight: 600 }}>{u.displayName || u.email}</div>
|
||||
{u.displayName && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{u.email}</div>}
|
||||
</td>
|
||||
<td>{n(u.requests)}</td>
|
||||
<td>{n(u.tokensIn)}</td>
|
||||
<td>{n(u.tokensOut)}</td>
|
||||
<td style={{ fontWeight: 600 }}>{n(u.tokensTotal)}</td>
|
||||
<td style={{ color: u.errors > 0 ? '#dc2626' : undefined }}>{u.errors}</td>
|
||||
<td style={{ paddingRight: 20, fontSize: 13, color: 'var(--text-muted)' }}>{fmt(u.lastUsed)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10, flexWrap: 'wrap', gap: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15 }}>Journal détaillé</h3>
|
||||
<select className="form-input" style={{ width: 240 }} value={logUserId} onChange={e => setLogUserId(e.target.value)}>
|
||||
<option value="">Tous les utilisateurs</option>
|
||||
{(summary?.byUser || []).map(u => (
|
||||
<option key={u.userId} value={u.userId}>{u.displayName || u.email}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
{loadingLog ? (
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</p>
|
||||
) : logRows.length === 0 ? (
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Aucune requête trouvée.</p>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ margin: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ paddingLeft: 20 }}>Date</th>
|
||||
<th>Utilisateur</th>
|
||||
<th>Investisseur</th>
|
||||
<th>Modèle</th>
|
||||
<th>Tokens (in / out)</th>
|
||||
<th>Outils</th>
|
||||
<th style={{ paddingRight: 20 }}>Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logRows.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ paddingLeft: 20, fontSize: 13, whiteSpace: 'nowrap' }}>{fmt(r.createdAt)}</td>
|
||||
<td style={{ fontSize: 13 }}>{r.displayName || r.email}</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{r.investisseurNom || '—'}</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{r.modele || '—'}</td>
|
||||
<td style={{ fontSize: 13 }}>{n(r.tokensIn)} / {n(r.tokensOut)}</td>
|
||||
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{r.outilsAppeles.length ? r.outilsAppeles.join(', ') : '—'}</td>
|
||||
<td style={{ paddingRight: 20 }} title={r.erreur || ''}>
|
||||
<LogStatusBadge status={r.erreur ? 'error' : 'ok'} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{logPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, alignItems: 'center', padding: '14px 0' }}>
|
||||
<button className="btn btn-outline" disabled={logPage === 0} onClick={() => setLogPage(p => p - 1)}>Précédent</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Page {logPage + 1} / {logPages}</span>
|
||||
<button className="btn btn-outline" disabled={logPage >= logPages - 1} onClick={() => setLogPage(p => p + 1)}>Suivant</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Navigation ───────────────────────────────────────────────── */
|
||||
const NAV = [
|
||||
{ id: 'providers', label: 'Fournisseurs', icon: <IconServer /> },
|
||||
{ id: 'usage', label: 'Usage', icon: <IconChart /> },
|
||||
{ id: 'config', label: 'Configuration', icon: <IconSettings /> },
|
||||
];
|
||||
|
||||
/* ── Page principale ──────────────────────────────────────────── */
|
||||
export default function AdminIA() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const section = new URLSearchParams(search).get('section') || 'providers';
|
||||
|
||||
return (
|
||||
<div className="account-layout">
|
||||
<aside className="account-sidebar">
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12,
|
||||
color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: '4px 0', marginBottom: 8 }}>
|
||||
<IconChevronLeft /> Administration
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="account-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<IconSparkle /> Assistant IA
|
||||
</h1>
|
||||
<div className="account-nav-group">
|
||||
{NAV.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`account-nav-item${section === item.id ? ' active' : ''}`}
|
||||
onClick={() => navigate(`/admin/ia?section=${item.id}`, { replace: true })}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="account-content">
|
||||
{section === 'providers' && <ProvidersSection />}
|
||||
{section === 'usage' && <UsageSection />}
|
||||
{section === 'config' && <ConfigSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* AssistantIA.jsx — Page dédiée de l'assistant IA (menu de gauche, sous
|
||||
* Fiscalité) : le même chat que la bulle flottante (components/AiAssistant.jsx),
|
||||
* en grand, avec la liste des conversations passées dans une colonne latérale
|
||||
* (voir components/ai/aiChatShared.jsx pour la logique et le rendu partagés).
|
||||
*
|
||||
* La conversation affichée est pilotée par le paramètre d'URL ?conversation=,
|
||||
* pour qu'un lien direct (ex. le bouton « Agrandir » du widget flottant)
|
||||
* l'ouvre directement, et que le bouton retour du navigateur fonctionne.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import {
|
||||
IconSparkle, IconNewChat, IconTrash,
|
||||
MessageThread, Composer, useAiChat, relativeDate,
|
||||
} from '../components/ai/aiChatShared.jsx';
|
||||
|
||||
export default function AssistantIA() {
|
||||
const [enabled, setEnabled] = useState(null); // null = pas encore su
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [input, setInput] = useState('');
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncUrl = useCallback((id) => {
|
||||
setSearchParams(id ? { conversation: String(id) } : {}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.get('/ai/conversations');
|
||||
setConversations(r.conversations || []);
|
||||
return r.conversations || [];
|
||||
} catch {
|
||||
setConversations([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const chat = useAiChat({
|
||||
onConversationChange: syncUrl,
|
||||
onTurnComplete: loadConversations,
|
||||
});
|
||||
const { conversationId, messages, sending, loadingHistory, selectConversation, startNewConversation, sendMessage, regenerateMessage } = chat;
|
||||
|
||||
/* ── Statut d'accès ─────────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get('/ai/status').then((r) => { if (!cancelled) setEnabled(!!r.enabled); }).catch(() => { if (!cancelled) setEnabled(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
/* ── Chargement initial : conversation demandée dans l'URL, sinon la plus
|
||||
* récente de la liste (reprendre là où on s'était arrêté). ────────────── */
|
||||
useEffect(() => {
|
||||
if (enabled !== true) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const list = await loadConversations();
|
||||
if (cancelled) return;
|
||||
const fromUrl = searchParams.get('conversation');
|
||||
if (fromUrl) {
|
||||
selectConversation(Number(fromUrl));
|
||||
} else if (list.length > 0) {
|
||||
selectConversation(list[0].id);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const text = input;
|
||||
setInput('');
|
||||
sendMessage(text);
|
||||
}, [input, sendMessage]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (sending) return;
|
||||
startNewConversation();
|
||||
}, [sending, startNewConversation]);
|
||||
|
||||
const handleSelect = useCallback((id) => {
|
||||
if (sending || id === conversationId) return;
|
||||
selectConversation(id);
|
||||
}, [sending, conversationId, selectConversation]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const id = confirmDeleteId;
|
||||
if (id == null) return;
|
||||
try {
|
||||
await api.del(`/ai/conversations/${id}`);
|
||||
if (id === conversationId) startNewConversation();
|
||||
await loadConversations();
|
||||
} catch { /* best-effort */ }
|
||||
finally { setConfirmDeleteId(null); }
|
||||
}, [confirmDeleteId, conversationId, startNewConversation, loadConversations]);
|
||||
|
||||
if (enabled === false) {
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<div className="ai-page-disabled">
|
||||
<IconSparkle />
|
||||
<p>L'assistant IA n'est pas activé pour votre compte.</p>
|
||||
<p className="text-muted">Contactez un administrateur si vous pensez qu'il s'agit d'une erreur.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentTitle = conversations.find((c) => c.id === conversationId)?.titre || 'Nouvelle conversation';
|
||||
|
||||
return (
|
||||
<div className="ai-page">
|
||||
<aside className="ai-page-sidebar">
|
||||
<button className="ai-page-new-btn" onClick={handleNewConversation} disabled={sending}>
|
||||
<IconNewChat /> Nouvelle conversation
|
||||
</button>
|
||||
<div className="ai-page-conv-list">
|
||||
{loadingList ? (
|
||||
<div className="ai-page-conv-empty">Chargement…</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="ai-page-conv-empty">Aucune conversation pour l'instant.</div>
|
||||
) : (
|
||||
conversations.map((c) => (
|
||||
<div key={c.id} className={`ai-page-conv-item${c.id === conversationId ? ' active' : ''}`}>
|
||||
<button className="ai-page-conv-select" onClick={() => handleSelect(c.id)} title={c.titre}>
|
||||
<span className="ai-page-conv-title">{c.titre}</span>
|
||||
<span className="ai-page-conv-date">{relativeDate(c.updatedAt)}</span>
|
||||
</button>
|
||||
<button
|
||||
className="ai-page-conv-delete"
|
||||
onClick={() => setConfirmDeleteId(c.id)}
|
||||
aria-label="Supprimer la conversation"
|
||||
title="Supprimer la conversation"
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="ai-page-main">
|
||||
<div className="ai-page-header">
|
||||
<IconSparkle />
|
||||
<span className="ai-page-header-title">{currentTitle}</span>
|
||||
</div>
|
||||
|
||||
{loadingHistory ? (
|
||||
<div className="ai-page-conv-empty" style={{ flex: 1 }}>Chargement de la conversation…</div>
|
||||
) : (
|
||||
<MessageThread
|
||||
messages={messages}
|
||||
emptyHint="Posez une question sur votre portefeuille — ex. « Quels sont mes intérêts nets perçus cette année ? »"
|
||||
sending={sending}
|
||||
onRegenerate={regenerateMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Composer value={input} onChange={setInput} onSend={handleSend} disabled={sending} autoFocusKey={conversationId} />
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmDeleteId != null}
|
||||
title="Supprimer la conversation"
|
||||
message="Supprimer cette conversation et tous ses messages ? Cette action est irréversible."
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,33 @@ const IcoPlay = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="no
|
||||
const IcoTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
|
||||
const IcoDots = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="19" r="1" fill="currentColor"/></svg>;
|
||||
const IcoSearch = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>;
|
||||
const IcoSparkle = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>;
|
||||
|
||||
// Interrupteur compact — accès individuel à l'Assistant IA (même style visuel
|
||||
// que le Toggle de AdminIA.jsx, dupliqué ici car non partagé entre les deux
|
||||
// pages). Un clic déclenche toggleAiAccess(u), qui passe par une confirmation.
|
||||
function AiToggle({ checked, onClick, disabled, title }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', width: 36, height: 20, borderRadius: 10,
|
||||
background: checked ? 'var(--primary)' : 'var(--border)', position: 'relative',
|
||||
border: 'none', cursor: disabled ? 'default' : 'pointer', padding: 0, flexShrink: 0,
|
||||
transition: 'background 0.2s', opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: checked ? 18 : 2, width: 16, height: 16,
|
||||
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuBtn({ onClick, icon, label, danger = false }) {
|
||||
return (
|
||||
@@ -82,6 +109,7 @@ function toRows(users) {
|
||||
ID: u.id, Nom: u.display_name || '', Email: u.email,
|
||||
Statut: STATUS_FR[computedStatus(u)] || '',
|
||||
Rôle: u.role === 'admin' ? 'Admin' : 'Utilisateur',
|
||||
'Assistant IA': u.ai_enabled ? 'Activé' : 'Désactivé',
|
||||
'2FA': u.totp_enabled ? 'Oui' : 'Non',
|
||||
'Inscrit le': u.created_at ? u.created_at.replace('T', ' ').slice(0, 16) : '',
|
||||
Plateformes: u.nb_plateformes ?? 0,
|
||||
@@ -100,7 +128,7 @@ function dlBlob(content, filename, type) {
|
||||
}
|
||||
|
||||
function toCSV(users) {
|
||||
const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', '2FA', 'Inscrit le', 'Plateformes', 'Investissements', 'Documents', 'Taille documents'];
|
||||
const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', 'Assistant IA', '2FA', 'Inscrit le', 'Plateformes', 'Investissements', 'Documents', 'Taille documents'];
|
||||
const rows = toRows(users).map(r => Object.values(r).map(v => `"${String(v).replace(/"/g,'""')}"`).join(','));
|
||||
return '' + [headers.map(h => `"${h}"`).join(','), ...rows].join('\n');
|
||||
}
|
||||
@@ -455,6 +483,15 @@ export default function UsersSection({ currentUserId }) {
|
||||
confirm(`${labels[status]} le compte`, `${labels[status]} le compte de ${u.display_name || u.email} ?`,
|
||||
async () => { await api.patch(`/admin/users/${u.id}/status`, { status }); load(); }, labels[status]);
|
||||
};
|
||||
const toggleAiAccess = u => {
|
||||
const enable = !u.ai_enabled;
|
||||
confirm(
|
||||
enable ? "Activer l'accès IA" : "Désactiver l'accès IA",
|
||||
`${enable ? 'Activer' : 'Désactiver'} l'accès à l'Assistant IA pour ${u.display_name || u.email} ?`,
|
||||
async () => { await api.patch(`/admin/users/${u.id}/ai-access`, { aiEnabled: enable }); load(); },
|
||||
'Confirmer',
|
||||
);
|
||||
};
|
||||
const deleteUser = u => {
|
||||
const identity = u.display_name ? `${u.display_name} (${u.email})` : u.email;
|
||||
confirm(
|
||||
@@ -615,6 +652,7 @@ export default function UsersSection({ currentUserId }) {
|
||||
<th style={{ paddingLeft: 20 }}>Utilisateur</th>
|
||||
<th>Statut</th>
|
||||
<th>Rôle</th>
|
||||
<th>Assistant IA</th>
|
||||
<th>2FA</th>
|
||||
<th>Inscrit le</th>
|
||||
<th style={{ textAlign: 'right' }}>Nb de plateformes</th>
|
||||
@@ -650,6 +688,13 @@ export default function UsersSection({ currentUserId }) {
|
||||
</td>
|
||||
<td><UserStatusBadge status={u.status || 'active'} emailVerified={u.email_verified} /></td>
|
||||
<td><Badge role={u.role} /></td>
|
||||
<td>
|
||||
<AiToggle
|
||||
checked={!!u.ai_enabled}
|
||||
onClick={() => toggleAiAccess(u)}
|
||||
title={u.ai_enabled ? "Désactiver l'accès à l'Assistant IA" : "Activer l'accès à l'Assistant IA"}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{u.totp_enabled
|
||||
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}>🔐 Actif</span>
|
||||
@@ -691,6 +736,7 @@ export default function UsersSection({ currentUserId }) {
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 200,
|
||||
}}>
|
||||
<MenuBtn icon={<IcoEdit />} label={openMenu.user.role === 'admin' ? 'Rétrograder en utilisateur' : 'Promouvoir en admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
|
||||
<MenuBtn icon={<IcoSparkle />} label={openMenu.user.ai_enabled ? "Désactiver l'accès IA" : "Activer l'accès IA"} onClick={() => { setOpenMenu(null); toggleAiAccess(openMenu.user); }} />
|
||||
{!openMenu.user.email_verified && <>
|
||||
<MenuBtn icon={<IcoMail />} label="Renvoyer l'invitation" onClick={() => { setOpenMenu(null); resendInvitation(openMenu.user); }} />
|
||||
<MenuBtn icon={<IcoCheck />} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
|
||||
|
||||
@@ -3329,3 +3329,334 @@ tr:hover td { background: var(--surface-2); }
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Assistant IA embarqué (widget flottant) ──────────────────────────────
|
||||
z-index 10000 : légèrement au-dessus du max existant (9999, .notif-dropdown)
|
||||
pour rester au premier plan si les deux sont ouverts en même temps. */
|
||||
|
||||
/* Couleur dédiée (indépendante du thème clair/sombre) : celle de l'icône
|
||||
Claude plutôt que le bleu --primary de l'application, pour identifier
|
||||
visuellement l'assistant IA comme un élément à part. */
|
||||
.ai-fab {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10000;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #d97757;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px rgba(217, 119, 87, .45);
|
||||
transition: background .15s, transform .15s;
|
||||
}
|
||||
.ai-fab svg { flex-shrink: 0; }
|
||||
.ai-fab:hover { background: #c8663f; transform: scale(1.05); }
|
||||
|
||||
.ai-panel {
|
||||
position: fixed;
|
||||
bottom: 92px;
|
||||
right: 24px;
|
||||
z-index: 10000;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: 560px;
|
||||
max-height: calc(100vh - 130px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, .18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
color: var(--primary);
|
||||
}
|
||||
.ai-panel-title { font-weight: 600; font-size: var(--fs-md); color: var(--text); flex: 1; }
|
||||
.ai-panel-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; padding: 0; border-radius: 50%; flex-shrink: 0;
|
||||
border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted);
|
||||
cursor: pointer; transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
/* La règle générique `button { padding: 7px 14px }` mangeait, sur un bouton
|
||||
de 28px de large, toute la largeur disponible (14px + 14px de padding),
|
||||
écrasant l'icône SVG à 0px de large — invisible bien que présente dans le
|
||||
DOM. D'où le `padding: 0` explicite ci-dessus. */
|
||||
.ai-panel-close svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
.ai-panel-close:hover { background: var(--border); color: var(--text); }
|
||||
.ai-panel-close:disabled { opacity: .45; cursor: default; }
|
||||
|
||||
.ai-thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.ai-empty { color: var(--text-muted); font-size: var(--fs-sm); text-align: center; margin-top: 24px; line-height: 1.5; }
|
||||
|
||||
.ai-message { display: flex; gap: 8px; align-items: flex-start; }
|
||||
.ai-message.from-user { flex-direction: row-reverse; }
|
||||
|
||||
.ai-message-avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--primary); color: #fff;
|
||||
font-size: 10px; font-weight: 700;
|
||||
}
|
||||
.ai-message.from-user .ai-message-avatar { background: #64748b; }
|
||||
|
||||
.ai-message-content {
|
||||
max-width: 82%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 11px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-message.from-ai .ai-message-content {
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
}
|
||||
.ai-message.from-user .ai-message-content { background: var(--surface-2); }
|
||||
|
||||
.ai-tool-badges { display: flex; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.ai-tool-badge {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: 20px; padding: 2px 8px; margin: 2px 4px 2px 0;
|
||||
}
|
||||
.ai-tool-badge.calling { color: var(--primary); border-color: color-mix(in srgb, var(--primary) 35%, transparent); }
|
||||
.ai-tool-badge.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); }
|
||||
|
||||
.ai-message-error { color: var(--danger); font-size: var(--fs-sm); margin-top: 6px; }
|
||||
|
||||
.ai-typing { display: inline-flex; gap: 3px; align-items: center; height: 14px; }
|
||||
.ai-typing span {
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: ai-typing-bounce 1.1s infinite ease-in-out;
|
||||
}
|
||||
.ai-typing span:nth-child(2) { animation-delay: .15s; }
|
||||
.ai-typing span:nth-child(3) { animation-delay: .3s; }
|
||||
@keyframes ai-typing-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: .5; }
|
||||
30% { transform: translateY(-3px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Curseur clignotant en fin de texte pendant qu'une réponse est encore en
|
||||
train d'arriver (une fois que le texte a commencé — avant ça, ce sont les
|
||||
points animés ci-dessus qui indiquent l'activité). */
|
||||
.ai-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 13px;
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
background: currentColor;
|
||||
animation: ai-cursor-blink 1s step-start infinite;
|
||||
}
|
||||
@keyframes ai-cursor-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Ligne d'horodatage + actions (copier / rejouer) sous chaque message. */
|
||||
.ai-message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
opacity: .7;
|
||||
}
|
||||
.ai-message-time {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.ai-message-action {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; padding: 0; border-radius: 5px; flex-shrink: 0;
|
||||
border: none; background: transparent; color: var(--text-muted);
|
||||
cursor: pointer; transition: background .15s, color .15s;
|
||||
}
|
||||
/* Même précaution que .ai-panel-close (voir plus haut) : padding: 0 explicite
|
||||
+ taille du svg fixée, pour ne pas que la règle générique `button { padding
|
||||
: 7px 14px }` écrase l'icône sur ce petit bouton. */
|
||||
.ai-message-action svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
.ai-message-action:hover { background: var(--surface-2); color: var(--text); }
|
||||
.ai-message-action:disabled { opacity: .4; cursor: default; }
|
||||
|
||||
.ai-input-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
max-height: 100px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: var(--fs-sm);
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
.ai-textarea:focus { outline: none; border-color: var(--primary); }
|
||||
.ai-textarea:disabled { opacity: .6; }
|
||||
|
||||
.ai-send-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; padding: 0; border-radius: 8px;
|
||||
border: none; background: var(--primary); color: #fff;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: background .15s, opacity .15s;
|
||||
}
|
||||
.ai-send-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
.ai-send-btn:hover:not(:disabled) { background: var(--primary-hover); }
|
||||
.ai-send-btn:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ai-panel { right: 16px; bottom: 84px; }
|
||||
.ai-fab { right: 16px; bottom: 16px; }
|
||||
}
|
||||
|
||||
/* ── Assistant IA — page dédiée (/assistant-ia) ────────────────────────────
|
||||
Même chat que la bulle flottante (classes .ai-thread/.ai-message/... déjà
|
||||
définies plus haut, réutilisées telles quelles), avec une colonne de
|
||||
gauche listant les conversations passées. La colonne et le panneau de
|
||||
chat restent visibles pendant le défilement (position: sticky, comme
|
||||
.account-sidebar plus haut dans ce fichier) plutôt que de contraindre
|
||||
toute la mise en page de .main — top: 60px / calc(100vh - 100px) sont
|
||||
les mêmes valeurs déjà utilisées par .account-sidebar. */
|
||||
|
||||
.ai-page {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.ai-page-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.ai-page-new-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--surface-2); color: var(--text);
|
||||
font-size: var(--fs-sm); font-weight: 600; cursor: pointer; flex-shrink: 0;
|
||||
transition: background .15s;
|
||||
}
|
||||
.ai-page-new-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
.ai-page-new-btn:hover:not(:disabled) { background: var(--border); }
|
||||
.ai-page-new-btn:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.ai-page-conv-list {
|
||||
flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
|
||||
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.ai-page-conv-empty { padding: 16px 8px; color: var(--text-muted); font-size: var(--fs-sm); text-align: center; }
|
||||
|
||||
.ai-page-conv-item { display: flex; align-items: center; border-radius: 8px; }
|
||||
.ai-page-conv-item:hover { background: var(--surface-2); }
|
||||
.ai-page-conv-item.active { background: color-mix(in srgb, var(--primary) 10%, var(--surface-2)); }
|
||||
|
||||
.ai-page-conv-select {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 9px 10px; border-radius: 8px;
|
||||
background: none; border: none; text-align: left; cursor: pointer;
|
||||
}
|
||||
.ai-page-conv-title {
|
||||
font-size: var(--fs-sm); color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ai-page-conv-item.active .ai-page-conv-title { color: var(--primary); font-weight: 600; }
|
||||
.ai-page-conv-date { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.ai-page-conv-delete {
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; padding: 0; margin-right: 4px; border-radius: 6px;
|
||||
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
opacity: 0; transition: opacity .12s, background .12s, color .12s;
|
||||
}
|
||||
.ai-page-conv-item:hover .ai-page-conv-delete { opacity: 1; }
|
||||
.ai-page-conv-delete svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
.ai-page-conv-delete:hover { background: var(--surface); color: var(--danger); }
|
||||
|
||||
.ai-page-main {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 60px;
|
||||
height: calc(100vh - 100px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-page-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-page-header svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
.ai-page-header-title {
|
||||
font-weight: 700; font-size: var(--fs-md); color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Réutilisation des classes du widget flottant, juste recentrées avec une
|
||||
largeur de lecture confortable sur un grand écran. */
|
||||
.ai-page-main .ai-thread { flex: 1; max-width: 760px; margin: 0 auto; width: 100%; padding: 20px; }
|
||||
.ai-page-main .ai-input-form { max-width: 760px; margin: 0 auto; width: 100%; }
|
||||
|
||||
.ai-page-disabled {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px; padding: 80px 20px; color: var(--text-muted); text-align: center; flex: 1;
|
||||
}
|
||||
.ai-page-disabled svg { width: 32px; height: 32px; color: var(--primary); flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ai-page { flex-direction: column; padding: 16px; }
|
||||
.ai-page-sidebar { width: 100%; position: static; height: auto; max-height: 220px; }
|
||||
.ai-page-main { position: static; height: calc(100vh - 280px); min-height: 420px; }
|
||||
}
|
||||
Reference in new issue
Block a user