IA Assistant version initiale
This commit is contained in:
1 parent
3e8508d48b
commit
662003fdd3
29 files changed
+4253
-10
No files matched your search
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* /api/admin/ai — Configuration de l'Assistant IA embarqué (back-office).
|
||||
* Protégé par requireAuth + requireAdmin (appliqués dans server.js).
|
||||
*
|
||||
* GET /providers — liste des fournisseurs (sans la clé API en clair)
|
||||
* POST /providers — crée un fournisseur (clé API obligatoire)
|
||||
* PUT /providers/:id — modifie un fournisseur (clé API optionnelle —
|
||||
* voir le pattern « write-only » de smtp.js :
|
||||
* omise = on conserve la clé déjà enregistrée)
|
||||
* DELETE /providers/:id — supprime un fournisseur
|
||||
* POST /providers/:id/test — vérifie la connexion (clé/modèle/base_url)
|
||||
* avec un appel minimal, sans passer par le
|
||||
* chat ni par le pont MCP — demandé par
|
||||
* Olivier pour valider une config sans
|
||||
* avoir à ouvrir le tchat utilisateur
|
||||
* GET /config — configuration globale (actif, quota)
|
||||
* PATCH /config — modifie la configuration globale
|
||||
*
|
||||
* Distinct de routes/ai.js (chat utilisateur, protégé par requireAiAccess) :
|
||||
* ce routeur ne fait que gérer la configuration ; seul /providers/:id/test
|
||||
* appelle réellement le fournisseur IA, et seulement pour un ping minimal.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { encryptSecret, decryptSecret } from '../ai/crypto.js';
|
||||
import { audit } from '../utils/audit.js';
|
||||
import { testConnection as testAnthropic } from '../ai/providers/anthropic.js';
|
||||
import { testConnection as testOpenAiCompatible } from '../ai/providers/openaiCompatible.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function serializeProvider(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
nom: row.nom,
|
||||
type: row.type,
|
||||
baseUrl: row.base_url || '',
|
||||
modele: row.modele,
|
||||
hasApiKey: !!row.api_key_chiffree,
|
||||
actif: !!row.actif,
|
||||
isDefault: !!row.is_default,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureConfigRow() {
|
||||
db.prepare('INSERT OR IGNORE INTO ai_config (id, actif) VALUES (1, 0)').run();
|
||||
}
|
||||
|
||||
// ── GET /providers ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/providers', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM ai_providers ORDER BY is_default DESC, id ASC').all();
|
||||
res.json(rows.map(serializeProvider));
|
||||
});
|
||||
|
||||
// ── POST /providers ──────────────────────────────────────────────────────
|
||||
|
||||
const CreateProviderSchema = z.object({
|
||||
nom: z.string().min(1).max(100),
|
||||
type: z.enum(['anthropic', 'openai_compatible']),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
modele: z.string().min(1).max(200),
|
||||
apiKey: z.string().min(1),
|
||||
actif: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
router.post('/providers', (req, res, next) => {
|
||||
try {
|
||||
const body = CreateProviderSchema.parse(req.body);
|
||||
if (body.type === 'openai_compatible' && !body.baseUrl?.trim()) {
|
||||
throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1).");
|
||||
}
|
||||
|
||||
const apiKeyChiffree = encryptSecret(body.apiKey);
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (body.isDefault) {
|
||||
db.prepare('UPDATE ai_providers SET is_default = 0').run();
|
||||
}
|
||||
const r = db.prepare(`
|
||||
INSERT INTO ai_providers (nom, type, base_url, modele, api_key_chiffree, actif, is_default, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`).run(
|
||||
body.nom.trim(),
|
||||
body.type,
|
||||
body.baseUrl?.trim() || null,
|
||||
body.modele.trim(),
|
||||
apiKeyChiffree,
|
||||
body.actif === false ? 0 : 1,
|
||||
body.isDefault ? 1 : 0,
|
||||
);
|
||||
return r.lastInsertRowid;
|
||||
});
|
||||
const id = tx();
|
||||
|
||||
audit(req, { action: 'ai_provider_created', category: 'ai', actorId: req.user.id, details: { id, nom: body.nom, type: body.type } });
|
||||
res.status(201).json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id)));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── PUT /providers/:id ───────────────────────────────────────────────────
|
||||
|
||||
const UpdateProviderSchema = z.object({
|
||||
nom: z.string().min(1).max(100).optional(),
|
||||
type: z.enum(['anthropic', 'openai_compatible']).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
modele: z.string().min(1).max(200).optional(),
|
||||
apiKey: z.string().min(1).optional(), // omis = clé conservée telle quelle
|
||||
actif: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
|
||||
router.put('/providers/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const row = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
|
||||
if (!row) throw new HttpError(404, 'Fournisseur introuvable');
|
||||
|
||||
const body = UpdateProviderSchema.parse(req.body);
|
||||
const type = body.type !== undefined ? body.type : row.type;
|
||||
const baseUrl = body.baseUrl !== undefined ? body.baseUrl.trim() : row.base_url;
|
||||
if (type === 'openai_compatible' && !baseUrl) {
|
||||
throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1).");
|
||||
}
|
||||
|
||||
const apiKeyChiffree = body.apiKey !== undefined ? encryptSecret(body.apiKey) : row.api_key_chiffree;
|
||||
|
||||
db.transaction(() => {
|
||||
if (body.isDefault) {
|
||||
db.prepare('UPDATE ai_providers SET is_default = 0 WHERE id != ?').run(id);
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE ai_providers SET
|
||||
nom = ?, type = ?, base_url = ?, modele = ?, api_key_chiffree = ?,
|
||||
actif = ?, is_default = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
body.nom !== undefined ? body.nom.trim() : row.nom,
|
||||
type,
|
||||
baseUrl || null,
|
||||
body.modele !== undefined ? body.modele.trim() : row.modele,
|
||||
apiKeyChiffree,
|
||||
body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif,
|
||||
body.isDefault !== undefined ? (body.isDefault ? 1 : 0) : row.is_default,
|
||||
id,
|
||||
);
|
||||
})();
|
||||
|
||||
audit(req, { action: 'ai_provider_updated', category: 'ai', actorId: req.user.id, details: { id, apiKeyChanged: body.apiKey !== undefined } });
|
||||
res.json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id)));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── DELETE /providers/:id ────────────────────────────────────────────────
|
||||
|
||||
router.delete('/providers/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const row = db.prepare('SELECT id, nom FROM ai_providers WHERE id = ?').get(id);
|
||||
if (!row) throw new HttpError(404, 'Fournisseur introuvable');
|
||||
|
||||
db.transaction(() => {
|
||||
// provider_defaut_id référence ai_providers sans PRAGMA foreign_keys=ON
|
||||
// garanti actif dans ce process : on nettoie explicitement plutôt que
|
||||
// de compter sur ON DELETE SET NULL.
|
||||
db.prepare('UPDATE ai_config SET provider_defaut_id = NULL WHERE provider_defaut_id = ?').run(id);
|
||||
db.prepare('UPDATE ai_usage_log SET provider_id = NULL WHERE provider_id = ?').run(id);
|
||||
db.prepare('DELETE FROM ai_providers WHERE id = ?').run(id);
|
||||
})();
|
||||
|
||||
audit(req, { action: 'ai_provider_deleted', category: 'ai', actorId: req.user.id, details: { id, nom: row.nom } });
|
||||
res.json({ deleted: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── POST /providers/:id/test ─────────────────────────────────────────────
|
||||
// Réponse toujours 200 : { ok:true, reponse, dureeMs } en cas de succès,
|
||||
// { ok:false, error } si le fournisseur a répondu une erreur ou n'a pas pu
|
||||
// être joint — seule une ligne introuvable ou une clé indéchiffrable est
|
||||
// une vraie erreur HTTP (le test lui-même a bien pu s'exécuter).
|
||||
|
||||
router.post('/providers/:id/test', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const provider = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
|
||||
if (!provider) throw new HttpError(404, 'Fournisseur introuvable');
|
||||
if (!provider.api_key_chiffree) {
|
||||
return res.json({ ok: false, error: "Aucune clé API enregistrée pour ce fournisseur." });
|
||||
}
|
||||
|
||||
let apiKey;
|
||||
try {
|
||||
apiKey = decryptSecret(provider.api_key_chiffree);
|
||||
} catch (e) {
|
||||
throw new HttpError(500, `Impossible de déchiffrer la clé API : ${e.message}`);
|
||||
}
|
||||
|
||||
if (provider.type === 'openai_compatible' && !provider.base_url) {
|
||||
return res.json({ ok: false, error: "URL de base manquante pour ce fournisseur." });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = provider.type === 'anthropic'
|
||||
? await testAnthropic({ apiKey, model: provider.modele })
|
||||
: await testOpenAiCompatible({ apiKey, baseUrl: provider.base_url, model: provider.modele });
|
||||
|
||||
audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: true, dureeMs: result.durationMs } });
|
||||
res.json({ ok: true, reponse: result.reply, dureeMs: result.durationMs });
|
||||
} catch (e) {
|
||||
audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: false, error: e.message } });
|
||||
res.json({ ok: false, error: e.message });
|
||||
}
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── GET /config ───────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/config', (_req, res) => {
|
||||
ensureConfigRow();
|
||||
const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get();
|
||||
res.json({
|
||||
actif: !!row.actif,
|
||||
quotaQuotidien: row.quota_quotidien ?? null,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /config ─────────────────────────────────────────────────────────
|
||||
|
||||
const PatchConfigSchema = z.object({
|
||||
actif: z.boolean().optional(),
|
||||
quotaQuotidien: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
router.patch('/config', (req, res, next) => {
|
||||
try {
|
||||
ensureConfigRow();
|
||||
const body = PatchConfigSchema.parse(req.body);
|
||||
const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get();
|
||||
|
||||
if (body.actif === true) {
|
||||
const activeProvider = db.prepare("SELECT id FROM ai_providers WHERE actif = 1 LIMIT 1").get();
|
||||
if (!activeProvider) {
|
||||
throw new HttpError(400, "Impossible d'activer l'Assistant IA : aucun fournisseur actif n'est configuré.");
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE ai_config SET actif = ?, quota_quotidien = ?, updated_at = datetime('now') WHERE id = 1
|
||||
`).run(
|
||||
body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif,
|
||||
body.quotaQuotidien !== undefined ? body.quotaQuotidien : row.quota_quotidien,
|
||||
);
|
||||
|
||||
audit(req, { action: 'ai_config_updated', category: 'ai', actorId: req.user.id, details: body });
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
||||
// ── GET /usage/summary ───────────────────────────────────────────────────
|
||||
// Vue d'ensemble de la consommation : total requetes/tokens sur la periode,
|
||||
// repartition par utilisateur, et serie quotidienne (pour un mini-graphe).
|
||||
// `days` : fenetre glissante en jours (defaut 30, borne a [1, 365]).
|
||||
|
||||
router.get('/usage/summary', (req, res) => {
|
||||
const days = Math.min(Math.max(Number(req.query.days) || 30, 1), 365);
|
||||
const since = `-${days} days`;
|
||||
|
||||
const totals = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(tokens_entree), 0) AS tokensIn,
|
||||
COALESCE(SUM(tokens_sortie), 0) AS tokensOut,
|
||||
SUM(CASE WHEN erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors
|
||||
FROM ai_usage_log
|
||||
WHERE created_at >= datetime('now', ?)
|
||||
`).get(since);
|
||||
|
||||
const byUser = db.prepare(`
|
||||
SELECT
|
||||
u.id AS userId, u.email, u.display_name AS displayName,
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(l.tokens_entree), 0) AS tokensIn,
|
||||
COALESCE(SUM(l.tokens_sortie), 0) AS tokensOut,
|
||||
SUM(CASE WHEN l.erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors,
|
||||
MAX(l.created_at) AS lastUsed
|
||||
FROM ai_usage_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
WHERE l.created_at >= datetime('now', ?)
|
||||
GROUP BY u.id
|
||||
ORDER BY requests DESC
|
||||
`).all(since);
|
||||
|
||||
const daily = db.prepare(`
|
||||
SELECT
|
||||
date(created_at) AS day,
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(tokens_entree), 0) AS tokensIn,
|
||||
COALESCE(SUM(tokens_sortie), 0) AS tokensOut
|
||||
FROM ai_usage_log
|
||||
WHERE created_at >= datetime('now', ?)
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY day ASC
|
||||
`).all(since);
|
||||
|
||||
res.json({
|
||||
days,
|
||||
totals: {
|
||||
requests: totals.requests,
|
||||
tokensIn: totals.tokensIn,
|
||||
tokensOut: totals.tokensOut,
|
||||
tokensTotal: totals.tokensIn + totals.tokensOut,
|
||||
errors: totals.errors,
|
||||
},
|
||||
byUser: byUser.map(r => ({
|
||||
userId: r.userId,
|
||||
email: r.email,
|
||||
displayName: r.displayName,
|
||||
requests: r.requests,
|
||||
tokensIn: r.tokensIn,
|
||||
tokensOut: r.tokensOut,
|
||||
tokensTotal: r.tokensIn + r.tokensOut,
|
||||
errors: r.errors,
|
||||
lastUsed: r.lastUsed,
|
||||
})),
|
||||
daily,
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /usage/log ───────────────────────────────────────────────────────
|
||||
// Journal detaille, pagine, requete par requete — pour l'audit fin.
|
||||
// Filtrable par utilisateur (userId) et par fenetre (days).
|
||||
|
||||
router.get('/usage/log', (req, res) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100);
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
||||
const userId = req.query.userId ? Number(req.query.userId) : null;
|
||||
const days = req.query.days ? Math.min(Math.max(Number(req.query.days), 1), 365) : null;
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (userId) { conditions.push('l.user_id = ?'); params.push(userId); }
|
||||
if (days) { conditions.push("l.created_at >= datetime('now', ?)"); params.push(`-${days} days`); }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const total = db.prepare(`SELECT COUNT(*) AS n FROM ai_usage_log l ${where}`).get(...params).n;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
l.id, l.created_at AS createdAt, l.modele,
|
||||
l.tokens_entree AS tokensIn, l.tokens_sortie AS tokensOut,
|
||||
l.outils_appeles AS outilsAppeles, l.erreur,
|
||||
u.id AS userId, u.email, u.display_name AS displayName,
|
||||
i.nom AS investisseurNom,
|
||||
p.nom AS providerNom
|
||||
FROM ai_usage_log l
|
||||
JOIN users u ON u.id = l.user_id
|
||||
LEFT JOIN investisseurs i ON i.id = l.investisseur_id
|
||||
LEFT JOIN ai_providers p ON p.id = l.provider_id
|
||||
${where}
|
||||
ORDER BY l.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset);
|
||||
|
||||
res.json({
|
||||
total,
|
||||
rows: rows.map(r => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
modele: r.modele,
|
||||
tokensIn: r.tokensIn,
|
||||
tokensOut: r.tokensOut,
|
||||
tokensTotal: (r.tokensIn || 0) + (r.tokensOut || 0),
|
||||
outilsAppeles: r.outilsAppeles ? JSON.parse(r.outilsAppeles) : [],
|
||||
erreur: r.erreur,
|
||||
userId: r.userId,
|
||||
email: r.email,
|
||||
displayName: r.displayName,
|
||||
investisseurNom: r.investisseurNom,
|
||||
providerNom: r.providerNom,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in new issue
Block a user