650 lines
32 KiB
React
650 lines
32 KiB
React
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>
|
|
);
|
|
}
|