IA Assistant version initiale
This commit is contained in:
1 parent
3e8508d48b
commit
662003fdd3
29 files changed
+4253
-10
No files matched your search
@@ -73,7 +73,7 @@ const router = Router();
|
||||
router.get('/users', (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT
|
||||
id, email, display_name, role, email_verified, totp_enabled, status, created_at,
|
||||
id, email, display_name, role, email_verified, totp_enabled, status, created_at, ai_enabled,
|
||||
(SELECT COUNT(*) FROM plateformes p WHERE p.user_id = users.id) AS nb_plateformes,
|
||||
(SELECT COUNT(*) FROM investissements i
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
@@ -156,6 +156,23 @@ router.patch('/users/:id/status', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Active / désactive l'accès à l'Assistant IA pour un utilisateur (décision admin) */
|
||||
const PatchAiAccessSchema = z.object({
|
||||
aiEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
router.patch('/users/:id/ai-access', (req, res, next) => {
|
||||
try {
|
||||
const { aiEnabled } = PatchAiAccessSchema.parse(req.body);
|
||||
const targetId = Number(req.params.id);
|
||||
const r = db.prepare("UPDATE users SET ai_enabled=?, updated_at=datetime('now') WHERE id=?")
|
||||
.run(aiEnabled ? 1 : 0, targetId);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
|
||||
audit(req, { action: 'ai_access_changed', category: 'ai', actorId: req.user.id, targetUserId: targetId, details: { ai_enabled: aiEnabled } });
|
||||
res.json({ id: targetId, aiEnabled });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Modifie le rôle d'un utilisateur */
|
||||
const PatchRoleSchema = z.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* routes/ai.js — Assistant IA embarqué.
|
||||
*
|
||||
* GET /api/ai/status — l'utilisateur courant a-t-il accès au chat ?
|
||||
* GET /api/ai/conversations — liste des conversations (les plus récentes en premier)
|
||||
* GET /api/ai/conversations/:id/messages — historique d'une conversation
|
||||
* DELETE /api/ai/conversations/:id — supprime une conversation (et ses messages)
|
||||
* POST /api/ai/chat — envoie un message et reçoit la réponse en streaming (SSE)
|
||||
*
|
||||
* Deux adaptateurs fournisseur, sélectionnés selon provider.type :
|
||||
* "anthropic" (ai/providers/anthropic.js, SDK officiel) et
|
||||
* "openai_compatible" (ai/providers/openaiCompatible.js, fetch + SSE manuel
|
||||
* — OpenAI, Mistral, Groq, un LLM local, ou OpenRouter via provider.base_url).
|
||||
*
|
||||
* Conversations : plusieurs fils nommés par couple user/investisseur (voir
|
||||
* ai/conversationHistory.js), partagés entre le widget flottant et la page
|
||||
* dédiée /assistant-ia via leur id. POST /chat accepte un conversationId
|
||||
* optionnel dans le corps de la requête — omis (ou introuvable), une
|
||||
* nouvelle conversation est créée à la volée, titrée à partir du message.
|
||||
* Le premier événement SSE envoyé est toujours `conversation` ({id, titre})
|
||||
* pour que le client apprenne l'id (nouveau ou confirmé) avant tout texte.
|
||||
*
|
||||
* Historique : le client n'envoie que le dernier message utilisateur (body :
|
||||
* { conversationId, message }) — l'historique complet est reconstitué
|
||||
* côté serveur depuis ai_messages puis complété par ce nouveau message
|
||||
* avant l'appel au modèle. Les deux messages (utilisateur puis assistant)
|
||||
* sont persistés : l'utilisateur dès réception (pour ne pas le perdre même
|
||||
* si le modèle échoue ensuite), l'assistant seulement si le tour se
|
||||
* termine par un événement 'done' (une réponse interrompue par une erreur
|
||||
* n'est pas enregistrée en tant que telle — l'utilisateur peut simplement
|
||||
* reposer sa question).
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||
import { requireAiAccess, getAiAccessStatus, checkAiQuota } from '../middleware/aiAccess.js';
|
||||
import { getDefaultActiveProvider } from '../ai/providerConfig.js';
|
||||
import { decryptSecret } from '../ai/crypto.js';
|
||||
import { createEphemeralInternalApiKey, revokeInternalApiKey } from '../ai/internalApiKey.js';
|
||||
import { createAiToolsClient } from '../ai/mcpBridge.js';
|
||||
import {
|
||||
listConversations, getConversation, createConversation, deleteConversation,
|
||||
getHistory, appendMessage,
|
||||
} from '../ai/conversationHistory.js';
|
||||
import { streamChat as streamChatAnthropic } from '../ai/providers/anthropic.js';
|
||||
import { streamChat as streamChatOpenAiCompatible } from '../ai/providers/openaiCompatible.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireInvestisseur);
|
||||
|
||||
const SELF_API_BASE = `http://localhost:${process.env.PORT || 4000}/api/v1`;
|
||||
|
||||
/** Même logique que makeApiGet() dans mcp-server/server.js (clé API en
|
||||
* en-tête X-API-Key vers l'API v1) — dupliquée ici volontairement plutôt
|
||||
* que partagée entre les deux process (backend et mcp-server tournent
|
||||
* séparément) ; toute divergence de comportement entre les deux serait un
|
||||
* bug à corriger dans les deux endroits. */
|
||||
function makeApiGet(apiKey) {
|
||||
return async function apiGet(path, params) {
|
||||
const url = new URL(SELF_API_BASE + path);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: { 'X-API-Key': apiKey, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Impossible de joindre l'API interne : ${e.message}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
||||
if (!res.ok) {
|
||||
const msg = (body && body.error) || res.statusText || 'Requête échouée';
|
||||
throw new Error(`Erreur API (${res.status}) : ${msg}`);
|
||||
}
|
||||
return body;
|
||||
};
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
"Tu es l'assistant IA embarqué de l'application Crowdlending Tracker. " +
|
||||
"Tu réponds aux questions de l'utilisateur sur son portefeuille de " +
|
||||
"crowdlending en t'appuyant exclusivement sur les outils mis à ta " +
|
||||
"disposition (lecture seule — tu ne peux rien modifier dans l'application). " +
|
||||
"Sois précis, cite les montants exacts renvoyés par les outils, et indique " +
|
||||
"clairement quand une information n'est pas disponible plutôt que de " +
|
||||
"l'inventer. Les montants sont en euros sauf indication contraire.";
|
||||
|
||||
/** Dérive un titre de conversation à partir du premier message (aplati sur
|
||||
* une ligne, tronqué). Jamais renommé automatiquement ensuite. */
|
||||
function titleFromMessage(content) {
|
||||
const flat = content.replace(/\s+/g, ' ').trim();
|
||||
if (!flat) return 'Nouvelle conversation';
|
||||
return flat.length > 60 ? `${flat.slice(0, 57)}…` : flat;
|
||||
}
|
||||
|
||||
/** GET /api/ai/status — volontairement SANS requireAiAccess : doit répondre
|
||||
* même pour un utilisateur non autorisé, pour que le frontend sache
|
||||
* masquer le point d'entrée du chat plutôt que de tenter un appel refusé. */
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ enabled: getAiAccessStatus(req.user.id).enabled });
|
||||
});
|
||||
|
||||
/** GET /api/ai/conversations — liste des conversations du fil user+investisseur
|
||||
* courant, les plus récemment actives en premier (widget + page dédiée). */
|
||||
router.get('/conversations', requireAiAccess, (req, res) => {
|
||||
res.json({ conversations: listConversations(req.user.id, req.investisseur.id) });
|
||||
});
|
||||
|
||||
/** GET /api/ai/conversations/:id/messages — historique d'une conversation
|
||||
* précise, pour que le widget ou la page dédiée puissent la recharger
|
||||
* (après un refresh de page, ou en la sélectionnant dans la liste). */
|
||||
router.get('/conversations/:id/messages', requireAiAccess, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const conversation = getConversation(req.user.id, req.investisseur.id, id);
|
||||
if (!conversation) throw new HttpError(404, 'Conversation introuvable.');
|
||||
res.json({ conversation, messages: getHistory(id, 200) });
|
||||
});
|
||||
|
||||
/** DELETE /api/ai/conversations/:id — supprime définitivement une
|
||||
* conversation et ses messages. */
|
||||
router.delete('/conversations/:id', requireAiAccess, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const ok = deleteConversation(req.user.id, req.investisseur.id, id);
|
||||
if (!ok) throw new HttpError(404, 'Conversation introuvable.');
|
||||
res.json({ deleted: true });
|
||||
});
|
||||
|
||||
/** POST /api/ai/chat — SSE. Body : { conversationId?: number, message: string }.
|
||||
* conversationId omis (ou introuvable) → nouvelle conversation créée à la volée. */
|
||||
router.post('/chat', requireAiAccess, async (req, res) => {
|
||||
const userContent = typeof req.body?.message === 'string' ? req.body.message.trim() : '';
|
||||
if (!userContent) {
|
||||
return res.status(400).json({ error: 'message requis (chaîne non vide)' });
|
||||
}
|
||||
|
||||
const quota = checkAiQuota(req.user.id);
|
||||
if (!quota.allowed) {
|
||||
return res.status(429).json({ error: `Quota quotidien atteint (${quota.limit} requêtes/jour).` });
|
||||
}
|
||||
|
||||
const provider = getDefaultActiveProvider();
|
||||
if (!provider) {
|
||||
return res.status(503).json({ error: "Aucun fournisseur IA actif n'est configuré." });
|
||||
}
|
||||
if (provider.type !== 'anthropic' && provider.type !== 'openai_compatible') {
|
||||
return res.status(501).json({ error: `Adaptateur "${provider.type}" non supporté.` });
|
||||
}
|
||||
if (provider.type === 'openai_compatible' && !provider.base_url) {
|
||||
return res.status(500).json({ error: "Fournisseur openai_compatible mal configuré : URL de base manquante." });
|
||||
}
|
||||
|
||||
let apiKey;
|
||||
try {
|
||||
apiKey = decryptSecret(provider.api_key_chiffree);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: `Impossible de déchiffrer la clé du fournisseur : ${e.message}` });
|
||||
}
|
||||
|
||||
// Résout la conversation : existante et possédée par l'utilisateur, ou
|
||||
// nouvelle (créée seulement maintenant qu'on sait que la requête est
|
||||
// valide — pas de conversation vide créée sur un échec de quota/config).
|
||||
const requestedId = req.body?.conversationId != null ? Number(req.body.conversationId) : null;
|
||||
let conversation = requestedId ? getConversation(req.user.id, req.investisseur.id, requestedId) : null;
|
||||
if (requestedId && !conversation) {
|
||||
return res.status(404).json({ error: 'Conversation introuvable.' });
|
||||
}
|
||||
if (!conversation) {
|
||||
conversation = createConversation(req.user.id, req.investisseur.id, titleFromMessage(userContent));
|
||||
}
|
||||
|
||||
// Historique persisté + nouveau message, pour l'appel au modèle.
|
||||
const priorHistory = getHistory(conversation.id, 40);
|
||||
const messages = [...priorHistory.map((m) => ({ role: m.role, content: m.content })), { role: 'user', content: userContent }];
|
||||
appendMessage(conversation.id, req.user.id, req.investisseur.id, 'user', userContent);
|
||||
|
||||
const internalKey = createEphemeralInternalApiKey(req.user.id, req.investisseur.id);
|
||||
let mcpClient = null;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const send = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
// Toujours le premier événement : le client apprend l'id de conversation
|
||||
// (nouveau ou confirmé) avant tout texte, pour synchroniser son URL/liste.
|
||||
send('conversation', { type: 'conversation', id: conversation.id, titre: conversation.titre });
|
||||
|
||||
let closed = false;
|
||||
// res.on('close'), pas req.on('close') : req ('close') peut se declencher des
|
||||
// que le corps de la requete POST a fini d'etre lu (faux positif frequent avec
|
||||
// Express/Node), bien avant toute vraie deconnexion du client. res ('close')
|
||||
// reflete l'etat reel de la connexion de reponse (SSE en cours).
|
||||
res.on('close', () => { closed = true; });
|
||||
|
||||
let assistantContent = '';
|
||||
const toolsUsedLog = [];
|
||||
let usageLog = null;
|
||||
let errorLog = null;
|
||||
let completed = false;
|
||||
|
||||
try {
|
||||
mcpClient = await createAiToolsClient(makeApiGet(internalKey.key));
|
||||
|
||||
const streamFn = provider.type === 'anthropic' ? streamChatAnthropic : streamChatOpenAiCompatible;
|
||||
const streamOpts = provider.type === 'anthropic'
|
||||
? { apiKey, model: provider.modele, systemPrompt: SYSTEM_PROMPT, messages, mcpClient }
|
||||
: { apiKey, baseUrl: provider.base_url, model: provider.modele, systemPrompt: SYSTEM_PROMPT, messages, mcpClient };
|
||||
|
||||
for await (const event of streamFn(streamOpts)) {
|
||||
if (closed) break;
|
||||
if (event.type === 'text_delta') assistantContent += event.text;
|
||||
if (event.type === 'tool_call') toolsUsedLog.push(event.name);
|
||||
if (event.type === 'done') { usageLog = event.usage; completed = true; }
|
||||
if (event.type === 'error') errorLog = event.message;
|
||||
send(event.type, event);
|
||||
}
|
||||
} catch (e) {
|
||||
errorLog = e.message;
|
||||
if (!closed) send('error', { type: 'error', message: e.message });
|
||||
} finally {
|
||||
revokeInternalApiKey(internalKey.id);
|
||||
if (mcpClient) {
|
||||
try { await mcpClient.close(); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
if (completed && assistantContent) {
|
||||
appendMessage(conversation.id, req.user.id, req.investisseur.id, 'assistant', assistantContent, toolsUsedLog);
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO ai_usage_log
|
||||
(user_id, investisseur_id, provider_id, modele, tokens_entree, tokens_sortie, outils_appeles, erreur)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user.id,
|
||||
req.investisseur.id,
|
||||
provider.id,
|
||||
provider.modele,
|
||||
usageLog?.input_tokens ?? null,
|
||||
usageLog?.output_tokens ?? null,
|
||||
toolsUsedLog.length ? JSON.stringify(toolsUsedLog) : null,
|
||||
errorLog,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[ai/chat] échec du log d'usage (ignoré) :", e.message);
|
||||
}
|
||||
|
||||
if (!closed) res.end();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,7 +7,7 @@ const router = Router();
|
||||
|
||||
const KEY_PREFIX_LEN = 12; // ex: "clk_live_ab3" — assez pour identifier sans exposer le secret
|
||||
|
||||
function generateKey() {
|
||||
export function generateKey() {
|
||||
const secret = crypto.randomBytes(24).toString('hex'); // 48 caractères hex
|
||||
const full = `clk_live_${secret}`;
|
||||
const hash = crypto.createHash('sha256').update(full).digest('hex');
|
||||
@@ -21,7 +21,7 @@ router.get('/', (req, res) => {
|
||||
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
|
||||
FROM api_keys k
|
||||
JOIN investisseurs i ON i.id = k.investisseur_id
|
||||
WHERE k.user_id = ?
|
||||
WHERE k.user_id = ? AND k.internal = 0
|
||||
ORDER BY k.revoked_at IS NOT NULL, k.created_at DESC
|
||||
`).all(req.user.id);
|
||||
res.json(rows);
|
||||
|
||||
Reference in new issue
Block a user