IA Assistant version initiale

This commit is contained in:
ocroguennec committed 2026-09-05 10:54:50 +02:00
1 parent 3e8508d48b
commit 662003fdd3
29 files changed
+4253 -10

No files matched your search

+9
View File
@@ -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=
+642
View File
@@ -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"
}
}
}
}
+2
View File
@@ -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",
+113
View File
@@ -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: [...] }.");
+107
View File
@@ -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);
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* crypto.js — Chiffrement au repos des clés API des fournisseurs IA
* (ai_providers.api_key_chiffree).
*
* Contrairement aux clés API internes de l'application (api_keys.key_hash,
* à sens unique — on ne fait que comparer un hash), une clé de fournisseur
* IA (Anthropic, OpenAI, OpenRouter...) doit être renvoyée EN CLAIR au
* fournisseur à chaque appel : elle est donc chiffrée de façon réversible
* (AES-256-GCM), jamais hashée.
*
* AI_ENC_KEY (obligatoire dès qu'un fournisseur IA est configuré) : chaîne
* hexadécimale de 64 caractères (32 octets), générée avec `openssl rand
* -hex 32` — voir .env.example. Ne jamais commiter cette clé ; la faire
* tourner invalide tous les fournisseurs déjà enregistrés (à re-saisir).
*/
import crypto from 'node:crypto';
const ALGO = 'aes-256-gcm';
const IV_LENGTH = 12; // taille recommandée pour GCM
function getKey() {
const hex = process.env.AI_ENC_KEY;
if (!hex) {
throw new Error(
"AI_ENC_KEY manquant — générez une clé avec `openssl rand -hex 32` et ajoutez-la à .env avant de configurer un fournisseur IA."
);
}
const key = Buffer.from(hex, 'hex');
if (key.length !== 32) {
throw new Error(
'AI_ENC_KEY doit être une chaîne hexadécimale de 64 caractères (32 octets) — générée avec `openssl rand -hex 32`.'
);
}
return key;
}
/**
* Chiffre une chaîne (typiquement une clé API de fournisseur IA) pour
* stockage en base. Format stocké : "<iv_hex>:<authTag_hex>:<ciphertext_hex>".
*/
export function encryptSecret(plaintext) {
const key = getKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${ciphertext.toString('hex')}`;
}
/** Déchiffre une valeur produite par encryptSecret(). Lève si AI_ENC_KEY a
* changé depuis le chiffrement (authTag invalide) ou si le format stocké
* est corrompu. */
export function decryptSecret(stored) {
const key = getKey();
const parts = String(stored).split(':');
if (parts.length !== 3) {
throw new Error('Valeur chiffrée malformée (attendu "iv:authTag:ciphertext")');
}
const [ivHex, authTagHex, ciphertextHex] = parts;
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextHex, 'hex')),
decipher.final(),
]);
return plaintext.toString('utf8');
}
+46
View File
@@ -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);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* mcpBridge.js — Pont MCP in-process vers les mêmes outils que le serveur
* MCP externe (mcp-server/tools.js), réutilisés TELS QUELS (import direct
* depuis mcp-server/, jamais modifiés) via un client MCP standard connecté
* en mémoire (InMemoryTransport) — donc un comportement strictement
* identique à ce que voit un client MCP externe (Claude Desktop...), sans
* passer par le réseau, et sans aucun risque pour le serveur MCP externe en
* production (ce fichier ne le touche pas).
*
* Chaque appel crée son propre couple client/serveur MCP, léger, sans état
* partagé entre requêtes.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { registerDataTools } from '../../../mcp-server/tools.js';
/**
* Crée un client MCP connecté aux 9 outils de lecture seule du portefeuille,
* exécutés via `apiGet` (typiquement une clé API interne éphémère — voir
* internalApiKey.js).
*
* @param {(path: string, params?: object) => Promise<any>} apiGet
* @returns {Promise<Client>} Client MCP prêt pour listTools()/callTool().
* Appeler client.close() en fin d'usage.
*/
export async function createAiToolsClient(apiGet) {
const server = new McpServer({ name: 'crowdlending-ai-embarque', version: '0.1.0' });
registerDataTools(server, { apiGet });
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'crowdlending-ai-embarque-client', version: '0.1.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
return client;
}
+16
View File
@@ -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();
}
+163
View File
@@ -0,0 +1,163 @@
/**
* providers/anthropic.js — Adaptateur fournisseur IA "anthropic" (natif,
* SDK officiel @anthropic-ai/sdk — pas de base_url, contrairement à
* l'adaptateur openai_compatible à venir).
*
* Boucle d'usage d'outils : tant que le modèle répond stop_reason
* 'tool_use', on exécute les outils demandés via le pont MCP (mcpBridge.js
* — les 9 mêmes outils en lecture seule que le serveur MCP externe) et on
* relance un tour avec le(s) tool_result ajoutés à l'historique, jusqu'à
* 'end_turn' ou MAX_TOOL_ITERATIONS (garde-fou anti-boucle).
*/
import Anthropic from '@anthropic-ai/sdk';
import { mcpToolsToAnthropic } from '../toolSchema.js';
const MAX_TOOL_ITERATIONS = 8;
const DEFAULT_MAX_TOKENS = 4096;
/**
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model Ex. "claude-sonnet-4-5" (configuré par l'admin, ai_providers.modele)
* @param {string} [opts.systemPrompt]
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
* Historique de conversation en texte simple (converti ici en blocs Anthropic).
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
* Client MCP connecté (voir mcpBridge.js).
*
* @yields {
* {type:'text_delta', text:string} |
* {type:'tool_call', name:string, input:object} |
* {type:'tool_result', name:string, isError:boolean} |
* {type:'done', usage:{input_tokens:number, output_tokens:number}, toolsUsed:string[]} |
* {type:'error', message:string}
* }
*/
export async function* streamChat({ apiKey, model, systemPrompt, messages, mcpClient }) {
const anthropic = new Anthropic({ apiKey });
const { tools: mcpTools } = await mcpClient.listTools();
const tools = mcpToolsToAnthropic(mcpTools);
// Historique mutable au format API Anthropic (content = tableau de blocs).
let history = messages.map((m) => ({
role: m.role,
content: [{ type: 'text', text: m.content }],
}));
let totalInputTokens = 0;
let totalOutputTokens = 0;
const toolsUsed = [];
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let stream;
try {
stream = anthropic.messages.stream({
model,
max_tokens: DEFAULT_MAX_TOKENS,
system: systemPrompt,
messages: history,
tools,
});
} catch (e) {
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
return;
}
try {
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
yield { type: 'text_delta', text: event.delta.text };
}
}
} catch (e) {
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
return;
}
let finalMessage;
try {
finalMessage = await stream.finalMessage();
} catch (e) {
yield { type: 'error', message: `Échec de récupération de la réponse : ${e.message}` };
return;
}
totalInputTokens += finalMessage.usage?.input_tokens || 0;
totalOutputTokens += finalMessage.usage?.output_tokens || 0;
history.push({ role: 'assistant', content: finalMessage.content });
if (finalMessage.stop_reason !== 'tool_use') {
yield {
type: 'done',
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
toolsUsed,
};
return;
}
const toolUseBlocks = finalMessage.content.filter((b) => b.type === 'tool_use');
const toolResultBlocks = [];
for (const block of toolUseBlocks) {
yield { type: 'tool_call', name: block.name, input: block.input };
toolsUsed.push(block.name);
let resultText;
let isError = false;
try {
const result = await mcpClient.callTool({ name: block.name, arguments: block.input });
isError = !!result.isError;
resultText =
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
} catch (e) {
isError = true;
resultText = `Erreur : ${e.message}`;
}
yield { type: 'tool_result', name: block.name, isError };
toolResultBlocks.push({
type: 'tool_result',
tool_use_id: block.id,
content: resultText,
is_error: isError,
});
}
history.push({ role: 'user', content: toolResultBlocks });
}
yield {
type: 'error',
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
};
}
/**
* testConnection() — appel minimal, sans streaming ni outils, pour
* vérifier depuis l'admin qu'une clé API + modèle fonctionnent (bouton
* « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
*/
export async function testConnection({ apiKey, model }) {
const anthropic = new Anthropic({ apiKey });
const started = Date.now();
const message = await anthropic.messages.create({
model,
max_tokens: 16,
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
});
const reply = (message.content || [])
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
.trim() || '(réponse vide)';
return { reply, durationMs: Date.now() - started, usage: message.usage };
}
@@ -0,0 +1,236 @@
/**
* providers/openaiCompatible.js — Adaptateur fournisseur IA
* "openai_compatible" : OpenAI, Mistral, Groq, un LLM local, ou OpenRouter
* (ai_providers.base_url pointé vers https://openrouter.ai/api/v1).
*
* Pas de SDK dédié : appel HTTP direct en POST {base_url}/chat/completions
* avec stream:true, et parsing manuel des trames SSE renvoyées par l'API
* (format `data: {...}\n\n`, terminé par `data: [DONE]`) — même technique
* que frontend/src/api.js:stream() côté client, pour rester cohérent avec
* le reste du projet plutôt que d'ajouter une dépendance SDK OpenAI.
*
* Boucle d'usage d'outils : même esprit que providers/anthropic.js (jusqu'à
* MAX_TOOL_ITERATIONS), adaptée au format "function calling" OpenAI : les
* tool_calls arrivent en deltas fragmentés (nom et arguments JSON accumulés
* morceau par morceau), puis sont exécutés via le pont MCP et renvoyés en
* messages role:"tool".
*
* NB : le support effectif du function calling en streaming varie selon le
* modèle/fournisseur derrière base_url (tous les modèles OpenRouter ne le
* supportent pas) — non testé en conditions réelles dans cette session,
* faute de clé API disponible (voir project_ia_embarquee.md, mémoire projet).
*/
import { mcpToolsToOpenAI } from '../toolSchema.js';
const MAX_TOOL_ITERATIONS = 8;
const DEFAULT_MAX_TOKENS = 4096;
/**
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.baseUrl Ex. "https://openrouter.ai/api/v1" (ai_providers.base_url)
* @param {string} opts.model Ex. "anthropic/claude-sonnet-4.5" (ai_providers.modele)
* @param {string} [opts.systemPrompt]
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
*
* @yields Mêmes événements que providers/anthropic.js#streamChat.
*/
export async function* streamChat({ apiKey, baseUrl, model, systemPrompt, messages, mcpClient }) {
const { tools: mcpTools } = await mcpClient.listTools();
const tools = mcpToolsToOpenAI(mcpTools);
let history = [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
...messages.map((m) => ({ role: m.role, content: m.content })),
];
let totalInputTokens = 0;
let totalOutputTokens = 0;
const toolsUsed = [];
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: history,
tools,
stream: true,
max_tokens: DEFAULT_MAX_TOKENS,
}),
});
} catch (e) {
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
return;
}
if (!res.ok || !res.body) {
let detail = '';
try { detail = await res.text(); } catch { /* noop */ }
yield { type: 'error', message: `Erreur API (${res.status}) : ${detail || res.statusText}` };
return;
}
// Accumulateurs pour reconstruire la réponse assistant à partir des
// deltas SSE (le contenu texte ET les tool_calls arrivent fragmentés).
let assistantContent = '';
const toolCallsAcc = new Map(); // index -> { id, name, argsText }
let finishReason = null;
let usage = null;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
let parsed;
try { parsed = JSON.parse(data); } catch { continue; }
if (parsed.usage) usage = parsed.usage;
const choice = parsed.choices?.[0];
if (!choice) continue;
const delta = choice.delta || {};
if (delta.content) {
assistantContent += delta.content;
yield { type: 'text_delta', text: delta.content };
}
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!toolCallsAcc.has(idx)) toolCallsAcc.set(idx, { id: tc.id || `call_${idx}`, name: '', argsText: '' });
const acc = toolCallsAcc.get(idx);
if (tc.id) acc.id = tc.id;
if (tc.function?.name) acc.name += tc.function.name;
if (tc.function?.arguments) acc.argsText += tc.function.arguments;
}
}
if (choice.finish_reason) finishReason = choice.finish_reason;
}
}
} catch (e) {
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
return;
}
if (usage) {
totalInputTokens += usage.prompt_tokens || 0;
totalOutputTokens += usage.completion_tokens || 0;
}
if (finishReason !== 'tool_calls' || toolCallsAcc.size === 0) {
history.push({ role: 'assistant', content: assistantContent });
yield {
type: 'done',
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
toolsUsed,
};
return;
}
const toolCalls = [...toolCallsAcc.values()];
history.push({
role: 'assistant',
content: assistantContent || null,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.argsText },
})),
});
for (const tc of toolCalls) {
let input = {};
try { input = tc.argsText ? JSON.parse(tc.argsText) : {}; }
catch { /* arguments JSON malformés renvoyés par le modèle — outil appelé avec {} */ }
yield { type: 'tool_call', name: tc.name, input };
toolsUsed.push(tc.name);
let resultText;
let isError = false;
try {
const result = await mcpClient.callTool({ name: tc.name, arguments: input });
isError = !!result.isError;
resultText =
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
} catch (e) {
isError = true;
resultText = `Erreur : ${e.message}`;
}
yield { type: 'tool_result', name: tc.name, isError };
history.push({ role: 'tool', tool_call_id: tc.id, content: resultText });
}
}
yield {
type: 'error',
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
};
}
/**
* testConnection() — appel minimal, sans streaming ni outils, pour
* vérifier depuis l'admin qu'une clé API + base_url + modèle fonctionnent
* (bouton « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.baseUrl
* @param {string} opts.model
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
*/
export async function testConnection({ apiKey, baseUrl, model }) {
const started = Date.now();
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model,
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
max_tokens: 16,
stream: false,
}),
signal: AbortSignal.timeout(20000),
});
} catch (e) {
throw new Error(`Impossible de joindre ${baseUrl} : ${e.message}`);
}
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
if (!res.ok) {
const msg = body?.error?.message || body?.error || res.statusText || `Erreur HTTP ${res.status}`;
throw new Error(`Erreur API (${res.status}) : ${msg}`);
}
const reply = body?.choices?.[0]?.message?.content?.trim() || '(réponse vide)';
return { reply, durationMs: Date.now() - started, usage: body?.usage };
}
+39
View File
@@ -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),
},
}));
}
+150
View File
@@ -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;
+69
View File
@@ -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 };
}
+18 -1
View File
@@ -73,7 +73,7 @@ const router = Router();
router.get('/users', (req, res) => {
const users = db.prepare(`
SELECT
id, email, display_name, role, email_verified, totp_enabled, status, created_at,
id, email, display_name, role, email_verified, totp_enabled, status, created_at, ai_enabled,
(SELECT COUNT(*) FROM plateformes p WHERE p.user_id = users.id) AS nb_plateformes,
(SELECT COUNT(*) FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
@@ -156,6 +156,23 @@ router.patch('/users/:id/status', (req, res, next) => {
} catch (e) { next(e); }
});
/** Active / désactive l'accès à l'Assistant IA pour un utilisateur (décision admin) */
const PatchAiAccessSchema = z.object({
aiEnabled: z.boolean(),
});
router.patch('/users/:id/ai-access', (req, res, next) => {
try {
const { aiEnabled } = PatchAiAccessSchema.parse(req.body);
const targetId = Number(req.params.id);
const r = db.prepare("UPDATE users SET ai_enabled=?, updated_at=datetime('now') WHERE id=?")
.run(aiEnabled ? 1 : 0, targetId);
if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
audit(req, { action: 'ai_access_changed', category: 'ai', actorId: req.user.id, targetUserId: targetId, details: { ai_enabled: aiEnabled } });
res.json({ id: targetId, aiEnabled });
} catch (e) { next(e); }
});
/** Modifie le rôle d'un utilisateur */
const PatchRoleSchema = z.object({
role: z.enum(['user', 'admin']),
+394
View File
@@ -0,0 +1,394 @@
/**
* /api/admin/ai — Configuration de l'Assistant IA embarqué (back-office).
* Protégé par requireAuth + requireAdmin (appliqués dans server.js).
*
* GET /providers — liste des fournisseurs (sans la clé API en clair)
* POST /providers — crée un fournisseur (clé API obligatoire)
* PUT /providers/:id — modifie un fournisseur (clé API optionnelle —
* voir le pattern « write-only » de smtp.js :
* omise = on conserve la clé déjà enregistrée)
* DELETE /providers/:id — supprime un fournisseur
* POST /providers/:id/test — vérifie la connexion (clé/modèle/base_url)
* avec un appel minimal, sans passer par le
* chat ni par le pont MCP — demandé par
* Olivier pour valider une config sans
* avoir à ouvrir le tchat utilisateur
* GET /config — configuration globale (actif, quota)
* PATCH /config — modifie la configuration globale
*
* Distinct de routes/ai.js (chat utilisateur, protégé par requireAiAccess) :
* ce routeur ne fait que gérer la configuration ; seul /providers/:id/test
* appelle réellement le fournisseur IA, et seulement pour un ping minimal.
*/
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { encryptSecret, decryptSecret } from '../ai/crypto.js';
import { audit } from '../utils/audit.js';
import { testConnection as testAnthropic } from '../ai/providers/anthropic.js';
import { testConnection as testOpenAiCompatible } from '../ai/providers/openaiCompatible.js';
const router = Router();
// ── Helpers ──────────────────────────────────────────────────────────────
function serializeProvider(row) {
return {
id: row.id,
nom: row.nom,
type: row.type,
baseUrl: row.base_url || '',
modele: row.modele,
hasApiKey: !!row.api_key_chiffree,
actif: !!row.actif,
isDefault: !!row.is_default,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function ensureConfigRow() {
db.prepare('INSERT OR IGNORE INTO ai_config (id, actif) VALUES (1, 0)').run();
}
// ── GET /providers ───────────────────────────────────────────────────────
router.get('/providers', (_req, res) => {
const rows = db.prepare('SELECT * FROM ai_providers ORDER BY is_default DESC, id ASC').all();
res.json(rows.map(serializeProvider));
});
// ── POST /providers ──────────────────────────────────────────────────────
const CreateProviderSchema = z.object({
nom: z.string().min(1).max(100),
type: z.enum(['anthropic', 'openai_compatible']),
baseUrl: z.string().max(500).optional(),
modele: z.string().min(1).max(200),
apiKey: z.string().min(1),
actif: z.boolean().optional(),
isDefault: z.boolean().optional(),
});
router.post('/providers', (req, res, next) => {
try {
const body = CreateProviderSchema.parse(req.body);
if (body.type === 'openai_compatible' && !body.baseUrl?.trim()) {
throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1).");
}
const apiKeyChiffree = encryptSecret(body.apiKey);
const tx = db.transaction(() => {
if (body.isDefault) {
db.prepare('UPDATE ai_providers SET is_default = 0').run();
}
const r = db.prepare(`
INSERT INTO ai_providers (nom, type, base_url, modele, api_key_chiffree, actif, is_default, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
`).run(
body.nom.trim(),
body.type,
body.baseUrl?.trim() || null,
body.modele.trim(),
apiKeyChiffree,
body.actif === false ? 0 : 1,
body.isDefault ? 1 : 0,
);
return r.lastInsertRowid;
});
const id = tx();
audit(req, { action: 'ai_provider_created', category: 'ai', actorId: req.user.id, details: { id, nom: body.nom, type: body.type } });
res.status(201).json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id)));
} catch (e) { next(e); }
});
// ── PUT /providers/:id ───────────────────────────────────────────────────
const UpdateProviderSchema = z.object({
nom: z.string().min(1).max(100).optional(),
type: z.enum(['anthropic', 'openai_compatible']).optional(),
baseUrl: z.string().max(500).optional(),
modele: z.string().min(1).max(200).optional(),
apiKey: z.string().min(1).optional(), // omis = clé conservée telle quelle
actif: z.boolean().optional(),
isDefault: z.boolean().optional(),
});
router.put('/providers/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
if (!row) throw new HttpError(404, 'Fournisseur introuvable');
const body = UpdateProviderSchema.parse(req.body);
const type = body.type !== undefined ? body.type : row.type;
const baseUrl = body.baseUrl !== undefined ? body.baseUrl.trim() : row.base_url;
if (type === 'openai_compatible' && !baseUrl) {
throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1).");
}
const apiKeyChiffree = body.apiKey !== undefined ? encryptSecret(body.apiKey) : row.api_key_chiffree;
db.transaction(() => {
if (body.isDefault) {
db.prepare('UPDATE ai_providers SET is_default = 0 WHERE id != ?').run(id);
}
db.prepare(`
UPDATE ai_providers SET
nom = ?, type = ?, base_url = ?, modele = ?, api_key_chiffree = ?,
actif = ?, is_default = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
body.nom !== undefined ? body.nom.trim() : row.nom,
type,
baseUrl || null,
body.modele !== undefined ? body.modele.trim() : row.modele,
apiKeyChiffree,
body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif,
body.isDefault !== undefined ? (body.isDefault ? 1 : 0) : row.is_default,
id,
);
})();
audit(req, { action: 'ai_provider_updated', category: 'ai', actorId: req.user.id, details: { id, apiKeyChanged: body.apiKey !== undefined } });
res.json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id)));
} catch (e) { next(e); }
});
// ── DELETE /providers/:id ────────────────────────────────────────────────
router.delete('/providers/:id', (req, res, next) => {
try {
const id = Number(req.params.id);
const row = db.prepare('SELECT id, nom FROM ai_providers WHERE id = ?').get(id);
if (!row) throw new HttpError(404, 'Fournisseur introuvable');
db.transaction(() => {
// provider_defaut_id référence ai_providers sans PRAGMA foreign_keys=ON
// garanti actif dans ce process : on nettoie explicitement plutôt que
// de compter sur ON DELETE SET NULL.
db.prepare('UPDATE ai_config SET provider_defaut_id = NULL WHERE provider_defaut_id = ?').run(id);
db.prepare('UPDATE ai_usage_log SET provider_id = NULL WHERE provider_id = ?').run(id);
db.prepare('DELETE FROM ai_providers WHERE id = ?').run(id);
})();
audit(req, { action: 'ai_provider_deleted', category: 'ai', actorId: req.user.id, details: { id, nom: row.nom } });
res.json({ deleted: true });
} catch (e) { next(e); }
});
// ── POST /providers/:id/test ─────────────────────────────────────────────
// Réponse toujours 200 : { ok:true, reponse, dureeMs } en cas de succès,
// { ok:false, error } si le fournisseur a répondu une erreur ou n'a pas pu
// être joint — seule une ligne introuvable ou une clé indéchiffrable est
// une vraie erreur HTTP (le test lui-même a bien pu s'exécuter).
router.post('/providers/:id/test', async (req, res, next) => {
try {
const id = Number(req.params.id);
const provider = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
if (!provider) throw new HttpError(404, 'Fournisseur introuvable');
if (!provider.api_key_chiffree) {
return res.json({ ok: false, error: "Aucune clé API enregistrée pour ce fournisseur." });
}
let apiKey;
try {
apiKey = decryptSecret(provider.api_key_chiffree);
} catch (e) {
throw new HttpError(500, `Impossible de déchiffrer la clé API : ${e.message}`);
}
if (provider.type === 'openai_compatible' && !provider.base_url) {
return res.json({ ok: false, error: "URL de base manquante pour ce fournisseur." });
}
try {
const result = provider.type === 'anthropic'
? await testAnthropic({ apiKey, model: provider.modele })
: await testOpenAiCompatible({ apiKey, baseUrl: provider.base_url, model: provider.modele });
audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: true, dureeMs: result.durationMs } });
res.json({ ok: true, reponse: result.reply, dureeMs: result.durationMs });
} catch (e) {
audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: false, error: e.message } });
res.json({ ok: false, error: e.message });
}
} catch (e) { next(e); }
});
// ── GET /config ───────────────────────────────────────────────────────────
router.get('/config', (_req, res) => {
ensureConfigRow();
const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get();
res.json({
actif: !!row.actif,
quotaQuotidien: row.quota_quotidien ?? null,
updatedAt: row.updated_at,
});
});
// ── PATCH /config ─────────────────────────────────────────────────────────
const PatchConfigSchema = z.object({
actif: z.boolean().optional(),
quotaQuotidien: z.number().int().positive().nullable().optional(),
});
router.patch('/config', (req, res, next) => {
try {
ensureConfigRow();
const body = PatchConfigSchema.parse(req.body);
const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get();
if (body.actif === true) {
const activeProvider = db.prepare("SELECT id FROM ai_providers WHERE actif = 1 LIMIT 1").get();
if (!activeProvider) {
throw new HttpError(400, "Impossible d'activer l'Assistant IA : aucun fournisseur actif n'est configuré.");
}
}
db.prepare(`
UPDATE ai_config SET actif = ?, quota_quotidien = ?, updated_at = datetime('now') WHERE id = 1
`).run(
body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif,
body.quotaQuotidien !== undefined ? body.quotaQuotidien : row.quota_quotidien,
);
audit(req, { action: 'ai_config_updated', category: 'ai', actorId: req.user.id, details: body });
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── GET /usage/summary ───────────────────────────────────────────────────
// Vue d'ensemble de la consommation : total requetes/tokens sur la periode,
// repartition par utilisateur, et serie quotidienne (pour un mini-graphe).
// `days` : fenetre glissante en jours (defaut 30, borne a [1, 365]).
router.get('/usage/summary', (req, res) => {
const days = Math.min(Math.max(Number(req.query.days) || 30, 1), 365);
const since = `-${days} days`;
const totals = db.prepare(`
SELECT
COUNT(*) AS requests,
COALESCE(SUM(tokens_entree), 0) AS tokensIn,
COALESCE(SUM(tokens_sortie), 0) AS tokensOut,
SUM(CASE WHEN erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors
FROM ai_usage_log
WHERE created_at >= datetime('now', ?)
`).get(since);
const byUser = db.prepare(`
SELECT
u.id AS userId, u.email, u.display_name AS displayName,
COUNT(*) AS requests,
COALESCE(SUM(l.tokens_entree), 0) AS tokensIn,
COALESCE(SUM(l.tokens_sortie), 0) AS tokensOut,
SUM(CASE WHEN l.erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors,
MAX(l.created_at) AS lastUsed
FROM ai_usage_log l
JOIN users u ON u.id = l.user_id
WHERE l.created_at >= datetime('now', ?)
GROUP BY u.id
ORDER BY requests DESC
`).all(since);
const daily = db.prepare(`
SELECT
date(created_at) AS day,
COUNT(*) AS requests,
COALESCE(SUM(tokens_entree), 0) AS tokensIn,
COALESCE(SUM(tokens_sortie), 0) AS tokensOut
FROM ai_usage_log
WHERE created_at >= datetime('now', ?)
GROUP BY date(created_at)
ORDER BY day ASC
`).all(since);
res.json({
days,
totals: {
requests: totals.requests,
tokensIn: totals.tokensIn,
tokensOut: totals.tokensOut,
tokensTotal: totals.tokensIn + totals.tokensOut,
errors: totals.errors,
},
byUser: byUser.map(r => ({
userId: r.userId,
email: r.email,
displayName: r.displayName,
requests: r.requests,
tokensIn: r.tokensIn,
tokensOut: r.tokensOut,
tokensTotal: r.tokensIn + r.tokensOut,
errors: r.errors,
lastUsed: r.lastUsed,
})),
daily,
});
});
// ── GET /usage/log ───────────────────────────────────────────────────────
// Journal detaille, pagine, requete par requete — pour l'audit fin.
// Filtrable par utilisateur (userId) et par fenetre (days).
router.get('/usage/log', (req, res) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const userId = req.query.userId ? Number(req.query.userId) : null;
const days = req.query.days ? Math.min(Math.max(Number(req.query.days), 1), 365) : null;
const conditions = [];
const params = [];
if (userId) { conditions.push('l.user_id = ?'); params.push(userId); }
if (days) { conditions.push("l.created_at >= datetime('now', ?)"); params.push(`-${days} days`); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const total = db.prepare(`SELECT COUNT(*) AS n FROM ai_usage_log l ${where}`).get(...params).n;
const rows = db.prepare(`
SELECT
l.id, l.created_at AS createdAt, l.modele,
l.tokens_entree AS tokensIn, l.tokens_sortie AS tokensOut,
l.outils_appeles AS outilsAppeles, l.erreur,
u.id AS userId, u.email, u.display_name AS displayName,
i.nom AS investisseurNom,
p.nom AS providerNom
FROM ai_usage_log l
JOIN users u ON u.id = l.user_id
LEFT JOIN investisseurs i ON i.id = l.investisseur_id
LEFT JOIN ai_providers p ON p.id = l.provider_id
${where}
ORDER BY l.id DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
res.json({
total,
rows: rows.map(r => ({
id: r.id,
createdAt: r.createdAt,
modele: r.modele,
tokensIn: r.tokensIn,
tokensOut: r.tokensOut,
tokensTotal: (r.tokensIn || 0) + (r.tokensOut || 0),
outilsAppeles: r.outilsAppeles ? JSON.parse(r.outilsAppeles) : [],
erreur: r.erreur,
userId: r.userId,
email: r.email,
displayName: r.displayName,
investisseurNom: r.investisseurNom,
providerNom: r.providerNom,
})),
});
});
export default router;
+264
View File
@@ -0,0 +1,264 @@
/**
* routes/ai.js — Assistant IA embarqué.
*
* GET /api/ai/status — l'utilisateur courant a-t-il accès au chat ?
* GET /api/ai/conversations — liste des conversations (les plus récentes en premier)
* GET /api/ai/conversations/:id/messages — historique d'une conversation
* DELETE /api/ai/conversations/:id — supprime une conversation (et ses messages)
* POST /api/ai/chat — envoie un message et reçoit la réponse en streaming (SSE)
*
* Deux adaptateurs fournisseur, sélectionnés selon provider.type :
* "anthropic" (ai/providers/anthropic.js, SDK officiel) et
* "openai_compatible" (ai/providers/openaiCompatible.js, fetch + SSE manuel
* — OpenAI, Mistral, Groq, un LLM local, ou OpenRouter via provider.base_url).
*
* Conversations : plusieurs fils nommés par couple user/investisseur (voir
* ai/conversationHistory.js), partagés entre le widget flottant et la page
* dédiée /assistant-ia via leur id. POST /chat accepte un conversationId
* optionnel dans le corps de la requête — omis (ou introuvable), une
* nouvelle conversation est créée à la volée, titrée à partir du message.
* Le premier événement SSE envoyé est toujours `conversation` ({id, titre})
* pour que le client apprenne l'id (nouveau ou confirmé) avant tout texte.
*
* Historique : le client n'envoie que le dernier message utilisateur (body :
* { conversationId, message }) — l'historique complet est reconstitué
* côté serveur depuis ai_messages puis complété par ce nouveau message
* avant l'appel au modèle. Les deux messages (utilisateur puis assistant)
* sont persistés : l'utilisateur dès réception (pour ne pas le perdre même
* si le modèle échoue ensuite), l'assistant seulement si le tour se
* termine par un événement 'done' (une réponse interrompue par une erreur
* n'est pas enregistrée en tant que telle — l'utilisateur peut simplement
* reposer sa question).
*/
import { Router } from 'express';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { requireAiAccess, getAiAccessStatus, checkAiQuota } from '../middleware/aiAccess.js';
import { getDefaultActiveProvider } from '../ai/providerConfig.js';
import { decryptSecret } from '../ai/crypto.js';
import { createEphemeralInternalApiKey, revokeInternalApiKey } from '../ai/internalApiKey.js';
import { createAiToolsClient } from '../ai/mcpBridge.js';
import {
listConversations, getConversation, createConversation, deleteConversation,
getHistory, appendMessage,
} from '../ai/conversationHistory.js';
import { streamChat as streamChatAnthropic } from '../ai/providers/anthropic.js';
import { streamChat as streamChatOpenAiCompatible } from '../ai/providers/openaiCompatible.js';
const router = Router();
router.use(requireInvestisseur);
const SELF_API_BASE = `http://localhost:${process.env.PORT || 4000}/api/v1`;
/** Même logique que makeApiGet() dans mcp-server/server.js (clé API en
* en-tête X-API-Key vers l'API v1) — dupliquée ici volontairement plutôt
* que partagée entre les deux process (backend et mcp-server tournent
* séparément) ; toute divergence de comportement entre les deux serait un
* bug à corriger dans les deux endroits. */
function makeApiGet(apiKey) {
return async function apiGet(path, params) {
const url = new URL(SELF_API_BASE + path);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
}
}
let res;
try {
res = await fetch(url, {
headers: { 'X-API-Key': apiKey, Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
});
} catch (e) {
throw new Error(`Impossible de joindre l'API interne : ${e.message}`);
}
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
if (!res.ok) {
const msg = (body && body.error) || res.statusText || 'Requête échouée';
throw new Error(`Erreur API (${res.status}) : ${msg}`);
}
return body;
};
}
const SYSTEM_PROMPT =
"Tu es l'assistant IA embarqué de l'application Crowdlending Tracker. " +
"Tu réponds aux questions de l'utilisateur sur son portefeuille de " +
"crowdlending en t'appuyant exclusivement sur les outils mis à ta " +
"disposition (lecture seule — tu ne peux rien modifier dans l'application). " +
"Sois précis, cite les montants exacts renvoyés par les outils, et indique " +
"clairement quand une information n'est pas disponible plutôt que de " +
"l'inventer. Les montants sont en euros sauf indication contraire.";
/** Dérive un titre de conversation à partir du premier message (aplati sur
* une ligne, tronqué). Jamais renommé automatiquement ensuite. */
function titleFromMessage(content) {
const flat = content.replace(/\s+/g, ' ').trim();
if (!flat) return 'Nouvelle conversation';
return flat.length > 60 ? `${flat.slice(0, 57)}…` : flat;
}
/** GET /api/ai/status — volontairement SANS requireAiAccess : doit répondre
* même pour un utilisateur non autorisé, pour que le frontend sache
* masquer le point d'entrée du chat plutôt que de tenter un appel refusé. */
router.get('/status', (req, res) => {
res.json({ enabled: getAiAccessStatus(req.user.id).enabled });
});
/** GET /api/ai/conversations — liste des conversations du fil user+investisseur
* courant, les plus récemment actives en premier (widget + page dédiée). */
router.get('/conversations', requireAiAccess, (req, res) => {
res.json({ conversations: listConversations(req.user.id, req.investisseur.id) });
});
/** GET /api/ai/conversations/:id/messages — historique d'une conversation
* précise, pour que le widget ou la page dédiée puissent la recharger
* (après un refresh de page, ou en la sélectionnant dans la liste). */
router.get('/conversations/:id/messages', requireAiAccess, (req, res) => {
const id = Number(req.params.id);
const conversation = getConversation(req.user.id, req.investisseur.id, id);
if (!conversation) throw new HttpError(404, 'Conversation introuvable.');
res.json({ conversation, messages: getHistory(id, 200) });
});
/** DELETE /api/ai/conversations/:id — supprime définitivement une
* conversation et ses messages. */
router.delete('/conversations/:id', requireAiAccess, (req, res) => {
const id = Number(req.params.id);
const ok = deleteConversation(req.user.id, req.investisseur.id, id);
if (!ok) throw new HttpError(404, 'Conversation introuvable.');
res.json({ deleted: true });
});
/** POST /api/ai/chat — SSE. Body : { conversationId?: number, message: string }.
* conversationId omis (ou introuvable) → nouvelle conversation créée à la volée. */
router.post('/chat', requireAiAccess, async (req, res) => {
const userContent = typeof req.body?.message === 'string' ? req.body.message.trim() : '';
if (!userContent) {
return res.status(400).json({ error: 'message requis (chaîne non vide)' });
}
const quota = checkAiQuota(req.user.id);
if (!quota.allowed) {
return res.status(429).json({ error: `Quota quotidien atteint (${quota.limit} requêtes/jour).` });
}
const provider = getDefaultActiveProvider();
if (!provider) {
return res.status(503).json({ error: "Aucun fournisseur IA actif n'est configuré." });
}
if (provider.type !== 'anthropic' && provider.type !== 'openai_compatible') {
return res.status(501).json({ error: `Adaptateur "${provider.type}" non supporté.` });
}
if (provider.type === 'openai_compatible' && !provider.base_url) {
return res.status(500).json({ error: "Fournisseur openai_compatible mal configuré : URL de base manquante." });
}
let apiKey;
try {
apiKey = decryptSecret(provider.api_key_chiffree);
} catch (e) {
return res.status(500).json({ error: `Impossible de déchiffrer la clé du fournisseur : ${e.message}` });
}
// Résout la conversation : existante et possédée par l'utilisateur, ou
// nouvelle (créée seulement maintenant qu'on sait que la requête est
// valide — pas de conversation vide créée sur un échec de quota/config).
const requestedId = req.body?.conversationId != null ? Number(req.body.conversationId) : null;
let conversation = requestedId ? getConversation(req.user.id, req.investisseur.id, requestedId) : null;
if (requestedId && !conversation) {
return res.status(404).json({ error: 'Conversation introuvable.' });
}
if (!conversation) {
conversation = createConversation(req.user.id, req.investisseur.id, titleFromMessage(userContent));
}
// Historique persisté + nouveau message, pour l'appel au modèle.
const priorHistory = getHistory(conversation.id, 40);
const messages = [...priorHistory.map((m) => ({ role: m.role, content: m.content })), { role: 'user', content: userContent }];
appendMessage(conversation.id, req.user.id, req.investisseur.id, 'user', userContent);
const internalKey = createEphemeralInternalApiKey(req.user.id, req.investisseur.id);
let mcpClient = null;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const send = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
// Toujours le premier événement : le client apprend l'id de conversation
// (nouveau ou confirmé) avant tout texte, pour synchroniser son URL/liste.
send('conversation', { type: 'conversation', id: conversation.id, titre: conversation.titre });
let closed = false;
// res.on('close'), pas req.on('close') : req ('close') peut se declencher des
// que le corps de la requete POST a fini d'etre lu (faux positif frequent avec
// Express/Node), bien avant toute vraie deconnexion du client. res ('close')
// reflete l'etat reel de la connexion de reponse (SSE en cours).
res.on('close', () => { closed = true; });
let assistantContent = '';
const toolsUsedLog = [];
let usageLog = null;
let errorLog = null;
let completed = false;
try {
mcpClient = await createAiToolsClient(makeApiGet(internalKey.key));
const streamFn = provider.type === 'anthropic' ? streamChatAnthropic : streamChatOpenAiCompatible;
const streamOpts = provider.type === 'anthropic'
? { apiKey, model: provider.modele, systemPrompt: SYSTEM_PROMPT, messages, mcpClient }
: { apiKey, baseUrl: provider.base_url, model: provider.modele, systemPrompt: SYSTEM_PROMPT, messages, mcpClient };
for await (const event of streamFn(streamOpts)) {
if (closed) break;
if (event.type === 'text_delta') assistantContent += event.text;
if (event.type === 'tool_call') toolsUsedLog.push(event.name);
if (event.type === 'done') { usageLog = event.usage; completed = true; }
if (event.type === 'error') errorLog = event.message;
send(event.type, event);
}
} catch (e) {
errorLog = e.message;
if (!closed) send('error', { type: 'error', message: e.message });
} finally {
revokeInternalApiKey(internalKey.id);
if (mcpClient) {
try { await mcpClient.close(); } catch { /* best-effort */ }
}
if (completed && assistantContent) {
appendMessage(conversation.id, req.user.id, req.investisseur.id, 'assistant', assistantContent, toolsUsedLog);
}
try {
db.prepare(`
INSERT INTO ai_usage_log
(user_id, investisseur_id, provider_id, modele, tokens_entree, tokens_sortie, outils_appeles, erreur)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user.id,
req.investisseur.id,
provider.id,
provider.modele,
usageLog?.input_tokens ?? null,
usageLog?.output_tokens ?? null,
toolsUsedLog.length ? JSON.stringify(toolsUsedLog) : null,
errorLog,
);
} catch (e) {
console.error("[ai/chat] échec du log d'usage (ignoré) :", e.message);
}
if (!closed) res.end();
}
});
export default router;
+2 -2
View File
@@ -7,7 +7,7 @@ const router = Router();
const KEY_PREFIX_LEN = 12; // ex: "clk_live_ab3" — assez pour identifier sans exposer le secret
function generateKey() {
export function generateKey() {
const secret = crypto.randomBytes(24).toString('hex'); // 48 caractères hex
const full = `clk_live_${secret}`;
const hash = crypto.createHash('sha256').update(full).digest('hex');
@@ -21,7 +21,7 @@ router.get('/', (req, res) => {
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k
JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.user_id = ?
WHERE k.user_id = ? AND k.internal = 0
ORDER BY k.revoked_at IS NOT NULL, k.created_at DESC
`).all(req.user.id);
res.json(rows);
+4
View File
@@ -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);
+4
View File
@@ -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() {
<Route path="frais" element={<FraisOnly><Frais /></FraisOnly>} />
<Route path="simul" element={<SimulRemboursements />} />
<Route path="taxreport" element={<TaxReport />} />
<Route path="assistant-ia" element={<AssistantIA />} />
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
<Route path="imports" element={<Navigate to="/settings?section=imports" replace />} />
<Route path="settings" element={<Settings />} />
@@ -106,6 +109,7 @@ export default function App() {
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
<Route path="aide" element={<Aide />} />
<Route path="notifications" element={<Notifications />} />
<Route path="communication" element={<Communication />} />
+57
View File
@@ -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); }
+157
View File
@@ -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 (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}
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(
<div className="ai-panel" role="dialog" aria-label="Assistant IA">
<div className="ai-panel-header">
<IconSparkle />
<span className="ai-panel-title">Assistant IA</span>
<button className="ai-panel-close" onClick={expand} aria-label="Agrandir" title="Agrandir dans une page dédiée">
<IconExpand />
</button>
<button className="ai-panel-close" onClick={startNewConversation} disabled={sending || messages.length === 0}
aria-label="Nouvelle conversation" title="Nouvelle conversation">
<IconNewChat />
</button>
<button className="ai-panel-close" onClick={() => setOpen(false)} aria-label="Fermer">
<IconClose />
</button>
</div>
<MessageThread
messages={messages}
emptyHint="Posez une question sur votre portefeuille — ex. « Quels sont mes intérêts nets perçus cette année ? »"
sending={sending}
onRegenerate={regenerateMessage}
/>
<Composer value={input} onChange={setInput} onSend={handleSend} disabled={sending} autoFocusKey={open} />
</div>,
document.body
);
return (
<>
<button
className="ai-fab"
onClick={() => setOpen((v) => !v)}
title="Assistant IA"
aria-label="Ouvrir l'assistant IA"
>
<IconSparkle />
</button>
{panel}
</>
);
}
+14
View File
@@ -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 = () => <I><polyline points="1,15 5,9 9,11 15,3"/><polyli
const IconRepayments = () => <I><path d="M15 9A6 6 0 1 1 9 3"/><polyline points="15,3 15,9 9,9"/></I>;
const IconFlatTax = () => <I><circle cx="9" cy="9" r="7.5"/><path d="M9 1.5a11 11 0 0 1 3.5 7.5 11 11 0 0 1-3.5 7.5 11 11 0 0 1-3.5-7.5 11 11 0 0 1 3.5-7.5z"/><line x1="1.5" y1="9" x2="16.5" y2="9"/></I>;
const IconFrais = () => <I><circle cx="5.5" cy="5.5" r="2"/><circle cx="12.5" cy="12.5" r="2"/><line x1="13.5" y1="3.5" x2="3.5" y2="13.5"/></I>;
const IconAssistantIa = () => <I><path d="M9 2l1.3 4.2L14.5 7.5l-4.2 1.3L9 13l-1.3-4.2L3.5 7.5l4.2-1.3L9 2z"/><path d="M14.5 11.5l.5 1.5 1.5.5-1.5.5-.5 1.5-.5-1.5-1.5-.5 1.5-.5.5-1.5z"/></I>;
/* 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 (
<div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
<aside className="sidebar">
@@ -340,6 +347,11 @@ export default function Layout() {
<NavLink to="/taxreport" title="Fiscalité">
<NavIcon libFilename={navIcons.tax} Fallback={IconFlatTax} /><span className="nav-label">Fiscalité</span>
</NavLink>
{aiEnabled && (
<NavLink to="/assistant-ia" title="Assistant IA">
<NavIcon libFilename={navIcons.assistantIa} Fallback={IconAssistantIa} /><span className="nav-label">Assistant IA</span>
</NavLink>
)}
</nav>
@@ -423,6 +435,8 @@ export default function Layout() {
<Outlet />
</main>
<AiAssistant />
</div>
);
}
+429
View File
@@ -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 (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z" />
<path d="M19 15l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7.7-2z" />
</svg>
);
}
export function IconSparkleSmall() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3z" />
</svg>
);
}
export function IconSend() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
);
}
export function IconTool() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
);
}
export function IconNewChat() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="12" y1="7" x2="12" y2="13" /><line x1="9" y1="10" x2="15" y2="10" />
</svg>
);
}
export function IconExpand() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" />
</svg>
);
}
export function IconTrash() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4h6v2" />
</svg>
);
}
export function IconCopy() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
}
export function IconCheck() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
export function IconRegenerate() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="1 4 1 10 7 10" /><polyline points="23 20 23 14 17 14" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
);
}
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 (
<div className="ai-tool-badges">
{tools.map((t, i) => (
<span key={i} className={`ai-tool-badge ${t.status}`}>
<IconTool />
{toolLabel(t.name)}
{t.status === 'calling' && '…'}
</span>
))}
</div>
);
}
/** 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 (
<div className="ai-thread" ref={threadRef}>
{messages.length === 0 && <div className="ai-empty">{emptyHint}</div>}
{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 (
<div key={m.id} className={`ai-message from-${m.role === 'assistant' ? 'ai' : 'user'}`}>
<div className="ai-message-avatar">
{m.role === 'assistant' ? <IconSparkleSmall /> : 'Moi'}
</div>
<div className="ai-message-content">
<ToolBadges tools={m.tools} />
{m.content}
{showCursor && <span className="ai-cursor" aria-hidden="true" />}
{showThinking && (
<span className="ai-typing" aria-label="L'assistant réfléchit">
<span /><span /><span />
</span>
)}
{m.error && <div className="ai-message-error">{m.error}</div>}
<div className="ai-message-meta">
{m.timestamp && <span className="ai-message-time">{relativeDate(m.timestamp)}</span>}
{canAct && (
<button
type="button"
className="ai-message-action"
onClick={() => handleCopy(m)}
aria-label="Copier le message"
title="Copier"
>
{copiedId === m.id ? <IconCheck /> : <IconCopy />}
</button>
)}
{canAct && m.role === 'assistant' && onRegenerate && (
<button
type="button"
className="ai-message-action"
onClick={() => onRegenerate(m.id)}
disabled={sending}
aria-label="Rejouer cette demande"
title="Rejouer cette demande"
>
<IconRegenerate />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
/** 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 (
<div className="ai-input-form">
<textarea
ref={textareaRef}
className="ai-textarea"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Votre question…"
rows={1}
disabled={disabled}
/>
<button
className="ai-send-btn"
onClick={onSend}
disabled={disabled || !value.trim()}
aria-label="Envoyer"
title="Envoyer (Entrée)"
>
<IconSend />
</button>
</div>
);
}
+3 -6
View File
@@ -18,6 +18,7 @@ function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24"
function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>; }
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
function IconDownload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; }
function IconSparkle() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>; }
const NAV = [
{
@@ -28,6 +29,8 @@ const NAV = [
{ id: 'audit-logs', label: 'Audit', icon: <IconShield /> },
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
{ id: 'smtp', label: 'Configuration SMTP', icon: <IconMail /> },
{ id: 'ia', label: 'Assistant IA', icon: <IconSparkle />, href: '/admin/ia' },
],
},
{
@@ -37,12 +40,6 @@ const NAV = [
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
],
},
{
group: 'Notifications',
items: [
{ id: 'smtp', label: 'SMTP', icon: <IconMail /> },
],
},
{
group: 'Maintenance',
items: [
+649
View File
@@ -0,0 +1,649 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import ResultBanner from '../components/ResultBanner.jsx';
import { fmt, StatusBadge as LogStatusBadge } from './admin/adminHelpers.jsx';
/* ── Icônes ───────────────────────────────────────────────────── */
function IconChevronLeft() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>;
}
function IconSparkle() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>;
}
function IconServer() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>;
}
function IconSettings() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>;
}
function IconChart() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>;
}
function IconEye({ open }) {
return open
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>;
}
const IconEdit = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
const IconTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
const IconStar = ({ filled }) => <svg width="14" height="14" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>;
const IconZap = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>;
/* ── Toggle switch (copié du pattern SmtpSection.jsx) ────────────── */
function Toggle({ checked, onChange, id }) {
return (
<label htmlFor={id} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<input id={id} type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} style={{ display: 'none' }} />
<span style={{
display: 'inline-block', width: 44, height: 24, borderRadius: 12,
background: checked ? 'var(--primary)' : 'var(--border)', position: 'relative',
transition: 'background 0.2s', flexShrink: 0,
}}>
<span style={{
position: 'absolute', top: 3, left: checked ? 23 : 3, width: 18, height: 18,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</span>
</label>
);
}
function StatusBadge({ actif }) {
return actif
? <span style={{ display: 'inline-block', padding: '1px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600, background: 'rgba(34,197,94,.12)', color: '#16a34a', border: '1px solid rgba(34,197,94,.3)' }}>Actif</span>
: <span style={{ display: 'inline-block', padding: '1px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600, background: 'rgba(148,163,184,.15)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>Inactif</span>;
}
const TYPE_LABELS = {
anthropic: 'Anthropic (natif)',
openai_compatible: 'Compatible OpenAI',
};
/* ── Modal création / édition d'un fournisseur ───────────────────── */
const EMPTY_PROVIDER = { nom: '', type: 'anthropic', baseUrl: '', modele: '', apiKey: '', actif: true, isDefault: false };
function ProviderModal({ provider, onClose, onSaved }) {
const isNew = !provider?.id;
const [form, setForm] = useState(isNew ? EMPTY_PROVIDER : {
nom: provider.nom, type: provider.type, baseUrl: provider.baseUrl || '',
modele: provider.modele, apiKey: '', actif: provider.actif, isDefault: provider.isDefault,
});
const [showKey, setShowKey] = useState(false);
const [err, setErr] = useState(null);
const [saving, setSaving] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.nom.trim()) { setErr('Le nom est requis.'); return; }
if (!form.modele.trim()) { setErr('Le modèle est requis.'); return; }
if (form.type === 'openai_compatible' && !form.baseUrl.trim()) {
setErr("L'URL de base est requise pour un déploiement compatible OpenAI (ex : https://openrouter.ai/api/v1)."); return;
}
if (isNew && !form.apiKey.trim()) { setErr('La clé API est requise.'); return; }
setSaving(true); setErr(null);
try {
const payload = {
nom: form.nom.trim(),
type: form.type,
baseUrl: form.baseUrl.trim() || undefined,
modele: form.modele.trim(),
actif: form.actif,
isDefault: form.isDefault,
};
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
if (isNew) { await api.post('/admin/ai/providers', payload); }
else { await api.put(`/admin/ai/providers/${provider.id}`, payload); }
onSaved(); onClose();
} catch (ex) { setErr(ex.message); }
finally { setSaving(false); }
};
const inp = { width: '100%', boxSizing: 'border-box' };
return (
<Modal open title={isNew ? 'Ajouter un fournisseur IA' : `Modifier — ${provider.nom}`}
onClose={onClose} width={520}
footer={<>
<button type="button" onClick={onClose} disabled={saving}>Annuler</button>
<button className="primary" form="ai-provider-form" type="submit" disabled={saving}>
{saving ? '…' : isNew ? 'Créer' : 'Enregistrer'}
</button>
</>}>
<form id="ai-provider-form" onSubmit={handleSubmit}>
{err && (
<div style={{ padding: '8px 12px', borderRadius: 6, marginBottom: 14, background: 'rgba(239,68,68,.1)', color: '#dc2626', border: '1px solid rgba(239,68,68,.3)', fontSize: 13 }}>
{err}
</div>
)}
<div style={{ marginBottom: 14 }}>
<label>Nom *</label>
<input style={inp} value={form.nom} onChange={e => set('nom', e.target.value)} placeholder="Ex : Anthropic (production)" />
</div>
<div style={{ marginBottom: 14 }}>
<label>Type de déploiement *</label>
<select style={inp} value={form.type} onChange={e => set('type', e.target.value)}>
<option value="anthropic">Anthropic (natif)</option>
<option value="openai_compatible">Compatible OpenAI — OpenRouter, OpenAI, Mistral, LLM local…</option>
</select>
</div>
{form.type === 'openai_compatible' && (
<div style={{ marginBottom: 14 }}>
<label>URL de base *</label>
<input style={inp} value={form.baseUrl} onChange={e => set('baseUrl', e.target.value)} placeholder="https://openrouter.ai/api/v1" />
</div>
)}
<div style={{ marginBottom: 14 }}>
<label>Modèle *</label>
<input style={inp} value={form.modele} onChange={e => set('modele', e.target.value)}
placeholder={form.type === 'anthropic' ? 'claude-sonnet-4-5-20250929' : 'anthropic/claude-sonnet-4.5'} />
</div>
<div style={{ marginBottom: 14 }}>
<label>Clé API {isNew ? '*' : ''}</label>
<div style={{ position: 'relative' }}>
<input
type={showKey ? 'text' : 'password'}
style={{ ...inp, paddingRight: 36 }}
value={form.apiKey}
onChange={e => set('apiKey', e.target.value)}
placeholder={!isNew && provider.hasApiKey ? '•••••••• (laisser vide pour conserver)' : 'sk-ant-…'}
autoComplete="new-password"
/>
<button type="button" onClick={() => setShowKey(s => !s)} tabIndex={-1}
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0, display: 'flex' }}>
<IconEye open={showKey} />
</button>
</div>
{!isNew && provider.hasApiKey && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
Une clé est déjà enregistrée. Laissez ce champ vide pour la conserver.
</div>
)}
</div>
<div style={{ display: 'flex', gap: 28, marginTop: 20 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<Toggle id="prov-actif" checked={form.actif} onChange={v => set('actif', v)} /> Actif
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<Toggle id="prov-default" checked={form.isDefault} onChange={v => set('isDefault', v)} /> Fournisseur par défaut
</label>
</div>
</form>
</Modal>
);
}
/* ── Section : fournisseurs ───────────────────────────────────────── */
function ProvidersSection() {
const [providers, setProviders] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // provider | { new: true } | null
const [confirmDel, setConfirmDel] = useState(null);
const [banner, setBanner] = useState(null);
const [testingId, setTestingId] = useState(null);
const load = async () => {
setLoading(true);
try { setProviders(await api.get('/admin/ai/providers')); }
catch (e) { setBanner({ ok: false, msg: e.message }); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const delProvider = (p) => {
setConfirmDel({
message: `Supprimer le fournisseur « ${p.nom} » ? Cette action est irréversible.`,
onConfirm: async () => {
try { await api.del(`/admin/ai/providers/${p.id}`); await load(); }
catch (e) { setBanner({ ok: false, msg: e.message }); }
finally { setConfirmDel(null); }
},
});
};
const toggleActif = async (p) => {
try { await api.put(`/admin/ai/providers/${p.id}`, { actif: !p.actif }); await load(); }
catch (e) { setBanner({ ok: false, msg: e.message }); }
};
const testProvider = async (p) => {
setTestingId(p.id);
setBanner(null);
try {
const r = await api.post(`/admin/ai/providers/${p.id}/test`, {});
if (r.ok) {
setBanner({ ok: true, msg: `${p.nom} — connexion réussie en ${r.dureeMs} ms (réponse du modèle : « ${r.reponse} »).` });
} else {
setBanner({ ok: false, msg: `${p.nom} — échec : ${r.error}` });
}
} catch (e) {
setBanner({ ok: false, msg: `${p.nom} — échec : ${e.message}` });
} finally {
setTestingId(null);
}
};
return (
<div className="account-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<h2 style={{ margin: 0 }}>Fournisseurs IA</h2>
<button className="primary" onClick={() => setEditing({ new: true })}>+ Ajouter</button>
</div>
<p style={{ color: 'var(--text-muted)', marginBottom: 20 }}>
Adaptateur natif Anthropic ou compatible OpenAI (OpenRouter, OpenAI, Mistral, LLM local…). Plusieurs fournisseurs peuvent
être configurés ; le fournisseur par défaut actif est utilisé pour les conversations.
</p>
{banner && <ResultBanner result={banner} onDismiss={() => setBanner(null)} style={{ marginBottom: 16 }} />}
<div className="card" style={{ padding: 0 }}>
{loading ? (
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</p>
) : providers.length === 0 ? (
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Aucun fournisseur configuré.</p>
) : (
<table style={{ margin: 0 }}>
<thead>
<tr>
<th style={{ paddingLeft: 20 }}>Nom</th>
<th>Type</th>
<th>Modèle</th>
<th>Clé API</th>
<th>Statut</th>
<th>Défaut</th>
<th style={{ width: 120, paddingRight: 20 }}></th>
</tr>
</thead>
<tbody>
{providers.map(p => (
<tr key={p.id}>
<td style={{ paddingLeft: 20, fontWeight: 600 }}>{p.nom}</td>
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{TYPE_LABELS[p.type] || p.type}</td>
<td style={{ fontSize: 13 }}>{p.modele}</td>
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{p.hasApiKey ? '••••••••' : <em>non renseignée</em>}</td>
<td>
<button onClick={() => toggleActif(p)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }} title="Basculer l'activation">
<StatusBadge actif={p.actif} />
</button>
</td>
<td><IconStar filled={p.isDefault} /></td>
<td style={{ paddingRight: 20, textAlign: 'right', whiteSpace: 'nowrap' }}>
<button onClick={() => testProvider(p)} disabled={testingId === p.id || !p.hasApiKey}
title={p.hasApiKey ? 'Tester la connexion' : 'Aucune clé API enregistrée'}
style={{ background: 'none', border: 'none', cursor: p.hasApiKey ? 'pointer' : 'not-allowed', color: testingId === p.id ? 'var(--primary)' : 'var(--text-muted)', padding: 4, opacity: p.hasApiKey ? 1 : 0.4 }}>
<IconZap />
</button>
<button onClick={() => setEditing(p)} title="Modifier"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 4 }}>
<IconEdit />
</button>
<button onClick={() => delProvider(p)} title="Supprimer"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 4 }}>
<IconTrash />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{editing && (
<ProviderModal
provider={editing.new ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => { setBanner({ ok: true, msg: editing.new ? 'Fournisseur créé.' : 'Fournisseur mis à jour.' }); load(); }}
/>
)}
<ConfirmModal
open={!!confirmDel}
title="Supprimer le fournisseur"
message={confirmDel?.message}
onConfirm={confirmDel?.onConfirm}
onCancel={() => setConfirmDel(null)}
/>
</div>
);
}
/* ── Ligne de paramètre (copié du pattern SmtpSection.jsx) ───────── */
function SettingRow({ label, description, children }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', alignItems: 'center', padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: description ? 4 : 0 }}>{label}</div>
{description && <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>{description}</div>}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>{children}</div>
</div>
);
}
/* ── Section : configuration globale ─────────────────────────────── */
function ConfigSection() {
const [config, setConfig] = useState({ actif: false, quotaQuotidien: null });
const [providers, setProviders] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [banner, setBanner] = useState(null);
const load = async () => {
setLoading(true);
try {
const [cfg, provs] = await Promise.all([api.get('/admin/ai/config'), api.get('/admin/ai/providers')]);
setConfig({ actif: cfg.actif, quotaQuotidien: cfg.quotaQuotidien });
setProviders(provs);
} catch (e) { setBanner({ ok: false, msg: e.message }); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const activeDefault = providers.find(p => p.actif && p.isDefault) || providers.find(p => p.actif);
const save = async (next) => {
setSaving(true);
try {
await api.patch('/admin/ai/config', next);
setBanner({ ok: true, msg: 'Configuration enregistrée.' });
await load();
} catch (e) { setBanner({ ok: false, msg: e.message }); }
finally { setSaving(false); }
};
const toggleActif = (v) => {
if (v && !activeDefault) {
setBanner({ ok: false, msg: "Impossible d'activer l'Assistant IA : configurez d'abord au moins un fournisseur actif." });
return;
}
save({ actif: v });
};
if (loading) return <div className="account-section"><p style={{ color: 'var(--text-muted)' }}>Chargement…</p></div>;
return (
<div className="account-section">
<h2 style={{ margin: '0 0 4px' }}>Configuration</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: 20 }}>
Interrupteur global de l'Assistant IA embarqué. L'accès reste également soumis à l'autorisation individuelle
de chaque utilisateur (page Utilisateurs) et à la disponibilité d'un fournisseur actif.
</p>
{banner && <ResultBanner result={banner} onDismiss={() => setBanner(null)} style={{ marginBottom: 16 }} />}
<div className="card" style={{ padding: '0 24px' }}>
<SettingRow
label="Activer l'Assistant IA"
description={activeDefault
? `Fournisseur par défaut actuel : ${activeDefault.nom} (${TYPE_LABELS[activeDefault.type] || activeDefault.type})`
: 'Aucun fournisseur actif — configurez-en un dans l\'onglet Fournisseurs avant d\'activer.'}
>
<Toggle id="ai-config-actif" checked={config.actif} onChange={toggleActif} />
</SettingRow>
<SettingRow label="Quota quotidien (requêtes / utilisateur)" description="Laissez vide pour ne fixer aucune limite.">
<input
type="number" min={1} className="form-input" style={{ width: 140 }}
value={config.quotaQuotidien ?? ''}
onChange={e => setConfig(c => ({ ...c, quotaQuotidien: e.target.value === '' ? null : Number(e.target.value) }))}
onBlur={() => save({ quotaQuotidien: config.quotaQuotidien })}
placeholder="Illimité"
disabled={saving}
/>
</SettingRow>
</div>
</div>
);
}
/* ── Carte de synthèse chiffrée ──────────────────────────────────── */
function StatCard({ label, value, accent }) {
return (
<div className="card" style={{ flex: '1 1 160px', minWidth: 160, padding: '16px 18px' }}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 6 }}>{label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: accent || 'var(--text)' }}>{value}</div>
</div>
);
}
const PERIODS = [
{ value: 7, label: '7 jours' },
{ value: 30, label: '30 jours' },
{ value: 90, label: '90 jours' },
{ value: 365, label: '1 an' },
];
/* ── Section : usage (requêtes & tokens consommés) ────────────────── */
function UsageSection() {
const [days, setDays] = useState(30);
const [summary, setSummary] = useState(null);
const [loadingSum, setLoadingSum] = useState(true);
const [err, setErr] = useState(null);
const [logRows, setLogRows] = useState([]);
const [logTotal, setLogTotal] = useState(0);
const [logPage, setLogPage] = useState(0);
const [logUserId, setLogUserId] = useState('');
const [loadingLog, setLoadingLog] = useState(true);
const LOG_PER_PAGE = 20;
const loadSummary = useCallback(async () => {
setLoadingSum(true);
try { setSummary(await api.get('/admin/ai/usage/summary', { days })); }
catch (e) { setErr(e.message); }
finally { setLoadingSum(false); }
}, [days]);
const loadLog = useCallback(async () => {
setLoadingLog(true);
try {
const params = { limit: LOG_PER_PAGE, offset: logPage * LOG_PER_PAGE };
if (logUserId) params.userId = logUserId;
const data = await api.get('/admin/ai/usage/log', params);
setLogRows(data.rows);
setLogTotal(data.total);
} catch (e) { setErr(e.message); }
finally { setLoadingLog(false); }
}, [logPage, logUserId]);
useEffect(() => { loadSummary(); }, [loadSummary]);
useEffect(() => { loadLog(); }, [loadLog]);
useEffect(() => { setLogPage(0); }, [logUserId]);
const logPages = Math.ceil(logTotal / LOG_PER_PAGE);
const n = (v) => (v ?? 0).toLocaleString('fr-FR');
return (
<div className="account-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4, flexWrap: 'wrap', gap: 12 }}>
<h2 style={{ margin: 0 }}>Usage</h2>
<div style={{ display: 'flex', gap: 6 }}>
{PERIODS.map(p => (
<button
key={p.value}
className="btn btn-outline"
onClick={() => setDays(p.value)}
style={{
fontSize: 12, padding: '4px 10px',
background: days === p.value ? 'var(--primary)' : undefined,
color: days === p.value ? '#fff' : undefined,
borderColor: days === p.value ? 'var(--primary)' : undefined,
}}>
{p.label}
</button>
))}
</div>
</div>
<p style={{ color: 'var(--text-muted)', marginBottom: 20 }}>
Nombre de requêtes envoyées à l'Assistant IA et tokens consommés (entrée / sortie), au global, par utilisateur,
et requête par requête, sur la période sélectionnée.
</p>
{err && <ResultBanner result={{ ok: false, msg: err }} onDismiss={() => setErr(null)} style={{ marginBottom: 16 }} />}
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginBottom: 24 }}>
<StatCard label="Requêtes" value={loadingSum ? '…' : n(summary?.totals.requests)} />
<StatCard label="Tokens entrée" value={loadingSum ? '…' : n(summary?.totals.tokensIn)} />
<StatCard label="Tokens sortie" value={loadingSum ? '…' : n(summary?.totals.tokensOut)} />
<StatCard label="Tokens (total)" value={loadingSum ? '…' : n(summary?.totals.tokensTotal)} accent="var(--primary)" />
<StatCard label="Erreurs" value={loadingSum ? '…' : n(summary?.totals.errors)} accent={(summary?.totals.errors ?? 0) > 0 ? '#dc2626' : undefined} />
</div>
<h3 style={{ margin: '0 0 10px', fontSize: 15 }}>Par utilisateur</h3>
<div className="card" style={{ padding: 0, marginBottom: 28 }}>
{loadingSum ? (
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</p>
) : !summary?.byUser.length ? (
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Aucune requête sur cette période.</p>
) : (
<table style={{ margin: 0 }}>
<thead>
<tr>
<th style={{ paddingLeft: 20 }}>Utilisateur</th>
<th>Requêtes</th>
<th>Tokens entrée</th>
<th>Tokens sortie</th>
<th>Tokens total</th>
<th>Erreurs</th>
<th style={{ paddingRight: 20 }}>Dernière utilisation</th>
</tr>
</thead>
<tbody>
{summary.byUser.map(u => (
<tr key={u.userId}>
<td style={{ paddingLeft: 20 }}>
<div style={{ fontWeight: 600 }}>{u.displayName || u.email}</div>
{u.displayName && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{u.email}</div>}
</td>
<td>{n(u.requests)}</td>
<td>{n(u.tokensIn)}</td>
<td>{n(u.tokensOut)}</td>
<td style={{ fontWeight: 600 }}>{n(u.tokensTotal)}</td>
<td style={{ color: u.errors > 0 ? '#dc2626' : undefined }}>{u.errors}</td>
<td style={{ paddingRight: 20, fontSize: 13, color: 'var(--text-muted)' }}>{fmt(u.lastUsed)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10, flexWrap: 'wrap', gap: 12 }}>
<h3 style={{ margin: 0, fontSize: 15 }}>Journal détaillé</h3>
<select className="form-input" style={{ width: 240 }} value={logUserId} onChange={e => setLogUserId(e.target.value)}>
<option value="">Tous les utilisateurs</option>
{(summary?.byUser || []).map(u => (
<option key={u.userId} value={u.userId}>{u.displayName || u.email}</option>
))}
</select>
</div>
<div className="card" style={{ padding: 0 }}>
{loadingLog ? (
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Chargement…</p>
) : logRows.length === 0 ? (
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>Aucune requête trouvée.</p>
) : (
<>
<table style={{ margin: 0 }}>
<thead>
<tr>
<th style={{ paddingLeft: 20 }}>Date</th>
<th>Utilisateur</th>
<th>Investisseur</th>
<th>Modèle</th>
<th>Tokens (in / out)</th>
<th>Outils</th>
<th style={{ paddingRight: 20 }}>Statut</th>
</tr>
</thead>
<tbody>
{logRows.map(r => (
<tr key={r.id}>
<td style={{ paddingLeft: 20, fontSize: 13, whiteSpace: 'nowrap' }}>{fmt(r.createdAt)}</td>
<td style={{ fontSize: 13 }}>{r.displayName || r.email}</td>
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{r.investisseurNom || '—'}</td>
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{r.modele || '—'}</td>
<td style={{ fontSize: 13 }}>{n(r.tokensIn)} / {n(r.tokensOut)}</td>
<td style={{ fontSize: 13, color: 'var(--text-muted)' }}>{r.outilsAppeles.length ? r.outilsAppeles.join(', ') : '—'}</td>
<td style={{ paddingRight: 20 }} title={r.erreur || ''}>
<LogStatusBadge status={r.erreur ? 'error' : 'ok'} />
</td>
</tr>
))}
</tbody>
</table>
{logPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, alignItems: 'center', padding: '14px 0' }}>
<button className="btn btn-outline" disabled={logPage === 0} onClick={() => setLogPage(p => p - 1)}>Précédent</button>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Page {logPage + 1} / {logPages}</span>
<button className="btn btn-outline" disabled={logPage >= logPages - 1} onClick={() => setLogPage(p => p + 1)}>Suivant</button>
</div>
)}
</>
)}
</div>
</div>
);
}
/* ── Navigation ───────────────────────────────────────────────── */
const NAV = [
{ id: 'providers', label: 'Fournisseurs', icon: <IconServer /> },
{ id: 'usage', label: 'Usage', icon: <IconChart /> },
{ id: 'config', label: 'Configuration', icon: <IconSettings /> },
];
/* ── Page principale ──────────────────────────────────────────── */
export default function AdminIA() {
const { search } = useLocation();
const navigate = useNavigate();
const section = new URLSearchParams(search).get('section') || 'providers';
return (
<div className="account-layout">
<aside className="account-sidebar">
<div style={{ marginBottom: 4 }}>
<button
onClick={() => navigate('/admin')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12,
color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer',
padding: '4px 0', marginBottom: 8 }}>
<IconChevronLeft /> Administration
</button>
</div>
<h1 className="account-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconSparkle /> Assistant IA
</h1>
<div className="account-nav-group">
{NAV.map(item => (
<button
key={item.id}
className={`account-nav-item${section === item.id ? ' active' : ''}`}
onClick={() => navigate(`/admin/ia?section=${item.id}`, { replace: true })}
>
{item.icon}
{item.label}
</button>
))}
</div>
</aside>
<div className="account-content">
{section === 'providers' && <ProvidersSection />}
{section === 'usage' && <UsageSection />}
{section === 'config' && <ConfigSection />}
</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
/**
* AssistantIA.jsx — Page dédiée de l'assistant IA (menu de gauche, sous
* Fiscalité) : le même chat que la bulle flottante (components/AiAssistant.jsx),
* en grand, avec la liste des conversations passées dans une colonne latérale
* (voir components/ai/aiChatShared.jsx pour la logique et le rendu partagés).
*
* La conversation affichée est pilotée par le paramètre d'URL ?conversation=,
* pour qu'un lien direct (ex. le bouton « Agrandir » du widget flottant)
* l'ouvre directement, et que le bouton retour du navigateur fonctionne.
*/
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api.js';
import ConfirmModal from '../components/ConfirmModal.jsx';
import {
IconSparkle, IconNewChat, IconTrash,
MessageThread, Composer, useAiChat, relativeDate,
} from '../components/ai/aiChatShared.jsx';
export default function AssistantIA() {
const [enabled, setEnabled] = useState(null); // null = pas encore su
const [conversations, setConversations] = useState([]);
const [loadingList, setLoadingList] = useState(true);
const [input, setInput] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const syncUrl = useCallback((id) => {
setSearchParams(id ? { conversation: String(id) } : {}, { replace: true });
}, [setSearchParams]);
const loadConversations = useCallback(async () => {
try {
const r = await api.get('/ai/conversations');
setConversations(r.conversations || []);
return r.conversations || [];
} catch {
setConversations([]);
return [];
} finally {
setLoadingList(false);
}
}, []);
const chat = useAiChat({
onConversationChange: syncUrl,
onTurnComplete: loadConversations,
});
const { conversationId, messages, sending, loadingHistory, selectConversation, startNewConversation, sendMessage, regenerateMessage } = chat;
/* ── Statut d'accès ─────────────────────────────────────────────────── */
useEffect(() => {
let cancelled = false;
api.get('/ai/status').then((r) => { if (!cancelled) setEnabled(!!r.enabled); }).catch(() => { if (!cancelled) setEnabled(false); });
return () => { cancelled = true; };
}, []);
/* ── Chargement initial : conversation demandée dans l'URL, sinon la plus
* récente de la liste (reprendre là où on s'était arrêté). ────────────── */
useEffect(() => {
if (enabled !== true) return;
let cancelled = false;
(async () => {
const list = await loadConversations();
if (cancelled) return;
const fromUrl = searchParams.get('conversation');
if (fromUrl) {
selectConversation(Number(fromUrl));
} else if (list.length > 0) {
selectConversation(list[0].id);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
const handleSend = useCallback(() => {
const text = input;
setInput('');
sendMessage(text);
}, [input, sendMessage]);
const handleNewConversation = useCallback(() => {
if (sending) return;
startNewConversation();
}, [sending, startNewConversation]);
const handleSelect = useCallback((id) => {
if (sending || id === conversationId) return;
selectConversation(id);
}, [sending, conversationId, selectConversation]);
const handleDelete = useCallback(async () => {
const id = confirmDeleteId;
if (id == null) return;
try {
await api.del(`/ai/conversations/${id}`);
if (id === conversationId) startNewConversation();
await loadConversations();
} catch { /* best-effort */ }
finally { setConfirmDeleteId(null); }
}, [confirmDeleteId, conversationId, startNewConversation, loadConversations]);
if (enabled === false) {
return (
<div className="ai-page">
<div className="ai-page-disabled">
<IconSparkle />
<p>L'assistant IA n'est pas activé pour votre compte.</p>
<p className="text-muted">Contactez un administrateur si vous pensez qu'il s'agit d'une erreur.</p>
</div>
</div>
);
}
const currentTitle = conversations.find((c) => c.id === conversationId)?.titre || 'Nouvelle conversation';
return (
<div className="ai-page">
<aside className="ai-page-sidebar">
<button className="ai-page-new-btn" onClick={handleNewConversation} disabled={sending}>
<IconNewChat /> Nouvelle conversation
</button>
<div className="ai-page-conv-list">
{loadingList ? (
<div className="ai-page-conv-empty">Chargement…</div>
) : conversations.length === 0 ? (
<div className="ai-page-conv-empty">Aucune conversation pour l'instant.</div>
) : (
conversations.map((c) => (
<div key={c.id} className={`ai-page-conv-item${c.id === conversationId ? ' active' : ''}`}>
<button className="ai-page-conv-select" onClick={() => handleSelect(c.id)} title={c.titre}>
<span className="ai-page-conv-title">{c.titre}</span>
<span className="ai-page-conv-date">{relativeDate(c.updatedAt)}</span>
</button>
<button
className="ai-page-conv-delete"
onClick={() => setConfirmDeleteId(c.id)}
aria-label="Supprimer la conversation"
title="Supprimer la conversation"
>
<IconTrash />
</button>
</div>
))
)}
</div>
</aside>
<div className="ai-page-main">
<div className="ai-page-header">
<IconSparkle />
<span className="ai-page-header-title">{currentTitle}</span>
</div>
{loadingHistory ? (
<div className="ai-page-conv-empty" style={{ flex: 1 }}>Chargement de la conversation…</div>
) : (
<MessageThread
messages={messages}
emptyHint="Posez une question sur votre portefeuille — ex. « Quels sont mes intérêts nets perçus cette année ? »"
sending={sending}
onRegenerate={regenerateMessage}
/>
)}
<Composer value={input} onChange={setInput} onSend={handleSend} disabled={sending} autoFocusKey={conversationId} />
</div>
<ConfirmModal
open={confirmDeleteId != null}
title="Supprimer la conversation"
message="Supprimer cette conversation et tous ses messages ? Cette action est irréversible."
onConfirm={handleDelete}
onCancel={() => setConfirmDeleteId(null)}
/>
</div>
);
}
+47 -1
View File
@@ -60,6 +60,33 @@ const IcoPlay = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="no
const IcoTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
const IcoDots = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="19" r="1" fill="currentColor"/></svg>;
const IcoSearch = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>;
const IcoSparkle = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>;
// Interrupteur compact — accès individuel à l'Assistant IA (même style visuel
// que le Toggle de AdminIA.jsx, dupliqué ici car non partagé entre les deux
// pages). Un clic déclenche toggleAiAccess(u), qui passe par une confirmation.
function AiToggle({ checked, onClick, disabled, title }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
style={{
display: 'inline-flex', alignItems: 'center', width: 36, height: 20, borderRadius: 10,
background: checked ? 'var(--primary)' : 'var(--border)', position: 'relative',
border: 'none', cursor: disabled ? 'default' : 'pointer', padding: 0, flexShrink: 0,
transition: 'background 0.2s', opacity: disabled ? 0.5 : 1,
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 18 : 2, width: 16, height: 16,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
);
}
function MenuBtn({ onClick, icon, label, danger = false }) {
return (
@@ -82,6 +109,7 @@ function toRows(users) {
ID: u.id, Nom: u.display_name || '', Email: u.email,
Statut: STATUS_FR[computedStatus(u)] || '',
Rôle: u.role === 'admin' ? 'Admin' : 'Utilisateur',
'Assistant IA': u.ai_enabled ? 'Activé' : 'Désactivé',
'2FA': u.totp_enabled ? 'Oui' : 'Non',
'Inscrit le': u.created_at ? u.created_at.replace('T', ' ').slice(0, 16) : '',
Plateformes: u.nb_plateformes ?? 0,
@@ -100,7 +128,7 @@ function dlBlob(content, filename, type) {
}
function toCSV(users) {
const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', '2FA', 'Inscrit le', 'Plateformes', 'Investissements', 'Documents', 'Taille documents'];
const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', 'Assistant IA', '2FA', 'Inscrit le', 'Plateformes', 'Investissements', 'Documents', 'Taille documents'];
const rows = toRows(users).map(r => Object.values(r).map(v => `"${String(v).replace(/"/g,'""')}"`).join(','));
return '' + [headers.map(h => `"${h}"`).join(','), ...rows].join('\n');
}
@@ -455,6 +483,15 @@ export default function UsersSection({ currentUserId }) {
confirm(`${labels[status]} le compte`, `${labels[status]} le compte de ${u.display_name || u.email} ?`,
async () => { await api.patch(`/admin/users/${u.id}/status`, { status }); load(); }, labels[status]);
};
const toggleAiAccess = u => {
const enable = !u.ai_enabled;
confirm(
enable ? "Activer l'accès IA" : "Désactiver l'accès IA",
`${enable ? 'Activer' : 'Désactiver'} l'accès à l'Assistant IA pour ${u.display_name || u.email} ?`,
async () => { await api.patch(`/admin/users/${u.id}/ai-access`, { aiEnabled: enable }); load(); },
'Confirmer',
);
};
const deleteUser = u => {
const identity = u.display_name ? `${u.display_name} (${u.email})` : u.email;
confirm(
@@ -615,6 +652,7 @@ export default function UsersSection({ currentUserId }) {
<th style={{ paddingLeft: 20 }}>Utilisateur</th>
<th>Statut</th>
<th>Rôle</th>
<th>Assistant IA</th>
<th>2FA</th>
<th>Inscrit le</th>
<th style={{ textAlign: 'right' }}>Nb de plateformes</th>
@@ -650,6 +688,13 @@ export default function UsersSection({ currentUserId }) {
</td>
<td><UserStatusBadge status={u.status || 'active'} emailVerified={u.email_verified} /></td>
<td><Badge role={u.role} /></td>
<td>
<AiToggle
checked={!!u.ai_enabled}
onClick={() => toggleAiAccess(u)}
title={u.ai_enabled ? "Désactiver l'accès à l'Assistant IA" : "Activer l'accès à l'Assistant IA"}
/>
</td>
<td>
{u.totp_enabled
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}>🔐 Actif</span>
@@ -691,6 +736,7 @@ export default function UsersSection({ currentUserId }) {
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 200,
}}>
<MenuBtn icon={<IcoEdit />} label={openMenu.user.role === 'admin' ? 'Rétrograder en utilisateur' : 'Promouvoir en admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
<MenuBtn icon={<IcoSparkle />} label={openMenu.user.ai_enabled ? "Désactiver l'accès IA" : "Activer l'accès IA"} onClick={() => { setOpenMenu(null); toggleAiAccess(openMenu.user); }} />
{!openMenu.user.email_verified && <>
<MenuBtn icon={<IcoMail />} label="Renvoyer l'invitation" onClick={() => { setOpenMenu(null); resendInvitation(openMenu.user); }} />
<MenuBtn icon={<IcoCheck />} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
+331
View File
@@ -3329,3 +3329,334 @@ tr:hover td { background: var(--surface-2); }
margin-right: auto;
}
}
/* ── Assistant IA embarqué (widget flottant) ──────────────────────────────
z-index 10000 : légèrement au-dessus du max existant (9999, .notif-dropdown)
pour rester au premier plan si les deux sont ouverts en même temps. */
/* Couleur dédiée (indépendante du thème clair/sombre) : celle de l'icône
Claude plutôt que le bleu --primary de l'application, pour identifier
visuellement l'assistant IA comme un élément à part. */
.ai-fab {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
width: 56px;
height: 56px;
padding: 0;
border-radius: 50%;
border: none;
background: #d97757;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 14px rgba(217, 119, 87, .45);
transition: background .15s, transform .15s;
}
.ai-fab svg { flex-shrink: 0; }
.ai-fab:hover { background: #c8663f; transform: scale(1.05); }
.ai-panel {
position: fixed;
bottom: 92px;
right: 24px;
z-index: 10000;
width: 380px;
max-width: calc(100vw - 32px);
height: 560px;
max-height: calc(100vh - 130px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, .18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.ai-panel-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
color: var(--primary);
}
.ai-panel-title { font-weight: 600; font-size: var(--fs-md); color: var(--text); flex: 1; }
.ai-panel-close {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; border-radius: 50%; flex-shrink: 0;
border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted);
cursor: pointer; transition: background .15s, color .15s, border-color .15s;
}
/* La règle générique `button { padding: 7px 14px }` mangeait, sur un bouton
de 28px de large, toute la largeur disponible (14px + 14px de padding),
écrasant l'icône SVG à 0px de large — invisible bien que présente dans le
DOM. D'où le `padding: 0` explicite ci-dessus. */
.ai-panel-close svg { width: 15px; height: 15px; flex-shrink: 0; }
.ai-panel-close:hover { background: var(--border); color: var(--text); }
.ai-panel-close:disabled { opacity: .45; cursor: default; }
.ai-thread {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.ai-empty { color: var(--text-muted); font-size: var(--fs-sm); text-align: center; margin-top: 24px; line-height: 1.5; }
.ai-message { display: flex; gap: 8px; align-items: flex-start; }
.ai-message.from-user { flex-direction: row-reverse; }
.ai-message-avatar {
width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--primary); color: #fff;
font-size: 10px; font-weight: 700;
}
.ai-message.from-user .ai-message-avatar { background: #64748b; }
.ai-message-content {
max-width: 82%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 11px;
font-size: var(--fs-sm);
color: var(--text);
line-height: 1.5;
white-space: pre-wrap;
}
.ai-message.from-ai .ai-message-content {
background: color-mix(in srgb, var(--primary) 6%, var(--surface));
border-color: color-mix(in srgb, var(--primary) 20%, transparent);
}
.ai-message.from-user .ai-message-content { background: var(--surface-2); }
.ai-tool-badges { display: flex; flex-wrap: wrap; margin-bottom: 6px; }
.ai-tool-badge {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--text-muted);
background: var(--surface-2); border: 1px solid var(--border);
border-radius: 20px; padding: 2px 8px; margin: 2px 4px 2px 0;
}
.ai-tool-badge.calling { color: var(--primary); border-color: color-mix(in srgb, var(--primary) 35%, transparent); }
.ai-tool-badge.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); }
.ai-message-error { color: var(--danger); font-size: var(--fs-sm); margin-top: 6px; }
.ai-typing { display: inline-flex; gap: 3px; align-items: center; height: 14px; }
.ai-typing span {
width: 5px; height: 5px; border-radius: 50%;
background: var(--text-muted);
animation: ai-typing-bounce 1.1s infinite ease-in-out;
}
.ai-typing span:nth-child(2) { animation-delay: .15s; }
.ai-typing span:nth-child(3) { animation-delay: .3s; }
@keyframes ai-typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: .5; }
30% { transform: translateY(-3px); opacity: 1; }
}
/* Curseur clignotant en fin de texte pendant qu'une réponse est encore en
train d'arriver (une fois que le texte a commencé — avant ça, ce sont les
points animés ci-dessus qui indiquent l'activité). */
.ai-cursor {
display: inline-block;
width: 2px;
height: 13px;
margin-left: 1px;
vertical-align: text-bottom;
background: currentColor;
animation: ai-cursor-blink 1s step-start infinite;
}
@keyframes ai-cursor-blink {
50% { opacity: 0; }
}
/* Ligne d'horodatage + actions (copier / rejouer) sous chaque message. */
.ai-message-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 5px;
opacity: .7;
}
.ai-message-time {
font-size: 10.5px;
color: var(--text-muted);
white-space: nowrap;
margin-right: 2px;
}
.ai-message-action {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; padding: 0; border-radius: 5px; flex-shrink: 0;
border: none; background: transparent; color: var(--text-muted);
cursor: pointer; transition: background .15s, color .15s;
}
/* Même précaution que .ai-panel-close (voir plus haut) : padding: 0 explicite
+ taille du svg fixée, pour ne pas que la règle générique `button { padding
: 7px 14px }` écrase l'icône sur ce petit bouton. */
.ai-message-action svg { width: 13px; height: 13px; flex-shrink: 0; }
.ai-message-action:hover { background: var(--surface-2); color: var(--text); }
.ai-message-action:disabled { opacity: .4; cursor: default; }
.ai-input-form {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.ai-textarea {
flex: 1;
resize: none;
min-height: 36px;
max-height: 100px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: var(--fs-sm);
font-family: inherit;
color: var(--text);
background: var(--input-bg);
}
.ai-textarea:focus { outline: none; border-color: var(--primary); }
.ai-textarea:disabled { opacity: .6; }
.ai-send-btn {
display: flex; align-items: center; justify-content: center;
width: 34px; height: 34px; padding: 0; border-radius: 8px;
border: none; background: var(--primary); color: #fff;
cursor: pointer; flex-shrink: 0;
transition: background .15s, opacity .15s;
}
.ai-send-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
.ai-send-btn:hover:not(:disabled) { background: var(--primary-hover); }
.ai-send-btn:disabled { opacity: .5; cursor: default; }
@media (max-width: 480px) {
.ai-panel { right: 16px; bottom: 84px; }
.ai-fab { right: 16px; bottom: 16px; }
}
/* ── Assistant IA — page dédiée (/assistant-ia) ────────────────────────────
Même chat que la bulle flottante (classes .ai-thread/.ai-message/... déjà
définies plus haut, réutilisées telles quelles), avec une colonne de
gauche listant les conversations passées. La colonne et le panneau de
chat restent visibles pendant le défilement (position: sticky, comme
.account-sidebar plus haut dans ce fichier) plutôt que de contraindre
toute la mise en page de .main — top: 60px / calc(100vh - 100px) sont
les mêmes valeurs déjà utilisées par .account-sidebar. */
.ai-page {
display: flex;
gap: 20px;
align-items: flex-start;
flex: 1;
padding: 20px 24px 24px;
}
.ai-page-sidebar {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
position: sticky;
top: 60px;
height: calc(100vh - 100px);
}
.ai-page-new-btn {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; border-radius: 8px;
border: 1px solid var(--border); background: var(--surface-2); color: var(--text);
font-size: var(--fs-sm); font-weight: 600; cursor: pointer; flex-shrink: 0;
transition: background .15s;
}
.ai-page-new-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
.ai-page-new-btn:hover:not(:disabled) { background: var(--border); }
.ai-page-new-btn:disabled { opacity: .5; cursor: default; }
.ai-page-conv-list {
flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.ai-page-conv-empty { padding: 16px 8px; color: var(--text-muted); font-size: var(--fs-sm); text-align: center; }
.ai-page-conv-item { display: flex; align-items: center; border-radius: 8px; }
.ai-page-conv-item:hover { background: var(--surface-2); }
.ai-page-conv-item.active { background: color-mix(in srgb, var(--primary) 10%, var(--surface-2)); }
.ai-page-conv-select {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
padding: 9px 10px; border-radius: 8px;
background: none; border: none; text-align: left; cursor: pointer;
}
.ai-page-conv-title {
font-size: var(--fs-sm); color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ai-page-conv-item.active .ai-page-conv-title { color: var(--primary); font-weight: 600; }
.ai-page-conv-date { font-size: 11px; color: var(--text-muted); }
.ai-page-conv-delete {
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; padding: 0; margin-right: 4px; border-radius: 6px;
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
opacity: 0; transition: opacity .12s, background .12s, color .12s;
}
.ai-page-conv-item:hover .ai-page-conv-delete { opacity: 1; }
.ai-page-conv-delete svg { width: 13px; height: 13px; flex-shrink: 0; }
.ai-page-conv-delete:hover { background: var(--surface); color: var(--danger); }
.ai-page-main {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
position: sticky; top: 60px;
height: calc(100vh - 100px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.ai-page-header {
display: flex; align-items: center; gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
color: var(--primary);
flex-shrink: 0;
}
.ai-page-header svg { width: 20px; height: 20px; flex-shrink: 0; }
.ai-page-header-title {
font-weight: 700; font-size: var(--fs-md); color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Réutilisation des classes du widget flottant, juste recentrées avec une
largeur de lecture confortable sur un grand écran. */
.ai-page-main .ai-thread { flex: 1; max-width: 760px; margin: 0 auto; width: 100%; padding: 20px; }
.ai-page-main .ai-input-form { max-width: 760px; margin: 0 auto; width: 100%; }
.ai-page-disabled {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; padding: 80px 20px; color: var(--text-muted); text-align: center; flex: 1;
}
.ai-page-disabled svg { width: 32px; height: 32px; color: var(--primary); flex-shrink: 0; }
@media (max-width: 900px) {
.ai-page { flex-direction: column; padding: 16px; }
.ai-page-sidebar { width: 100%; position: static; height: auto; max-height: 220px; }
.ai-page-main { position: static; height: calc(100vh - 280px); min-height: 420px; }
}