From 662003fdd3e5b2ba6b24ae7478a911bf1c418518 Mon Sep 17 00:00:00 2001 From: Olivier Date: Sat, 5 Sep 2026 10:54:50 +0200 Subject: [PATCH] IA Assistant version initiale --- .env.example | 9 + backend/package-lock.json | 642 ++++++++++++++++++ backend/package.json | 2 + backend/scripts/ai-seed-provider.js | 113 ++++ backend/src/ai/conversationHistory.js | 107 +++ backend/src/ai/crypto.js | 68 ++ backend/src/ai/internalApiKey.js | 46 ++ backend/src/ai/mcpBridge.js | 39 ++ backend/src/ai/providerConfig.js | 16 + backend/src/ai/providers/anthropic.js | 163 +++++ backend/src/ai/providers/openaiCompatible.js | 236 +++++++ backend/src/ai/toolSchema.js | 39 ++ backend/src/db/index.js | 150 +++++ backend/src/middleware/aiAccess.js | 69 ++ backend/src/routes/admin.js | 19 +- backend/src/routes/adminAi.js | 394 +++++++++++ backend/src/routes/ai.js | 264 ++++++++ backend/src/routes/apiKeys.js | 4 +- backend/src/server.js | 4 + frontend/src/App.jsx | 4 + frontend/src/api.js | 57 ++ frontend/src/components/AiAssistant.jsx | 157 +++++ frontend/src/components/Layout.jsx | 14 + frontend/src/components/ai/aiChatShared.jsx | 429 ++++++++++++ frontend/src/pages/Admin.jsx | 9 +- frontend/src/pages/AdminIA.jsx | 649 +++++++++++++++++++ frontend/src/pages/AssistantIA.jsx | 181 ++++++ frontend/src/pages/admin/UsersSection.jsx | 48 +- frontend/src/styles.css | 331 ++++++++++ 29 files changed, 4253 insertions(+), 10 deletions(-) create mode 100644 backend/scripts/ai-seed-provider.js create mode 100644 backend/src/ai/conversationHistory.js create mode 100644 backend/src/ai/crypto.js create mode 100644 backend/src/ai/internalApiKey.js create mode 100644 backend/src/ai/mcpBridge.js create mode 100644 backend/src/ai/providerConfig.js create mode 100644 backend/src/ai/providers/anthropic.js create mode 100644 backend/src/ai/providers/openaiCompatible.js create mode 100644 backend/src/ai/toolSchema.js create mode 100644 backend/src/middleware/aiAccess.js create mode 100644 backend/src/routes/adminAi.js create mode 100644 backend/src/routes/ai.js create mode 100644 frontend/src/components/AiAssistant.jsx create mode 100644 frontend/src/components/ai/aiChatShared.jsx create mode 100644 frontend/src/pages/AdminIA.jsx create mode 100644 frontend/src/pages/AssistantIA.jsx diff --git a/.env.example b/.env.example index c407580..dbfade0 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,12 @@ UPLOAD_DIR=/app/uploads # Identité de la plateforme (utilisée dans les emails et sur la page de connexion) # APP_NAME=Crowdlending Tracker # APP_URL=https://votre-domaine.com + +# --- Assistant IA embarqué (optionnel) --- +# Clé de chiffrement des clés API des fournisseurs IA (Anthropic, OpenAI, +# OpenRouter...) enregistrées par l'admin — AES-256-GCM, réversible (les clés +# doivent être renvoyées en clair au fournisseur à chaque appel). +# Générer avec : openssl rand -hex 32 +# Obligatoire dès qu'un fournisseur IA est configuré ; faire tourner cette +# clé invalide tous les fournisseurs déjà enregistrés (à re-saisir). +# AI_ENC_KEY= diff --git a/backend/package-lock.json b/backend/package-lock.json index 2057ec7..ce69519 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,8 @@ "name": "crowdlending-backend", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.123.0", + "@modelcontextprotocol/sdk": "^1.30.0", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.11.1", "cors": "^2.8.5", @@ -31,6 +33,27 @@ "nodemon": "^3.1.14" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", @@ -79,6 +102,15 @@ "openapi-types": ">=7" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -89,6 +121,18 @@ "tslib": "^2.4.0" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -563,6 +607,419 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -647,6 +1104,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -696,6 +1159,23 @@ } } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1379,6 +1859,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1455,6 +1956,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", @@ -1729,6 +2236,15 @@ "node": ">=16.0.0" } }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1800,6 +2316,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1864,6 +2389,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -1891,6 +2422,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -1913,12 +2453,31 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2490,6 +3049,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2691,6 +3259,55 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2993,6 +3610,16 @@ "node": ">=10" } }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3191,6 +3818,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3390,6 +4023,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/backend/package.json b/backend/package.json index 2e3431f..1137cd1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,8 @@ "db:seed": "node src/db/seed.js" }, "dependencies": { + "@anthropic-ai/sdk": "^0.123.0", + "@modelcontextprotocol/sdk": "^1.30.0", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.11.1", "cors": "^2.8.5", diff --git a/backend/scripts/ai-seed-provider.js b/backend/scripts/ai-seed-provider.js new file mode 100644 index 0000000..30f6d61 --- /dev/null +++ b/backend/scripts/ai-seed-provider.js @@ -0,0 +1,113 @@ +/** + * ai-seed-provider.js — Configure un fournisseur IA en ligne de commande, en + * attendant le back-office admin (Task future : CRUD ai_providers dans + * Admin.jsx). Usage (depuis le dossier backend/) : + * + * node scripts/ai-seed-provider.js --email vous@exemple.fr --type anthropic \ + * --model claude-sonnet-4-5 --key sk-ant-xxxxx [--nom "Anthropic (test)"] + * + * Effets : + * - crée/met à jour un fournisseur ai_providers (nom sert de clé d'upsert), + * le marque is_default=1 (et retire ce flag des autres fournisseurs) + * - active ai_config.actif (interrupteur global) — sauf --no-activate + * - active users.ai_enabled pour l'email donné — sauf --no-user-enable + * + * Nécessite AI_ENC_KEY dans .env (voir .env.example) — sinon échoue avec un + * message explicite (chiffrement de la clé API du fournisseur). + */ + +import 'dotenv/config'; +import db from '../src/db/index.js'; +import { encryptSecret } from '../src/ai/crypto.js'; + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + const key = argv[i].slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; // flag booléen (ex. --no-activate) + } else { + args[key] = next; + i++; + } + } + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); + +const email = args.email; +const key = args.key; +const model = args.model; +const type = args.type || 'anthropic'; +const nom = args.nom || (type === 'anthropic' ? 'Anthropic' : `${type} (via seed)`); +const baseUrl = args['base-url'] || null; +const activate = !args['no-activate']; +const enableUser = !args['no-user-enable']; + +function fail(msg) { + console.error(`✗ ${msg}`); + process.exit(1); +} + +if (!email) fail('--email requis (email du compte utilisateur à autoriser)'); +if (!key) fail('--key requis (clé API du fournisseur, en clair — sera chiffrée avant stockage)'); +if (!model) fail("--model requis (ex. claude-sonnet-4-5 pour l'adaptateur anthropic)"); +if (!['anthropic', 'openai_compatible'].includes(type)) { + fail(`--type invalide (${type}) — attendu "anthropic" ou "openai_compatible"`); +} +if (type === 'openai_compatible' && !baseUrl) { + fail('--base-url requis pour --type openai_compatible (ex. https://openrouter.ai/api/v1)'); +} + +const user = db.prepare('SELECT id, email FROM users WHERE email = ?').get(email); +if (!user) fail(`Aucun utilisateur avec l'email "${email}"`); + +let apiKeyChiffree; +try { + apiKeyChiffree = encryptSecret(key); +} catch (e) { + fail(e.message); +} + +const tx = db.transaction(() => { + db.exec('UPDATE ai_providers SET is_default = 0'); + + const existing = db.prepare('SELECT id FROM ai_providers WHERE nom = ?').get(nom); + let providerId; + if (existing) { + db.prepare(` + UPDATE ai_providers + SET type = ?, base_url = ?, modele = ?, api_key_chiffree = ?, actif = 1, is_default = 1, updated_at = datetime('now') + WHERE id = ? + `).run(type, baseUrl, model, apiKeyChiffree, existing.id); + providerId = existing.id; + console.log(`✓ Fournisseur "${nom}" mis à jour (id=${providerId})`); + } else { + const info = db.prepare(` + INSERT INTO ai_providers (nom, type, base_url, modele, api_key_chiffree, actif, is_default) + VALUES (?, ?, ?, ?, ?, 1, 1) + `).run(nom, type, baseUrl, model, apiKeyChiffree); + providerId = info.lastInsertRowid; + console.log(`✓ Fournisseur "${nom}" créé (id=${providerId})`); + } + + if (activate) { + db.prepare(` + UPDATE ai_config SET actif = 1, provider_defaut_id = ?, updated_at = datetime('now') WHERE id = 1 + `).run(providerId); + console.log('✓ Interrupteur global IA activé (ai_config.actif = 1)'); + } + + if (enableUser) { + db.prepare('UPDATE users SET ai_enabled = 1 WHERE id = ?').run(user.id); + console.log(`✓ Accès IA activé pour ${user.email}`); + } +}); +tx(); + +console.log('\nPrêt à tester : GET /api/ai/status devrait répondre { enabled: true } pour cet utilisateur,'); +console.log("puis POST /api/ai/chat avec un en-tête X-Investisseur-Id valide et un body { messages: [...] }."); diff --git a/backend/src/ai/conversationHistory.js b/backend/src/ai/conversationHistory.js new file mode 100644 index 0000000..b37b35a --- /dev/null +++ b/backend/src/ai/conversationHistory.js @@ -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); + } +} diff --git a/backend/src/ai/crypto.js b/backend/src/ai/crypto.js new file mode 100644 index 0000000..0f3b786 --- /dev/null +++ b/backend/src/ai/crypto.js @@ -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é : "::". + */ +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'); +} diff --git a/backend/src/ai/internalApiKey.js b/backend/src/ai/internalApiKey.js new file mode 100644 index 0000000..4661957 --- /dev/null +++ b/backend/src/ai/internalApiKey.js @@ -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); + } +} diff --git a/backend/src/ai/mcpBridge.js b/backend/src/ai/mcpBridge.js new file mode 100644 index 0000000..8bc99b3 --- /dev/null +++ b/backend/src/ai/mcpBridge.js @@ -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} apiGet + * @returns {Promise} 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; +} diff --git a/backend/src/ai/providerConfig.js b/backend/src/ai/providerConfig.js new file mode 100644 index 0000000..bd2983f --- /dev/null +++ b/backend/src/ai/providerConfig.js @@ -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(); +} diff --git a/backend/src/ai/providers/anthropic.js b/backend/src/ai/providers/anthropic.js new file mode 100644 index 0000000..9fa0bd0 --- /dev/null +++ b/backend/src/ai/providers/anthropic.js @@ -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 }; +} diff --git a/backend/src/ai/providers/openaiCompatible.js b/backend/src/ai/providers/openaiCompatible.js new file mode 100644 index 0000000..af60af0 --- /dev/null +++ b/backend/src/ai/providers/openaiCompatible.js @@ -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 }; +} diff --git a/backend/src/ai/toolSchema.js b/backend/src/ai/toolSchema.js new file mode 100644 index 0000000..565b53c --- /dev/null +++ b/backend/src/ai/toolSchema.js @@ -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), + }, + })); +} diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 0a58952..cda42cb 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/middleware/aiAccess.js b/backend/src/middleware/aiAccess.js new file mode 100644 index 0000000..385c1aa --- /dev/null +++ b/backend/src/middleware/aiAccess.js @@ -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 }; +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 3a8f575..9bdfbd7 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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']), diff --git a/backend/src/routes/adminAi.js b/backend/src/routes/adminAi.js new file mode 100644 index 0000000..95cd651 --- /dev/null +++ b/backend/src/routes/adminAi.js @@ -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; diff --git a/backend/src/routes/ai.js b/backend/src/routes/ai.js new file mode 100644 index 0000000..3622fa2 --- /dev/null +++ b/backend/src/routes/ai.js @@ -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; diff --git a/backend/src/routes/apiKeys.js b/backend/src/routes/apiKeys.js index e7102a9..8451cf3 100644 --- a/backend/src/routes/apiKeys.js +++ b/backend/src/routes/apiKeys.js @@ -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); diff --git a/backend/src/server.js b/backend/src/server.js index 9cdfd13..041b0f5 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7e4cccd..69263a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,11 +21,13 @@ import MonCompte from './pages/MonCompte.jsx'; import Admin from './pages/Admin.jsx'; import AdminPlateformes from './pages/AdminPlateformes.jsx'; import AdminFiscalite from './pages/AdminFiscalite.jsx'; +import AdminIA from './pages/AdminIA.jsx'; import Aide from './pages/Aide.jsx'; import PlatformeProfile from './pages/PlatformeProfile.jsx'; import Plateformes from './pages/Plateformes.jsx'; import Notifications from './pages/Notifications.jsx'; import Communication from './pages/Communication.jsx'; +import AssistantIA from './pages/AssistantIA.jsx'; function Protected({ children }) { const { token, loading } = useAuth(); @@ -98,6 +100,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -106,6 +109,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index d5009e5..8539c8c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -64,6 +64,63 @@ export const api = { fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle), postForm: (path, formData) => fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle), + /** Flux SSE (Server-Sent Events) en POST — utilisé par l'assistant IA + * (POST /api/ai/chat). EventSource ne permet pas d'en-têtes personnalisés + * (Authorization, X-Investisseur-Id) : on lit donc le flux manuellement + * via fetch + ReadableStream, en parsant les trames "event:"/"data:" + * séparées par une ligne vide, au format écrit par le backend. Générateur + * asynchrone : `for await (const {event, data} of api.stream(...))`. + */ + stream: async function* (path, body) { + const res = await fetch(BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(body), + }); + + if (!res.ok || !res.body) { + let msg = res.statusText || 'Requête échouée'; + try { + const text = await res.text(); + const parsed = text ? JSON.parse(text) : null; + if (parsed && parsed.error) msg = parsed.error; + } catch { /* corps non-JSON, on garde statusText */ } + const err = new Error(msg); + err.status = res.status; + throw err; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + + let sepIdx; + while ((sepIdx = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, sepIdx); + buf = buf.slice(sepIdx + 2); + + let event = 'message'; + const dataLines = []; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); + } + if (dataLines.length === 0) continue; + + let data; + try { data = JSON.parse(dataLines.join('\n')); } + catch { data = dataLines.join('\n'); } + + yield { event, data }; + } + } + }, + blob: (path) => fetch(BASE + path, { headers: authHeaders(), cache: 'no-store' }).then(async res => { if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); } diff --git a/frontend/src/components/AiAssistant.jsx b/frontend/src/components/AiAssistant.jsx new file mode 100644 index 0000000..a389a4f --- /dev/null +++ b/frontend/src/components/AiAssistant.jsx @@ -0,0 +1,157 @@ +/** + * AiAssistant.jsx — Bulle flottante de l'assistant IA, disponible sur + * toutes les pages (sauf /assistant-ia, où le chat est déjà affiché en + * grand — voir pages/AssistantIA.jsx). Partage sa logique de chat + * (chargement, envoi en streaming, etc.) avec la page dédiée via + * components/ai/aiChatShared.jsx ; les deux surfaces affichent la même + * conversation, synchronisée par son id (cl_ai_conversation_id en + * localStorage ici, paramètre ?conversation= sur la page dédiée). + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import { + IconSparkle, IconNewChat, IconExpand, + MessageThread, Composer, useAiChat, +} from './ai/aiChatShared.jsx'; + +const STORAGE_KEY_OPEN = 'cl_ui_ai_chat_open'; +const STORAGE_KEY_CONVERSATION = 'cl_ai_conversation_id'; +// Fermeture automatique du panneau (pas de la conversation, qui reste en historique +// côté serveur) après inactivité, pour ne pas laisser la fenêtre ouverte indéfiniment. +const INACTIVITY_CLOSE_MS = 5 * 60 * 1000; // 5 minutes + +function IconClose() { + return ( + + ); +} + +export default function AiAssistant() { + const [enabled, setEnabled] = useState(false); + const [open, setOpen] = useState(() => { + try { return localStorage.getItem(STORAGE_KEY_OPEN) === '1'; } catch { return false; } + }); + const [input, setInput] = useState(''); + const [initialised, setInitialised] = useState(false); + const inactivityTimerRef = useRef(null); + const location = useLocation(); + const navigate = useNavigate(); + const lastPathRef = useRef(location.pathname); + + const persistConversationId = useCallback((id) => { + try { + if (id) localStorage.setItem(STORAGE_KEY_CONVERSATION, String(id)); + else localStorage.removeItem(STORAGE_KEY_CONVERSATION); + } catch { /* ignore */ } + }, []); + + const chat = useAiChat({ onConversationChange: persistConversationId }); + const { conversationId, messages, sending, selectConversation, startNewConversation, sendMessage, regenerateMessage } = chat; + + /* ── Statut : l'utilisateur courant a-t-il accès à l'assistant ? ──────── */ + useEffect(() => { + let cancelled = false; + api.get('/ai/status') + .then((r) => { if (!cancelled) setEnabled(!!r.enabled); }) + .catch(() => { if (!cancelled) setEnabled(false); }); + return () => { cancelled = true; }; + }, []); + + /* ── Reprend la conversation en cours (si connue) dès que l'accès est + * confirmé — au plus une fois. */ + useEffect(() => { + if (!enabled || initialised) return; + setInitialised(true); + let storedId = null; + try { storedId = localStorage.getItem(STORAGE_KEY_CONVERSATION); } catch { /* ignore */ } + if (storedId) selectConversation(Number(storedId)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, initialised]); + + useEffect(() => { + try { localStorage.setItem(STORAGE_KEY_OPEN, open ? '1' : '0'); } catch { /* ignore */ } + }, [open]); + + /* ── Fermeture automatique en changeant de page ──────────────────────── + * La conversation reste disponible (persistée côté serveur) ; seul le + * panneau se replie, pour ne pas rester affiché par-dessus la nouvelle page. */ + useEffect(() => { + if (location.pathname !== lastPathRef.current) { + lastPathRef.current = location.pathname; + setOpen(false); + } + }, [location.pathname]); + + /* ── Fermeture automatique après inactivité ──────────────────────────── + * Le minuteur repart à chaque frappe, envoi, ou réception de contenu + * (donc jamais pendant qu'une réponse est en cours de génération). */ + useEffect(() => { + if (inactivityTimerRef.current) clearTimeout(inactivityTimerRef.current); + if (!open) return undefined; + inactivityTimerRef.current = setTimeout(() => setOpen(false), INACTIVITY_CLOSE_MS); + return () => clearTimeout(inactivityTimerRef.current); + }, [open, input, messages]); + + const handleSend = useCallback(() => { + const text = input; + setInput(''); + sendMessage(text); + }, [input, sendMessage]); + + const expand = useCallback(() => { + navigate(conversationId ? `/assistant-ia?conversation=${conversationId}` : '/assistant-ia'); + }, [conversationId, navigate]); + + // Masquée sur la page dédiée : le chat y est déjà affiché en grand, pas + // besoin d'une seconde interface de chat par-dessus. + if (!enabled || location.pathname === '/assistant-ia') return null; + + const panel = open && createPortal( +
+
+ + Assistant IA + + + +
+ + + + +
, + document.body + ); + + return ( + <> + + {panel} + + ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 8c0aec1..8b8fa89 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -7,6 +7,7 @@ import { useUi } from '../context/UiContext.jsx'; import Logo from './Logo.jsx'; import UserMenu from './UserMenu.jsx'; import NotificationBell from './NotificationBell.jsx'; +import AiAssistant from './AiAssistant.jsx'; /* ── Icônes nav ─────────────────────────────────────────────── */ const ICONS_BASE = '/api/icons-files/'; @@ -25,6 +26,7 @@ const IconInvestments = () => ; const IconFlatTax = () => ; const IconFrais = () => ; +const IconAssistantIa = () => ; /* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */ function NavIcon({ libFilename, Fallback }) { @@ -253,6 +255,7 @@ export default function Layout() { const { hasFrais } = useAuth(); const navigate = useNavigate(); const [navIcons, setNavIcons] = useState({}); + const [aiEnabled, setAiEnabled] = useState(false); const [burgerOpen, setBurgerOpen] = useState(false); const burgerRef = useRef(null); @@ -283,6 +286,10 @@ export default function Layout() { }).catch(() => {}); }, []); + useEffect(() => { + api.get('/ai/status').then(r => setAiEnabled(!!r.enabled)).catch(() => setAiEnabled(false)); + }, []); + return (
); } diff --git a/frontend/src/components/ai/aiChatShared.jsx b/frontend/src/components/ai/aiChatShared.jsx new file mode 100644 index 0000000..1420594 --- /dev/null +++ b/frontend/src/components/ai/aiChatShared.jsx @@ -0,0 +1,429 @@ +/** + * aiChatShared.jsx — Logique et rendu du chat de l'assistant IA, partagés + * entre la bulle flottante (components/AiAssistant.jsx) et la page dédiée + * (pages/AssistantIA.jsx), qui affichent la même conversation en cours + * (synchronisée via son id — voir useAiChat) dans deux tailles de fenêtre + * différentes. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from '../../api.js'; + +let _uid = 0; +export const nextId = () => `m${++_uid}`; + +/* ── Icônes réutilisées par le widget et la page ───────────────────────── */ +export function IconSparkle() { + return ( + + ); +} +export function IconSparkleSmall() { + return ( + + ); +} +export function IconSend() { + return ( + + ); +} +export function IconTool() { + return ( + + ); +} +export function IconNewChat() { + return ( + + ); +} +export function IconExpand() { + return ( + + ); +} +export function IconTrash() { + return ( + + ); +} +export function IconCopy() { + return ( + + ); +} +export function IconCheck() { + return ( + + ); +} +export function IconRegenerate() { + return ( + + ); +} + +const TOOL_LABELS = { + crowdlending_get_investisseur: 'profil investisseur', + crowdlending_get_dashboard: 'synthèse du portefeuille', + crowdlending_list_investissements:'liste des investissements', + crowdlending_get_investissement: "détail d'un investissement", + crowdlending_list_remboursements: 'liste des remboursements', + crowdlending_list_frais_operations:'liste des frais', + crowdlending_list_depots_retraits:'dépôts / retraits', + crowdlending_list_objectifs: 'objectifs', + crowdlending_get_taxreport: 'synthèse fiscale', +}; +const toolLabel = (name) => TOOL_LABELS[name] || name; + +/** Horodatage compact pour les messages et la liste des conversations : + * heure si aujourd'hui, « Hier » si hier, sinon jj/mm (jj/mm/aaaa si année + * différente). Partagé entre le fil de discussion (MessageThread) et la + * liste des conversations de la page dédiée. */ +export function relativeDate(iso) { + if (!iso) return ''; + const d = new Date(iso.includes('T') || iso.endsWith('Z') ? iso : iso.replace(' ', 'T') + 'Z'); + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); + if (d.toDateString() === yesterday.toDateString()) return 'Hier'; + const opts = d.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: 'numeric' }; + return d.toLocaleDateString('fr-FR', opts); +} + +/** Badges des outils consultés pour un message assistant donné. */ +export function ToolBadges({ tools }) { + if (!tools || tools.length === 0) return null; + return ( +
+ {tools.map((t, i) => ( + + + {toolLabel(t.name)} + {t.status === 'calling' && '…'} + + ))} +
+ ); +} + +/** Convertit un message persisté (GET /ai/conversations/:id/messages — + * {role, content, tools:string[]}) au format interne du composant + * ({id, role, content, tools:[{name,status}], ...}). */ +function fromPersisted(m) { + return { + id: nextId(), + role: m.role, + content: m.content, + tools: (m.tools || []).map((name) => ({ name, status: 'done' })), + pending: false, + error: null, + timestamp: m.createdAt || null, + }; +} + +/** + * useAiChat — état et logique du fil de discussion courant : chargement + * d'une conversation existante, envoi de message en streaming (SSE), + * démarrage d'une nouvelle conversation (déconnexion locale seulement — + * rien n'est supprimé côté serveur, la nouvelle conversation n'est créée + * qu'à l'envoi du premier message, voir routes/ai.js POST /chat). + * + * onConversationChange(id, titre) est appelé chaque fois que l'id de + * conversation change, quelle qu'en soit la raison (sélection explicite, + * "Nouvelle conversation", ou création à la volée par le serveur au premier + * message) — le widget s'en sert pour retenir l'id (localStorage), la page + * dédiée pour synchroniser l'URL. onTurnComplete() est appelé à la fin de + * chaque tour (succès ou échec) — la page dédiée s'en sert pour rafraîchir + * la liste des conversations (nouvel élément, ou position remontée par + * récence d'activité). + */ +export function useAiChat({ onConversationChange, onTurnComplete } = {}) { + const [conversationId, setConversationId] = useState(null); + const [messages, setMessages] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + const [sending, setSending] = useState(false); + + const updateMessage = useCallback((id, updater) => { + setMessages((prev) => prev.map((m) => (m.id === id ? updater(m) : m))); + }, []); + + const applyConversationId = useCallback((id, titre) => { + setConversationId(id); + onConversationChange?.(id, titre ?? null); + }, [onConversationChange]); + + /** Charge le fil d'une conversation existante ; id falsy = vide l'affichage + * (ne supprime rien côté serveur). */ + const selectConversation = useCallback(async (id) => { + if (!id) { + setMessages([]); + applyConversationId(null, null); + return; + } + setLoadingHistory(true); + try { + const r = await api.get(`/ai/conversations/${id}/messages`); + setMessages((r.messages || []).map(fromPersisted)); + applyConversationId(id, r.conversation?.titre); + } catch { + setMessages([]); + applyConversationId(null, null); + } finally { + setLoadingHistory(false); + } + }, [applyConversationId]); + + const startNewConversation = useCallback(() => { + if (sending) return; + setMessages([]); + applyConversationId(null, null); + }, [sending, applyConversationId]); + + /** Logique d'envoi commune à sendMessage() et regenerateMessage() : ajoute + * la paire utilisateur/assistant et consomme le flux SSE de la réponse. */ + const runTurn = useCallback(async (trimmed) => { + const now = new Date().toISOString(); + const userMsg = { id: nextId(), role: 'user', content: trimmed, timestamp: now }; + const assistantId = nextId(); + const assistantMsg = { id: assistantId, role: 'assistant', content: '', tools: [], pending: true, error: null, timestamp: now }; + setMessages((prev) => [...prev, userMsg, assistantMsg]); + setSending(true); + + try { + for await (const { data } of api.stream('/ai/chat', { conversationId, message: trimmed })) { + if (!data || typeof data !== 'object') continue; + + if (data.type === 'conversation') { + if (data.id !== conversationId) applyConversationId(data.id, data.titre); + } else if (data.type === 'text_delta') { + updateMessage(assistantId, (m) => ({ ...m, content: m.content + data.text })); + } else if (data.type === 'tool_call') { + updateMessage(assistantId, (m) => ({ + ...m, + tools: [...m.tools, { name: data.name, status: 'calling' }], + })); + } else if (data.type === 'tool_result') { + updateMessage(assistantId, (m) => ({ + ...m, + tools: m.tools.map((t) => + t.name === data.name && t.status === 'calling' + ? { ...t, status: data.isError ? 'error' : 'done' } + : t + ), + })); + } else if (data.type === 'done') { + updateMessage(assistantId, (m) => ({ ...m, pending: false })); + } else if (data.type === 'error') { + updateMessage(assistantId, (m) => ({ ...m, pending: false, error: data.message })); + } + } + } catch (e) { + updateMessage(assistantId, (m) => ({ ...m, pending: false, error: e.message || 'Erreur de connexion' })); + } finally { + setSending(false); + onTurnComplete?.(); + } + }, [conversationId, updateMessage, applyConversationId, onTurnComplete]); + + const sendMessage = useCallback((text) => { + const trimmed = (text || '').trim(); + if (!trimmed || sending) return; + return runTurn(trimmed); + }, [sending, runTurn]); + + /** Rejoue une demande : reprend le texte du message utilisateur associé + * (celui-ci s'il en est un, sinon celui qui précède immédiatement le + * message assistant visé) et l'envoie à nouveau comme un nouveau tour — + * l'historique existant n'est ni modifié ni supprimé, la nouvelle réponse + * s'ajoute simplement à la suite (cohérent avec le modèle d'historique + * "append only" côté serveur). */ + const regenerateMessage = useCallback((messageId) => { + if (sending) return; + const idx = messages.findIndex((m) => m.id === messageId); + if (idx === -1) return; + const target = messages[idx]; + const userMessage = target.role === 'user' ? target : messages[idx - 1]; + if (!userMessage || userMessage.role !== 'user') return; + runTurn(userMessage.content); + }, [sending, messages, runTurn]); + + return { + conversationId, messages, sending, loadingHistory, + selectConversation, startNewConversation, sendMessage, regenerateMessage, + }; +} + +/** Fil de messages (bulles + badges d'outils + indicateur de saisie), + * auto-scrollé vers le bas à chaque nouveau contenu. Utilisé tel quel + * dans le panneau flottant et dans la page dédiée (seule la taille du + * conteneur parent diffère, via CSS). */ +export function MessageThread({ messages, emptyHint, sending, onRegenerate }) { + const threadRef = useRef(null); + const [copiedId, setCopiedId] = useState(null); + + useEffect(() => { + if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight; + }, [messages]); + + const handleCopy = useCallback(async (m) => { + try { + await navigator.clipboard.writeText(m.content); + } catch { + // Repli si l'API Clipboard est indisponible (contexte non sécurisé, etc.). + try { + const ta = document.createElement('textarea'); + ta.value = m.content; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } catch { + return; + } + } + setCopiedId(m.id); + setTimeout(() => setCopiedId((cur) => (cur === m.id ? null : cur)), 1500); + }, []); + + return ( +
+ {messages.length === 0 &&
{emptyHint}
} + {messages.map((m) => { + const anyToolCalling = (m.tools || []).some((t) => t.status === 'calling'); + // "Réfléchit" (points animés) tant qu'il n'y a ni texte ni outil en + // cours d'appel (le badge d'outil affiche déjà son propre "…") ; + // curseur clignotant en fin de texte une fois la réponse démarrée — + // pour qu'il y ait toujours un signe visible que ça travaille encore. + const showThinking = m.pending && m.content === '' && !anyToolCalling; + const showCursor = m.pending && m.content !== ''; + const canAct = !m.pending && !m.error && !!m.content; + return ( +
+
+ {m.role === 'assistant' ? : 'Moi'} +
+
+ + {m.content} + {showCursor &&
+
+ ); + })} +
+ ); +} + +/** Zone de saisie (textarea + bouton d'envoi), Entrée pour envoyer / + * Maj+Entrée pour un saut de ligne. */ +export function Composer({ value, onChange, onSend, disabled, autoFocusKey }) { + const textareaRef = useRef(null); + + useEffect(() => { + if (autoFocusKey !== undefined) setTimeout(() => textareaRef.current?.focus(), 50); + }, [autoFocusKey]); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSend(); + } + }; + + return ( +
+