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

+18 -1
View File
@@ -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']),
+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;
+264
View File
@@ -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;
+2 -2
View File
@@ -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);