IA Assistant version initiale

This commit is contained in:
ocroguennec committed 2026-09-05 10:54:50 +02:00
1 parent 3e8508d48b
commit 662003fdd3
29 files changed
+4253 -10

No files matched your search

+394
View File
@@ -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;