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,107 @@
|
||||
/**
|
||||
* conversationHistory.js — Persistance des conversations de l'assistant IA
|
||||
* (tables ai_conversations / ai_messages, migration dans db/index.js).
|
||||
*
|
||||
* Plusieurs conversations nommées par couple (user_id, investisseur_id) —
|
||||
* même granularité de scope que le reste de l'application (cf.
|
||||
* middleware/investisseurScope.js). Le client n'envoie que le dernier
|
||||
* message utilisateur et l'id de conversation (ou aucun id pour en créer
|
||||
* une nouvelle) ; c'est ce module qui reconstitue l'historique complet à
|
||||
* chaque appel au modèle (voir routes/ai.js).
|
||||
*/
|
||||
|
||||
import db from '../db/index.js';
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
function serializeConversation(row) {
|
||||
if (!row) return null;
|
||||
return { id: row.id, titre: row.titre, createdAt: row.created_at, updatedAt: row.updated_at };
|
||||
}
|
||||
|
||||
/** Liste des conversations d'un utilisateur/investisseur, les plus
|
||||
* récemment actives en premier. */
|
||||
export function listConversations(userId, investisseurId, limit = 100) {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, titre, created_at, updated_at
|
||||
FROM ai_conversations
|
||||
WHERE user_id = ? AND investisseur_id IS ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
`).all(userId, investisseurId, limit);
|
||||
return rows.map(serializeConversation);
|
||||
}
|
||||
|
||||
/** Une conversation précise, uniquement si elle appartient bien à ce
|
||||
* couple user/investisseur (sinon null — jamais d'accès croisé). */
|
||||
export function getConversation(userId, investisseurId, conversationId) {
|
||||
const row = db.prepare(`
|
||||
SELECT id, titre, created_at, updated_at
|
||||
FROM ai_conversations WHERE id = ? AND user_id = ? AND investisseur_id IS ?
|
||||
`).get(conversationId, userId, investisseurId);
|
||||
return serializeConversation(row);
|
||||
}
|
||||
|
||||
/** Crée une nouvelle conversation (titre dérivé du premier message côté
|
||||
* routes/ai.js) et la retourne. */
|
||||
export function createConversation(userId, investisseurId, titre) {
|
||||
const { lastInsertRowid } = db.prepare(`
|
||||
INSERT INTO ai_conversations (user_id, investisseur_id, titre) VALUES (?, ?, ?)
|
||||
`).run(userId, investisseurId, titre || 'Nouvelle conversation');
|
||||
return getConversation(userId, investisseurId, lastInsertRowid);
|
||||
}
|
||||
|
||||
/** Supprime une conversation (et ses messages, ON DELETE CASCADE) si elle
|
||||
* appartient à ce couple user/investisseur. Retourne true si une ligne a
|
||||
* bien été supprimée. */
|
||||
export function deleteConversation(userId, investisseurId, conversationId) {
|
||||
const info = db.prepare(`
|
||||
DELETE FROM ai_conversations WHERE id = ? AND user_id = ? AND investisseur_id IS ?
|
||||
`).run(conversationId, userId, investisseurId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/** Historique d'une conversation (ordre chronologique croissant), au
|
||||
* format {role, content, tools:string[]} — tools est toujours [] pour
|
||||
* role:'user'. Aucune vérification de propriété ici : à faire par
|
||||
* l'appelant via getConversation() avant d'appeler cette fonction. */
|
||||
export function getHistory(conversationId, limit = DEFAULT_LIMIT) {
|
||||
const rows = db.prepare(`
|
||||
SELECT role, content, outils_appeles, created_at
|
||||
FROM ai_messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`).all(conversationId, limit);
|
||||
|
||||
return rows.reverse().map((r) => ({
|
||||
role: r.role,
|
||||
content: r.content,
|
||||
tools: r.outils_appeles ? JSON.parse(r.outils_appeles) : [],
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Enregistre un message dans une conversation et met à jour sa date
|
||||
* d'activité (pour le tri par récence dans la liste). toolsUsed (tableau
|
||||
* de noms d'outil ou null) n'a de sens que pour role:'assistant'.
|
||||
* Best-effort : ne lève jamais (un échec de persistance ne doit pas
|
||||
* casser la conversation). */
|
||||
export function appendMessage(conversationId, userId, investisseurId, role, content, toolsUsed = null) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO ai_messages (conversation_id, user_id, investisseur_id, role, content, outils_appeles)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
conversationId,
|
||||
userId,
|
||||
investisseurId,
|
||||
role,
|
||||
content,
|
||||
toolsUsed && toolsUsed.length ? JSON.stringify(toolsUsed) : null,
|
||||
);
|
||||
db.prepare(`UPDATE ai_conversations SET updated_at = datetime('now') WHERE id = ?`).run(conversationId);
|
||||
} catch (e) {
|
||||
console.error('[ai/conversationHistory] échec de persistance (ignoré) :', e.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* crypto.js — Chiffrement au repos des clés API des fournisseurs IA
|
||||
* (ai_providers.api_key_chiffree).
|
||||
*
|
||||
* Contrairement aux clés API internes de l'application (api_keys.key_hash,
|
||||
* à sens unique — on ne fait que comparer un hash), une clé de fournisseur
|
||||
* IA (Anthropic, OpenAI, OpenRouter...) doit être renvoyée EN CLAIR au
|
||||
* fournisseur à chaque appel : elle est donc chiffrée de façon réversible
|
||||
* (AES-256-GCM), jamais hashée.
|
||||
*
|
||||
* AI_ENC_KEY (obligatoire dès qu'un fournisseur IA est configuré) : chaîne
|
||||
* hexadécimale de 64 caractères (32 octets), générée avec `openssl rand
|
||||
* -hex 32` — voir .env.example. Ne jamais commiter cette clé ; la faire
|
||||
* tourner invalide tous les fournisseurs déjà enregistrés (à re-saisir).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const ALGO = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // taille recommandée pour GCM
|
||||
|
||||
function getKey() {
|
||||
const hex = process.env.AI_ENC_KEY;
|
||||
if (!hex) {
|
||||
throw new Error(
|
||||
"AI_ENC_KEY manquant — générez une clé avec `openssl rand -hex 32` et ajoutez-la à .env avant de configurer un fournisseur IA."
|
||||
);
|
||||
}
|
||||
const key = Buffer.from(hex, 'hex');
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
'AI_ENC_KEY doit être une chaîne hexadécimale de 64 caractères (32 octets) — générée avec `openssl rand -hex 32`.'
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiffre une chaîne (typiquement une clé API de fournisseur IA) pour
|
||||
* stockage en base. Format stocké : "<iv_hex>:<authTag_hex>:<ciphertext_hex>".
|
||||
*/
|
||||
export function encryptSecret(plaintext) {
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${ciphertext.toString('hex')}`;
|
||||
}
|
||||
|
||||
/** Déchiffre une valeur produite par encryptSecret(). Lève si AI_ENC_KEY a
|
||||
* changé depuis le chiffrement (authTag invalide) ou si le format stocké
|
||||
* est corrompu. */
|
||||
export function decryptSecret(stored) {
|
||||
const key = getKey();
|
||||
const parts = String(stored).split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Valeur chiffrée malformée (attendu "iv:authTag:ciphertext")');
|
||||
}
|
||||
const [ivHex, authTagHex, ciphertextHex] = parts;
|
||||
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivHex, 'hex'));
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextHex, 'hex')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* internalApiKey.js — Clé API interne, éphémère, pour l'assistant IA embarqué.
|
||||
*
|
||||
* Donne au chat un accès à /api/v1 strictement identique à un client MCP
|
||||
* externe (même middleware requireApiKey, même scope investisseur), sans
|
||||
* dupliquer cette logique — voir mcpBridge.js pour l'usage.
|
||||
*
|
||||
* api_keys ne stocke normalement qu'un hash à sens unique (voir
|
||||
* routes/apiKeys.js : "la valeur en clair n'est renvoyée qu'une fois"), donc
|
||||
* impossible de relire une clé déjà créée pour la réutiliser. Plutôt que
|
||||
* d'introduire un second mécanisme de stockage réversible, on génère une
|
||||
* clé neuve à chaque requête POST /api/ai/chat (surcoût négligeable : un
|
||||
* INSERT SQLite), utilisée uniquement pour la durée de cette requête, puis
|
||||
* révoquée. Jamais affichée à l'utilisateur (colonne internal=1, filtrée de
|
||||
* GET /api/api-keys).
|
||||
*/
|
||||
|
||||
import db from '../db/index.js';
|
||||
import { generateKey } from '../routes/apiKeys.js';
|
||||
|
||||
/** Crée une clé API interne à usage unique pour (userId, investisseurId).
|
||||
* Retourne { id, key } — `key` est la valeur en clair à passer en
|
||||
* en-tête X-API-Key, jamais stockée ailleurs que dans ce retour. */
|
||||
export function createEphemeralInternalApiKey(userId, investisseurId) {
|
||||
const { full, hash, prefix } = generateKey();
|
||||
|
||||
const info = db.prepare(`
|
||||
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all, internal)
|
||||
VALUES (?, ?, 'Assistant IA (interne)', ?, ?, 'read', 0, 1)
|
||||
`).run(userId, investisseurId, prefix, hash);
|
||||
|
||||
return { id: info.lastInsertRowid, key: full };
|
||||
}
|
||||
|
||||
/** Révoque (supprime) une clé interne créée par createEphemeralInternalApiKey.
|
||||
* À appeler systématiquement en fin de requête (bloc finally), y compris en
|
||||
* cas d'erreur — best-effort, ne doit jamais faire échouer l'appelant. Le
|
||||
* filtre `internal = 1` est une double sécurité : cette fonction ne peut
|
||||
* jamais supprimer une vraie clé créée par l'utilisateur. */
|
||||
export function revokeInternalApiKey(id) {
|
||||
try {
|
||||
db.prepare('DELETE FROM api_keys WHERE id = ? AND internal = 1').run(id);
|
||||
} catch (e) {
|
||||
console.error('[ai/internalApiKey] échec de révocation (ignoré) :', e.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* mcpBridge.js — Pont MCP in-process vers les mêmes outils que le serveur
|
||||
* MCP externe (mcp-server/tools.js), réutilisés TELS QUELS (import direct
|
||||
* depuis mcp-server/, jamais modifiés) via un client MCP standard connecté
|
||||
* en mémoire (InMemoryTransport) — donc un comportement strictement
|
||||
* identique à ce que voit un client MCP externe (Claude Desktop...), sans
|
||||
* passer par le réseau, et sans aucun risque pour le serveur MCP externe en
|
||||
* production (ce fichier ne le touche pas).
|
||||
*
|
||||
* Chaque appel crée son propre couple client/serveur MCP, léger, sans état
|
||||
* partagé entre requêtes.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { registerDataTools } from '../../../mcp-server/tools.js';
|
||||
|
||||
/**
|
||||
* Crée un client MCP connecté aux 9 outils de lecture seule du portefeuille,
|
||||
* exécutés via `apiGet` (typiquement une clé API interne éphémère — voir
|
||||
* internalApiKey.js).
|
||||
*
|
||||
* @param {(path: string, params?: object) => Promise<any>} apiGet
|
||||
* @returns {Promise<Client>} Client MCP prêt pour listTools()/callTool().
|
||||
* Appeler client.close() en fin d'usage.
|
||||
*/
|
||||
export async function createAiToolsClient(apiGet) {
|
||||
const server = new McpServer({ name: 'crowdlending-ai-embarque', version: '0.1.0' });
|
||||
registerDataTools(server, { apiGet });
|
||||
|
||||
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'crowdlending-ai-embarque-client', version: '0.1.0' });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* providerConfig.js — Résolution du fournisseur IA actif par défaut.
|
||||
* Seul endroit qui définit cette règle (is_default=1 en priorité, sinon le
|
||||
* plus ancien actif) — réutilisé par le middleware d'accès (aiAccess.js) et
|
||||
* par la route de chat (routes/ai.js), pour ne jamais diverger.
|
||||
*/
|
||||
|
||||
import db from '../db/index.js';
|
||||
|
||||
/** @returns {object|undefined} La ligne ai_providers par défaut active, ou
|
||||
* undefined si aucun fournisseur actif n'est configuré. */
|
||||
export function getDefaultActiveProvider() {
|
||||
return db
|
||||
.prepare('SELECT * FROM ai_providers WHERE actif = 1 ORDER BY is_default DESC, id ASC LIMIT 1')
|
||||
.get();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* providers/anthropic.js — Adaptateur fournisseur IA "anthropic" (natif,
|
||||
* SDK officiel @anthropic-ai/sdk — pas de base_url, contrairement à
|
||||
* l'adaptateur openai_compatible à venir).
|
||||
*
|
||||
* Boucle d'usage d'outils : tant que le modèle répond stop_reason
|
||||
* 'tool_use', on exécute les outils demandés via le pont MCP (mcpBridge.js
|
||||
* — les 9 mêmes outils en lecture seule que le serveur MCP externe) et on
|
||||
* relance un tour avec le(s) tool_result ajoutés à l'historique, jusqu'à
|
||||
* 'end_turn' ou MAX_TOOL_ITERATIONS (garde-fou anti-boucle).
|
||||
*/
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { mcpToolsToAnthropic } from '../toolSchema.js';
|
||||
|
||||
const MAX_TOOL_ITERATIONS = 8;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.apiKey
|
||||
* @param {string} opts.model Ex. "claude-sonnet-4-5" (configuré par l'admin, ai_providers.modele)
|
||||
* @param {string} [opts.systemPrompt]
|
||||
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
|
||||
* Historique de conversation en texte simple (converti ici en blocs Anthropic).
|
||||
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
|
||||
* Client MCP connecté (voir mcpBridge.js).
|
||||
*
|
||||
* @yields {
|
||||
* {type:'text_delta', text:string} |
|
||||
* {type:'tool_call', name:string, input:object} |
|
||||
* {type:'tool_result', name:string, isError:boolean} |
|
||||
* {type:'done', usage:{input_tokens:number, output_tokens:number}, toolsUsed:string[]} |
|
||||
* {type:'error', message:string}
|
||||
* }
|
||||
*/
|
||||
export async function* streamChat({ apiKey, model, systemPrompt, messages, mcpClient }) {
|
||||
const anthropic = new Anthropic({ apiKey });
|
||||
|
||||
const { tools: mcpTools } = await mcpClient.listTools();
|
||||
const tools = mcpToolsToAnthropic(mcpTools);
|
||||
|
||||
// Historique mutable au format API Anthropic (content = tableau de blocs).
|
||||
let history = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: [{ type: 'text', text: m.content }],
|
||||
}));
|
||||
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
const toolsUsed = [];
|
||||
|
||||
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
|
||||
let stream;
|
||||
try {
|
||||
stream = anthropic.messages.stream({
|
||||
model,
|
||||
max_tokens: DEFAULT_MAX_TOKENS,
|
||||
system: systemPrompt,
|
||||
messages: history,
|
||||
tools,
|
||||
});
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const event of stream) {
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
||||
yield { type: 'text_delta', text: event.delta.text };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
|
||||
return;
|
||||
}
|
||||
|
||||
let finalMessage;
|
||||
try {
|
||||
finalMessage = await stream.finalMessage();
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: `Échec de récupération de la réponse : ${e.message}` };
|
||||
return;
|
||||
}
|
||||
|
||||
totalInputTokens += finalMessage.usage?.input_tokens || 0;
|
||||
totalOutputTokens += finalMessage.usage?.output_tokens || 0;
|
||||
|
||||
history.push({ role: 'assistant', content: finalMessage.content });
|
||||
|
||||
if (finalMessage.stop_reason !== 'tool_use') {
|
||||
yield {
|
||||
type: 'done',
|
||||
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
|
||||
toolsUsed,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const toolUseBlocks = finalMessage.content.filter((b) => b.type === 'tool_use');
|
||||
const toolResultBlocks = [];
|
||||
|
||||
for (const block of toolUseBlocks) {
|
||||
yield { type: 'tool_call', name: block.name, input: block.input };
|
||||
toolsUsed.push(block.name);
|
||||
|
||||
let resultText;
|
||||
let isError = false;
|
||||
try {
|
||||
const result = await mcpClient.callTool({ name: block.name, arguments: block.input });
|
||||
isError = !!result.isError;
|
||||
resultText =
|
||||
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
|
||||
} catch (e) {
|
||||
isError = true;
|
||||
resultText = `Erreur : ${e.message}`;
|
||||
}
|
||||
|
||||
yield { type: 'tool_result', name: block.name, isError };
|
||||
|
||||
toolResultBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: block.id,
|
||||
content: resultText,
|
||||
is_error: isError,
|
||||
});
|
||||
}
|
||||
|
||||
history.push({ role: 'user', content: toolResultBlocks });
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'error',
|
||||
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* testConnection() — appel minimal, sans streaming ni outils, pour
|
||||
* vérifier depuis l'admin qu'une clé API + modèle fonctionnent (bouton
|
||||
* « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.apiKey
|
||||
* @param {string} opts.model
|
||||
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
|
||||
*/
|
||||
export async function testConnection({ apiKey, model }) {
|
||||
const anthropic = new Anthropic({ apiKey });
|
||||
const started = Date.now();
|
||||
const message = await anthropic.messages.create({
|
||||
model,
|
||||
max_tokens: 16,
|
||||
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
|
||||
});
|
||||
const reply = (message.content || [])
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.trim() || '(réponse vide)';
|
||||
return { reply, durationMs: Date.now() - started, usage: message.usage };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* providers/openaiCompatible.js — Adaptateur fournisseur IA
|
||||
* "openai_compatible" : OpenAI, Mistral, Groq, un LLM local, ou OpenRouter
|
||||
* (ai_providers.base_url pointé vers https://openrouter.ai/api/v1).
|
||||
*
|
||||
* Pas de SDK dédié : appel HTTP direct en POST {base_url}/chat/completions
|
||||
* avec stream:true, et parsing manuel des trames SSE renvoyées par l'API
|
||||
* (format `data: {...}\n\n`, terminé par `data: [DONE]`) — même technique
|
||||
* que frontend/src/api.js:stream() côté client, pour rester cohérent avec
|
||||
* le reste du projet plutôt que d'ajouter une dépendance SDK OpenAI.
|
||||
*
|
||||
* Boucle d'usage d'outils : même esprit que providers/anthropic.js (jusqu'à
|
||||
* MAX_TOOL_ITERATIONS), adaptée au format "function calling" OpenAI : les
|
||||
* tool_calls arrivent en deltas fragmentés (nom et arguments JSON accumulés
|
||||
* morceau par morceau), puis sont exécutés via le pont MCP et renvoyés en
|
||||
* messages role:"tool".
|
||||
*
|
||||
* NB : le support effectif du function calling en streaming varie selon le
|
||||
* modèle/fournisseur derrière base_url (tous les modèles OpenRouter ne le
|
||||
* supportent pas) — non testé en conditions réelles dans cette session,
|
||||
* faute de clé API disponible (voir project_ia_embarquee.md, mémoire projet).
|
||||
*/
|
||||
|
||||
import { mcpToolsToOpenAI } from '../toolSchema.js';
|
||||
|
||||
const MAX_TOOL_ITERATIONS = 8;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.apiKey
|
||||
* @param {string} opts.baseUrl Ex. "https://openrouter.ai/api/v1" (ai_providers.base_url)
|
||||
* @param {string} opts.model Ex. "anthropic/claude-sonnet-4.5" (ai_providers.modele)
|
||||
* @param {string} [opts.systemPrompt]
|
||||
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
|
||||
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
|
||||
*
|
||||
* @yields Mêmes événements que providers/anthropic.js#streamChat.
|
||||
*/
|
||||
export async function* streamChat({ apiKey, baseUrl, model, systemPrompt, messages, mcpClient }) {
|
||||
const { tools: mcpTools } = await mcpClient.listTools();
|
||||
const tools = mcpToolsToOpenAI(mcpTools);
|
||||
|
||||
let history = [
|
||||
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
|
||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
];
|
||||
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
const toolsUsed = [];
|
||||
|
||||
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: history,
|
||||
tools,
|
||||
stream: true,
|
||||
max_tokens: DEFAULT_MAX_TOKENS,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
let detail = '';
|
||||
try { detail = await res.text(); } catch { /* noop */ }
|
||||
yield { type: 'error', message: `Erreur API (${res.status}) : ${detail || res.statusText}` };
|
||||
return;
|
||||
}
|
||||
|
||||
// Accumulateurs pour reconstruire la réponse assistant à partir des
|
||||
// deltas SSE (le contenu texte ET les tool_calls arrivent fragmentés).
|
||||
let assistantContent = '';
|
||||
const toolCallsAcc = new Map(); // index -> { id, name, argsText }
|
||||
let finishReason = null;
|
||||
let usage = null;
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
for (const frame of frames) {
|
||||
const line = frame.split('\n').find((l) => l.startsWith('data:'));
|
||||
if (!line) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') continue;
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(data); } catch { continue; }
|
||||
if (parsed.usage) usage = parsed.usage;
|
||||
const choice = parsed.choices?.[0];
|
||||
if (!choice) continue;
|
||||
const delta = choice.delta || {};
|
||||
if (delta.content) {
|
||||
assistantContent += delta.content;
|
||||
yield { type: 'text_delta', text: delta.content };
|
||||
}
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0;
|
||||
if (!toolCallsAcc.has(idx)) toolCallsAcc.set(idx, { id: tc.id || `call_${idx}`, name: '', argsText: '' });
|
||||
const acc = toolCallsAcc.get(idx);
|
||||
if (tc.id) acc.id = tc.id;
|
||||
if (tc.function?.name) acc.name += tc.function.name;
|
||||
if (tc.function?.arguments) acc.argsText += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
|
||||
return;
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
totalInputTokens += usage.prompt_tokens || 0;
|
||||
totalOutputTokens += usage.completion_tokens || 0;
|
||||
}
|
||||
|
||||
if (finishReason !== 'tool_calls' || toolCallsAcc.size === 0) {
|
||||
history.push({ role: 'assistant', content: assistantContent });
|
||||
yield {
|
||||
type: 'done',
|
||||
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
|
||||
toolsUsed,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCalls = [...toolCallsAcc.values()];
|
||||
history.push({
|
||||
role: 'assistant',
|
||||
content: assistantContent || null,
|
||||
tool_calls: toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: tc.argsText },
|
||||
})),
|
||||
});
|
||||
|
||||
for (const tc of toolCalls) {
|
||||
let input = {};
|
||||
try { input = tc.argsText ? JSON.parse(tc.argsText) : {}; }
|
||||
catch { /* arguments JSON malformés renvoyés par le modèle — outil appelé avec {} */ }
|
||||
|
||||
yield { type: 'tool_call', name: tc.name, input };
|
||||
toolsUsed.push(tc.name);
|
||||
|
||||
let resultText;
|
||||
let isError = false;
|
||||
try {
|
||||
const result = await mcpClient.callTool({ name: tc.name, arguments: input });
|
||||
isError = !!result.isError;
|
||||
resultText =
|
||||
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
|
||||
} catch (e) {
|
||||
isError = true;
|
||||
resultText = `Erreur : ${e.message}`;
|
||||
}
|
||||
|
||||
yield { type: 'tool_result', name: tc.name, isError };
|
||||
|
||||
history.push({ role: 'tool', tool_call_id: tc.id, content: resultText });
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'error',
|
||||
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* testConnection() — appel minimal, sans streaming ni outils, pour
|
||||
* vérifier depuis l'admin qu'une clé API + base_url + modèle fonctionnent
|
||||
* (bouton « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.apiKey
|
||||
* @param {string} opts.baseUrl
|
||||
* @param {string} opts.model
|
||||
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
|
||||
*/
|
||||
export async function testConnection({ apiKey, baseUrl, model }) {
|
||||
const started = Date.now();
|
||||
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Impossible de joindre ${baseUrl} : ${e.message}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = body?.error?.message || body?.error || res.statusText || `Erreur HTTP ${res.status}`;
|
||||
throw new Error(`Erreur API (${res.status}) : ${msg}`);
|
||||
}
|
||||
|
||||
const reply = body?.choices?.[0]?.message?.content?.trim() || '(réponse vide)';
|
||||
return { reply, durationMs: Date.now() - started, usage: body?.usage };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* toolSchema.js — Conversion du format d'outil MCP (client.listTools(), voir
|
||||
* mcpBridge.js) vers les formats `tools` attendus par les deux adaptateurs :
|
||||
* API Messages Anthropic (mcpToolsToAnthropic) et function calling OpenAI
|
||||
* (mcpToolsToOpenAI, utilisé par providers/openaiCompatible.js — OpenAI,
|
||||
* Mistral, Groq, LLM local, OpenRouter…).
|
||||
*/
|
||||
|
||||
/** Retire les champs de méta-schéma (ex. $schema) que le SDK MCP ajoute mais
|
||||
* qu'Anthropic n'attend pas dans input_schema. */
|
||||
function cleanInputSchema(schema) {
|
||||
if (!schema || typeof schema !== 'object') return { type: 'object', properties: {} };
|
||||
const { $schema, ...rest } = schema;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Convertit la liste d'outils MCP (résultat de client.listTools().tools) au
|
||||
* format `tools` de l'API Messages Anthropic. */
|
||||
export function mcpToolsToAnthropic(mcpTools) {
|
||||
return mcpTools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
input_schema: cleanInputSchema(t.inputSchema),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Convertit la liste d'outils MCP au format `tools` de function calling
|
||||
* OpenAI (utilisé aussi par OpenRouter, Mistral, Groq, et la plupart des
|
||||
* LLM locaux compatibles OpenAI). */
|
||||
export function mcpToolsToOpenAI(mcpTools) {
|
||||
return mcpTools.map((t) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
parameters: cleanInputSchema(t.inputSchema),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -2727,4 +2727,154 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_fraisop_date ON frais_operations(date_op
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Migration : Assistant IA embarqué (04/09/26) ──────────────────────────
|
||||
// Chat conversationnel branché sur les mêmes outils lecture seule que le
|
||||
// serveur MCP externe (mcp-server/tools.js, réutilisé tel quel — jamais
|
||||
// modifié — via un pont MCP in-process, voir backend/src/ai/mcpBridge.js).
|
||||
// Contrôle d'accès à 3 niveaux : ai_config.actif (global), users.ai_enabled
|
||||
// (par utilisateur, décidé par l'admin), ai_providers.actif (par
|
||||
// fournisseur). Deux adaptateurs prévus dès le départ (type 'anthropic'
|
||||
// natif et 'openai_compatible', ce dernier pouvant pointer vers OpenAI,
|
||||
// Mistral, un LLM local, ou OpenRouter via base_url) — seul l'adaptateur
|
||||
// Anthropic est implémenté dans cette première passe. Voir
|
||||
// backend/src/routes/ai.js et project_ia_embarquee.md (mémoire projet) pour
|
||||
// le détail de l'architecture.
|
||||
{
|
||||
const usersColsAi = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
|
||||
if (!usersColsAi.includes('ai_enabled')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN ai_enabled INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nom TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('anthropic','openai_compatible')),
|
||||
base_url TEXT,
|
||||
modele TEXT NOT NULL,
|
||||
api_key_chiffree TEXT NOT NULL,
|
||||
actif INTEGER NOT NULL DEFAULT 1,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_providers_actif ON ai_providers(actif)');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
actif INTEGER NOT NULL DEFAULT 0,
|
||||
provider_defaut_id INTEGER REFERENCES ai_providers(id) ON DELETE SET NULL,
|
||||
quota_quotidien INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec(`INSERT OR IGNORE INTO ai_config (id, actif) VALUES (1, 0)`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL,
|
||||
provider_id INTEGER REFERENCES ai_providers(id) ON DELETE SET NULL,
|
||||
modele TEXT,
|
||||
tokens_entree INTEGER,
|
||||
tokens_sortie INTEGER,
|
||||
outils_appeles TEXT,
|
||||
erreur TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_usage_user ON ai_usage_log(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_usage_date ON ai_usage_log(created_at)');
|
||||
|
||||
// Clés API internes auto-provisionnées pour l'assistant IA (une par couple
|
||||
// user/investisseur, jamais affichées dans Mon compte → Clés API) : lui
|
||||
// donnent accès à /api/v1 exactement comme un client MCP externe, en
|
||||
// réutilisant l'infrastructure api_keys existante sans la dupliquer — voir
|
||||
// backend/src/ai/internalApiKey.js.
|
||||
{
|
||||
const apiKeysCols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
|
||||
if (!apiKeysCols.includes('internal')) {
|
||||
db.exec('ALTER TABLE api_keys ADD COLUMN internal INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
|
||||
// Conversations de l'assistant IA — plusieurs fils nommés par couple
|
||||
// user/investisseur (même granularité de scope que le reste de l'appli, cf.
|
||||
// requireInvestisseur), consultables et supprimables individuellement
|
||||
// (page dédiée /assistant-ia + widget flottant, qui partagent la même
|
||||
// conversation via son id). titre : dérivé du premier message utilisateur
|
||||
// à la création (voir routes/ai.js), jamais renommé automatiquement ensuite.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
titre TEXT NOT NULL DEFAULT 'Nouvelle conversation',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_conversations_scope ON ai_conversations(user_id, investisseur_id, updated_at DESC)');
|
||||
|
||||
// Messages de l'assistant IA — rattachés à une conversation (ai_conversations).
|
||||
// outils_appeles : JSON (tableau de noms d'outil) pour les messages assistant
|
||||
// uniquement, sinon NULL. Voir backend/src/ai/conversationHistory.js.
|
||||
// user_id/investisseur_id restent présents sur chaque message (redondants
|
||||
// avec ai_conversations mais pratiques pour des requêtes directes futures,
|
||||
// et c'est le schéma d'origine avant l'introduction du multi-conversations).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id INTEGER REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK(role IN ('user','assistant')),
|
||||
content TEXT NOT NULL,
|
||||
outils_appeles TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
// Migration : ajoute conversation_id si la table ai_messages existait déjà
|
||||
// (créée avant l'introduction du multi-conversations, 04/09/26).
|
||||
{
|
||||
const aiMsgCols = db.prepare('PRAGMA table_info(ai_messages)').all().map(c => c.name);
|
||||
if (!aiMsgCols.includes('conversation_id')) {
|
||||
db.exec('ALTER TABLE ai_messages ADD COLUMN conversation_id INTEGER REFERENCES ai_conversations(id) ON DELETE CASCADE');
|
||||
}
|
||||
}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation ON ai_messages(conversation_id, id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_thread ON ai_messages(user_id, investisseur_id, created_at)');
|
||||
|
||||
// Backfill : les messages enregistrés avant l'introduction du
|
||||
// multi-conversations n'ont pas de conversation_id — on les regroupe dans
|
||||
// une conversation unique par couple user/investisseur pour ne rien perdre
|
||||
// de l'historique existant. Idempotent (ne trouve plus rien à faire une
|
||||
// fois exécuté, puisque conversation_id n'est alors plus jamais NULL).
|
||||
{
|
||||
const orphanScopes = db.prepare(`
|
||||
SELECT DISTINCT user_id, investisseur_id FROM ai_messages WHERE conversation_id IS NULL
|
||||
`).all();
|
||||
for (const { user_id, investisseur_id } of orphanScopes) {
|
||||
const firstUserMsg = db.prepare(`
|
||||
SELECT content FROM ai_messages
|
||||
WHERE user_id = ? AND investisseur_id IS ? AND conversation_id IS NULL AND role = 'user'
|
||||
ORDER BY id ASC LIMIT 1
|
||||
`).get(user_id, investisseur_id);
|
||||
const rawTitre = firstUserMsg ? firstUserMsg.content.replace(/\s+/g, ' ').trim() : 'Conversation';
|
||||
const titre = rawTitre.length > 60 ? rawTitre.slice(0, 57) + '…' : (rawTitre || 'Conversation');
|
||||
const { lastInsertRowid: convId } = db.prepare(`
|
||||
INSERT INTO ai_conversations (user_id, investisseur_id, titre) VALUES (?, ?, ?)
|
||||
`).run(user_id, investisseur_id, titre);
|
||||
db.prepare(`
|
||||
UPDATE ai_messages SET conversation_id = ?
|
||||
WHERE user_id = ? AND investisseur_id IS ? AND conversation_id IS NULL
|
||||
`).run(convId, user_id, investisseur_id);
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* aiAccess.js — Contrôle d'accès à l'assistant IA embarqué, à 3 niveaux :
|
||||
* 1. Interrupteur global (ai_config.actif)
|
||||
* 2. Accès par utilisateur (users.ai_enabled, décidé par l'admin)
|
||||
* 3. Au moins un fournisseur actif configuré (ai_providers)
|
||||
* … plus un 4e contrôle distinct, le quota (checkAiQuota) : contrairement
|
||||
* aux 3 niveaux ci-dessus qui sont du tout-ou-rien, le quota est un
|
||||
* comptage qui varie dans la journée — géré séparément (429, pas 403) et
|
||||
* appelé explicitement dans routes/ai.js plutôt que via un middleware, pour
|
||||
* garder requireAiAccess simple et testable indépendamment.
|
||||
*
|
||||
* getAiAccessStatus() est la seule fonction qui définit la règle — utilisée
|
||||
* à la fois par GET /api/ai/status (qui doit répondre même à un utilisateur
|
||||
* non autorisé, pour que le frontend sache masquer le chat) et par le
|
||||
* middleware requireAiAccess ci-dessous (à utiliser après requireAuth sur
|
||||
* les routes qui appellent réellement le modèle, ex. POST /api/ai/chat).
|
||||
*/
|
||||
|
||||
import db from '../db/index.js';
|
||||
import { getDefaultActiveProvider } from '../ai/providerConfig.js';
|
||||
|
||||
export function getAiAccessStatus(userId) {
|
||||
const config = db.prepare('SELECT actif FROM ai_config WHERE id = 1').get();
|
||||
const globalActif = !!(config && config.actif);
|
||||
|
||||
const user = db.prepare('SELECT ai_enabled FROM users WHERE id = ?').get(userId);
|
||||
const userActif = !!(user && user.ai_enabled);
|
||||
|
||||
const providerDisponible = !!getDefaultActiveProvider();
|
||||
|
||||
return {
|
||||
enabled: globalActif && userActif && providerDisponible,
|
||||
globalActif,
|
||||
userActif,
|
||||
providerDisponible,
|
||||
};
|
||||
}
|
||||
|
||||
/** À utiliser après requireAuth sur les routes qui font réellement appel au
|
||||
* modèle. Ne PAS l'utiliser sur GET /api/ai/status — voir plus haut. */
|
||||
export function requireAiAccess(req, res, next) {
|
||||
const status = getAiAccessStatus(req.user.id);
|
||||
if (!status.enabled) {
|
||||
let message = "Fonctionnalité IA désactivée.";
|
||||
if (!status.globalActif) message = "L'assistant IA n'est pas activé sur cette instance.";
|
||||
else if (!status.userActif) message = "Vous n'avez pas accès à l'assistant IA — contactez un administrateur.";
|
||||
else if (!status.providerDisponible) message = "Aucun fournisseur IA actif n'est configuré.";
|
||||
return res.status(403).json({ error: message });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** Vérifie le quota quotidien global (ai_config.quota_quotidien — nombre de
|
||||
* requêtes/jour/utilisateur, NULL = illimité). Compte les lignes déjà
|
||||
* enregistrées aujourd'hui dans ai_usage_log pour cet utilisateur (succès
|
||||
* et échecs confondus — une requête qui a consommé un tour de modèle
|
||||
* compte, qu'elle ait abouti ou non). Retourne {allowed, used, limit}. */
|
||||
export function checkAiQuota(userId) {
|
||||
const config = db.prepare('SELECT quota_quotidien FROM ai_config WHERE id = 1').get();
|
||||
const limit = config?.quota_quotidien ?? null;
|
||||
if (!limit) return { allowed: true, used: 0, limit: null };
|
||||
|
||||
const { n } = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM ai_usage_log
|
||||
WHERE user_id = ? AND date(created_at) = date('now')
|
||||
`).get(userId);
|
||||
|
||||
return { allowed: n < limit, used: n, limit };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -43,6 +43,7 @@ import invitationsRouter from './routes/invitations.js';
|
||||
import auditLogsRouter from './routes/auditLogs.js';
|
||||
import smtpRouter from './routes/smtp.js';
|
||||
import generalRouter from './routes/general.js';
|
||||
import adminAiRouter from './routes/adminAi.js';
|
||||
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
|
||||
import referentielRouter from './routes/referentiel.js';
|
||||
import referentielPublicRouter from './routes/referentielPublic.js';
|
||||
@@ -56,6 +57,7 @@ import ticketsRouter from './routes/tickets.js';
|
||||
import apiKeysRouter from './routes/apiKeys.js';
|
||||
import v1Router from './routes/v1/index.js';
|
||||
import documentsRouter from './routes/documents.js';
|
||||
import aiRouter from './routes/ai.js';
|
||||
import { requireApiKey } from './middleware/apiKey.js';
|
||||
import { swaggerSpec, swaggerUi } from './swagger.js';
|
||||
import db from './db/index.js';
|
||||
@@ -146,6 +148,7 @@ app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
|
||||
app.use('/api/admin/audit-logs', requireAuth, requireAdmin, auditLogsRouter);
|
||||
app.use('/api/admin/smtp', requireAuth, requireAdmin, smtpRouter);
|
||||
app.use('/api/admin/general', requireAuth, requireAdmin, generalRouter);
|
||||
app.use('/api/admin/ai', requireAuth, requireAdmin, adminAiRouter);
|
||||
// Invitations : routes admin protégées + routes publiques (register/validate)
|
||||
app.use('/api/admin/invitations', requireAuth, requireAdmin, invitationsRouter);
|
||||
app.use('/api/invitations', invitationsRouter);
|
||||
@@ -161,6 +164,7 @@ app.use('/api/notifications', notificationsRouter);
|
||||
app.use('/api/tickets', requireAuth, ticketsRouter);
|
||||
app.use('/api/api-keys', requireAuth, apiKeysRouter);
|
||||
app.use('/api/documents', requireAuth, documentsRouter);
|
||||
app.use('/api/ai', requireAuth, aiRouter);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
Reference in new issue
Block a user