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),
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user