Spaces:
Sleeping
Sleeping
adam-hassen commited on
Commit ·
ac348e2
1
Parent(s): ef5913c
fix: nouvelles modifications ajout de chatbot
Browse files- Interface Graphique/.env +1 -0
- Interface Graphique/app.py +442 -3
- Interface Graphique/assets/chatbot.css +921 -0
- Interface Graphique/assets/chatbot.js +1245 -0
- Interface Graphique/pages/admin/testimonials.py +2 -2
- Interface Graphique/pages/analyse_page.py +0 -3
- Interface Graphique/pages/face_login.py +20 -20
- Interface Graphique/pages/home.py +3 -3
- Interface Graphique/pages/login.py +3 -3
- Interface Graphique/pages/mon_suivi.py +49 -10
- Interface Graphique/pages/profil.py +1 -1
- Interface Graphique/pages/signup.py +4 -4
- Interface Graphique/services/chat_service.py +156 -0
- Interface Graphique/services/database.py +34 -28
- Interface Graphique/services/face_auth.py +32 -32
- Interface Graphique/users.db +0 -0
- Modele_AI_prévisions_boursières (1).ipynb +0 -0
- PIPELINE_SCHEMA.md +202 -0
- requirements.txt +3 -1
- users.db +0 -0
Interface Graphique/.env
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
GROQ_API_KEY=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
|
Interface Graphique/app.py
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import dash
|
| 2 |
from dash import dcc, html, Input, Output, State
|
| 3 |
import yfinance as yf
|
| 4 |
import pandas as pd
|
| 5 |
-
from flask import jsonify, request as flask_request
|
| 6 |
import requests
|
| 7 |
from bs4 import BeautifulSoup
|
| 8 |
from services.database import init_db
|
| 9 |
import logging
|
| 10 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
# Supprimer les logs trop bavards
|
| 13 |
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
| 14 |
|
|
@@ -27,6 +41,9 @@ app = dash.Dash(
|
|
| 27 |
|
| 28 |
app.title = "ENSIM - Predictions Boursieres"
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
# === TICKERS ===
|
| 31 |
TICKERS = {
|
| 32 |
"BTC-USD": "BTC/USD",
|
|
@@ -91,7 +108,101 @@ app.layout = html.Div([
|
|
| 91 |
html.Div(id="navbar-container"),
|
| 92 |
|
| 93 |
# === PAGE CONTENT ===
|
| 94 |
-
dash.page_container
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
])
|
| 96 |
|
| 97 |
# === LISTE DES PAGES PROTÉGÉES ===
|
|
@@ -129,7 +240,7 @@ def update_layout(pathname, session):
|
|
| 129 |
])
|
| 130 |
|
| 131 |
if session and session.get("is_admin"):
|
| 132 |
-
print(f"
|
| 133 |
nav_links.append(dcc.Link("Admin", href="/admin", className=nav_cls("/admin")))
|
| 134 |
|
| 135 |
nav_links.append(html.Button("Déconnexion", id="logout-btn", className="nav-link"))
|
|
@@ -317,6 +428,334 @@ def api_prices():
|
|
| 317 |
return jsonify(results)
|
| 318 |
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
# === LANCEMENT ===
|
| 321 |
if __name__ == "__main__":
|
| 322 |
import os
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
# Doit être défini AVANT tout import de tensorflow/keras/protobuf
|
| 3 |
+
os.environ.setdefault('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', 'python')
|
| 4 |
+
os.environ.setdefault('TF_CPP_MIN_LOG_LEVEL', '3')
|
| 5 |
+
|
| 6 |
import dash
|
| 7 |
from dash import dcc, html, Input, Output, State
|
| 8 |
import yfinance as yf
|
| 9 |
import pandas as pd
|
| 10 |
+
from flask import jsonify, request as flask_request, Response, stream_with_context
|
| 11 |
import requests
|
| 12 |
from bs4 import BeautifulSoup
|
| 13 |
from services.database import init_db
|
| 14 |
import logging
|
| 15 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 16 |
|
| 17 |
+
# Charger les variables d'environnement depuis .env (chemin absolu)
|
| 18 |
+
import os as _os
|
| 19 |
+
try:
|
| 20 |
+
from dotenv import load_dotenv
|
| 21 |
+
_env_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), ".env")
|
| 22 |
+
load_dotenv(dotenv_path=_env_path, override=True)
|
| 23 |
+
except ImportError:
|
| 24 |
+
pass # python-dotenv non installé, les env vars système seront utilisées
|
| 25 |
+
|
| 26 |
# Supprimer les logs trop bavards
|
| 27 |
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
| 28 |
|
|
|
|
| 41 |
|
| 42 |
app.title = "ENSIM - Predictions Boursieres"
|
| 43 |
|
| 44 |
+
# Désactiver le cache navigateur pour les assets (force rechargement chatbot.js à chaque fois)
|
| 45 |
+
app.server.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
|
| 46 |
+
|
| 47 |
# === TICKERS ===
|
| 48 |
TICKERS = {
|
| 49 |
"BTC-USD": "BTC/USD",
|
|
|
|
| 108 |
html.Div(id="navbar-container"),
|
| 109 |
|
| 110 |
# === PAGE CONTENT ===
|
| 111 |
+
dash.page_container,
|
| 112 |
+
|
| 113 |
+
# === CHATBOT WIDGET ===
|
| 114 |
+
html.Button(
|
| 115 |
+
html.I(className="fa-solid fa-robot"),
|
| 116 |
+
id="chatbot-toggle",
|
| 117 |
+
title="Assistant IA"
|
| 118 |
+
),
|
| 119 |
+
html.Div(
|
| 120 |
+
id="chatbot-panel",
|
| 121 |
+
className="chatbot-hidden",
|
| 122 |
+
children=[
|
| 123 |
+
|
| 124 |
+
# ── Header ──────────────────────────────────────────
|
| 125 |
+
html.Div(id="chatbot-header", children=[
|
| 126 |
+
html.Div(className="chatbot-header-left", children=[
|
| 127 |
+
html.Div(className="chatbot-avatar", children=[
|
| 128 |
+
html.I(className="fa-solid fa-robot")
|
| 129 |
+
]),
|
| 130 |
+
html.Div(className="chatbot-header-info", children=[
|
| 131 |
+
html.Span("Conseiller IA", className="chatbot-header-name"),
|
| 132 |
+
html.Div(className="chatbot-header-status", children=[
|
| 133 |
+
html.Span(className="chatbot-status-dot"),
|
| 134 |
+
html.Span("En ligne"),
|
| 135 |
+
]),
|
| 136 |
+
]),
|
| 137 |
+
]),
|
| 138 |
+
html.Div(className="chatbot-header-actions", children=[
|
| 139 |
+
html.Button(
|
| 140 |
+
html.I(className="fa-solid fa-clock-rotate-left"),
|
| 141 |
+
id="chatbot-history-btn",
|
| 142 |
+
title="Historique des conversations"
|
| 143 |
+
),
|
| 144 |
+
html.Button(
|
| 145 |
+
html.I(className="fa-solid fa-pen-to-square"),
|
| 146 |
+
id="chatbot-new-btn",
|
| 147 |
+
title="Nouvelle conversation"
|
| 148 |
+
),
|
| 149 |
+
html.Button(
|
| 150 |
+
html.I(className="fa-solid fa-xmark"),
|
| 151 |
+
id="chatbot-close",
|
| 152 |
+
title="Fermer"
|
| 153 |
+
),
|
| 154 |
+
]),
|
| 155 |
+
]),
|
| 156 |
+
|
| 157 |
+
# ── Vue Chat (par défaut) ────────────────────────────
|
| 158 |
+
html.Div(id="chatbot-chat-view", children=[
|
| 159 |
+
html.Div(id="chatbot-messages", children=[
|
| 160 |
+
html.Div(
|
| 161 |
+
id="chatbot-welcome",
|
| 162 |
+
className="chatbot-msg chatbot-msg-bot",
|
| 163 |
+
children=[
|
| 164 |
+
html.Div(className="chatbot-bubble", children=[
|
| 165 |
+
html.Strong("Bonjour !"),
|
| 166 |
+
html.Br(),
|
| 167 |
+
"Je suis votre assistant IA. Posez-moi vos questions sur la plateforme, les modèles de prédiction ou les actions disponibles."
|
| 168 |
+
])
|
| 169 |
+
]
|
| 170 |
+
)
|
| 171 |
+
]),
|
| 172 |
+
html.Div(id="chatbot-input-row", children=[
|
| 173 |
+
html.Button(
|
| 174 |
+
html.I(className="fa-solid fa-microphone"),
|
| 175 |
+
id="chatbot-mic",
|
| 176 |
+
title="Parler"
|
| 177 |
+
),
|
| 178 |
+
html.Textarea(
|
| 179 |
+
id="chatbot-input",
|
| 180 |
+
placeholder="Posez votre question ou parlez…",
|
| 181 |
+
rows=1
|
| 182 |
+
),
|
| 183 |
+
html.Button(
|
| 184 |
+
html.I(className="fa-solid fa-paper-plane"),
|
| 185 |
+
id="chatbot-send",
|
| 186 |
+
title="Envoyer"
|
| 187 |
+
),
|
| 188 |
+
]),
|
| 189 |
+
]),
|
| 190 |
+
|
| 191 |
+
# ── Vue Historique (cachée par défaut) ───────────────
|
| 192 |
+
html.Div(id="chatbot-history-view", className="cb-view-hidden", children=[
|
| 193 |
+
html.Div(id="chatbot-history-header", children=[
|
| 194 |
+
html.Span("Conversations", className="cb-hist-title"),
|
| 195 |
+
html.Button(
|
| 196 |
+
[html.I(className="fa-solid fa-plus"), " Nouvelle"],
|
| 197 |
+
id="chatbot-new-btn2",
|
| 198 |
+
className="cb-new-btn"
|
| 199 |
+
),
|
| 200 |
+
]),
|
| 201 |
+
html.Div(id="chatbot-history-list"),
|
| 202 |
+
]),
|
| 203 |
+
|
| 204 |
+
]
|
| 205 |
+
),
|
| 206 |
])
|
| 207 |
|
| 208 |
# === LISTE DES PAGES PROTÉGÉES ===
|
|
|
|
| 240 |
])
|
| 241 |
|
| 242 |
if session and session.get("is_admin"):
|
| 243 |
+
print(f"[ADMIN] Lien admin ajouté pour {session.get('email')}")
|
| 244 |
nav_links.append(dcc.Link("Admin", href="/admin", className=nav_cls("/admin")))
|
| 245 |
|
| 246 |
nav_links.append(html.Button("Déconnexion", id="logout-btn", className="nav-link"))
|
|
|
|
| 428 |
return jsonify(results)
|
| 429 |
|
| 430 |
|
| 431 |
+
# === API TRANSCRIPTION VOCALE (Groq Whisper) ===
|
| 432 |
+
@app.server.route('/api/transcribe', methods=['POST'])
|
| 433 |
+
def api_transcribe():
|
| 434 |
+
from groq import Groq
|
| 435 |
+
|
| 436 |
+
audio_file = flask_request.files.get('audio')
|
| 437 |
+
if not audio_file:
|
| 438 |
+
return jsonify({'error': 'Aucun fichier audio'}), 400
|
| 439 |
+
|
| 440 |
+
api_key = os.environ.get("GROQ_API_KEY")
|
| 441 |
+
if not api_key:
|
| 442 |
+
return jsonify({'error': 'Clé API non configurée'}), 500
|
| 443 |
+
|
| 444 |
+
try:
|
| 445 |
+
client = Groq(api_key=api_key)
|
| 446 |
+
audio_bytes = audio_file.read()
|
| 447 |
+
transcription = client.audio.transcriptions.create(
|
| 448 |
+
file=("audio.webm", audio_bytes),
|
| 449 |
+
model="whisper-large-v3-turbo",
|
| 450 |
+
language="fr",
|
| 451 |
+
response_format="text",
|
| 452 |
+
)
|
| 453 |
+
return jsonify({'text': str(transcription).strip()})
|
| 454 |
+
except Exception as e:
|
| 455 |
+
return jsonify({'error': str(e)}), 500
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
# === API INVESTISSEMENT CHATBOT ===
|
| 459 |
+
@app.server.route('/api/chat-invest', methods=['POST'])
|
| 460 |
+
def api_chat_invest():
|
| 461 |
+
from services.database import get_connection
|
| 462 |
+
|
| 463 |
+
data = flask_request.get_json(force=True, silent=True) or {}
|
| 464 |
+
email = data.get('email', '').strip()
|
| 465 |
+
symbol = data.get('symbol', '').strip().upper()
|
| 466 |
+
model = data.get('model', 'sentiment').strip().lower()
|
| 467 |
+
action = data.get('action', 'ACHETER').strip().upper()
|
| 468 |
+
amount = float(data.get('amount', 0) or 0)
|
| 469 |
+
|
| 470 |
+
print(f"[chat-invest] REÇU → email={email!r} symbol={symbol!r} model={model!r} action={action!r} amount={amount}")
|
| 471 |
+
|
| 472 |
+
ALLOWED_SYMBOLS = {'AAPL','MSFT','TSLA','NVDA','GOOGL','AMZN','META','BTC-USD'}
|
| 473 |
+
ALLOWED_MODELS = {'sentiment','lstm','transformer'}
|
| 474 |
+
|
| 475 |
+
if not email or symbol not in ALLOWED_SYMBOLS or model not in ALLOWED_MODELS or amount <= 0:
|
| 476 |
+
print(f"[chat-invest] VALIDATION ÉCHOUÉE — email={email!r} symbol={symbol!r} model={model!r} amount={amount}")
|
| 477 |
+
return jsonify({'error': 'Données invalides', 'debug': {'email': email, 'symbol': symbol, 'model': model, 'amount': amount}}), 400
|
| 478 |
+
|
| 479 |
+
try:
|
| 480 |
+
# Prix actuel
|
| 481 |
+
h = yf.Ticker(symbol).history(period='2d', interval='1d')
|
| 482 |
+
price = float(h['Close'].iloc[-1]) if not h.empty else 0.0
|
| 483 |
+
|
| 484 |
+
# Mapper action → directions compatibles avec Mon Suivi
|
| 485 |
+
pred_dir = 'up' if action == 'ACHETER' else 'down'
|
| 486 |
+
actual_dir = 'up' if action == 'ACHETER' else 'down'
|
| 487 |
+
|
| 488 |
+
conn = get_connection()
|
| 489 |
+
conn.execute("""
|
| 490 |
+
INSERT INTO user_trades
|
| 491 |
+
(user_email, symbol, entry_price, quantity,
|
| 492 |
+
prediction_direction, actual_direction, pnl, pnl_percentage,
|
| 493 |
+
model_type, status)
|
| 494 |
+
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open')
|
| 495 |
+
""", (email, symbol, price, amount, pred_dir, actual_dir, model))
|
| 496 |
+
conn.commit()
|
| 497 |
+
conn.close()
|
| 498 |
+
|
| 499 |
+
print(f"[chat-invest] Trade sauvegardé: {email} | {symbol} | {model} | {action} | {amount}€ à {price}$")
|
| 500 |
+
|
| 501 |
+
return jsonify({
|
| 502 |
+
'success': True,
|
| 503 |
+
'symbol': symbol,
|
| 504 |
+
'price': round(price, 2),
|
| 505 |
+
'amount': amount,
|
| 506 |
+
'action': action,
|
| 507 |
+
'model': model,
|
| 508 |
+
})
|
| 509 |
+
except Exception as e:
|
| 510 |
+
print(f"[chat-invest] Erreur: {e}")
|
| 511 |
+
return jsonify({'error': str(e)}), 500
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
# === API COMPARAISON MODÈLES PAR ACTION ===
|
| 515 |
+
@app.server.route('/api/model-compare', methods=['GET'])
|
| 516 |
+
def api_model_compare():
|
| 517 |
+
from services.database import get_connection
|
| 518 |
+
|
| 519 |
+
email = flask_request.args.get('email', '').strip()
|
| 520 |
+
symbol = flask_request.args.get('symbol', '').strip().upper()
|
| 521 |
+
|
| 522 |
+
if not email or not symbol:
|
| 523 |
+
return jsonify({'error': 'Missing params'}), 400
|
| 524 |
+
|
| 525 |
+
MODEL_LABELS = {'lstm': 'LSTM', 'transformer': 'Transformer', 'sentiment': 'Actualités'}
|
| 526 |
+
|
| 527 |
+
try:
|
| 528 |
+
conn = get_connection()
|
| 529 |
+
# Résumé par modèle
|
| 530 |
+
rows = conn.execute("""
|
| 531 |
+
SELECT model_type,
|
| 532 |
+
COUNT(*) as trades,
|
| 533 |
+
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins,
|
| 534 |
+
COALESCE(SUM(pnl), 0) as total_pnl,
|
| 535 |
+
COALESCE(AVG(pnl_percentage), 0) as avg_pct
|
| 536 |
+
FROM user_trades
|
| 537 |
+
WHERE user_email = ? AND symbol = ? AND status = 'closed'
|
| 538 |
+
GROUP BY model_type
|
| 539 |
+
""", (email, symbol)).fetchall()
|
| 540 |
+
|
| 541 |
+
models = {}
|
| 542 |
+
for model_type, trades, wins, total_pnl, avg_pct in rows:
|
| 543 |
+
# Derniers trades de ce modèle
|
| 544 |
+
recent = conn.execute("""
|
| 545 |
+
SELECT entry_date, prediction_direction, quantity, pnl, pnl_percentage
|
| 546 |
+
FROM user_trades
|
| 547 |
+
WHERE user_email = ? AND symbol = ? AND model_type = ? AND status = 'closed'
|
| 548 |
+
ORDER BY entry_date DESC LIMIT 5
|
| 549 |
+
""", (email, symbol, model_type)).fetchall()
|
| 550 |
+
|
| 551 |
+
models[model_type] = {
|
| 552 |
+
'label': MODEL_LABELS.get(model_type, model_type),
|
| 553 |
+
'trades': trades,
|
| 554 |
+
'wins': wins,
|
| 555 |
+
'losses': trades - wins,
|
| 556 |
+
'win_rate': round(wins / trades * 100, 1) if trades > 0 else 0.0,
|
| 557 |
+
'total_pnl': round(total_pnl, 2),
|
| 558 |
+
'avg_pct': round(avg_pct, 2),
|
| 559 |
+
'recent': [
|
| 560 |
+
{
|
| 561 |
+
'date': str(r[0])[:10],
|
| 562 |
+
'signal': r[1],
|
| 563 |
+
'amount': r[2] or 0,
|
| 564 |
+
'pnl': round(r[3] or 0, 2),
|
| 565 |
+
'pnl_pct': round(r[4] or 0, 2),
|
| 566 |
+
}
|
| 567 |
+
for r in recent
|
| 568 |
+
],
|
| 569 |
+
}
|
| 570 |
+
conn.close()
|
| 571 |
+
|
| 572 |
+
best_model = None
|
| 573 |
+
if models:
|
| 574 |
+
best_model = max(models.items(), key=lambda x: x[1]['total_pnl'])[0]
|
| 575 |
+
|
| 576 |
+
return jsonify({'symbol': symbol, 'models': models, 'best_model': best_model})
|
| 577 |
+
except Exception as e:
|
| 578 |
+
print(f"[model-compare] Erreur: {e}")
|
| 579 |
+
return jsonify({'error': str(e)}), 500
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
# === API COMPARAISON MODÈLES — SIMULATION BACKTESTS ===
|
| 583 |
+
@app.server.route('/api/backtest-compare', methods=['GET'])
|
| 584 |
+
def api_backtest_compare():
|
| 585 |
+
symbol = flask_request.args.get('symbol', '').strip().upper()
|
| 586 |
+
if not symbol:
|
| 587 |
+
return jsonify({'error': 'Missing symbol'}), 400
|
| 588 |
+
|
| 589 |
+
START = 500.0
|
| 590 |
+
DAYS = 180
|
| 591 |
+
|
| 592 |
+
def _run_lstm():
|
| 593 |
+
try:
|
| 594 |
+
from pages.mon_suivi import _run_backtest_lstm
|
| 595 |
+
return 'lstm', _run_backtest_lstm(symbol, start_amount=START, days=DAYS)
|
| 596 |
+
except Exception as e:
|
| 597 |
+
print(f"[backtest-compare] lstm {symbol}: {e}")
|
| 598 |
+
return 'lstm', None
|
| 599 |
+
|
| 600 |
+
def _run_transformer():
|
| 601 |
+
try:
|
| 602 |
+
from services.transformer_service import predict_backtest as _bt
|
| 603 |
+
return 'transformer', _bt(symbol, start_amount=START, days=DAYS)
|
| 604 |
+
except Exception as e:
|
| 605 |
+
print(f"[backtest-compare] transformer {symbol}: {e}")
|
| 606 |
+
return 'transformer', None
|
| 607 |
+
|
| 608 |
+
def _run_sentiment():
|
| 609 |
+
try:
|
| 610 |
+
from pages.mon_suivi import _run_backtest
|
| 611 |
+
return 'sentiment', _run_backtest(symbol, start_amount=START, days=DAYS)
|
| 612 |
+
except Exception as e:
|
| 613 |
+
print(f"[backtest-compare] sentiment {symbol}: {e}")
|
| 614 |
+
return 'sentiment', None
|
| 615 |
+
|
| 616 |
+
def _ds(arr, n=30):
|
| 617 |
+
if not arr or len(arr) <= n:
|
| 618 |
+
return [round(v, 2) for v in arr]
|
| 619 |
+
step = len(arr) / n
|
| 620 |
+
pts = [arr[int(i * step)] for i in range(n)] + [arr[-1]]
|
| 621 |
+
return [round(v, 2) for v in pts]
|
| 622 |
+
|
| 623 |
+
results = {}
|
| 624 |
+
with ThreadPoolExecutor(max_workers=3) as ex:
|
| 625 |
+
futures = [ex.submit(_run_lstm), ex.submit(_run_transformer), ex.submit(_run_sentiment)]
|
| 626 |
+
for f in as_completed(futures):
|
| 627 |
+
key, bt = f.result()
|
| 628 |
+
if bt:
|
| 629 |
+
results[key] = {
|
| 630 |
+
'label': {'lstm': 'LSTM', 'transformer': 'Transformer', 'sentiment': 'Actualités'}[key],
|
| 631 |
+
'start': START,
|
| 632 |
+
'final': bt['final_value'],
|
| 633 |
+
'return_pct': bt['total_return'],
|
| 634 |
+
'bh_final': round(bt['buy_hold'][-1], 2) if bt.get('buy_hold') else START,
|
| 635 |
+
'bh_return': bt['buy_hold_return'],
|
| 636 |
+
'n_trades': bt['n_trades'],
|
| 637 |
+
'wins': bt['wins'],
|
| 638 |
+
'losses': bt['losses'],
|
| 639 |
+
'win_rate': bt['win_rate'],
|
| 640 |
+
'start_date': bt['start_date'],
|
| 641 |
+
'series': _ds(bt.get('portfolio', [])),
|
| 642 |
+
'bh_series': _ds(bt.get('buy_hold', [])),
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
if not results:
|
| 646 |
+
return jsonify({'error': f'Données insuffisantes pour {symbol}'}), 404
|
| 647 |
+
|
| 648 |
+
best = max(results.items(), key=lambda x: x[1]['return_pct'])[0]
|
| 649 |
+
return jsonify({'symbol': symbol, 'models': results, 'best_model': best, 'start': START})
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
# === API DÉTECTION INVESTISSEMENT (post-streaming, fiable) ===
|
| 653 |
+
@app.server.route('/api/chat-detect-invest', methods=['POST'])
|
| 654 |
+
def api_chat_detect_invest():
|
| 655 |
+
"""
|
| 656 |
+
Après le streaming, vérifie si la conversation contient un investissement à enregistrer.
|
| 657 |
+
Retourne {"invest": {...}} ou {"invest": null}.
|
| 658 |
+
Utilise un prompt dédié à l'extraction JSON — beaucoup plus fiable que le marqueur inline.
|
| 659 |
+
"""
|
| 660 |
+
import json as _json
|
| 661 |
+
from groq import Groq
|
| 662 |
+
|
| 663 |
+
data = flask_request.get_json(force=True, silent=True) or {}
|
| 664 |
+
messages = data.get('messages', [])[-6:] # derniers messages seulement
|
| 665 |
+
|
| 666 |
+
api_key = os.environ.get("GROQ_API_KEY")
|
| 667 |
+
if not api_key:
|
| 668 |
+
return jsonify({'invest': None})
|
| 669 |
+
|
| 670 |
+
EXTRACT_PROMPT = """Tu es un extracteur JSON. Analyse le dernier message de l'utilisateur et extrait les données d'un investissement à enregistrer.
|
| 671 |
+
|
| 672 |
+
MAPPINGS OBLIGATOIRES — noms d'entreprises → symboles boursiers :
|
| 673 |
+
Apple / AAPL → "AAPL"
|
| 674 |
+
Microsoft / MSFT → "MSFT"
|
| 675 |
+
Tesla / TSLA → "TSLA"
|
| 676 |
+
Nvidia / NVDA → "NVDA"
|
| 677 |
+
Google / Alphabet / GOOGL → "GOOGL"
|
| 678 |
+
Amazon / AMZN → "AMZN"
|
| 679 |
+
Meta / Facebook / META → "META"
|
| 680 |
+
Bitcoin / BTC / crypto → "BTC-USD"
|
| 681 |
+
|
| 682 |
+
MAPPINGS MODÈLES :
|
| 683 |
+
LSTM / LST / lstm / réseau de neurones / prix → "lstm"
|
| 684 |
+
Transformer / transformeur / hybride → "transformer"
|
| 685 |
+
Sentiment / actualités / actualites / news → "sentiment"
|
| 686 |
+
|
| 687 |
+
Si l'utilisateur dit qu'il a investi / mis de l'argent / suivi un conseil / acheté / vendu, extrais :
|
| 688 |
+
{"symbol":"AAPL","model":"lstm","action":"ACHETER","amount":500}
|
| 689 |
+
|
| 690 |
+
RÈGLES :
|
| 691 |
+
- action = "ACHETER" si l'utilisateur a acheté ou suivi un conseil haussier, "VENDRE" sinon
|
| 692 |
+
- amount = le montant en euros (nombre seul, ex: 500)
|
| 693 |
+
- Réponds UNIQUEMENT avec le JSON brut, rien d'autre
|
| 694 |
+
- Si une info manque ou si ce n'est PAS une intention d'enregistrement : réponds null"""
|
| 695 |
+
|
| 696 |
+
try:
|
| 697 |
+
client = Groq(api_key=api_key)
|
| 698 |
+
resp = client.chat.completions.create(
|
| 699 |
+
model="llama-3.3-70b-versatile",
|
| 700 |
+
messages=[{"role": "system", "content": EXTRACT_PROMPT}] + messages,
|
| 701 |
+
max_tokens=80,
|
| 702 |
+
stream=False,
|
| 703 |
+
temperature=0,
|
| 704 |
+
)
|
| 705 |
+
raw = (resp.choices[0].message.content or "").strip()
|
| 706 |
+
print(f"[detect-invest] raw='{raw}'")
|
| 707 |
+
if raw == "null" or not raw.startswith("{"):
|
| 708 |
+
return jsonify({"invest": None})
|
| 709 |
+
invest = _json.loads(raw)
|
| 710 |
+
# Validation
|
| 711 |
+
ALLOWED_SYMBOLS = {"AAPL","MSFT","TSLA","NVDA","GOOGL","AMZN","META","BTC-USD"}
|
| 712 |
+
ALLOWED_MODELS = {"sentiment","lstm","transformer"}
|
| 713 |
+
if (invest.get("symbol") in ALLOWED_SYMBOLS
|
| 714 |
+
and invest.get("model","").lower() in ALLOWED_MODELS
|
| 715 |
+
and invest.get("action") in {"ACHETER","VENDRE"}
|
| 716 |
+
and float(invest.get("amount", 0) or 0) > 0):
|
| 717 |
+
invest["model"] = invest["model"].lower()
|
| 718 |
+
return jsonify({"invest": invest})
|
| 719 |
+
return jsonify({"invest": None})
|
| 720 |
+
except Exception as e:
|
| 721 |
+
print(f"[detect-invest] erreur: {e}")
|
| 722 |
+
return jsonify({"invest": None})
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
# === API CHATBOT (SSE streaming) ===
|
| 726 |
+
@app.server.route('/api/chat', methods=['POST'])
|
| 727 |
+
def api_chat():
|
| 728 |
+
from services.chat_service import stream_chat
|
| 729 |
+
import json
|
| 730 |
+
|
| 731 |
+
data = flask_request.get_json(force=True, silent=True) or {}
|
| 732 |
+
messages = data.get('messages', [])
|
| 733 |
+
|
| 734 |
+
# Validation basique
|
| 735 |
+
if not isinstance(messages, list):
|
| 736 |
+
return jsonify({'error': 'Invalid payload'}), 400
|
| 737 |
+
|
| 738 |
+
def generate():
|
| 739 |
+
try:
|
| 740 |
+
for chunk in stream_chat(messages):
|
| 741 |
+
payload = json.dumps({'text': chunk}, ensure_ascii=False)
|
| 742 |
+
yield f"data: {payload}\n\n"
|
| 743 |
+
except Exception as e:
|
| 744 |
+
payload = json.dumps({'error': str(e)}, ensure_ascii=False)
|
| 745 |
+
yield f"data: {payload}\n\n"
|
| 746 |
+
finally:
|
| 747 |
+
yield "data: [DONE]\n\n"
|
| 748 |
+
|
| 749 |
+
return Response(
|
| 750 |
+
stream_with_context(generate()),
|
| 751 |
+
content_type='text/event-stream',
|
| 752 |
+
headers={
|
| 753 |
+
'Cache-Control': 'no-cache',
|
| 754 |
+
'X-Accel-Buffering': 'no',
|
| 755 |
+
}
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
|
| 759 |
# === LANCEMENT ===
|
| 760 |
if __name__ == "__main__":
|
| 761 |
import os
|
Interface Graphique/assets/chatbot.css
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ================================================================
|
| 2 |
+
CHATBOT.CSS — Widget assistant IA — Design premium + draggable
|
| 3 |
+
================================================================ */
|
| 4 |
+
|
| 5 |
+
/* ── Bouton flottant draggable ───────────────────────────────── */
|
| 6 |
+
#chatbot-toggle {
|
| 7 |
+
position: fixed;
|
| 8 |
+
bottom: 28px;
|
| 9 |
+
right: 28px;
|
| 10 |
+
z-index: 9100;
|
| 11 |
+
width: 60px;
|
| 12 |
+
height: 60px;
|
| 13 |
+
border-radius: 50%;
|
| 14 |
+
border: none;
|
| 15 |
+
cursor: grab;
|
| 16 |
+
background: linear-gradient(135deg, #00f0ff 0%, #0055ff 100%);
|
| 17 |
+
box-shadow:
|
| 18 |
+
0 4px 20px rgba(0, 200, 255, 0.5),
|
| 19 |
+
0 0 0 0 rgba(0, 200, 255, 0.35),
|
| 20 |
+
inset 0 1px 1px rgba(255,255,255,0.25);
|
| 21 |
+
display: flex;
|
| 22 |
+
align-items: center;
|
| 23 |
+
justify-content: center;
|
| 24 |
+
transition: box-shadow 0.3s, transform 0.2s;
|
| 25 |
+
animation: cbPulse 3.5s ease-in-out infinite;
|
| 26 |
+
user-select: none;
|
| 27 |
+
touch-action: none;
|
| 28 |
+
}
|
| 29 |
+
#chatbot-toggle:active { cursor: grabbing; }
|
| 30 |
+
#chatbot-toggle:hover {
|
| 31 |
+
box-shadow: 0 6px 30px rgba(0,200,255,0.65), inset 0 1px 1px rgba(255,255,255,0.2);
|
| 32 |
+
transform: scale(1.08);
|
| 33 |
+
}
|
| 34 |
+
#chatbot-toggle.cb-dragging {
|
| 35 |
+
animation: none;
|
| 36 |
+
transform: scale(1.12);
|
| 37 |
+
box-shadow: 0 8px 36px rgba(0,200,255,0.7);
|
| 38 |
+
cursor: grabbing;
|
| 39 |
+
}
|
| 40 |
+
#chatbot-toggle i {
|
| 41 |
+
font-size: 1.5rem;
|
| 42 |
+
color: #010214;
|
| 43 |
+
transition: transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
|
| 44 |
+
pointer-events: none;
|
| 45 |
+
}
|
| 46 |
+
#chatbot-toggle.chatbot-toggle-active i { transform: rotate(20deg) scale(0.9); }
|
| 47 |
+
|
| 48 |
+
@keyframes cbPulse {
|
| 49 |
+
0%,100% { box-shadow: 0 4px 20px rgba(0,200,255,.5), 0 0 0 0 rgba(0,200,255,.35), inset 0 1px 1px rgba(255,255,255,.25); }
|
| 50 |
+
50% { box-shadow: 0 4px 20px rgba(0,200,255,.5), 0 0 0 14px rgba(0,200,255,0), inset 0 1px 1px rgba(255,255,255,.25); }
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* ── Panel ───────────────────────────────────────────────────── */
|
| 54 |
+
#chatbot-panel {
|
| 55 |
+
position: fixed;
|
| 56 |
+
z-index: 9099;
|
| 57 |
+
width: 380px;
|
| 58 |
+
max-height: 560px;
|
| 59 |
+
border-radius: 22px;
|
| 60 |
+
background: linear-gradient(160deg, rgba(6,10,35,0.97) 0%, rgba(3,6,22,0.98) 100%);
|
| 61 |
+
border: 1px solid rgba(0,210,255,0.15);
|
| 62 |
+
box-shadow:
|
| 63 |
+
0 20px 60px rgba(0,0,0,0.7),
|
| 64 |
+
0 0 0 1px rgba(0,180,255,0.05) inset,
|
| 65 |
+
0 1px 0 rgba(0,220,255,0.12) inset;
|
| 66 |
+
backdrop-filter: blur(24px);
|
| 67 |
+
-webkit-backdrop-filter: blur(24px);
|
| 68 |
+
display: flex;
|
| 69 |
+
flex-direction: column;
|
| 70 |
+
overflow: hidden;
|
| 71 |
+
/* Position par défaut (sera écrasée par le JS) */
|
| 72 |
+
bottom: 102px;
|
| 73 |
+
right: 28px;
|
| 74 |
+
}
|
| 75 |
+
#chatbot-panel.chatbot-hidden { display: none; }
|
| 76 |
+
#chatbot-panel.chatbot-open { animation: cbSlideIn 0.3s cubic-bezier(0.22,1,0.36,1) both; }
|
| 77 |
+
@keyframes cbSlideIn {
|
| 78 |
+
from { opacity:0; transform: scale(0.88) translateY(18px); }
|
| 79 |
+
to { opacity:1; transform: scale(1) translateY(0); }
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/* ── Header ──────────────────────────────────────────────────── */
|
| 83 |
+
#chatbot-header {
|
| 84 |
+
display: flex;
|
| 85 |
+
align-items: center;
|
| 86 |
+
justify-content: space-between;
|
| 87 |
+
padding: 15px 18px 13px;
|
| 88 |
+
background: linear-gradient(135deg, rgba(0,180,255,0.1) 0%, rgba(0,80,255,0.07) 100%);
|
| 89 |
+
border-bottom: 1px solid rgba(0,200,255,0.1);
|
| 90 |
+
flex-shrink: 0;
|
| 91 |
+
position: relative;
|
| 92 |
+
cursor: grab;
|
| 93 |
+
user-select: none;
|
| 94 |
+
}
|
| 95 |
+
#chatbot-header:active { cursor: grabbing; }
|
| 96 |
+
#chatbot-header::after {
|
| 97 |
+
content: '';
|
| 98 |
+
position: absolute;
|
| 99 |
+
bottom: 0; left: 18px; right: 18px;
|
| 100 |
+
height: 1px;
|
| 101 |
+
background: linear-gradient(90deg, transparent, rgba(0,220,255,0.25), transparent);
|
| 102 |
+
}
|
| 103 |
+
/* Poignée de drag — 6 points centrés en haut du header */
|
| 104 |
+
#chatbot-header::before {
|
| 105 |
+
content: '⋮⋮';
|
| 106 |
+
position: absolute;
|
| 107 |
+
top: 5px; left: 50%; transform: translateX(-50%);
|
| 108 |
+
font-size: 0.65rem; letter-spacing: 3px;
|
| 109 |
+
color: rgba(0,200,255,0.25);
|
| 110 |
+
pointer-events: none;
|
| 111 |
+
line-height: 1;
|
| 112 |
+
}
|
| 113 |
+
.chatbot-header-left { display: flex; align-items: center; gap: 11px; }
|
| 114 |
+
.chatbot-avatar {
|
| 115 |
+
width: 38px; height: 38px;
|
| 116 |
+
border-radius: 50%;
|
| 117 |
+
background: linear-gradient(135deg, #00d4ff, #0050ff);
|
| 118 |
+
display: flex; align-items: center; justify-content: center;
|
| 119 |
+
font-size: 1rem; color: #fff;
|
| 120 |
+
box-shadow: 0 2px 12px rgba(0,180,255,0.4);
|
| 121 |
+
flex-shrink: 0;
|
| 122 |
+
position: relative;
|
| 123 |
+
}
|
| 124 |
+
.chatbot-avatar::after {
|
| 125 |
+
content: '';
|
| 126 |
+
position: absolute;
|
| 127 |
+
inset: -2px;
|
| 128 |
+
border-radius: 50%;
|
| 129 |
+
background: linear-gradient(135deg, rgba(0,220,255,0.5), transparent);
|
| 130 |
+
z-index: -1;
|
| 131 |
+
}
|
| 132 |
+
.chatbot-header-info { display: flex; flex-direction: column; gap: 3px; }
|
| 133 |
+
.chatbot-header-name {
|
| 134 |
+
font-size: 0.9rem; font-weight: 700;
|
| 135 |
+
color: #eaf6ff; letter-spacing: 0.025em;
|
| 136 |
+
}
|
| 137 |
+
.chatbot-header-status {
|
| 138 |
+
font-size: 0.7rem; color: #3ef5a0;
|
| 139 |
+
display: flex; align-items: center; gap: 5px;
|
| 140 |
+
}
|
| 141 |
+
.chatbot-status-dot {
|
| 142 |
+
width: 6px; height: 6px; border-radius: 50%;
|
| 143 |
+
background: #3ef5a0;
|
| 144 |
+
box-shadow: 0 0 6px rgba(62,245,160,0.8);
|
| 145 |
+
animation: cbStatusBlink 2.5s ease-in-out infinite;
|
| 146 |
+
flex-shrink: 0;
|
| 147 |
+
}
|
| 148 |
+
@keyframes cbStatusBlink {
|
| 149 |
+
0%,100% { opacity:1; }
|
| 150 |
+
50% { opacity:0.5; }
|
| 151 |
+
}
|
| 152 |
+
.chatbot-header-actions {
|
| 153 |
+
display: flex;
|
| 154 |
+
align-items: center;
|
| 155 |
+
gap: 6px;
|
| 156 |
+
}
|
| 157 |
+
#chatbot-close,
|
| 158 |
+
#chatbot-clear {
|
| 159 |
+
background: rgba(255,255,255,0.05);
|
| 160 |
+
border: 1px solid rgba(255,255,255,0.07);
|
| 161 |
+
color: rgba(184,223,240,0.45);
|
| 162 |
+
font-size: 0.82rem; cursor: pointer;
|
| 163 |
+
width: 30px; height: 30px;
|
| 164 |
+
border-radius: 8px;
|
| 165 |
+
display: flex; align-items: center; justify-content: center;
|
| 166 |
+
transition: all 0.2s;
|
| 167 |
+
flex-shrink: 0;
|
| 168 |
+
}
|
| 169 |
+
#chatbot-close:hover {
|
| 170 |
+
background: rgba(0,200,255,0.1);
|
| 171 |
+
border-color: rgba(0,200,255,0.2);
|
| 172 |
+
color: #00f0ff;
|
| 173 |
+
transform: rotate(90deg);
|
| 174 |
+
}
|
| 175 |
+
#chatbot-clear:hover {
|
| 176 |
+
background: rgba(255,80,80,0.1);
|
| 177 |
+
border-color: rgba(255,80,80,0.2);
|
| 178 |
+
color: #ff6060;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/* Badge utilisateur connecté */
|
| 182 |
+
.chatbot-user-badge {
|
| 183 |
+
font-size: 0.68rem;
|
| 184 |
+
color: rgba(0,220,255,0.55);
|
| 185 |
+
background: rgba(0,200,255,0.07);
|
| 186 |
+
border: 1px solid rgba(0,200,255,0.1);
|
| 187 |
+
border-radius: 20px;
|
| 188 |
+
padding: 1px 8px;
|
| 189 |
+
margin-top: 2px;
|
| 190 |
+
letter-spacing: 0.02em;
|
| 191 |
+
max-width: 160px;
|
| 192 |
+
overflow: hidden;
|
| 193 |
+
text-overflow: ellipsis;
|
| 194 |
+
white-space: nowrap;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* ── Zone messages ───────────────────────────────────────────── */
|
| 198 |
+
#chatbot-messages {
|
| 199 |
+
flex: 1;
|
| 200 |
+
overflow-y: auto;
|
| 201 |
+
padding: 16px 14px 10px;
|
| 202 |
+
display: flex;
|
| 203 |
+
flex-direction: column;
|
| 204 |
+
gap: 10px;
|
| 205 |
+
scrollbar-width: thin;
|
| 206 |
+
scrollbar-color: rgba(0,200,255,0.15) transparent;
|
| 207 |
+
}
|
| 208 |
+
#chatbot-messages::-webkit-scrollbar { width: 3px; }
|
| 209 |
+
#chatbot-messages::-webkit-scrollbar-thumb { background: rgba(0,200,255,0.2); border-radius: 3px; }
|
| 210 |
+
|
| 211 |
+
/* ── Bulles ──────────────────────────────────────────────────── */
|
| 212 |
+
.chatbot-msg {
|
| 213 |
+
display: flex;
|
| 214 |
+
max-width: 86%;
|
| 215 |
+
animation: cbMsgIn 0.22s cubic-bezier(0.22,1,0.36,1) both;
|
| 216 |
+
}
|
| 217 |
+
@keyframes cbMsgIn {
|
| 218 |
+
from { opacity:0; transform: translateY(10px) scale(0.96); }
|
| 219 |
+
to { opacity:1; transform: translateY(0) scale(1); }
|
| 220 |
+
}
|
| 221 |
+
.chatbot-msg-user { align-self: flex-end; flex-direction: row-reverse; }
|
| 222 |
+
.chatbot-msg-bot { align-self: flex-start; }
|
| 223 |
+
|
| 224 |
+
.chatbot-bubble {
|
| 225 |
+
padding: 10px 15px;
|
| 226 |
+
border-radius: 18px;
|
| 227 |
+
font-size: 0.84rem;
|
| 228 |
+
line-height: 1.6;
|
| 229 |
+
white-space: pre-wrap;
|
| 230 |
+
word-break: break-word;
|
| 231 |
+
position: relative;
|
| 232 |
+
}
|
| 233 |
+
.chatbot-msg-user .chatbot-bubble {
|
| 234 |
+
background: linear-gradient(135deg, #00b8e6 0%, #0044cc 100%);
|
| 235 |
+
color: #fff;
|
| 236 |
+
border-bottom-right-radius: 5px;
|
| 237 |
+
box-shadow: 0 3px 14px rgba(0,140,220,0.3);
|
| 238 |
+
}
|
| 239 |
+
.chatbot-msg-bot .chatbot-bubble {
|
| 240 |
+
background: rgba(255,255,255,0.04);
|
| 241 |
+
border: 1px solid rgba(0,200,255,0.1);
|
| 242 |
+
color: #cde8ff;
|
| 243 |
+
border-bottom-left-radius: 5px;
|
| 244 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* Message de bienvenue */
|
| 248 |
+
#chatbot-welcome { align-self: flex-start; max-width: 98%; }
|
| 249 |
+
#chatbot-welcome .chatbot-bubble {
|
| 250 |
+
background: linear-gradient(135deg, rgba(0,180,255,0.08), rgba(0,60,200,0.06));
|
| 251 |
+
border: 1px solid rgba(0,200,255,0.13);
|
| 252 |
+
color: #a8d8f0;
|
| 253 |
+
font-size: 0.81rem;
|
| 254 |
+
}
|
| 255 |
+
#chatbot-welcome .chatbot-bubble strong { color: #00e5ff; }
|
| 256 |
+
|
| 257 |
+
/* ── Typing dots ─────────────────────────────────────────────── */
|
| 258 |
+
.chatbot-typing-bubble {
|
| 259 |
+
display: flex; align-items: center; gap: 5px;
|
| 260 |
+
padding: 12px 16px; min-width: 58px;
|
| 261 |
+
}
|
| 262 |
+
.chatbot-dot {
|
| 263 |
+
width: 7px; height: 7px; border-radius: 50%;
|
| 264 |
+
background: rgba(0,220,255,0.55);
|
| 265 |
+
animation: cbDot 1.4s ease-in-out infinite;
|
| 266 |
+
}
|
| 267 |
+
.chatbot-dot:nth-child(1) { animation-delay: 0s; }
|
| 268 |
+
.chatbot-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 269 |
+
.chatbot-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 270 |
+
@keyframes cbDot {
|
| 271 |
+
0%,60%,100% { transform: translateY(0); opacity:0.45; }
|
| 272 |
+
30% { transform: translateY(-7px); opacity:1; }
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/* ── Input row ───────────────────────────────────────────────── */
|
| 276 |
+
#chatbot-input-row {
|
| 277 |
+
display: flex; align-items: flex-end; gap: 8px;
|
| 278 |
+
padding: 12px 14px 14px;
|
| 279 |
+
border-top: 1px solid rgba(0,200,255,0.07);
|
| 280 |
+
flex-shrink: 0;
|
| 281 |
+
background: rgba(0,0,0,0.25);
|
| 282 |
+
position: relative;
|
| 283 |
+
}
|
| 284 |
+
#chatbot-input-row::before {
|
| 285 |
+
content: '';
|
| 286 |
+
position: absolute;
|
| 287 |
+
top: 0; left: 14px; right: 14px; height: 1px;
|
| 288 |
+
background: linear-gradient(90deg, transparent, rgba(0,200,255,0.15), transparent);
|
| 289 |
+
}
|
| 290 |
+
#chatbot-input {
|
| 291 |
+
flex: 1;
|
| 292 |
+
background: rgba(255,255,255,0.05);
|
| 293 |
+
border: 1px solid rgba(0,200,255,0.13);
|
| 294 |
+
border-radius: 14px;
|
| 295 |
+
color: #dff0ff;
|
| 296 |
+
font-family: inherit;
|
| 297 |
+
font-size: 0.83rem;
|
| 298 |
+
padding: 10px 14px;
|
| 299 |
+
resize: none;
|
| 300 |
+
outline: none;
|
| 301 |
+
min-height: 40px;
|
| 302 |
+
max-height: 110px;
|
| 303 |
+
overflow-y: auto;
|
| 304 |
+
transition: border-color 0.25s, box-shadow 0.25s, background 0.25s;
|
| 305 |
+
line-height: 1.5;
|
| 306 |
+
scrollbar-width: thin;
|
| 307 |
+
}
|
| 308 |
+
#chatbot-input::placeholder { color: rgba(160,210,240,0.3); }
|
| 309 |
+
#chatbot-input:focus {
|
| 310 |
+
border-color: rgba(0,200,255,0.38);
|
| 311 |
+
background: rgba(255,255,255,0.07);
|
| 312 |
+
box-shadow: 0 0 0 3px rgba(0,200,255,0.07);
|
| 313 |
+
}
|
| 314 |
+
#chatbot-send {
|
| 315 |
+
width: 40px; height: 40px;
|
| 316 |
+
border-radius: 12px; border: none;
|
| 317 |
+
background: linear-gradient(135deg, #00d4ff, #0050ff);
|
| 318 |
+
color: #010214;
|
| 319 |
+
font-size: 0.88rem; cursor: pointer;
|
| 320 |
+
display: flex; align-items: center; justify-content: center;
|
| 321 |
+
flex-shrink: 0;
|
| 322 |
+
transition: transform 0.15s, box-shadow 0.2s;
|
| 323 |
+
box-shadow: 0 3px 12px rgba(0,160,255,0.35);
|
| 324 |
+
}
|
| 325 |
+
#chatbot-send:hover { transform: scale(1.08); box-shadow: 0 4px 18px rgba(0,160,255,0.55); }
|
| 326 |
+
#chatbot-send:active { transform: scale(0.93); }
|
| 327 |
+
|
| 328 |
+
/* ── Boutons header supplémentaires ─────────────────────────── */
|
| 329 |
+
#chatbot-history-btn,
|
| 330 |
+
#chatbot-new-btn {
|
| 331 |
+
background: rgba(255,255,255,0.05);
|
| 332 |
+
border: 1px solid rgba(255,255,255,0.07);
|
| 333 |
+
color: rgba(184,223,240,0.45);
|
| 334 |
+
font-size: 0.82rem; cursor: pointer;
|
| 335 |
+
width: 30px; height: 30px;
|
| 336 |
+
border-radius: 8px;
|
| 337 |
+
display: flex; align-items: center; justify-content: center;
|
| 338 |
+
transition: all 0.2s;
|
| 339 |
+
flex-shrink: 0;
|
| 340 |
+
}
|
| 341 |
+
#chatbot-history-btn:hover {
|
| 342 |
+
background: rgba(0,200,255,0.1);
|
| 343 |
+
border-color: rgba(0,200,255,0.2);
|
| 344 |
+
color: #00f0ff;
|
| 345 |
+
}
|
| 346 |
+
#chatbot-history-btn.cb-btn-active {
|
| 347 |
+
background: rgba(0,200,255,0.15);
|
| 348 |
+
border-color: rgba(0,200,255,0.3);
|
| 349 |
+
color: #00f0ff;
|
| 350 |
+
}
|
| 351 |
+
#chatbot-new-btn:hover {
|
| 352 |
+
background: rgba(60,255,160,0.1);
|
| 353 |
+
border-color: rgba(60,255,160,0.2);
|
| 354 |
+
color: #3ef5a0;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/* ── Vues toggle ─────────────────────────────────────────────── */
|
| 358 |
+
#chatbot-chat-view {
|
| 359 |
+
display: flex;
|
| 360 |
+
flex-direction: column;
|
| 361 |
+
flex: 1;
|
| 362 |
+
overflow: hidden;
|
| 363 |
+
min-height: 0;
|
| 364 |
+
}
|
| 365 |
+
#chatbot-history-view {
|
| 366 |
+
display: flex;
|
| 367 |
+
flex-direction: column;
|
| 368 |
+
flex: 1;
|
| 369 |
+
overflow: hidden;
|
| 370 |
+
min-height: 0;
|
| 371 |
+
}
|
| 372 |
+
.cb-view-hidden { display: none !important; }
|
| 373 |
+
|
| 374 |
+
/* ── Header vue historique ───────────────────────────────────── */
|
| 375 |
+
#chatbot-history-header {
|
| 376 |
+
display: flex;
|
| 377 |
+
align-items: center;
|
| 378 |
+
justify-content: space-between;
|
| 379 |
+
padding: 14px 16px 10px;
|
| 380 |
+
border-bottom: 1px solid rgba(0,200,255,0.08);
|
| 381 |
+
flex-shrink: 0;
|
| 382 |
+
}
|
| 383 |
+
.cb-hist-title {
|
| 384 |
+
font-size: 0.85rem;
|
| 385 |
+
font-weight: 700;
|
| 386 |
+
color: #eaf6ff;
|
| 387 |
+
letter-spacing: 0.02em;
|
| 388 |
+
}
|
| 389 |
+
.cb-new-btn {
|
| 390 |
+
display: flex; align-items: center; gap: 6px;
|
| 391 |
+
background: linear-gradient(135deg, rgba(0,200,255,0.12), rgba(0,80,255,0.1));
|
| 392 |
+
border: 1px solid rgba(0,200,255,0.2);
|
| 393 |
+
color: #00e5ff;
|
| 394 |
+
font-size: 0.75rem; font-weight: 600;
|
| 395 |
+
padding: 5px 11px;
|
| 396 |
+
border-radius: 8px;
|
| 397 |
+
cursor: pointer;
|
| 398 |
+
transition: all 0.2s;
|
| 399 |
+
}
|
| 400 |
+
.cb-new-btn:hover {
|
| 401 |
+
background: linear-gradient(135deg, rgba(0,200,255,0.2), rgba(0,80,255,0.15));
|
| 402 |
+
box-shadow: 0 2px 10px rgba(0,180,255,0.2);
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
/* ── Liste des conversations ─────────────────────────────────── */
|
| 406 |
+
#chatbot-history-list {
|
| 407 |
+
flex: 1;
|
| 408 |
+
overflow-y: auto;
|
| 409 |
+
padding: 8px 10px 12px;
|
| 410 |
+
display: flex;
|
| 411 |
+
flex-direction: column;
|
| 412 |
+
gap: 4px;
|
| 413 |
+
scrollbar-width: thin;
|
| 414 |
+
scrollbar-color: rgba(0,200,255,0.15) transparent;
|
| 415 |
+
}
|
| 416 |
+
#chatbot-history-list::-webkit-scrollbar { width: 3px; }
|
| 417 |
+
#chatbot-history-list::-webkit-scrollbar-thumb { background: rgba(0,200,255,0.2); border-radius: 3px; }
|
| 418 |
+
|
| 419 |
+
.cb-hist-section { margin-bottom: 6px; }
|
| 420 |
+
|
| 421 |
+
.cb-hist-section-label {
|
| 422 |
+
font-size: 0.68rem;
|
| 423 |
+
font-weight: 600;
|
| 424 |
+
color: rgba(0,200,255,0.4);
|
| 425 |
+
text-transform: uppercase;
|
| 426 |
+
letter-spacing: 0.08em;
|
| 427 |
+
padding: 6px 6px 4px;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.cb-hist-item {
|
| 431 |
+
display: flex;
|
| 432 |
+
align-items: center;
|
| 433 |
+
gap: 8px;
|
| 434 |
+
padding: 9px 10px;
|
| 435 |
+
border-radius: 10px;
|
| 436 |
+
cursor: pointer;
|
| 437 |
+
border: 1px solid transparent;
|
| 438 |
+
transition: all 0.18s;
|
| 439 |
+
position: relative;
|
| 440 |
+
}
|
| 441 |
+
.cb-hist-item:hover {
|
| 442 |
+
background: rgba(0,180,255,0.07);
|
| 443 |
+
border-color: rgba(0,200,255,0.1);
|
| 444 |
+
}
|
| 445 |
+
.cb-hist-item-active {
|
| 446 |
+
background: rgba(0,180,255,0.1);
|
| 447 |
+
border-color: rgba(0,200,255,0.18);
|
| 448 |
+
}
|
| 449 |
+
.cb-hist-item-active::before {
|
| 450 |
+
content: '';
|
| 451 |
+
position: absolute;
|
| 452 |
+
left: 0; top: 20%; bottom: 20%;
|
| 453 |
+
width: 3px;
|
| 454 |
+
background: #00e5ff;
|
| 455 |
+
border-radius: 0 3px 3px 0;
|
| 456 |
+
}
|
| 457 |
+
.cb-hist-item-body {
|
| 458 |
+
flex: 1;
|
| 459 |
+
min-width: 0;
|
| 460 |
+
}
|
| 461 |
+
.cb-hist-item-title {
|
| 462 |
+
font-size: 0.8rem;
|
| 463 |
+
font-weight: 600;
|
| 464 |
+
color: #d4eeff;
|
| 465 |
+
white-space: nowrap;
|
| 466 |
+
overflow: hidden;
|
| 467 |
+
text-overflow: ellipsis;
|
| 468 |
+
margin-bottom: 2px;
|
| 469 |
+
}
|
| 470 |
+
.cb-hist-item-preview {
|
| 471 |
+
font-size: 0.71rem;
|
| 472 |
+
color: rgba(160,210,240,0.45);
|
| 473 |
+
white-space: nowrap;
|
| 474 |
+
overflow: hidden;
|
| 475 |
+
text-overflow: ellipsis;
|
| 476 |
+
}
|
| 477 |
+
.cb-hist-delete {
|
| 478 |
+
background: none;
|
| 479 |
+
border: none;
|
| 480 |
+
color: rgba(184,223,240,0.2);
|
| 481 |
+
font-size: 0.72rem;
|
| 482 |
+
cursor: pointer;
|
| 483 |
+
padding: 4px 5px;
|
| 484 |
+
border-radius: 6px;
|
| 485 |
+
opacity: 0;
|
| 486 |
+
transition: all 0.15s;
|
| 487 |
+
flex-shrink: 0;
|
| 488 |
+
}
|
| 489 |
+
.cb-hist-item:hover .cb-hist-delete { opacity: 1; }
|
| 490 |
+
.cb-hist-delete:hover {
|
| 491 |
+
background: rgba(255,80,80,0.12);
|
| 492 |
+
color: #ff6060;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
/* ── Empty state ─────────────────────────────────────────────── */
|
| 496 |
+
.cb-hist-empty {
|
| 497 |
+
display: flex;
|
| 498 |
+
flex-direction: column;
|
| 499 |
+
align-items: center;
|
| 500 |
+
justify-content: center;
|
| 501 |
+
gap: 10px;
|
| 502 |
+
padding: 40px 20px;
|
| 503 |
+
color: rgba(160,210,240,0.3);
|
| 504 |
+
text-align: center;
|
| 505 |
+
}
|
| 506 |
+
.cb-hist-empty i { font-size: 2rem; }
|
| 507 |
+
.cb-hist-empty p { font-size: 0.8rem; margin: 0; }
|
| 508 |
+
|
| 509 |
+
/* ── État verrouillé (non connecté) ─────────────────────────── */
|
| 510 |
+
.cb-locked-wrap {
|
| 511 |
+
display: flex;
|
| 512 |
+
flex-direction: column;
|
| 513 |
+
align-items: center;
|
| 514 |
+
justify-content: center;
|
| 515 |
+
text-align: center;
|
| 516 |
+
padding: 30px 24px 24px;
|
| 517 |
+
gap: 12px;
|
| 518 |
+
height: 100%;
|
| 519 |
+
min-height: 240px;
|
| 520 |
+
}
|
| 521 |
+
.cb-locked-icon {
|
| 522 |
+
width: 64px; height: 64px;
|
| 523 |
+
border-radius: 50%;
|
| 524 |
+
background: linear-gradient(135deg, rgba(0,200,255,0.08), rgba(0,60,200,0.06));
|
| 525 |
+
border: 1px solid rgba(0,200,255,0.15);
|
| 526 |
+
display: flex; align-items: center; justify-content: center;
|
| 527 |
+
font-size: 1.6rem;
|
| 528 |
+
color: rgba(0,200,255,0.5);
|
| 529 |
+
margin-bottom: 4px;
|
| 530 |
+
}
|
| 531 |
+
.cb-locked-title {
|
| 532 |
+
font-size: 0.95rem;
|
| 533 |
+
font-weight: 700;
|
| 534 |
+
color: #eaf6ff;
|
| 535 |
+
}
|
| 536 |
+
.cb-locked-desc {
|
| 537 |
+
font-size: 0.78rem;
|
| 538 |
+
color: rgba(160,210,240,0.5);
|
| 539 |
+
line-height: 1.6;
|
| 540 |
+
max-width: 260px;
|
| 541 |
+
}
|
| 542 |
+
.cb-locked-btn {
|
| 543 |
+
display: inline-flex;
|
| 544 |
+
align-items: center;
|
| 545 |
+
gap: 7px;
|
| 546 |
+
margin-top: 6px;
|
| 547 |
+
padding: 9px 22px;
|
| 548 |
+
border-radius: 12px;
|
| 549 |
+
background: linear-gradient(135deg, #00c4e0, #0050cc);
|
| 550 |
+
color: #fff;
|
| 551 |
+
font-size: 0.82rem;
|
| 552 |
+
font-weight: 600;
|
| 553 |
+
text-decoration: none;
|
| 554 |
+
transition: opacity 0.2s, transform 0.15s;
|
| 555 |
+
box-shadow: 0 3px 14px rgba(0,160,255,0.3);
|
| 556 |
+
}
|
| 557 |
+
.cb-locked-btn:hover { opacity: 0.88; transform: translateY(-1px); }
|
| 558 |
+
|
| 559 |
+
/* Input désactivé */
|
| 560 |
+
#chatbot-input:disabled {
|
| 561 |
+
opacity: 0.35;
|
| 562 |
+
cursor: not-allowed;
|
| 563 |
+
}
|
| 564 |
+
#chatbot-send:disabled {
|
| 565 |
+
opacity: 0.25;
|
| 566 |
+
cursor: not-allowed;
|
| 567 |
+
pointer-events: none;
|
| 568 |
+
}
|
| 569 |
+
#chatbot-mic:disabled {
|
| 570 |
+
opacity: 0.25;
|
| 571 |
+
cursor: not-allowed;
|
| 572 |
+
/* PAS de pointer-events:none → le clic doit toujours arriver à toggleVoice() */
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
/* ── Bouton micro ────────────────────────────────────────────── */
|
| 576 |
+
#chatbot-mic {
|
| 577 |
+
width: 36px; height: 36px;
|
| 578 |
+
border-radius: 10px; border: none;
|
| 579 |
+
background: rgba(255,255,255,0.05);
|
| 580 |
+
border: 1px solid rgba(0,200,255,0.13);
|
| 581 |
+
color: rgba(160,210,240,0.5);
|
| 582 |
+
font-size: 0.85rem; cursor: pointer;
|
| 583 |
+
display: flex; align-items: center; justify-content: center;
|
| 584 |
+
flex-shrink: 0;
|
| 585 |
+
transition: all 0.2s;
|
| 586 |
+
}
|
| 587 |
+
#chatbot-mic:hover {
|
| 588 |
+
background: rgba(0,200,255,0.08);
|
| 589 |
+
border-color: rgba(0,200,255,0.25);
|
| 590 |
+
color: #00e5ff;
|
| 591 |
+
}
|
| 592 |
+
#chatbot-mic.cb-mic-active {
|
| 593 |
+
background: rgba(255,60,60,0.15);
|
| 594 |
+
border-color: rgba(255,80,80,0.4);
|
| 595 |
+
color: #ff5555;
|
| 596 |
+
animation: cbMicPulse 1s ease-in-out infinite;
|
| 597 |
+
}
|
| 598 |
+
@keyframes cbMicPulse {
|
| 599 |
+
0%,100% { box-shadow: 0 0 0 0 rgba(255,80,80,0.4); }
|
| 600 |
+
50% { box-shadow: 0 0 0 6px rgba(255,80,80,0); }
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
/* ── Carte confirmation investissement ───────────────────────── */
|
| 604 |
+
.cb-invest-card {
|
| 605 |
+
margin: 8px 0 8px 10px;
|
| 606 |
+
border-radius: 14px;
|
| 607 |
+
border: 1.5px solid rgba(0,230,128,0.35);
|
| 608 |
+
background: linear-gradient(135deg, rgba(0,30,60,0.97), rgba(0,20,45,0.99));
|
| 609 |
+
box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,200,120,0.08) inset;
|
| 610 |
+
overflow: hidden;
|
| 611 |
+
animation: cbMsgIn 0.25s ease both;
|
| 612 |
+
max-width: 320px;
|
| 613 |
+
}
|
| 614 |
+
.cb-invest-card-pulse {
|
| 615 |
+
animation: cbMsgIn 0.25s ease both, cbInvestGlow 2s ease-in-out 0.3s 3;
|
| 616 |
+
}
|
| 617 |
+
@keyframes cbInvestGlow {
|
| 618 |
+
0%,100% { border-color: rgba(0,230,128,0.35); box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
|
| 619 |
+
50% { border-color: rgba(0,230,128,0.8); box-shadow: 0 4px 32px rgba(0,200,100,0.3); }
|
| 620 |
+
}
|
| 621 |
+
.cb-invest-note {
|
| 622 |
+
margin: 0 12px 6px;
|
| 623 |
+
font-size: 0.7rem;
|
| 624 |
+
color: rgba(0,230,150,0.7);
|
| 625 |
+
text-align: center;
|
| 626 |
+
}
|
| 627 |
+
.cb-invest-header {
|
| 628 |
+
display: flex; align-items: center; gap: 8px;
|
| 629 |
+
padding: 8px 12px 6px;
|
| 630 |
+
background: linear-gradient(135deg, rgba(0,180,255,0.1), rgba(0,60,200,0.07));
|
| 631 |
+
border-bottom: 1px solid rgba(0,200,255,0.08);
|
| 632 |
+
font-size: 0.76rem; font-weight: 700;
|
| 633 |
+
color: #00e5ff; letter-spacing: 0.02em;
|
| 634 |
+
}
|
| 635 |
+
.cb-invest-details {
|
| 636 |
+
padding: 7px 12px;
|
| 637 |
+
display: flex; flex-direction: column; gap: 3px;
|
| 638 |
+
}
|
| 639 |
+
.cb-invest-row {
|
| 640 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 641 |
+
font-size: 0.76rem;
|
| 642 |
+
}
|
| 643 |
+
.cb-invest-row span { color: rgba(160,210,240,0.5); }
|
| 644 |
+
.cb-invest-row strong { color: #d4eeff; font-weight: 600; }
|
| 645 |
+
.cb-invest-row .cb-buy { color: #3ef5a0; }
|
| 646 |
+
.cb-invest-row .cb-sell { color: #ff6060; }
|
| 647 |
+
.cb-invest-actions {
|
| 648 |
+
display: flex; gap: 8px;
|
| 649 |
+
padding: 0 12px 10px;
|
| 650 |
+
}
|
| 651 |
+
.cb-invest-confirm {
|
| 652 |
+
flex: 1;
|
| 653 |
+
padding: 7px 10px;
|
| 654 |
+
border-radius: 9px; border: none;
|
| 655 |
+
background: linear-gradient(135deg, #00d084, #00a868);
|
| 656 |
+
color: #fff; font-size: 0.78rem; font-weight: 700;
|
| 657 |
+
cursor: pointer; transition: opacity 0.2s, transform 0.15s;
|
| 658 |
+
letter-spacing: 0.01em;
|
| 659 |
+
}
|
| 660 |
+
.cb-invest-confirm:hover { opacity: 0.88; transform: translateY(-1px); }
|
| 661 |
+
.cb-invest-confirm:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
| 662 |
+
.cb-invest-cancel {
|
| 663 |
+
padding: 7px 12px;
|
| 664 |
+
border-radius: 9px;
|
| 665 |
+
border: 1px solid rgba(255,255,255,0.08);
|
| 666 |
+
background: rgba(255,255,255,0.04);
|
| 667 |
+
color: rgba(160,210,240,0.5);
|
| 668 |
+
font-size: 0.75rem; cursor: pointer;
|
| 669 |
+
transition: all 0.2s;
|
| 670 |
+
}
|
| 671 |
+
.cb-invest-cancel:hover {
|
| 672 |
+
background: rgba(255,80,80,0.08);
|
| 673 |
+
border-color: rgba(255,80,80,0.2);
|
| 674 |
+
color: #ff7070;
|
| 675 |
+
}
|
| 676 |
+
.cb-invest-success, .cb-invest-error {
|
| 677 |
+
padding: 12px 14px;
|
| 678 |
+
font-size: 0.78rem; line-height: 1.6;
|
| 679 |
+
}
|
| 680 |
+
.cb-invest-success { color: #3ef5a0; }
|
| 681 |
+
.cb-invest-error { color: #ff6060; }
|
| 682 |
+
.cb-invest-link {
|
| 683 |
+
display: inline-block;
|
| 684 |
+
margin-top: 6px;
|
| 685 |
+
color: #00e5ff;
|
| 686 |
+
text-decoration: none;
|
| 687 |
+
font-weight: 600;
|
| 688 |
+
font-size: 0.76rem;
|
| 689 |
+
}
|
| 690 |
+
.cb-invest-link:hover { text-decoration: underline; }
|
| 691 |
+
|
| 692 |
+
/* ── Modal comparaison modèles — fenêtre déplaçable ─────────── */
|
| 693 |
+
#cb-compare-modal {
|
| 694 |
+
position: fixed;
|
| 695 |
+
z-index: 99999;
|
| 696 |
+
width: 440px;
|
| 697 |
+
max-height: 90vh;
|
| 698 |
+
background: linear-gradient(160deg, rgba(4,10,34,0.99), rgba(2,6,20,1));
|
| 699 |
+
border: 1px solid rgba(0,200,255,0.18);
|
| 700 |
+
border-radius: 18px;
|
| 701 |
+
box-shadow: 0 24px 80px rgba(0,0,0,0.75), 0 0 0 1px rgba(0,180,255,0.08) inset;
|
| 702 |
+
display: flex;
|
| 703 |
+
flex-direction: column;
|
| 704 |
+
overflow: hidden;
|
| 705 |
+
animation: cbModalIn 0.22s cubic-bezier(0.22,1,0.36,1) both;
|
| 706 |
+
}
|
| 707 |
+
@keyframes cbModalIn {
|
| 708 |
+
from { opacity: 0; transform: translateY(14px) scale(0.96); }
|
| 709 |
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
| 710 |
+
}
|
| 711 |
+
.cb-modal-header {
|
| 712 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 713 |
+
padding: 11px 14px 10px;
|
| 714 |
+
background: rgba(0,180,255,0.08);
|
| 715 |
+
border-bottom: 1px solid rgba(0,200,255,0.13);
|
| 716 |
+
flex-shrink: 0;
|
| 717 |
+
user-select: none;
|
| 718 |
+
}
|
| 719 |
+
.cb-modal-title {
|
| 720 |
+
display: flex; align-items: center; gap: 8px;
|
| 721 |
+
font-size: 0.77rem; font-weight: 700; color: #00d4ff;
|
| 722 |
+
letter-spacing: 0.02em;
|
| 723 |
+
}
|
| 724 |
+
.cb-modal-drag-hint {
|
| 725 |
+
font-size: 0.72rem;
|
| 726 |
+
color: rgba(120,180,220,0.35);
|
| 727 |
+
pointer-events: none;
|
| 728 |
+
}
|
| 729 |
+
.cb-modal-close {
|
| 730 |
+
background: rgba(255,255,255,0.05);
|
| 731 |
+
border: 1px solid rgba(255,255,255,0.08);
|
| 732 |
+
cursor: pointer;
|
| 733 |
+
color: rgba(160,210,240,0.6);
|
| 734 |
+
font-size: 0.78rem;
|
| 735 |
+
padding: 4px 8px; border-radius: 6px;
|
| 736 |
+
transition: all 0.15s;
|
| 737 |
+
}
|
| 738 |
+
.cb-modal-close:hover { background: rgba(255,80,80,0.15); color: #ff6060; border-color: rgba(255,80,80,0.3); }
|
| 739 |
+
.cb-modal-body {
|
| 740 |
+
flex: 1;
|
| 741 |
+
overflow-y: auto;
|
| 742 |
+
padding: 12px 14px 14px;
|
| 743 |
+
scrollbar-width: thin;
|
| 744 |
+
scrollbar-color: rgba(0,180,255,0.15) transparent;
|
| 745 |
+
}
|
| 746 |
+
.cb-modal-body::-webkit-scrollbar { width: 3px; }
|
| 747 |
+
.cb-modal-body::-webkit-scrollbar-thumb { background: rgba(0,180,255,0.2); border-radius: 2px; }
|
| 748 |
+
.cb-modal-subtitle {
|
| 749 |
+
font-size: 0.66rem;
|
| 750 |
+
color: rgba(120,180,220,0.4);
|
| 751 |
+
text-align: center;
|
| 752 |
+
margin-bottom: 12px;
|
| 753 |
+
}
|
| 754 |
+
.cb-modal-loading {
|
| 755 |
+
text-align: center;
|
| 756 |
+
padding: 50px 10px;
|
| 757 |
+
font-size: 0.82rem;
|
| 758 |
+
color: rgba(160,210,240,0.55);
|
| 759 |
+
line-height: 2;
|
| 760 |
+
}
|
| 761 |
+
.cb-modal-loading i { color: #00d4ff; font-size: 2rem; display: block; margin-bottom: 14px; }
|
| 762 |
+
.cb-modal-loading small { color: rgba(120,180,220,0.3); font-size: 0.69rem; }
|
| 763 |
+
.cb-modal-error {
|
| 764 |
+
text-align: center;
|
| 765 |
+
padding: 30px 10px;
|
| 766 |
+
font-size: 0.75rem;
|
| 767 |
+
color: rgba(255,150,150,0.65);
|
| 768 |
+
line-height: 1.7;
|
| 769 |
+
}
|
| 770 |
+
.cb-modal-model {
|
| 771 |
+
border-radius: 12px;
|
| 772 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 773 |
+
background: rgba(255,255,255,0.025);
|
| 774 |
+
padding: 11px 13px;
|
| 775 |
+
margin-bottom: 10px;
|
| 776 |
+
}
|
| 777 |
+
.cb-modal-model-best {
|
| 778 |
+
border-color: rgba(0,230,128,0.28);
|
| 779 |
+
background: rgba(0,230,128,0.035);
|
| 780 |
+
box-shadow: 0 0 0 1px rgba(0,230,128,0.08) inset;
|
| 781 |
+
}
|
| 782 |
+
.cb-modal-model-head {
|
| 783 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 784 |
+
margin-bottom: 7px;
|
| 785 |
+
}
|
| 786 |
+
.cb-modal-explain {
|
| 787 |
+
font-size: 0.72rem;
|
| 788 |
+
color: rgba(160,210,240,0.65);
|
| 789 |
+
line-height: 1.65;
|
| 790 |
+
margin-bottom: 8px;
|
| 791 |
+
padding: 7px 10px;
|
| 792 |
+
background: rgba(0,0,0,0.2);
|
| 793 |
+
border-radius: 7px;
|
| 794 |
+
border-left: 2px solid rgba(0,200,255,0.2);
|
| 795 |
+
}
|
| 796 |
+
.cb-modal-explain strong { font-weight: 700; }
|
| 797 |
+
.cb-modal-view-btn {
|
| 798 |
+
font-size: 0.62rem; font-weight: 600;
|
| 799 |
+
color: rgba(0,200,255,0.7);
|
| 800 |
+
background: rgba(0,200,255,0.08);
|
| 801 |
+
border: 1px solid rgba(0,200,255,0.2);
|
| 802 |
+
border-radius: 5px;
|
| 803 |
+
padding: 2px 7px;
|
| 804 |
+
text-decoration: none;
|
| 805 |
+
transition: all 0.15s;
|
| 806 |
+
white-space: nowrap;
|
| 807 |
+
}
|
| 808 |
+
.cb-modal-view-btn:hover { background: rgba(0,200,255,0.18); color: #00d4ff; }
|
| 809 |
+
.cb-modal-footer {
|
| 810 |
+
text-align: center;
|
| 811 |
+
font-size: 0.71rem;
|
| 812 |
+
color: rgba(160,210,240,0.38);
|
| 813 |
+
padding: 8px 0 2px;
|
| 814 |
+
border-top: 1px solid rgba(255,255,255,0.05);
|
| 815 |
+
margin-top: 4px;
|
| 816 |
+
}
|
| 817 |
+
.cb-modal-footer strong { color: #00d4ff; }
|
| 818 |
+
|
| 819 |
+
/* ── Shared compare styles ────────────────────────────────────── */
|
| 820 |
+
.cb-cmp-name {
|
| 821 |
+
font-size: 0.78rem; font-weight: 700; color: #d4eeff;
|
| 822 |
+
display: flex; align-items: center; gap: 6px;
|
| 823 |
+
}
|
| 824 |
+
.cb-cmp-name i { color: #00d4ff; font-size: 0.74rem; }
|
| 825 |
+
.cb-cmp-badge {
|
| 826 |
+
font-size: 0.61rem; font-weight: 700; letter-spacing: 0.05em;
|
| 827 |
+
background: rgba(0,230,128,0.18); color: #3ef5a0;
|
| 828 |
+
border: 1px solid rgba(0,230,128,0.35);
|
| 829 |
+
padding: 1px 7px; border-radius: 20px;
|
| 830 |
+
}
|
| 831 |
+
.cb-cmp-sim-summary {
|
| 832 |
+
font-size: 0.70rem; color: rgba(160,210,240,0.55);
|
| 833 |
+
margin-bottom: 4px; line-height: 1.5;
|
| 834 |
+
}
|
| 835 |
+
.cb-cmp-sim-summary strong { font-weight: 700; }
|
| 836 |
+
.cb-cmp-spark {
|
| 837 |
+
margin: 3px 0 6px;
|
| 838 |
+
border-radius: 6px;
|
| 839 |
+
background: rgba(0,0,0,0.35);
|
| 840 |
+
padding: 3px 4px 2px;
|
| 841 |
+
overflow: hidden;
|
| 842 |
+
}
|
| 843 |
+
.cb-cmp-spark-legend {
|
| 844 |
+
display: flex; gap: 12px;
|
| 845 |
+
font-size: 0.59rem; color: rgba(120,180,220,0.45);
|
| 846 |
+
margin-top: 2px; padding-left: 2px;
|
| 847 |
+
}
|
| 848 |
+
.cb-cmp-spark-ai { color: #00d4ff; }
|
| 849 |
+
.cb-cmp-spark-bh { color: rgba(255,255,255,0.28); }
|
| 850 |
+
.cb-cmp-grid {
|
| 851 |
+
display: grid;
|
| 852 |
+
grid-template-columns: 1fr 1fr;
|
| 853 |
+
gap: 5px;
|
| 854 |
+
margin-top: 5px;
|
| 855 |
+
}
|
| 856 |
+
.cb-cmp-cell {
|
| 857 |
+
background: rgba(255,255,255,0.03);
|
| 858 |
+
border: 1px solid rgba(255,255,255,0.05);
|
| 859 |
+
border-radius: 7px;
|
| 860 |
+
padding: 5px 7px;
|
| 861 |
+
display: flex; flex-direction: column; gap: 2px;
|
| 862 |
+
}
|
| 863 |
+
.cb-cmp-lbl {
|
| 864 |
+
font-size: 0.60rem; color: rgba(120,180,220,0.45);
|
| 865 |
+
text-transform: uppercase; letter-spacing: 0.04em;
|
| 866 |
+
}
|
| 867 |
+
.cb-cmp-val { font-size: 0.72rem; font-weight: 600; color: #c8e4f8; }
|
| 868 |
+
.cb-cmp-pos { color: #3ef5a0; }
|
| 869 |
+
.cb-cmp-neg { color: #ff6060; }
|
| 870 |
+
|
| 871 |
+
/* ── Badge "action en attente" sur le bouton toggle ──────────── */
|
| 872 |
+
.cb-pending-badge {
|
| 873 |
+
position: absolute;
|
| 874 |
+
top: -4px;
|
| 875 |
+
right: -4px;
|
| 876 |
+
width: 18px;
|
| 877 |
+
height: 18px;
|
| 878 |
+
border-radius: 50%;
|
| 879 |
+
background: #ff4040;
|
| 880 |
+
color: #fff;
|
| 881 |
+
font-size: 0.65rem;
|
| 882 |
+
font-weight: 700;
|
| 883 |
+
display: flex;
|
| 884 |
+
align-items: center;
|
| 885 |
+
justify-content: center;
|
| 886 |
+
animation: cbPendingPulse 1s ease-in-out infinite;
|
| 887 |
+
pointer-events: none;
|
| 888 |
+
}
|
| 889 |
+
@keyframes cbPendingPulse {
|
| 890 |
+
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255,64,64,0.5); }
|
| 891 |
+
50% { transform: scale(1.15); box-shadow: 0 0 0 6px rgba(255,64,64,0); }
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
/* ── Loader détection investissement ────────────────────────── */
|
| 895 |
+
.cb-detect-loader {
|
| 896 |
+
display: flex; align-items: center;
|
| 897 |
+
margin: 4px 0 4px 10px;
|
| 898 |
+
padding: 6px 12px;
|
| 899 |
+
border-radius: 10px;
|
| 900 |
+
background: rgba(0,200,255,0.06);
|
| 901 |
+
border: 1px solid rgba(0,200,255,0.12);
|
| 902 |
+
width: fit-content;
|
| 903 |
+
font-size: 0.72rem;
|
| 904 |
+
color: rgba(160,210,240,0.6);
|
| 905 |
+
animation: cbMsgIn 0.2s ease both;
|
| 906 |
+
}
|
| 907 |
+
.cb-detect-loader .chatbot-dot {
|
| 908 |
+
width: 5px; height: 5px;
|
| 909 |
+
border-radius: 50%;
|
| 910 |
+
background: rgba(0,200,255,0.5);
|
| 911 |
+
margin: 0 2px;
|
| 912 |
+
animation: cbTypingDot 1.2s infinite;
|
| 913 |
+
}
|
| 914 |
+
.cb-detect-loader .chatbot-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 915 |
+
.cb-detect-loader .chatbot-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 916 |
+
|
| 917 |
+
/* ── Mobile ──────────────────────────────────────────────────── */
|
| 918 |
+
@media (max-width: 480px) {
|
| 919 |
+
#chatbot-panel { width: calc(100vw - 24px); right: 12px !important; left: 12px !important; }
|
| 920 |
+
#chatbot-toggle { right: 16px !important; bottom: 20px !important; }
|
| 921 |
+
}
|
Interface Graphique/assets/chatbot.js
ADDED
|
@@ -0,0 +1,1245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ================================================================
|
| 2 |
+
CHATBOT.JS — v6 — Extraction avec contexte historique
|
| 3 |
+
================================================================ */
|
| 4 |
+
console.log("[Chatbot] v6 chargé — extraction avec contexte historique");
|
| 5 |
+
|
| 6 |
+
(function () {
|
| 7 |
+
"use strict";
|
| 8 |
+
|
| 9 |
+
// ── État global ──────────────────────────────────────────────
|
| 10 |
+
let _history = []; // messages de la conversation active
|
| 11 |
+
let _activeId = null; // id de la conversation active
|
| 12 |
+
let _isStreaming = false;
|
| 13 |
+
let _bound = false;
|
| 14 |
+
let _historyOpen = false;
|
| 15 |
+
let _initialized = false;
|
| 16 |
+
|
| 17 |
+
const POS_KEY = "chatbot_btn_pos";
|
| 18 |
+
const PANEL_POS_KEY = "chatbot_panel_pos";
|
| 19 |
+
const MAX_MSG = 60; // messages max par conversation
|
| 20 |
+
const MAX_CONVS = 30; // conversations max à conserver
|
| 21 |
+
|
| 22 |
+
// ── Clés localStorage ────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
function _getUserEmail() {
|
| 25 |
+
try {
|
| 26 |
+
const raw = sessionStorage.getItem("session-store");
|
| 27 |
+
if (!raw) return null;
|
| 28 |
+
const d = JSON.parse(raw);
|
| 29 |
+
return d && d.email ? d.email : null;
|
| 30 |
+
} catch (_) { return null; }
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function _storeKey() {
|
| 34 |
+
const email = _getUserEmail();
|
| 35 |
+
const hash = email ? btoa(email).replace(/[^a-zA-Z0-9]/g, "") : "guest";
|
| 36 |
+
return "chatbot_convs_" + hash;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// ── Persistance conversations ────────────────────────────────
|
| 40 |
+
|
| 41 |
+
function _loadStore() {
|
| 42 |
+
try {
|
| 43 |
+
const raw = localStorage.getItem(_storeKey());
|
| 44 |
+
return raw ? JSON.parse(raw) : { activeId: null, list: [] };
|
| 45 |
+
} catch (_) { return { activeId: null, list: [] }; }
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function _saveStore(store) {
|
| 49 |
+
try { localStorage.setItem(_storeKey(), JSON.stringify(store)); } catch (_) {}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Génère un titre à partir du 1er message utilisateur
|
| 53 |
+
function _autoTitle(text) {
|
| 54 |
+
if (!text) return "Nouvelle conversation";
|
| 55 |
+
const t = text.trim().replace(/\s+/g, " ");
|
| 56 |
+
return t.length > 45 ? t.slice(0, 42) + "…" : t;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Formate la date de façon relative
|
| 60 |
+
function _relDate(isoStr) {
|
| 61 |
+
try {
|
| 62 |
+
const d = new Date(isoStr);
|
| 63 |
+
const now = new Date();
|
| 64 |
+
const diff = Math.floor((now - d) / 86400000);
|
| 65 |
+
if (diff === 0) return "Aujourd'hui";
|
| 66 |
+
if (diff === 1) return "Hier";
|
| 67 |
+
if (diff < 7) return "Il y a " + diff + " jours";
|
| 68 |
+
if (diff < 30) return "Il y a " + Math.floor(diff / 7) + " sem.";
|
| 69 |
+
return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
|
| 70 |
+
} catch (_) { return ""; }
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// ── Gestion des conversations ─────────────────────────────────
|
| 74 |
+
|
| 75 |
+
function _newConversation() {
|
| 76 |
+
_history = [];
|
| 77 |
+
_activeId = "conv_" + Date.now();
|
| 78 |
+
|
| 79 |
+
// Réinitialiser la zone messages
|
| 80 |
+
const container = $("#chatbot-messages");
|
| 81 |
+
if (container) {
|
| 82 |
+
container.innerHTML = "";
|
| 83 |
+
const wrap = document.createElement("div");
|
| 84 |
+
wrap.id = "chatbot-welcome";
|
| 85 |
+
wrap.className = "chatbot-msg chatbot-msg-bot";
|
| 86 |
+
wrap.innerHTML = `<div class="chatbot-bubble">
|
| 87 |
+
<strong> Bonjour !</strong><br>
|
| 88 |
+
Je suis votre assistant IA. Posez-moi vos questions sur la plateforme, les modèles de prédiction ou les actions disponibles.
|
| 89 |
+
</div>`;
|
| 90 |
+
container.appendChild(wrap);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Passer à la vue chat
|
| 94 |
+
if (_historyOpen) _toggleHistoryView(false);
|
| 95 |
+
|
| 96 |
+
setTimeout(() => { const i = $("#chatbot-input"); if (i) { i.value = ""; i.focus(); } }, 100);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function _saveCurrentConversation() {
|
| 100 |
+
if (!_activeId || _history.length === 0) return;
|
| 101 |
+
const store = _loadStore();
|
| 102 |
+
const idx = store.list.findIndex(c => c.id === _activeId);
|
| 103 |
+
const now = new Date().toISOString();
|
| 104 |
+
|
| 105 |
+
const title = _autoTitle(
|
| 106 |
+
(_history.find(m => m.role === "user") || {}).content || ""
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
if (idx >= 0) {
|
| 110 |
+
store.list[idx].messages = _history.slice(-MAX_MSG);
|
| 111 |
+
store.list[idx].updated = now;
|
| 112 |
+
store.list[idx].title = title;
|
| 113 |
+
} else {
|
| 114 |
+
store.list.unshift({
|
| 115 |
+
id: _activeId,
|
| 116 |
+
title: title,
|
| 117 |
+
created: now,
|
| 118 |
+
updated: now,
|
| 119 |
+
messages: _history.slice(-MAX_MSG),
|
| 120 |
+
});
|
| 121 |
+
// Limiter le nombre de conversations
|
| 122 |
+
if (store.list.length > MAX_CONVS) {
|
| 123 |
+
store.list = store.list.slice(0, MAX_CONVS);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
store.activeId = _activeId;
|
| 127 |
+
_saveStore(store);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function _loadConversation(id) {
|
| 131 |
+
const store = _loadStore();
|
| 132 |
+
const conv = store.list.find(c => c.id === id);
|
| 133 |
+
if (!conv) return;
|
| 134 |
+
|
| 135 |
+
_activeId = id;
|
| 136 |
+
_history = conv.messages || [];
|
| 137 |
+
|
| 138 |
+
// Rerendre les messages
|
| 139 |
+
const container = $("#chatbot-messages");
|
| 140 |
+
if (container) {
|
| 141 |
+
container.innerHTML = "";
|
| 142 |
+
_history.forEach(function (msg) {
|
| 143 |
+
const role = msg.role === "assistant" ? "bot" : msg.role;
|
| 144 |
+
const wrap = document.createElement("div");
|
| 145 |
+
wrap.className = "chatbot-msg chatbot-msg-" + role;
|
| 146 |
+
const bubble = document.createElement("div");
|
| 147 |
+
bubble.className = "chatbot-bubble";
|
| 148 |
+
bubble.textContent = msg.content;
|
| 149 |
+
wrap.appendChild(bubble);
|
| 150 |
+
container.appendChild(wrap);
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
scrollBottom();
|
| 154 |
+
|
| 155 |
+
// Fermer l'historique, aller sur la vue chat
|
| 156 |
+
_toggleHistoryView(false);
|
| 157 |
+
setTimeout(() => { const i = $("#chatbot-input"); if (i) i.focus(); }, 200);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function _deleteConversation(id, e) {
|
| 161 |
+
e.stopPropagation();
|
| 162 |
+
const store = _loadStore();
|
| 163 |
+
store.list = store.list.filter(c => c.id !== id);
|
| 164 |
+
if (store.activeId === id) store.activeId = null;
|
| 165 |
+
_saveStore(store);
|
| 166 |
+
|
| 167 |
+
// Si on vient de supprimer la conv active → nouvelle conv
|
| 168 |
+
if (_activeId === id) _newConversation();
|
| 169 |
+
|
| 170 |
+
// Rafraîchir la liste
|
| 171 |
+
_renderHistoryList();
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// ── Vue historique ────────────────────────────────────────────
|
| 175 |
+
|
| 176 |
+
function _toggleHistoryView(forceState) {
|
| 177 |
+
_historyOpen = (forceState !== undefined) ? forceState : !_historyOpen;
|
| 178 |
+
|
| 179 |
+
const chatView = $("#chatbot-chat-view");
|
| 180 |
+
const histView = $("#chatbot-history-view");
|
| 181 |
+
const btn = $("#chatbot-history-btn");
|
| 182 |
+
if (!chatView || !histView) return;
|
| 183 |
+
|
| 184 |
+
if (_historyOpen) {
|
| 185 |
+
chatView.classList.add("cb-view-hidden");
|
| 186 |
+
histView.classList.remove("cb-view-hidden");
|
| 187 |
+
btn && btn.classList.add("cb-btn-active");
|
| 188 |
+
_renderHistoryList();
|
| 189 |
+
} else {
|
| 190 |
+
histView.classList.add("cb-view-hidden");
|
| 191 |
+
chatView.classList.remove("cb-view-hidden");
|
| 192 |
+
btn && btn.classList.remove("cb-btn-active");
|
| 193 |
+
scrollBottom();
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function _renderHistoryList() {
|
| 198 |
+
const list = $("#chatbot-history-list");
|
| 199 |
+
if (!list) return;
|
| 200 |
+
|
| 201 |
+
const store = _loadStore();
|
| 202 |
+
list.innerHTML = "";
|
| 203 |
+
|
| 204 |
+
if (store.list.length === 0) {
|
| 205 |
+
list.innerHTML = `<div class="cb-hist-empty">
|
| 206 |
+
<i class="fa-solid fa-comments"></i>
|
| 207 |
+
<p>Aucune conversation sauvegardée</p>
|
| 208 |
+
</div>`;
|
| 209 |
+
return;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Grouper par date (Aujourd'hui / Hier / Cette semaine / Plus ancien)
|
| 213 |
+
const groups = {};
|
| 214 |
+
store.list.forEach(function (conv) {
|
| 215 |
+
const label = _relDate(conv.updated);
|
| 216 |
+
if (!groups[label]) groups[label] = [];
|
| 217 |
+
groups[label].push(conv);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
Object.keys(groups).forEach(function (label) {
|
| 221 |
+
const section = document.createElement("div");
|
| 222 |
+
section.className = "cb-hist-section";
|
| 223 |
+
|
| 224 |
+
const heading = document.createElement("div");
|
| 225 |
+
heading.className = "cb-hist-section-label";
|
| 226 |
+
heading.textContent = label;
|
| 227 |
+
section.appendChild(heading);
|
| 228 |
+
|
| 229 |
+
groups[label].forEach(function (conv) {
|
| 230 |
+
const item = document.createElement("div");
|
| 231 |
+
item.className = "cb-hist-item" + (conv.id === _activeId ? " cb-hist-item-active" : "");
|
| 232 |
+
|
| 233 |
+
const preview = (conv.messages.slice().reverse().find(m => m.role === "assistant") || {}).content || "";
|
| 234 |
+
const previewShort = preview.slice(0, 55) + (preview.length > 55 ? "…" : "");
|
| 235 |
+
|
| 236 |
+
item.innerHTML = `
|
| 237 |
+
<div class="cb-hist-item-body">
|
| 238 |
+
<div class="cb-hist-item-title">${_esc(conv.title)}</div>
|
| 239 |
+
<div class="cb-hist-item-preview">${_esc(previewShort)}</div>
|
| 240 |
+
</div>
|
| 241 |
+
<button class="cb-hist-delete" title="Supprimer">
|
| 242 |
+
<i class="fa-solid fa-trash-can"></i>
|
| 243 |
+
</button>
|
| 244 |
+
`;
|
| 245 |
+
|
| 246 |
+
item.querySelector(".cb-hist-item-body").addEventListener("click", function () {
|
| 247 |
+
_loadConversation(conv.id);
|
| 248 |
+
});
|
| 249 |
+
item.querySelector(".cb-hist-delete").addEventListener("click", function (e) {
|
| 250 |
+
_deleteConversation(conv.id, e);
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
section.appendChild(item);
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
list.appendChild(section);
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function _esc(str) {
|
| 261 |
+
return (str || "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// ── Helpers DOM ──────────────────────────────────────────────
|
| 265 |
+
|
| 266 |
+
function $(sel) { return document.querySelector(sel); }
|
| 267 |
+
|
| 268 |
+
function scrollBottom() {
|
| 269 |
+
const msgs = $("#chatbot-messages");
|
| 270 |
+
if (msgs) msgs.scrollTop = msgs.scrollHeight;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
function addMessage(role, text, id) {
|
| 274 |
+
const msgs = $("#chatbot-messages");
|
| 275 |
+
if (!msgs) return null;
|
| 276 |
+
const wrap = document.createElement("div");
|
| 277 |
+
wrap.className = "chatbot-msg chatbot-msg-" + role;
|
| 278 |
+
if (id) wrap.id = id;
|
| 279 |
+
const bubble = document.createElement("div");
|
| 280 |
+
bubble.className = "chatbot-bubble";
|
| 281 |
+
bubble.textContent = text;
|
| 282 |
+
wrap.appendChild(bubble);
|
| 283 |
+
msgs.appendChild(wrap);
|
| 284 |
+
scrollBottom();
|
| 285 |
+
return bubble;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function showTyping() {
|
| 289 |
+
const msgs = $("#chatbot-messages");
|
| 290 |
+
if (!msgs) return;
|
| 291 |
+
const wrap = document.createElement("div");
|
| 292 |
+
wrap.className = "chatbot-msg chatbot-msg-bot";
|
| 293 |
+
wrap.id = "chatbot-typing";
|
| 294 |
+
wrap.innerHTML = `<div class="chatbot-bubble chatbot-typing-bubble">
|
| 295 |
+
<span class="chatbot-dot"></span><span class="chatbot-dot"></span><span class="chatbot-dot"></span>
|
| 296 |
+
</div>`;
|
| 297 |
+
msgs.appendChild(wrap);
|
| 298 |
+
scrollBottom();
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function removeTyping() {
|
| 302 |
+
const t = $("#chatbot-typing");
|
| 303 |
+
if (t) t.remove();
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// ── Voix : MediaRecorder + Groq Whisper ─────────────────────
|
| 307 |
+
// Fiable sur HTTP local — pas de dépendance Chrome Speech API
|
| 308 |
+
|
| 309 |
+
let _isListening = false;
|
| 310 |
+
let _mediaRecorder = null;
|
| 311 |
+
let _audioChunks = [];
|
| 312 |
+
|
| 313 |
+
function _setMicState(active) {
|
| 314 |
+
_isListening = active;
|
| 315 |
+
const btn = $("#chatbot-mic");
|
| 316 |
+
if (!btn) return;
|
| 317 |
+
if (active) {
|
| 318 |
+
btn.classList.add("cb-mic-active");
|
| 319 |
+
btn.title = "Arrêter";
|
| 320 |
+
} else {
|
| 321 |
+
btn.classList.remove("cb-mic-active");
|
| 322 |
+
btn.title = "Parler";
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function toggleVoice() {
|
| 327 |
+
const input = $("#chatbot-input");
|
| 328 |
+
|
| 329 |
+
if (!_getUserEmail()) {
|
| 330 |
+
if (input) input.placeholder = "Connectez-vous pour utiliser l'assistant…";
|
| 331 |
+
return;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// Arrêter l'enregistrement
|
| 335 |
+
if (_isListening && _mediaRecorder) {
|
| 336 |
+
_mediaRecorder.stop();
|
| 337 |
+
return;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
| 341 |
+
addMessage("bot", "Accès au microphone non disponible sur ce navigateur.");
|
| 342 |
+
return;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
navigator.mediaDevices.getUserMedia({ audio: true })
|
| 346 |
+
.then(function (stream) {
|
| 347 |
+
_audioChunks = [];
|
| 348 |
+
|
| 349 |
+
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
| 350 |
+
? 'audio/webm;codecs=opus'
|
| 351 |
+
: MediaRecorder.isTypeSupported('audio/webm')
|
| 352 |
+
? 'audio/webm'
|
| 353 |
+
: 'audio/ogg;codecs=opus';
|
| 354 |
+
|
| 355 |
+
_mediaRecorder = new MediaRecorder(stream, { mimeType });
|
| 356 |
+
|
| 357 |
+
_mediaRecorder.ondataavailable = function (e) {
|
| 358 |
+
if (e.data && e.data.size > 0) _audioChunks.push(e.data);
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
_mediaRecorder.onstop = async function () {
|
| 362 |
+
stream.getTracks().forEach(t => t.stop());
|
| 363 |
+
_setMicState(false);
|
| 364 |
+
if (_audioChunks.length === 0) return;
|
| 365 |
+
|
| 366 |
+
const blob = new Blob(_audioChunks, { type: mimeType });
|
| 367 |
+
_audioChunks = [];
|
| 368 |
+
if (input) input.placeholder = "Transcription en cours…";
|
| 369 |
+
|
| 370 |
+
try {
|
| 371 |
+
const formData = new FormData();
|
| 372 |
+
formData.append('audio', blob, 'audio.webm');
|
| 373 |
+
const resp = await fetch('/api/transcribe', { method: 'POST', body: formData });
|
| 374 |
+
const data = await resp.json();
|
| 375 |
+
|
| 376 |
+
if (data.text && data.text.trim()) {
|
| 377 |
+
if (input) {
|
| 378 |
+
input.value = data.text.trim();
|
| 379 |
+
input.placeholder = "Posez votre question ou parlez…";
|
| 380 |
+
}
|
| 381 |
+
setTimeout(sendMessage, 300);
|
| 382 |
+
} else {
|
| 383 |
+
if (input) input.placeholder = "Posez votre question ou parlez…";
|
| 384 |
+
addMessage("bot", data.error ? data.error : "Aucune parole détectée, réessayez.");
|
| 385 |
+
}
|
| 386 |
+
} catch (err) {
|
| 387 |
+
if (input) input.placeholder = "Posez votre question ou parlez…";
|
| 388 |
+
addMessage("bot", "Erreur transcription : " + err.message);
|
| 389 |
+
}
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
_setMicState(true);
|
| 393 |
+
if (input) input.placeholder = "Parlez… re-cliquez pour arrêter";
|
| 394 |
+
_mediaRecorder.start();
|
| 395 |
+
})
|
| 396 |
+
.catch(function (err) {
|
| 397 |
+
_setMicState(false);
|
| 398 |
+
if (err.name === 'NotAllowedError') {
|
| 399 |
+
addMessage("bot", "Permission micro refusée.\nAutorisez le micro dans les paramètres du navigateur.");
|
| 400 |
+
} else {
|
| 401 |
+
addMessage("bot", "Micro inaccessible : " + err.message);
|
| 402 |
+
}
|
| 403 |
+
});
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// ── Comparaison modèles ──────────────────────────────────────
|
| 407 |
+
|
| 408 |
+
// Intention : "quel modèle pour Apple ?" / "compare les modèles sur TSLA" etc.
|
| 409 |
+
// Regex sans caractères spéciaux pour éviter les problèmes d'encodage
|
| 410 |
+
const _COMPARE_RE = /quel\s+mod.le|meilleur\s+mod.le|compare\s+.{0,15}mod.le|mod.le\s+.{0,20}conseill|recommand.{0,10}mod.le|quel\s+mod.l|si\s+j.avais\s+suivi|quel\s+algo|comparer\s+les\s+mod/i;
|
| 411 |
+
|
| 412 |
+
function _extractCompareSymbol(text, history) {
|
| 413 |
+
const lo = ' ' + text.toLowerCase() + ' ';
|
| 414 |
+
const ctxLo = lo + ' ' + (history || []).slice(-8).map(m => (m.content || '').toLowerCase()).join(' ') + ' ';
|
| 415 |
+
const sym = _findSymbol(lo) || _findSymbol(ctxLo);
|
| 416 |
+
console.log("[Chatbot] _extractCompareSymbol:", sym, "| lo:", lo.slice(0,60));
|
| 417 |
+
return sym;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
function _makeSpark(series, bhSeries, W, H) {
|
| 421 |
+
W = W || 380; H = H || 80;
|
| 422 |
+
if (!series || series.length < 2) return '';
|
| 423 |
+
const all = [...series, ...(bhSeries || [])];
|
| 424 |
+
const mn = Math.min(...all), mx = Math.max(...all);
|
| 425 |
+
const rng = mx - mn || 1;
|
| 426 |
+
const PAD = { t: 6, b: 6, l: 4, r: 4 };
|
| 427 |
+
const iW = W - PAD.l - PAD.r, iH = H - PAD.t - PAD.b;
|
| 428 |
+
const vy = (v) => (PAD.t + (mx - v) / rng * iH).toFixed(1);
|
| 429 |
+
const vx = (i, len) => (PAD.l + i / (len - 1) * iW).toFixed(1);
|
| 430 |
+
const pts = (arr) => arr.map((v, i) => `${vx(i, arr.length)},${vy(v)}`).join(' ');
|
| 431 |
+
const bhLine = bhSeries && bhSeries.length > 1
|
| 432 |
+
? `<polyline points="${pts(bhSeries)}" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.2" stroke-dasharray="4,3"/>`
|
| 433 |
+
: '';
|
| 434 |
+
// Coloring: green if final > start, red otherwise
|
| 435 |
+
const finalV = series[series.length - 1];
|
| 436 |
+
const startV = series[0];
|
| 437 |
+
const lineCol = finalV >= startV ? '#3ef5a0' : '#ff6060';
|
| 438 |
+
// Dashed line at start value (reference)
|
| 439 |
+
const refY = vy(startV);
|
| 440 |
+
const refLine = `<line x1="${PAD.l}" y1="${refY}" x2="${W - PAD.r}" y2="${refY}" stroke="rgba(255,255,255,0.1)" stroke-width="1" stroke-dasharray="2,4"/>`;
|
| 441 |
+
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" style="width:100%;height:${H}px;display:block">
|
| 442 |
+
${refLine}
|
| 443 |
+
${bhLine}
|
| 444 |
+
<polyline points="${pts(series)}" fill="none" stroke="${lineCol}" stroke-width="2"/>
|
| 445 |
+
</svg>`;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
function _makeDraggable(el, handle) {
|
| 449 |
+
let sx = 0, sy = 0, sl = 0, st = 0, drag = false;
|
| 450 |
+
handle.style.cursor = 'grab';
|
| 451 |
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
| 452 |
+
const onDown = (cx, cy) => {
|
| 453 |
+
drag = true;
|
| 454 |
+
const r = el.getBoundingClientRect();
|
| 455 |
+
sx = cx; sy = cy; sl = r.left; st = r.top;
|
| 456 |
+
handle.style.cursor = 'grabbing';
|
| 457 |
+
el.style.transition = 'none';
|
| 458 |
+
};
|
| 459 |
+
const onMove = (cx, cy) => {
|
| 460 |
+
if (!drag) return;
|
| 461 |
+
const nl = clamp(sl + cx - sx, 0, window.innerWidth - el.offsetWidth);
|
| 462 |
+
const nt = clamp(st + cy - sy, 0, window.innerHeight - el.offsetHeight);
|
| 463 |
+
el.style.left = nl + 'px'; el.style.top = nt + 'px';
|
| 464 |
+
el.style.right = 'auto'; el.style.bottom = 'auto';
|
| 465 |
+
};
|
| 466 |
+
const onUp = () => { drag = false; handle.style.cursor = 'grab'; };
|
| 467 |
+
handle.addEventListener('mousedown', e => {
|
| 468 |
+
if (e.target.closest('.cb-modal-close')) return;
|
| 469 |
+
e.preventDefault(); onDown(e.clientX, e.clientY);
|
| 470 |
+
});
|
| 471 |
+
document.addEventListener('mousemove', e => onMove(e.clientX, e.clientY));
|
| 472 |
+
document.addEventListener('mouseup', onUp);
|
| 473 |
+
handle.addEventListener('touchstart', e => {
|
| 474 |
+
const t = e.touches[0]; onDown(t.clientX, t.clientY);
|
| 475 |
+
}, { passive: true });
|
| 476 |
+
handle.addEventListener('touchmove', e => {
|
| 477 |
+
const t = e.touches[0]; onMove(t.clientX, t.clientY);
|
| 478 |
+
}, { passive: true });
|
| 479 |
+
handle.addEventListener('touchend', onUp, { passive: true });
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
async function _loadAndShowCompare(symbol, bubble) {
|
| 483 |
+
if (!symbol) return;
|
| 484 |
+
|
| 485 |
+
console.log("[Chatbot] Ouverture modal comparaison pour:", symbol);
|
| 486 |
+
const modal = _openCompareModal(symbol);
|
| 487 |
+
if (!modal) { console.warn("[Chatbot] panel introuvable pour modal"); return; }
|
| 488 |
+
|
| 489 |
+
try {
|
| 490 |
+
const resp = await fetch(`/api/backtest-compare?symbol=${encodeURIComponent(symbol)}`);
|
| 491 |
+
const data = await resp.json();
|
| 492 |
+
const body = document.getElementById("cb-modal-body");
|
| 493 |
+
if (!body) return;
|
| 494 |
+
if (data.error) {
|
| 495 |
+
body.innerHTML = `<div class="cb-modal-error">Donnees insuffisantes pour ${symbol}.<br><small>Ce ticker n'est peut-etre pas supporte par nos modeles.</small></div>`;
|
| 496 |
+
} else {
|
| 497 |
+
_populateCompareModal(body, symbol, data);
|
| 498 |
+
// Ajouter un message chat avec les vrais résultats (corrige la reco générique du LLM)
|
| 499 |
+
_addBacktestSummaryToChat(symbol, data);
|
| 500 |
+
}
|
| 501 |
+
} catch (e) {
|
| 502 |
+
const body = document.getElementById("cb-modal-body");
|
| 503 |
+
if (body) body.innerHTML = `<div class="cb-modal-error">Erreur de connexion au serveur.</div>`;
|
| 504 |
+
console.warn("[Chatbot] backtest-compare fetch error:", e);
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
function _addBacktestSummaryToChat(symbol, data) {
|
| 509 |
+
const models = data.models || {};
|
| 510 |
+
const best = data.best_model;
|
| 511 |
+
const start = data.start || 500;
|
| 512 |
+
const keys = Object.keys(models);
|
| 513 |
+
if (keys.length === 0) return;
|
| 514 |
+
|
| 515 |
+
const lines = keys.map(k => {
|
| 516 |
+
const m = models[k];
|
| 517 |
+
const gain = m.final - start;
|
| 518 |
+
const sign = gain >= 0 ? '+' : '';
|
| 519 |
+
const isBest = k === best;
|
| 520 |
+
return `• ${m.label} : ${sign}${gain.toFixed(2)}€ (${sign}${m.return_pct}%) sur 500€${isBest ? ' ← meilleur' : ''}`;
|
| 521 |
+
}).join('\n');
|
| 522 |
+
|
| 523 |
+
const bestM = models[best] || {};
|
| 524 |
+
const bestGain = (bestM.final || start) - start;
|
| 525 |
+
const bestSign = bestGain >= 0 ? '+' : '';
|
| 526 |
+
const bestLabel = bestM.label || best;
|
| 527 |
+
|
| 528 |
+
const msg = `Voici les resultats reels de la simulation sur les 180 derniers jours pour ${symbol} :\n${lines}\n\nSur la base des donnees historiques, le modele le plus performant pour ${symbol} est **${bestLabel}** (${bestSign}${bestGain.toFixed(2)}€ sur 500€ investis). C'est celui que je recommande pour ${symbol}.`;
|
| 529 |
+
|
| 530 |
+
addMessage("bot", msg);
|
| 531 |
+
_history.push({ role: "assistant", content: msg });
|
| 532 |
+
_saveCurrentConversation();
|
| 533 |
+
setTimeout(() => { const m = $("#chatbot-messages"); if (m) m.scrollTop = m.scrollHeight; }, 100);
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
function _openCompareModal(symbol) {
|
| 537 |
+
const old = document.getElementById("cb-compare-modal");
|
| 538 |
+
if (old) old.remove();
|
| 539 |
+
|
| 540 |
+
const modal = document.createElement("div");
|
| 541 |
+
modal.id = "cb-compare-modal";
|
| 542 |
+
|
| 543 |
+
// Position initiale : à gauche du chatbot panel
|
| 544 |
+
const panel = document.getElementById("chatbot-panel");
|
| 545 |
+
if (panel) {
|
| 546 |
+
const r = panel.getBoundingClientRect();
|
| 547 |
+
const mw = 440;
|
| 548 |
+
let left = r.left - mw - 14;
|
| 549 |
+
if (left < 10) left = Math.min(r.right + 14, window.innerWidth - mw - 10);
|
| 550 |
+
const top = Math.max(10, r.top - 10);
|
| 551 |
+
modal.style.cssText = `left:${left}px;top:${top}px;`;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
modal.innerHTML = `
|
| 555 |
+
<div class="cb-modal-header" id="cb-modal-drag-handle">
|
| 556 |
+
<div class="cb-modal-title">
|
| 557 |
+
<i class="fas fa-chart-line"></i>
|
| 558 |
+
<span>Si j'avais suivi les conseils — ${symbol}</span>
|
| 559 |
+
</div>
|
| 560 |
+
<div style="display:flex;align-items:center;gap:6px">
|
| 561 |
+
<span class="cb-modal-drag-hint"><i class="fas fa-arrows-alt"></i></span>
|
| 562 |
+
<button class="cb-modal-close" id="cb-modal-close-btn"><i class="fas fa-times"></i></button>
|
| 563 |
+
</div>
|
| 564 |
+
</div>
|
| 565 |
+
<div class="cb-modal-body" id="cb-modal-body">
|
| 566 |
+
<div class="cb-modal-loading">
|
| 567 |
+
<i class="fas fa-spinner fa-spin"></i>
|
| 568 |
+
Simulation en cours...<br>
|
| 569 |
+
<small>Les 3 modeles sont calcules en parallele (~15s)</small>
|
| 570 |
+
</div>
|
| 571 |
+
</div>`;
|
| 572 |
+
|
| 573 |
+
document.body.appendChild(modal);
|
| 574 |
+
document.getElementById("cb-modal-close-btn").addEventListener("click", () => modal.remove());
|
| 575 |
+
_makeDraggable(modal, document.getElementById("cb-modal-drag-handle"));
|
| 576 |
+
return modal;
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
function _populateCompareModal(body, symbol, data) {
|
| 580 |
+
const MODEL_ICON = { lstm: 'fas fa-brain', transformer: 'fas fa-atom', sentiment: 'fas fa-newspaper' };
|
| 581 |
+
const models = data.models || {};
|
| 582 |
+
const best = data.best_model;
|
| 583 |
+
const start = data.start || 500;
|
| 584 |
+
const keys = Object.keys(models);
|
| 585 |
+
if (keys.length === 0) {
|
| 586 |
+
body.innerHTML = `<div class="cb-modal-error">Aucun modele n'a pu simuler ${symbol}.</div>`;
|
| 587 |
+
return;
|
| 588 |
+
}
|
| 589 |
+
const rows = keys.map(k => {
|
| 590 |
+
const m = models[k];
|
| 591 |
+
const isBest = k === best;
|
| 592 |
+
const gain = m.final - start;
|
| 593 |
+
const gainEur = (gain >= 0 ? '+' : '') + gain.toFixed(2);
|
| 594 |
+
const gainCls = gain >= 0 ? 'cb-cmp-pos' : 'cb-cmp-neg';
|
| 595 |
+
const retSign = m.return_pct >= 0 ? '+' : '';
|
| 596 |
+
const bhSign = m.bh_return >= 0 ? '+' : '';
|
| 597 |
+
const vsPassif = m.final - m.bh_final;
|
| 598 |
+
const vsEur = (vsPassif >= 0 ? '+' : '') + vsPassif.toFixed(2);
|
| 599 |
+
const vsCls = vsPassif >= 0 ? 'cb-cmp-pos' : 'cb-cmp-neg';
|
| 600 |
+
const vsText = vsPassif >= 0
|
| 601 |
+
? `<span class="${vsCls}">${vsEur}€ de plus</span> qu'un simple achat sans IA`
|
| 602 |
+
: `<span class="${vsCls}">${vsEur}€ de moins</span> qu'un simple achat sans IA`;
|
| 603 |
+
const spark = _makeSpark(m.series, m.bh_series, 400, 80);
|
| 604 |
+
return `
|
| 605 |
+
<div class="cb-modal-model${isBest ? ' cb-modal-model-best' : ''}">
|
| 606 |
+
<div class="cb-modal-model-head">
|
| 607 |
+
<span class="cb-cmp-name"><i class="${MODEL_ICON[k] || 'fas fa-robot'}"></i> ${m.label}</span>
|
| 608 |
+
<div style="display:flex;align-items:center;gap:7px">
|
| 609 |
+
${isBest ? '<span class="cb-cmp-badge">Recommande</span>' : ''}
|
| 610 |
+
<a href="/mon-suivi?mode=${k}&ticker=${symbol}" class="cb-modal-view-btn" title="Voir dans Mon Suivi">
|
| 611 |
+
<i class="fas fa-external-link-alt"></i> Mon Suivi
|
| 612 |
+
</a>
|
| 613 |
+
</div>
|
| 614 |
+
</div>
|
| 615 |
+
<div class="cb-modal-explain">
|
| 616 |
+
Si vous aviez investi <strong>${start}€</strong> sur <strong>${symbol}</strong>
|
| 617 |
+
depuis le <strong>${m.start_date}</strong> en suivant le modele <strong>${m.label}</strong>,
|
| 618 |
+
vous auriez maintenant
|
| 619 |
+
<strong class="${gainCls}">${m.final.toFixed(2)}€</strong> — soit
|
| 620 |
+
<strong class="${gainCls}">${gainEur}€ (${retSign}${m.return_pct}%)</strong>.
|
| 621 |
+
C'est ${vsText} (qui aurait donne <strong>${m.bh_final.toFixed(2)}€</strong>).
|
| 622 |
+
</div>
|
| 623 |
+
${spark ? `<div class="cb-cmp-spark">${spark}<div class="cb-cmp-spark-legend"><span class="cb-cmp-spark-ai">— Modele IA</span><span class="cb-cmp-spark-bh">- - Sans IA (buy&hold)</span></div></div>` : ''}
|
| 624 |
+
<div class="cb-cmp-grid">
|
| 625 |
+
<div class="cb-cmp-cell">
|
| 626 |
+
<span class="cb-cmp-lbl">Valeur finale</span>
|
| 627 |
+
<span class="cb-cmp-val ${gainCls}">${m.final.toFixed(2)}€</span>
|
| 628 |
+
</div>
|
| 629 |
+
<div class="cb-cmp-cell">
|
| 630 |
+
<span class="cb-cmp-lbl">Taux reussite</span>
|
| 631 |
+
<span class="cb-cmp-val">${m.win_rate}%</span>
|
| 632 |
+
</div>
|
| 633 |
+
<div class="cb-cmp-cell">
|
| 634 |
+
<span class="cb-cmp-lbl">Sans IA (buy&hold)</span>
|
| 635 |
+
<span class="cb-cmp-val">${m.bh_final.toFixed(2)}€ (${bhSign}${m.bh_return}%)</span>
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
</div>`;
|
| 639 |
+
}).join('');
|
| 640 |
+
const bestLabel = best ? (models[best]?.label || best) : '—';
|
| 641 |
+
body.innerHTML = `
|
| 642 |
+
<div class="cb-modal-subtitle">Simulation sur 180 jours · investissement de reference : ${start}€</div>
|
| 643 |
+
${rows}
|
| 644 |
+
<div class="cb-modal-footer">Recommandation : <strong>${bestLabel}</strong> — meilleur rendement sur la periode</div>`;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
// _showCompareCard gardé pour compat (ne fait rien, modal gere tout)
|
| 648 |
+
function _showCompareCard() {}
|
| 649 |
+
|
| 650 |
+
// ── Détection marqueur investissement ───────────────────────
|
| 651 |
+
|
| 652 |
+
const INVEST_RE = /##INVEST##\s*(\{[\s\S]*?\})\s*##/;
|
| 653 |
+
|
| 654 |
+
function _parseInvestMarker(text) {
|
| 655 |
+
const m = text.match(INVEST_RE);
|
| 656 |
+
if (!m) return null;
|
| 657 |
+
try { return JSON.parse(m[1]); } catch(_) { return null; }
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
function _stripInvestMarker(text) {
|
| 661 |
+
return text.replace(INVEST_RE, "").trim();
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
function _showInvestConfirm(bubble, data) {
|
| 665 |
+
console.log("[Chatbot] _showInvestConfirm appelé:", data);
|
| 666 |
+
const MODEL_LABELS = { sentiment: "Actualités", lstm: "LSTM", transformer: "Transformer" };
|
| 667 |
+
const card = document.createElement("div");
|
| 668 |
+
card.className = "cb-invest-card cb-invest-card-pulse";
|
| 669 |
+
card.innerHTML = `
|
| 670 |
+
<div class="cb-invest-header">
|
| 671 |
+
<i class="fa-solid fa-briefcase"></i>
|
| 672 |
+
<span>Confirmez l'enregistrement</span>
|
| 673 |
+
</div>
|
| 674 |
+
<div class="cb-invest-details">
|
| 675 |
+
<div class="cb-invest-row"><span>Action</span><strong>${data.symbol}</strong></div>
|
| 676 |
+
<div class="cb-invest-row"><span>Modèle</span><strong>${MODEL_LABELS[data.model] || data.model}</strong></div>
|
| 677 |
+
<div class="cb-invest-row"><span>Sens</span><strong class="${data.action==='ACHETER'?'cb-buy':'cb-sell'}">${data.action}</strong></div>
|
| 678 |
+
<div class="cb-invest-row"><span>Montant</span><strong>${data.amount} €</strong></div>
|
| 679 |
+
</div>
|
| 680 |
+
<p class="cb-invest-note">Cliquez sur le bouton ci-dessous pour enregistrer dans Mon Suivi</p>
|
| 681 |
+
<div class="cb-invest-actions">
|
| 682 |
+
<button class="cb-invest-confirm">Enregistrer dans Mon Suivi</button>
|
| 683 |
+
<button class="cb-invest-cancel">Annuler</button>
|
| 684 |
+
</div>
|
| 685 |
+
`;
|
| 686 |
+
|
| 687 |
+
// Insérer après la bulle bot (avec fallback sur le container)
|
| 688 |
+
const wrap = bubble ? bubble.closest(".chatbot-msg") : null;
|
| 689 |
+
if (wrap && wrap.parentNode) {
|
| 690 |
+
wrap.parentNode.insertBefore(card, wrap.nextSibling);
|
| 691 |
+
} else {
|
| 692 |
+
const msgs = $("#chatbot-messages");
|
| 693 |
+
if (msgs) msgs.appendChild(card);
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
// Forcer l'ouverture du panel si fermé — ouvrir directement sans réinitialiser
|
| 697 |
+
const panel = $("#chatbot-panel");
|
| 698 |
+
if (panel && !panel.classList.contains("chatbot-open")) {
|
| 699 |
+
_positionPanel();
|
| 700 |
+
panel.classList.remove("chatbot-hidden");
|
| 701 |
+
panel.classList.add("chatbot-open");
|
| 702 |
+
const tb = $("#chatbot-toggle");
|
| 703 |
+
if (tb) tb.classList.add("chatbot-toggle-active");
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
// Badge rouge sur le bouton toggle pour signaler une action en attente
|
| 707 |
+
const toggleBtn = $("#chatbot-toggle");
|
| 708 |
+
if (toggleBtn && !toggleBtn.querySelector(".cb-pending-badge")) {
|
| 709 |
+
const badge = document.createElement("span");
|
| 710 |
+
badge.className = "cb-pending-badge";
|
| 711 |
+
badge.textContent = "!";
|
| 712 |
+
toggleBtn.appendChild(badge);
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
// Scroll précis : amener le bas de la carte dans la zone visible du panel
|
| 716 |
+
function _scrollCardIntoPanel() {
|
| 717 |
+
const msgs = $("#chatbot-messages");
|
| 718 |
+
if (!msgs) return;
|
| 719 |
+
const msgsRect = msgs.getBoundingClientRect();
|
| 720 |
+
const cardRect = card.getBoundingClientRect();
|
| 721 |
+
const overflow = cardRect.bottom - msgsRect.bottom;
|
| 722 |
+
if (overflow > 0) {
|
| 723 |
+
msgs.scrollTop += overflow + 16;
|
| 724 |
+
} else {
|
| 725 |
+
// Fallback si la carte est déjà dans la zone
|
| 726 |
+
msgs.scrollTop = msgs.scrollHeight;
|
| 727 |
+
}
|
| 728 |
+
}
|
| 729 |
+
setTimeout(_scrollCardIntoPanel, 100);
|
| 730 |
+
setTimeout(_scrollCardIntoPanel, 400);
|
| 731 |
+
setTimeout(_scrollCardIntoPanel, 800);
|
| 732 |
+
|
| 733 |
+
// Supprimer le badge quand l'utilisateur interagit avec la carte
|
| 734 |
+
card.addEventListener("click", function() {
|
| 735 |
+
const b = toggleBtn && toggleBtn.querySelector(".cb-pending-badge");
|
| 736 |
+
if (b) b.remove();
|
| 737 |
+
}, { once: true });
|
| 738 |
+
|
| 739 |
+
card.querySelector(".cb-invest-confirm").addEventListener("click", async function () {
|
| 740 |
+
this.disabled = true;
|
| 741 |
+
this.textContent = "Enregistrement…";
|
| 742 |
+
const email = _getUserEmail() || "";
|
| 743 |
+
console.log("[Chatbot] Enregistrer cliqué — email:", email, "data:", data);
|
| 744 |
+
try {
|
| 745 |
+
const resp = await fetch("/api/chat-invest", {
|
| 746 |
+
method: "POST",
|
| 747 |
+
headers: { "Content-Type": "application/json" },
|
| 748 |
+
body: JSON.stringify({ ...data, email }),
|
| 749 |
+
});
|
| 750 |
+
const result = await resp.json();
|
| 751 |
+
console.log("[Chatbot] /api/chat-invest réponse:", resp.status, result);
|
| 752 |
+
const MODEL_TABS = { sentiment: "Actualités", lstm: "LSTM (prix)", transformer: "Transformer (hybride)" };
|
| 753 |
+
const tabLabel = MODEL_TABS[data.model] || data.model;
|
| 754 |
+
if (result.success) {
|
| 755 |
+
card.innerHTML = `<div class="cb-invest-success">
|
| 756 |
+
Investissement enregistré<br>
|
| 757 |
+
<small>${data.symbol} · ${data.amount} € au prix de $${result.price}</small><br>
|
| 758 |
+
<small style="color:rgba(0,230,150,0.6)">Retrouvez-le dans <strong>Mon Suivi</strong> › onglet <strong>${tabLabel}</strong></small>
|
| 759 |
+
<br><a href="/mon-suivi?mode=${data.model}" class="cb-invest-link">Voir dans Mon Suivi</a>
|
| 760 |
+
</div>`;
|
| 761 |
+
// Injecter dans l'historique pour que le LLM régénère le marqueur la prochaine fois
|
| 762 |
+
_history.push({
|
| 763 |
+
role: "user",
|
| 764 |
+
content: `[Système] Investissement ${data.symbol} (${data.amount}€, ${data.model}) enregistré avec succès via le marqueur ##INVEST##. Pour tout prochain investissement, vous devez obligatoirement générer un nouveau marqueur ##INVEST## — sans marqueur, rien n'est enregistré.`
|
| 765 |
+
});
|
| 766 |
+
_saveCurrentConversation();
|
| 767 |
+
} else {
|
| 768 |
+
card.innerHTML = `<div class="cb-invest-error" style="font-size:0.85rem;padding:14px">
|
| 769 |
+
Enregistrement échoué<br>
|
| 770 |
+
<strong style="color:#ff8080">${result.error || 'données invalides'}</strong><br>
|
| 771 |
+
<small>Vérifiez que vous êtes connecté et réessayez.</small>
|
| 772 |
+
<br><button onclick="this.closest('.cb-invest-card').remove()" style="margin-top:8px;padding:4px 12px;border-radius:6px;border:1px solid rgba(255,100,100,0.3);background:none;color:#ff8080;cursor:pointer;font-size:0.75rem">Fermer</button>
|
| 773 |
+
</div>`;
|
| 774 |
+
}
|
| 775 |
+
} catch(err) {
|
| 776 |
+
console.error("[Chatbot] Erreur fetch invest:", err);
|
| 777 |
+
card.innerHTML = `<div class="cb-invest-error" style="font-size:0.85rem;padding:14px">
|
| 778 |
+
Impossible de contacter le serveur.<br>
|
| 779 |
+
<small>Vérifiez votre connexion et réessayez.</small>
|
| 780 |
+
</div>`;
|
| 781 |
+
}
|
| 782 |
+
scrollBottom();
|
| 783 |
+
});
|
| 784 |
+
|
| 785 |
+
card.querySelector(".cb-invest-cancel").addEventListener("click", function () {
|
| 786 |
+
card.remove();
|
| 787 |
+
});
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
// ── Extraction investissement (100% client, sans API) ────────
|
| 791 |
+
|
| 792 |
+
const _SYMBOL_MAP = {
|
| 793 |
+
'apple':'AAPL', 'aapl':'AAPL',
|
| 794 |
+
'microsoft':'MSFT', 'msft':'MSFT',
|
| 795 |
+
'tesla':'TSLA', 'tsla':'TSLA',
|
| 796 |
+
'nvidia':'NVDA', 'nvda':'NVDA',
|
| 797 |
+
'google':'GOOGL', 'alphabet':'GOOGL', 'googl':'GOOGL',
|
| 798 |
+
'amazon':'AMZN', 'amzn':'AMZN',
|
| 799 |
+
'meta':'META', 'facebook':'META',
|
| 800 |
+
'bitcoin':'BTC-USD', 'btc':'BTC-USD', 'crypto':'BTC-USD',
|
| 801 |
+
};
|
| 802 |
+
const _MODEL_LIST = [
|
| 803 |
+
['lstm','lstm'],['ltsm','lstm'],['bilstm','lstm'],['réseau de neurones','lstm'],
|
| 804 |
+
['transformer','transformer'],['transformeur','transformer'],['hybride','transformer'],
|
| 805 |
+
['sentiment','sentiment'],['actualit','sentiment'],['news','sentiment'],
|
| 806 |
+
];
|
| 807 |
+
|
| 808 |
+
// Regex d'intention : .? entre j et ai couvre TOUTES les variantes d'apostrophe
|
| 809 |
+
const _INTENT_RE = /j.?ai\s*(investi|mis\b|achet|vendu|suivi|fait\b)|enregistr|vas-y/i;
|
| 810 |
+
|
| 811 |
+
// Cherche un symbole ou un modèle dans une chaîne basse
|
| 812 |
+
function _findSymbol(lo) {
|
| 813 |
+
for (const [k, v] of Object.entries(_SYMBOL_MAP)) {
|
| 814 |
+
if (lo.includes(k)) return v;
|
| 815 |
+
}
|
| 816 |
+
return null;
|
| 817 |
+
}
|
| 818 |
+
function _findModel(lo) {
|
| 819 |
+
for (const [k, v] of _MODEL_LIST) {
|
| 820 |
+
if (lo.includes(k)) return v;
|
| 821 |
+
}
|
| 822 |
+
return null;
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
// history = tableau de messages {role, content} — utilisé comme contexte si le
|
| 826 |
+
// symbole/modèle n'est pas dans le message actuel
|
| 827 |
+
function _extractInvestment(text, history) {
|
| 828 |
+
console.log("[Chatbot] _extractInvestment appelé sur:", text.slice(0,80));
|
| 829 |
+
if (!_INTENT_RE.test(text)) {
|
| 830 |
+
console.log("[Chatbot] aucune intention d'investissement détectée");
|
| 831 |
+
return null;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
const lo = ' ' + text.toLowerCase() + ' ';
|
| 835 |
+
|
| 836 |
+
// Contexte: tous les messages récents (pour trouver le symbole si non répété)
|
| 837 |
+
const ctxLo = lo + ' ' + (history || []).slice(-10)
|
| 838 |
+
.map(m => (m.content || '').toLowerCase()).join(' ') + ' ';
|
| 839 |
+
|
| 840 |
+
// Symbol: d'abord dans le message actuel, puis dans le contexte
|
| 841 |
+
const symbol = _findSymbol(lo) || _findSymbol(ctxLo);
|
| 842 |
+
|
| 843 |
+
// Model: idem
|
| 844 |
+
const model = _findModel(lo) || _findModel(ctxLo);
|
| 845 |
+
|
| 846 |
+
// Amount : cherche un nombre + €/euro dans le message actuel
|
| 847 |
+
let amount = null;
|
| 848 |
+
const m1 = text.match(/(\d[\d\s]*(?:[.,]\d{1,2})?)\s*(?:€|euros?)/i);
|
| 849 |
+
if (m1) amount = parseFloat(m1[1].replace(/\s/g,'').replace(',','.'));
|
| 850 |
+
if (!amount) {
|
| 851 |
+
const m2 = text.match(/(?:€|euros?)\s*(\d[\d\s]*(?:[.,]\d{1,2})?)/i);
|
| 852 |
+
if (m2) amount = parseFloat(m2[1].replace(/\s/g,'').replace(',','.'));
|
| 853 |
+
}
|
| 854 |
+
if (!amount) {
|
| 855 |
+
// fallback : plus grand nombre dans le texte
|
| 856 |
+
const nums = text.match(/\b(\d{2,7}(?:[.,]\d{1,2})?)\b/g);
|
| 857 |
+
if (nums) amount = Math.max(...nums.map(n => parseFloat(n.replace(',','.'))));
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
const action = /vend|short|baissier|sold/i.test(text) ? 'VENDRE' : 'ACHETER';
|
| 861 |
+
|
| 862 |
+
console.log("[Chatbot] extraction →", {symbol, model, amount, action});
|
| 863 |
+
if (symbol && amount > 0) {
|
| 864 |
+
return { symbol, model: model || 'lstm', action, amount };
|
| 865 |
+
}
|
| 866 |
+
console.log("[Chatbot] extraction échouée — symbol:", symbol, "amount:", amount);
|
| 867 |
+
return null;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
// ── Envoi du message ─────────────────────────────────────────
|
| 871 |
+
|
| 872 |
+
async function sendMessage() {
|
| 873 |
+
if (_isStreaming) return;
|
| 874 |
+
const input = $("#chatbot-input");
|
| 875 |
+
if (!input) return;
|
| 876 |
+
const text = input.value.trim();
|
| 877 |
+
if (!text) return;
|
| 878 |
+
|
| 879 |
+
console.log("[Chatbot] sendMessage appelé:", text.slice(0, 60));
|
| 880 |
+
|
| 881 |
+
const welcome = $("#chatbot-welcome");
|
| 882 |
+
if (welcome) welcome.remove();
|
| 883 |
+
|
| 884 |
+
input.value = "";
|
| 885 |
+
_isStreaming = true;
|
| 886 |
+
addMessage("user", text);
|
| 887 |
+
_history.push({ role: "user", content: text });
|
| 888 |
+
showTyping();
|
| 889 |
+
|
| 890 |
+
// Extraction AVANT le fetch — message actuel + historique pour le contexte
|
| 891 |
+
const pendingInvest = _extractInvestment(text, _history);
|
| 892 |
+
const isCompareQuery = _COMPARE_RE.test(text);
|
| 893 |
+
const compareSymbol = isCompareQuery ? _extractCompareSymbol(text, _history) : null;
|
| 894 |
+
console.log("[Chatbot] pendingInvest:", pendingInvest, "| compareSymbol:", compareSymbol);
|
| 895 |
+
|
| 896 |
+
try {
|
| 897 |
+
const response = await fetch("/api/chat", {
|
| 898 |
+
method: "POST",
|
| 899 |
+
headers: { "Content-Type": "application/json" },
|
| 900 |
+
body: JSON.stringify({ messages: _history }),
|
| 901 |
+
});
|
| 902 |
+
if (!response.ok) throw new Error("Erreur serveur " + response.status);
|
| 903 |
+
|
| 904 |
+
removeTyping();
|
| 905 |
+
const botBubble = addMessage("bot", "");
|
| 906 |
+
if (!botBubble) return;
|
| 907 |
+
|
| 908 |
+
const reader = response.body.getReader();
|
| 909 |
+
const decoder = new TextDecoder();
|
| 910 |
+
let fullText = "";
|
| 911 |
+
|
| 912 |
+
while (true) {
|
| 913 |
+
const { done, value } = await reader.read();
|
| 914 |
+
if (done) break;
|
| 915 |
+
for (const line of decoder.decode(value, { stream: true }).split("\n")) {
|
| 916 |
+
if (!line.startsWith("data: ")) continue;
|
| 917 |
+
const chunk = line.slice(6).trim();
|
| 918 |
+
if (chunk === "[DONE]") break;
|
| 919 |
+
try {
|
| 920 |
+
const p = JSON.parse(chunk);
|
| 921 |
+
if (p.text) { fullText += p.text; botBubble.textContent = fullText; scrollBottom(); }
|
| 922 |
+
if (p.error) { botBubble.textContent = p.error; }
|
| 923 |
+
} catch (_) {}
|
| 924 |
+
}
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
// Sauvegarder la réponse bot dans l'historique
|
| 928 |
+
const savedText = fullText || botBubble.textContent || "";
|
| 929 |
+
_history.push({ role: "assistant", content: savedText });
|
| 930 |
+
if (_history.length > 40) _history = _history.slice(-40);
|
| 931 |
+
_saveCurrentConversation();
|
| 932 |
+
|
| 933 |
+
// Toujours stripper le marqueur ##INVEST## du texte affiché (quel que soit l'intent)
|
| 934 |
+
botBubble.textContent = _stripInvestMarker(savedText);
|
| 935 |
+
|
| 936 |
+
// 1. Marqueur inline du LLM (prioritaire)
|
| 937 |
+
const markerData = _parseInvestMarker(savedText);
|
| 938 |
+
const hadInvestIntent = _INTENT_RE.test(text);
|
| 939 |
+
if (markerData && markerData.amount > 0) {
|
| 940 |
+
if (hadInvestIntent) {
|
| 941 |
+
console.log("[Chatbot] Marqueur LLM détecté:", markerData);
|
| 942 |
+
_showInvestConfirm(botBubble, markerData);
|
| 943 |
+
} else {
|
| 944 |
+
console.log("[Chatbot] Marqueur ignoré — pas d'intention dans le message utilisateur");
|
| 945 |
+
}
|
| 946 |
+
} else if (markerData && markerData.amount <= 0) {
|
| 947 |
+
console.log("[Chatbot] Marqueur ignoré — montant nul ou manquant");
|
| 948 |
+
} else if (pendingInvest) {
|
| 949 |
+
// 2. Extraction client sur le message utilisateur (toujours fiable)
|
| 950 |
+
console.log("[Chatbot] Affichage carte confirmation (client):", pendingInvest);
|
| 951 |
+
_showInvestConfirm(botBubble, pendingInvest);
|
| 952 |
+
} else {
|
| 953 |
+
// 3. Détection d'un faux-positif : le bot prétend avoir enregistré sans marqueur
|
| 954 |
+
const falseSave = /enregistr|sauvegard|c.est fait|c.est not|confirm/i.test(savedText);
|
| 955 |
+
if (falseSave && pendingInvest === null && /investi|achet|vendu|mis\b|suivi/i.test(text)) {
|
| 956 |
+
addMessage("bot", "Pour enregistrer votre investissement, j'ai besoin des détails complets : quelle action, quel modèle (LSTM / Transformer / Actualités) et quel montant ?");
|
| 957 |
+
}
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
// 4. Comparaison des modèles — afficher la carte après la réponse LLM
|
| 961 |
+
if (compareSymbol) {
|
| 962 |
+
_loadAndShowCompare(compareSymbol, botBubble);
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
} catch (err) {
|
| 966 |
+
removeTyping();
|
| 967 |
+
addMessage("bot", "Impossible de contacter l'assistant.");
|
| 968 |
+
console.error("[Chatbot]", err);
|
| 969 |
+
} finally {
|
| 970 |
+
_isStreaming = false;
|
| 971 |
+
}
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
// ── Toggle panel ─────────────────────────────────────────────
|
| 975 |
+
|
| 976 |
+
function togglePanel() {
|
| 977 |
+
const panel = $("#chatbot-panel");
|
| 978 |
+
const btn = $("#chatbot-toggle");
|
| 979 |
+
if (!panel || !btn) return;
|
| 980 |
+
|
| 981 |
+
const isOpen = panel.classList.contains("chatbot-open");
|
| 982 |
+
if (isOpen) {
|
| 983 |
+
panel.classList.remove("chatbot-open");
|
| 984 |
+
panel.classList.add("chatbot-hidden");
|
| 985 |
+
btn.classList.remove("chatbot-toggle-active");
|
| 986 |
+
} else {
|
| 987 |
+
_positionPanel();
|
| 988 |
+
panel.classList.remove("chatbot-hidden");
|
| 989 |
+
panel.classList.add("chatbot-open");
|
| 990 |
+
btn.classList.add("chatbot-toggle-active");
|
| 991 |
+
|
| 992 |
+
// Re-init à chaque ouverture (re-vérifie la connexion)
|
| 993 |
+
if (!_initialized) {
|
| 994 |
+
_initialized = true;
|
| 995 |
+
}
|
| 996 |
+
_initSession();
|
| 997 |
+
|
| 998 |
+
scrollBottom();
|
| 999 |
+
setTimeout(_initPanelDrag, 50);
|
| 1000 |
+
setTimeout(() => { const i = $("#chatbot-input"); if (i) i.focus(); }, 320);
|
| 1001 |
+
}
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
// Charge la dernière conversation ou crée une nouvelle
|
| 1005 |
+
function _initSession() {
|
| 1006 |
+
// ── Vérification connexion ──────────────────────────────
|
| 1007 |
+
if (!_getUserEmail()) {
|
| 1008 |
+
_showLockedState();
|
| 1009 |
+
return;
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
_updateUserBadge();
|
| 1013 |
+
const store = _loadStore();
|
| 1014 |
+
|
| 1015 |
+
if (store.activeId) {
|
| 1016 |
+
const conv = store.list.find(c => c.id === store.activeId);
|
| 1017 |
+
if (conv && conv.messages.length > 0) {
|
| 1018 |
+
_activeId = conv.id;
|
| 1019 |
+
_history = conv.messages;
|
| 1020 |
+
const container = $("#chatbot-messages");
|
| 1021 |
+
if (container) {
|
| 1022 |
+
const w = container.querySelector("#chatbot-welcome");
|
| 1023 |
+
if (w) w.remove();
|
| 1024 |
+
_history.forEach(function (msg) {
|
| 1025 |
+
const role = msg.role === "assistant" ? "bot" : msg.role;
|
| 1026 |
+
const wrap = document.createElement("div");
|
| 1027 |
+
wrap.className = "chatbot-msg chatbot-msg-" + role;
|
| 1028 |
+
const bubble = document.createElement("div");
|
| 1029 |
+
bubble.className = "chatbot-bubble";
|
| 1030 |
+
bubble.textContent = msg.content;
|
| 1031 |
+
wrap.appendChild(bubble);
|
| 1032 |
+
container.appendChild(wrap);
|
| 1033 |
+
});
|
| 1034 |
+
}
|
| 1035 |
+
scrollBottom();
|
| 1036 |
+
return;
|
| 1037 |
+
}
|
| 1038 |
+
}
|
| 1039 |
+
_activeId = "conv_" + Date.now();
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
function _showLockedState() {
|
| 1043 |
+
// Remplacer les messages par un message de verrouillage
|
| 1044 |
+
const container = $("#chatbot-messages");
|
| 1045 |
+
if (container) {
|
| 1046 |
+
container.innerHTML = `
|
| 1047 |
+
<div class="cb-locked-wrap">
|
| 1048 |
+
<div class="cb-locked-icon"><i class="fa-solid fa-lock"></i></div>
|
| 1049 |
+
<div class="cb-locked-title">Connexion requise</div>
|
| 1050 |
+
<div class="cb-locked-desc">
|
| 1051 |
+
Le Conseiller IA est réservé aux membres connectés.
|
| 1052 |
+
Connectez-vous pour accéder à l'assistant.
|
| 1053 |
+
</div>
|
| 1054 |
+
<a href="/login" class="cb-locked-btn">
|
| 1055 |
+
<i class="fa-solid fa-right-to-bracket"></i> Se connecter
|
| 1056 |
+
</a>
|
| 1057 |
+
</div>
|
| 1058 |
+
`;
|
| 1059 |
+
}
|
| 1060 |
+
// Désactiver l'input et les boutons
|
| 1061 |
+
const input = $("#chatbot-input");
|
| 1062 |
+
const send = $("#chatbot-send");
|
| 1063 |
+
const mic = $("#chatbot-mic");
|
| 1064 |
+
const newBtn = $("#chatbot-new-btn");
|
| 1065 |
+
const histBtn = $("#chatbot-history-btn");
|
| 1066 |
+
if (input) { input.disabled = true; input.placeholder = "Connectez-vous pour utiliser l'assistant…"; }
|
| 1067 |
+
if (send) send.disabled = true;
|
| 1068 |
+
if (mic) mic.disabled = true;
|
| 1069 |
+
if (newBtn) newBtn.disabled = true;
|
| 1070 |
+
if (histBtn) histBtn.disabled = true;
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
function _updateUserBadge() {
|
| 1074 |
+
const email = _getUserEmail();
|
| 1075 |
+
const info = $(".chatbot-header-info");
|
| 1076 |
+
if (!info || !email) return;
|
| 1077 |
+
const old = info.querySelector(".chatbot-user-badge");
|
| 1078 |
+
if (old) old.remove();
|
| 1079 |
+
const badge = document.createElement("div");
|
| 1080 |
+
badge.className = "chatbot-user-badge";
|
| 1081 |
+
badge.textContent = email.split("@")[0];
|
| 1082 |
+
info.appendChild(badge);
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
// ── Drag panel ───────────────────────────────────────────────
|
| 1086 |
+
|
| 1087 |
+
function _positionPanel() {
|
| 1088 |
+
const panel = $("#chatbot-panel");
|
| 1089 |
+
const btn = $("#chatbot-toggle");
|
| 1090 |
+
if (!panel || !btn) return;
|
| 1091 |
+
const saved = _loadPanelPos();
|
| 1092 |
+
if (saved) { _applyPanelPos(saved.left, saved.top); return; }
|
| 1093 |
+
const r = btn.getBoundingClientRect();
|
| 1094 |
+
const panelW = 380, panelH = 580, m = 12;
|
| 1095 |
+
const vw = window.innerWidth, vh = window.innerHeight;
|
| 1096 |
+
let left = r.left + r.width / 2 - panelW / 2;
|
| 1097 |
+
let top = r.top - panelH - m;
|
| 1098 |
+
if (top < m) top = r.bottom + m;
|
| 1099 |
+
if (left + panelW > vw - m) left = vw - panelW - m;
|
| 1100 |
+
if (left < m) left = m;
|
| 1101 |
+
if (top + panelH > vh - m) top = vh - panelH - m;
|
| 1102 |
+
_applyPanelPos(left, top);
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
function _applyPanelPos(l, t) {
|
| 1106 |
+
const p = $("#chatbot-panel");
|
| 1107 |
+
if (!p) return;
|
| 1108 |
+
p.style.left = l+"px"; p.style.top = t+"px"; p.style.right = "auto"; p.style.bottom = "auto";
|
| 1109 |
+
}
|
| 1110 |
+
function _savePanelPos(l, t) { try { localStorage.setItem(PANEL_POS_KEY, JSON.stringify({left:l,top:t})); } catch(_){} }
|
| 1111 |
+
function _loadPanelPos() {
|
| 1112 |
+
try {
|
| 1113 |
+
const raw = localStorage.getItem(PANEL_POS_KEY);
|
| 1114 |
+
if (!raw) return null;
|
| 1115 |
+
const p = JSON.parse(raw); const vw=window.innerWidth,vh=window.innerHeight;
|
| 1116 |
+
if (p.left<0||p.top<0||p.left>vw-100||p.top>vh-100) return null; return p;
|
| 1117 |
+
} catch(_){ return null; }
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
function _initPanelDrag() {
|
| 1121 |
+
const panel = $("#chatbot-panel"), header = $("#chatbot-header");
|
| 1122 |
+
if (!panel||!header||header._panelDragBound) return;
|
| 1123 |
+
header._panelDragBound = true;
|
| 1124 |
+
let sx,sy,sl,st,drag=false;
|
| 1125 |
+
function onStart(e) {
|
| 1126 |
+
if (e.target.closest("#chatbot-close")||e.target.closest("#chatbot-history-btn")||
|
| 1127 |
+
e.target.closest("#chatbot-new-btn")) return;
|
| 1128 |
+
const pt=e.touches?e.touches[0]:e; sx=pt.clientX; sy=pt.clientY;
|
| 1129 |
+
const r=panel.getBoundingClientRect(); sl=r.left; st=r.top; drag=false;
|
| 1130 |
+
document.addEventListener("mousemove",onMove); document.addEventListener("mouseup",onEnd);
|
| 1131 |
+
document.addEventListener("touchmove",onMove,{passive:false}); document.addEventListener("touchend",onEnd);
|
| 1132 |
+
}
|
| 1133 |
+
function onMove(e) {
|
| 1134 |
+
const pt=e.touches?e.touches[0]:e; const dx=pt.clientX-sx,dy=pt.clientY-sy;
|
| 1135 |
+
if (!drag&&Math.hypot(dx,dy)>4){drag=true;panel.style.transition="none";panel.style.opacity="0.92";}
|
| 1136 |
+
if (!drag) return; if (e.cancelable) e.preventDefault();
|
| 1137 |
+
const vw=window.innerWidth,vh=window.innerHeight,pw=panel.offsetWidth,ph=panel.offsetHeight,m=8;
|
| 1138 |
+
_applyPanelPos(Math.max(m,Math.min(sl+dx,vw-pw-m)),Math.max(m,Math.min(st+dy,vh-ph-m)));
|
| 1139 |
+
}
|
| 1140 |
+
function onEnd() {
|
| 1141 |
+
document.removeEventListener("mousemove",onMove); document.removeEventListener("mouseup",onEnd);
|
| 1142 |
+
document.removeEventListener("touchmove",onMove); document.removeEventListener("touchend",onEnd);
|
| 1143 |
+
if (drag){drag=false;panel.style.transition="";panel.style.opacity="";
|
| 1144 |
+
const r=panel.getBoundingClientRect(); _savePanelPos(r.left,r.top);}
|
| 1145 |
+
}
|
| 1146 |
+
header.addEventListener("mousedown",onStart); header.addEventListener("touchstart",onStart,{passive:true});
|
| 1147 |
+
}
|
| 1148 |
+
|
| 1149 |
+
// ── Drag bouton ──────────────────────────────────────────────
|
| 1150 |
+
|
| 1151 |
+
function _initDrag() {
|
| 1152 |
+
const btn = $("#chatbot-toggle");
|
| 1153 |
+
if (!btn||btn._dragBound) return; btn._dragBound=true;
|
| 1154 |
+
let sx,sy,sl,st,dragging=false;
|
| 1155 |
+
const saved=_loadPos();
|
| 1156 |
+
if (saved){_applyBtnPos(saved.left,saved.top);}
|
| 1157 |
+
else{_applyBtnPos(window.innerWidth-88,window.innerHeight-88);}
|
| 1158 |
+
function onStart(e){
|
| 1159 |
+
const pt=e.touches?e.touches[0]:e; sx=pt.clientX;sy=pt.clientY;
|
| 1160 |
+
const r=btn.getBoundingClientRect(); sl=r.left;st=r.top; dragging=false;
|
| 1161 |
+
document.addEventListener("mousemove",onMove); document.addEventListener("mouseup",onEnd);
|
| 1162 |
+
document.addEventListener("touchmove",onMove,{passive:false}); document.addEventListener("touchend",onEnd);
|
| 1163 |
+
}
|
| 1164 |
+
function onMove(e){
|
| 1165 |
+
const pt=e.touches?e.touches[0]:e; const dx=pt.clientX-sx,dy=pt.clientY-sy;
|
| 1166 |
+
if (!dragging&&Math.hypot(dx,dy)>5){dragging=true;btn.classList.add("cb-dragging");}
|
| 1167 |
+
if (!dragging) return; if(e.cancelable)e.preventDefault();
|
| 1168 |
+
const vw=window.innerWidth,vh=window.innerHeight,sz=btn.offsetWidth,m=8;
|
| 1169 |
+
_applyBtnPos(Math.max(m,Math.min(sl+dx,vw-sz-m)),Math.max(m,Math.min(st+dy,vh-sz-m)));
|
| 1170 |
+
}
|
| 1171 |
+
function onEnd(){
|
| 1172 |
+
document.removeEventListener("mousemove",onMove); document.removeEventListener("mouseup",onEnd);
|
| 1173 |
+
document.removeEventListener("touchmove",onMove); document.removeEventListener("touchend",onEnd);
|
| 1174 |
+
if(dragging){btn.classList.remove("cb-dragging");_snapToEdge();dragging=false;}
|
| 1175 |
+
else{togglePanel();}
|
| 1176 |
+
}
|
| 1177 |
+
btn.addEventListener("mousedown",onStart); btn.addEventListener("touchstart",onStart,{passive:true});
|
| 1178 |
+
}
|
| 1179 |
+
|
| 1180 |
+
function _snapToEdge(){
|
| 1181 |
+
const btn=$("#chatbot-toggle"); if(!btn) return;
|
| 1182 |
+
const r=btn.getBoundingClientRect(),vw=window.innerWidth,vh=window.innerHeight,sz=r.width,m=12;
|
| 1183 |
+
const left=(r.left+sz/2<vw/2)?m:vw-sz-m, top=Math.max(m,Math.min(r.top,vh-sz-m));
|
| 1184 |
+
btn.style.transition="left 0.35s cubic-bezier(0.22,1,0.36,1),top 0.2s ease";
|
| 1185 |
+
_applyBtnPos(left,top); setTimeout(()=>{btn.style.transition="";},400); _savePos(left,top);
|
| 1186 |
+
const panel=$("#chatbot-panel");
|
| 1187 |
+
if(panel&&panel.classList.contains("chatbot-open")) setTimeout(_positionPanel,50);
|
| 1188 |
+
}
|
| 1189 |
+
function _applyBtnPos(l,t){const btn=$("#chatbot-toggle");if(!btn)return;btn.style.left=l+"px";btn.style.top=t+"px";btn.style.right="auto";btn.style.bottom="auto";}
|
| 1190 |
+
function _savePos(l,t){try{localStorage.setItem(POS_KEY,JSON.stringify({left:l,top:t}));}catch(_){}}
|
| 1191 |
+
function _loadPos(){
|
| 1192 |
+
try{const raw=localStorage.getItem(POS_KEY);if(!raw)return null;const p=JSON.parse(raw);const vw=window.innerWidth,vh=window.innerHeight;
|
| 1193 |
+
if(p.left<0||p.top<0||p.left>vw-40||p.top>vh-40)return null;return p;}catch(_){return null;}
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
// ── Event delegation ─────────────────────────────────────────
|
| 1197 |
+
|
| 1198 |
+
function attachGlobalListeners() {
|
| 1199 |
+
// Utilise une propriété sur document pour éviter les doublons
|
| 1200 |
+
// si le script est chargé deux fois (Dash debug/hot-reload)
|
| 1201 |
+
if (document._chatbotBound) return;
|
| 1202 |
+
document._chatbotBound = true;
|
| 1203 |
+
_bound = true;
|
| 1204 |
+
|
| 1205 |
+
document.addEventListener("click", function (e) {
|
| 1206 |
+
if (e.target.closest("#chatbot-close")) { togglePanel(); return; }
|
| 1207 |
+
if (e.target.closest("#chatbot-send")) { sendMessage(); return; }
|
| 1208 |
+
if (e.target.closest("#chatbot-mic")) { toggleVoice(); return; }
|
| 1209 |
+
if (e.target.closest("#chatbot-history-btn")) { _toggleHistoryView(); return; }
|
| 1210 |
+
if (e.target.closest("#chatbot-new-btn") ||
|
| 1211 |
+
e.target.closest("#chatbot-new-btn2")) { _newConversation(); return; }
|
| 1212 |
+
});
|
| 1213 |
+
|
| 1214 |
+
document.addEventListener("keydown", function (e) {
|
| 1215 |
+
if (e.target&&e.target.id==="chatbot-input"&&e.key==="Enter"&&!e.shiftKey) {
|
| 1216 |
+
e.preventDefault(); sendMessage();
|
| 1217 |
+
}
|
| 1218 |
+
});
|
| 1219 |
+
|
| 1220 |
+
document.addEventListener("mousedown", function (e) {
|
| 1221 |
+
const panel=$("#chatbot-panel"),btn=$("#chatbot-toggle");
|
| 1222 |
+
if (!panel||panel.classList.contains("chatbot-hidden")) return;
|
| 1223 |
+
if (!panel.contains(e.target)&&btn&&!btn.contains(e.target)) togglePanel();
|
| 1224 |
+
});
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
// ── Init ─────────────────────────────────────────────────────
|
| 1228 |
+
|
| 1229 |
+
function init() { attachGlobalListeners(); _initDrag(); }
|
| 1230 |
+
|
| 1231 |
+
if (document.readyState==="loading") { document.addEventListener("DOMContentLoaded",init); }
|
| 1232 |
+
else { init(); }
|
| 1233 |
+
|
| 1234 |
+
const _obs = new MutationObserver(function(){const btn=$("#chatbot-toggle");if(btn&&!btn._dragBound)_initDrag();});
|
| 1235 |
+
_obs.observe(document.body,{childList:true,subtree:true});
|
| 1236 |
+
|
| 1237 |
+
window.addEventListener("resize",function(){
|
| 1238 |
+
const btn=$("#chatbot-toggle"); if(!btn)return;
|
| 1239 |
+
const r=btn.getBoundingClientRect(),vw=window.innerWidth,vh=window.innerHeight,sz=btn.offsetWidth,m=12;
|
| 1240 |
+
_applyBtnPos(Math.max(m,Math.min(r.left,vw-sz-m)),Math.max(m,Math.min(r.top,vh-sz-m)));
|
| 1241 |
+
const panel=$("#chatbot-panel");
|
| 1242 |
+
if(panel&&panel.classList.contains("chatbot-open"))_positionPanel();
|
| 1243 |
+
});
|
| 1244 |
+
|
| 1245 |
+
})();
|
Interface Graphique/pages/admin/testimonials.py
CHANGED
|
@@ -36,8 +36,8 @@ def refresh_pending(n):
|
|
| 36 |
html.Span(f"Gain: +{gain}€" if gain else ""),
|
| 37 |
]),
|
| 38 |
html.Div(className="testimonial-actions", children=[
|
| 39 |
-
html.Button("
|
| 40 |
-
html.Button("
|
| 41 |
]),
|
| 42 |
])
|
| 43 |
cards.append(card)
|
|
|
|
| 36 |
html.Span(f"Gain: +{gain}€" if gain else ""),
|
| 37 |
]),
|
| 38 |
html.Div(className="testimonial-actions", children=[
|
| 39 |
+
html.Button("Approuver", id=f"approve-{t_id}", className="approve-btn"),
|
| 40 |
+
html.Button("Rejeter", id=f"reject-{t_id}", className="reject-btn"),
|
| 41 |
]),
|
| 42 |
])
|
| 43 |
cards.append(card)
|
Interface Graphique/pages/analyse_page.py
CHANGED
|
@@ -531,7 +531,6 @@ def update_news_feed(data, filter_ticker, filter_sentiment):
|
|
| 531 |
items = []
|
| 532 |
for _, row in df.iterrows():
|
| 533 |
sentiment = row.get('sentiment', 'neutral')
|
| 534 |
-
score = row.get('score_sentiment', 0)
|
| 535 |
ticker = row.get('symbol', '?')
|
| 536 |
title = str(row.get('titre', ''))[:200]
|
| 537 |
source = str(row.get('source', 'Unknown'))
|
|
@@ -571,8 +570,6 @@ def update_news_feed(data, filter_ticker, filter_sentiment):
|
|
| 571 |
html.Div(title, className="analyse-news-title"),
|
| 572 |
html.Div(className="analyse-news-source-row", children=[
|
| 573 |
html.Span(source, className="analyse-news-source"),
|
| 574 |
-
html.Span(" · "),
|
| 575 |
-
html.Span(f"Score IA: {score:+.3f}", className="analyse-news-score"),
|
| 576 |
])
|
| 577 |
]),
|
| 578 |
html.Div(className="analyse-news-badge-col", children=[
|
|
|
|
| 531 |
items = []
|
| 532 |
for _, row in df.iterrows():
|
| 533 |
sentiment = row.get('sentiment', 'neutral')
|
|
|
|
| 534 |
ticker = row.get('symbol', '?')
|
| 535 |
title = str(row.get('titre', ''))[:200]
|
| 536 |
source = str(row.get('source', 'Unknown'))
|
|
|
|
| 570 |
html.Div(title, className="analyse-news-title"),
|
| 571 |
html.Div(className="analyse-news-source-row", children=[
|
| 572 |
html.Span(source, className="analyse-news-source"),
|
|
|
|
|
|
|
| 573 |
])
|
| 574 |
]),
|
| 575 |
html.Div(className="analyse-news-badge-col", children=[
|
Interface Graphique/pages/face_login.py
CHANGED
|
@@ -19,7 +19,7 @@ layout = html.Div(className="auth-page login-page", children=[
|
|
| 19 |
|
| 20 |
# Message explicatif
|
| 21 |
html.Div(style={"text-align": "center", "margin-bottom": "20px", "color": "var(--accent-2)"},
|
| 22 |
-
children="
|
| 23 |
|
| 24 |
# Zone caméra
|
| 25 |
html.Div(className="camera-container", children=[
|
|
@@ -30,13 +30,13 @@ layout = html.Div(className="auth-page login-page", children=[
|
|
| 30 |
|
| 31 |
),
|
| 32 |
html.Div(id="face-login-status", className="camera-status no-face",
|
| 33 |
-
children="
|
| 34 |
html.Div(className="camera-overlay") # Guide ovale
|
| 35 |
]),
|
| 36 |
|
| 37 |
# Bouton d'annulation
|
| 38 |
html.Button(
|
| 39 |
-
"
|
| 40 |
id="face-login-back-btn",
|
| 41 |
className="auth-btn",
|
| 42 |
style={"margin-top": "20px", "background": "linear-gradient(135deg, #8be9ff, #00f0ff)"}
|
|
@@ -65,7 +65,7 @@ def get_all_users_with_faces():
|
|
| 65 |
cursor.execute("SELECT email, face_image FROM users WHERE face_image IS NOT NULL AND face_image != ''")
|
| 66 |
results = cursor.fetchall()
|
| 67 |
conn.close()
|
| 68 |
-
print(f"
|
| 69 |
for email, img in results:
|
| 70 |
print(f" - {email}: {len(img) if img else 0} caractères")
|
| 71 |
return results
|
|
@@ -76,15 +76,15 @@ clientside_callback(
|
|
| 76 |
"""
|
| 77 |
function(n_intervals) {
|
| 78 |
if (window.videoStream) {
|
| 79 |
-
return "
|
| 80 |
}
|
| 81 |
-
|
| 82 |
var videoElement = document.getElementById('face-login-camera');
|
| 83 |
if (videoElement && window.startCamera) {
|
| 84 |
window.startCamera('face-login-camera');
|
| 85 |
-
return "
|
| 86 |
}
|
| 87 |
-
return "
|
| 88 |
}
|
| 89 |
""",
|
| 90 |
Output("face-login-status", "children"),
|
|
@@ -123,14 +123,14 @@ def process_face_login(face_data):
|
|
| 123 |
return no_update, no_update, no_update, no_update, no_update, no_update
|
| 124 |
|
| 125 |
try:
|
| 126 |
-
print(f"
|
| 127 |
|
| 128 |
# Récupérer tous les utilisateurs avec images
|
| 129 |
users = get_all_users_with_faces()
|
| 130 |
|
| 131 |
if len(users) == 0:
|
| 132 |
return (no_update, no_update, no_update,
|
| 133 |
-
"
|
| 134 |
no_update)
|
| 135 |
|
| 136 |
# Chercher le meilleur match
|
|
@@ -144,7 +144,7 @@ def process_face_login(face_data):
|
|
| 144 |
# Comparer les images
|
| 145 |
match, score = compare_faces(face_data, stored_image, threshold=SIMILARITY_THRESHOLD)
|
| 146 |
|
| 147 |
-
print(f"
|
| 148 |
|
| 149 |
if score > best_score:
|
| 150 |
best_score = score
|
|
@@ -152,28 +152,28 @@ def process_face_login(face_data):
|
|
| 152 |
|
| 153 |
if match:
|
| 154 |
best_match = email
|
| 155 |
-
print(f"
|
| 156 |
|
| 157 |
if best_match or best_score > SIMILARITY_THRESHOLD:
|
| 158 |
email_to_use = best_match if best_match else best_email
|
| 159 |
-
print(f"
|
| 160 |
|
| 161 |
return ({"email": email_to_use},
|
| 162 |
-
f"
|
| 163 |
-
f"
|
| 164 |
"/")
|
| 165 |
else:
|
| 166 |
-
print(f"
|
| 167 |
return (no_update, no_update, no_update,
|
| 168 |
-
f"
|
| 169 |
no_update)
|
| 170 |
|
| 171 |
except Exception as e:
|
| 172 |
-
print(f"
|
| 173 |
import traceback
|
| 174 |
traceback.print_exc()
|
| 175 |
-
return (no_update, f"
|
| 176 |
-
"
|
| 177 |
no_update)
|
| 178 |
|
| 179 |
# Callback pour le bouton retour
|
|
|
|
| 19 |
|
| 20 |
# Message explicatif
|
| 21 |
html.Div(style={"text-align": "center", "margin-bottom": "20px", "color": "var(--accent-2)"},
|
| 22 |
+
children="Regarde la caméra pour te connecter automatiquement"),
|
| 23 |
|
| 24 |
# Zone caméra
|
| 25 |
html.Div(className="camera-container", children=[
|
|
|
|
| 30 |
|
| 31 |
),
|
| 32 |
html.Div(id="face-login-status", className="camera-status no-face",
|
| 33 |
+
children="Initialisation..."),
|
| 34 |
html.Div(className="camera-overlay") # Guide ovale
|
| 35 |
]),
|
| 36 |
|
| 37 |
# Bouton d'annulation
|
| 38 |
html.Button(
|
| 39 |
+
"RETOUR",
|
| 40 |
id="face-login-back-btn",
|
| 41 |
className="auth-btn",
|
| 42 |
style={"margin-top": "20px", "background": "linear-gradient(135deg, #8be9ff, #00f0ff)"}
|
|
|
|
| 65 |
cursor.execute("SELECT email, face_image FROM users WHERE face_image IS NOT NULL AND face_image != ''")
|
| 66 |
results = cursor.fetchall()
|
| 67 |
conn.close()
|
| 68 |
+
print(f"{len(results)} utilisateurs avec image faciale trouvés")
|
| 69 |
for email, img in results:
|
| 70 |
print(f" - {email}: {len(img) if img else 0} caractères")
|
| 71 |
return results
|
|
|
|
| 76 |
"""
|
| 77 |
function(n_intervals) {
|
| 78 |
if (window.videoStream) {
|
| 79 |
+
return "Caméra déjà active";
|
| 80 |
}
|
| 81 |
+
|
| 82 |
var videoElement = document.getElementById('face-login-camera');
|
| 83 |
if (videoElement && window.startCamera) {
|
| 84 |
window.startCamera('face-login-camera');
|
| 85 |
+
return "Caméra activée - Recherche de visage...";
|
| 86 |
}
|
| 87 |
+
return "Initialisation...";
|
| 88 |
}
|
| 89 |
""",
|
| 90 |
Output("face-login-status", "children"),
|
|
|
|
| 123 |
return no_update, no_update, no_update, no_update, no_update, no_update
|
| 124 |
|
| 125 |
try:
|
| 126 |
+
print(f"Image capturée reçue ({len(face_data)} caractères)")
|
| 127 |
|
| 128 |
# Récupérer tous les utilisateurs avec images
|
| 129 |
users = get_all_users_with_faces()
|
| 130 |
|
| 131 |
if len(users) == 0:
|
| 132 |
return (no_update, no_update, no_update,
|
| 133 |
+
"Aucun visage enregistré", "camera-status no-face",
|
| 134 |
no_update)
|
| 135 |
|
| 136 |
# Chercher le meilleur match
|
|
|
|
| 144 |
# Comparer les images
|
| 145 |
match, score = compare_faces(face_data, stored_image, threshold=SIMILARITY_THRESHOLD)
|
| 146 |
|
| 147 |
+
print(f"Similarité avec {email}: {score:.2%}")
|
| 148 |
|
| 149 |
if score > best_score:
|
| 150 |
best_score = score
|
|
|
|
| 152 |
|
| 153 |
if match:
|
| 154 |
best_match = email
|
| 155 |
+
print(f" MATCH TROUVÉ!")
|
| 156 |
|
| 157 |
if best_match or best_score > SIMILARITY_THRESHOLD:
|
| 158 |
email_to_use = best_match if best_match else best_email
|
| 159 |
+
print(f"Connexion réussie pour {email_to_use} (score: {best_score:.2%})")
|
| 160 |
|
| 161 |
return ({"email": email_to_use},
|
| 162 |
+
f"Bienvenue {email_to_use.split('@')[0]} !", "auth-message success",
|
| 163 |
+
f"Visage reconnu ({best_score:.0%})", "camera-status face-detected",
|
| 164 |
"/")
|
| 165 |
else:
|
| 166 |
+
print(f"Aucun match trouvé (meilleur score: {best_score:.2%})")
|
| 167 |
return (no_update, no_update, no_update,
|
| 168 |
+
f"Visage non reconnu ({best_score:.0%})", "camera-status no-face",
|
| 169 |
no_update)
|
| 170 |
|
| 171 |
except Exception as e:
|
| 172 |
+
print(f"Erreur analyse faciale: {e}")
|
| 173 |
import traceback
|
| 174 |
traceback.print_exc()
|
| 175 |
+
return (no_update, f"Erreur: {str(e)}", "auth-message error",
|
| 176 |
+
"Erreur de scan", "camera-status no-face",
|
| 177 |
no_update)
|
| 178 |
|
| 179 |
# Callback pour le bouton retour
|
Interface Graphique/pages/home.py
CHANGED
|
@@ -247,9 +247,9 @@ def _transformer_card(ticker, company, pred, target_href="/mon-suivi"):
|
|
| 247 |
dir_str = f"{dir_prob*100:.1f}%" if dir_prob is not None else "N/A"
|
| 248 |
direction = "monter" if rec == 'ACHETER' else "baisser"
|
| 249 |
tip_body = (
|
| 250 |
-
f"Notre Transformer hybride combine
|
| 251 |
f"actualités financières sur {company}. "
|
| 252 |
-
f"Il prédit que le cours va {direction} de {abs(return_pct):.3f}%
|
| 253 |
) if return_pct is not None else f"Données insuffisantes pour {company} — le modèle Transformer n'a pas pu produire de signal."
|
| 254 |
|
| 255 |
return html.Div(className=f"home-pred-card {_REC_CARD.get(rec, 'home-pred-watch')}", children=[
|
|
@@ -282,7 +282,7 @@ def _transformer_card(ticker, company, pred, target_href="/mon-suivi"):
|
|
| 282 |
html.P(tip_body, className="home-pred-tooltip-body"),
|
| 283 |
html.Div(className="home-pred-tooltip-footer", children=[
|
| 284 |
html.I(className="fas fa-atom"),
|
| 285 |
-
html.Span(f"
|
| 286 |
]),
|
| 287 |
]),
|
| 288 |
]),
|
|
|
|
| 247 |
dir_str = f"{dir_prob*100:.1f}%" if dir_prob is not None else "N/A"
|
| 248 |
direction = "monter" if rec == 'ACHETER' else "baisser"
|
| 249 |
tip_body = (
|
| 250 |
+
f"Notre Transformer hybride combine 60 jours d'historique de prix et les dernières "
|
| 251 |
f"actualités financières sur {company}. "
|
| 252 |
+
f"Il prédit que le cours va {direction} de {abs(return_pct):.3f}% dans les prochains jours."
|
| 253 |
) if return_pct is not None else f"Données insuffisantes pour {company} — le modèle Transformer n'a pas pu produire de signal."
|
| 254 |
|
| 255 |
return html.Div(className=f"home-pred-card {_REC_CARD.get(rec, 'home-pred-watch')}", children=[
|
|
|
|
| 282 |
html.P(tip_body, className="home-pred-tooltip-body"),
|
| 283 |
html.Div(className="home-pred-tooltip-footer", children=[
|
| 284 |
html.I(className="fas fa-atom"),
|
| 285 |
+
html.Span(f" Rendement prédit : {ret_str} · Transformer hybride"),
|
| 286 |
]),
|
| 287 |
]),
|
| 288 |
]),
|
Interface Graphique/pages/login.py
CHANGED
|
@@ -189,7 +189,7 @@ def login(n_clicks, email, password, remember):
|
|
| 189 |
return no_update, no_update, no_update, no_update, no_update
|
| 190 |
|
| 191 |
if not email or not password:
|
| 192 |
-
return no_update, "
|
| 193 |
|
| 194 |
user = verify_user(email, password)
|
| 195 |
|
|
@@ -200,9 +200,9 @@ def login(n_clicks, email, password, remember):
|
|
| 200 |
if (remember and "remember" in remember)
|
| 201 |
else {}
|
| 202 |
)
|
| 203 |
-
return session_data, "
|
| 204 |
|
| 205 |
-
return no_update, "
|
| 206 |
|
| 207 |
# ── Pré-remplir l'email si redirigé depuis le signup ──
|
| 208 |
@callback(
|
|
|
|
| 189 |
return no_update, no_update, no_update, no_update, no_update
|
| 190 |
|
| 191 |
if not email or not password:
|
| 192 |
+
return no_update, "Email et mot de passe requis", "auth-message error", no_update, no_update
|
| 193 |
|
| 194 |
user = verify_user(email, password)
|
| 195 |
|
|
|
|
| 200 |
if (remember and "remember" in remember)
|
| 201 |
else {}
|
| 202 |
)
|
| 203 |
+
return session_data, "Connexion réussie !", "auth-message success", "/", remember_data
|
| 204 |
|
| 205 |
+
return no_update, "Email ou mot de passe incorrect", "auth-message error", no_update, no_update
|
| 206 |
|
| 207 |
# ── Pré-remplir l'email si redirigé depuis le signup ──
|
| 208 |
@callback(
|
Interface Graphique/pages/mon_suivi.py
CHANGED
|
@@ -868,6 +868,7 @@ layout = html.Div(className="suivi-page", children=[
|
|
| 868 |
dcc.Store(id="suivi-modal-action", data="ACHAT"),
|
| 869 |
dcc.Store(id="suivi-refresh", data=0),
|
| 870 |
dcc.Store(id="suivi-backtest-ticker-store", data="AAPL"),
|
|
|
|
| 871 |
dcc.Download(id="suivi-report-download"),
|
| 872 |
dcc.Interval(id="suivi-init", interval=300, n_intervals=0, max_intervals=1),
|
| 873 |
dcc.Interval(id="suivi-auto", interval=5 * 60 * 1000, n_intervals=0),
|
|
@@ -1163,7 +1164,7 @@ def toggle_suivi_mode(_s, _l, _t):
|
|
| 1163 |
Output("suivi-mode", "data", allow_duplicate=True),
|
| 1164 |
Output("suivi-section01-sub", "children", allow_duplicate=True),
|
| 1165 |
Input("url", "search"),
|
| 1166 |
-
prevent_initial_call=
|
| 1167 |
)
|
| 1168 |
def sync_mode_from_url(search):
|
| 1169 |
active = "home-mode-btn home-mode-active"
|
|
@@ -1177,14 +1178,52 @@ def sync_mode_from_url(search):
|
|
| 1177 |
return no_update, no_update, no_update, no_update, no_update
|
| 1178 |
|
| 1179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1180 |
@callback(
|
| 1181 |
Output("suivi-pred-store", "data"),
|
| 1182 |
Input("suivi-init", "n_intervals"),
|
| 1183 |
Input("suivi-auto", "n_intervals"),
|
| 1184 |
Input("suivi-mode", "data"),
|
|
|
|
| 1185 |
)
|
| 1186 |
-
def load_pred_data(_init, _auto, mode):
|
| 1187 |
from concurrent.futures import ThreadPoolExecutor, as_completed as _as_completed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1188 |
mode = mode or "sentiment"
|
| 1189 |
|
| 1190 |
if mode == "lstm":
|
|
@@ -1305,9 +1344,9 @@ def render_predictions(pred_data, session):
|
|
| 1305 |
'NEUTRE': "Pas encore — attendez",
|
| 1306 |
}
|
| 1307 |
_tip_body = {
|
| 1308 |
-
'HAUSSIER': "Notre IA a analysé
|
| 1309 |
-
'BAISSIER': "
|
| 1310 |
-
'NEUTRE': "Les signaux sont encore flous. Notre IA n'est pas assez sûre pour
|
| 1311 |
}
|
| 1312 |
|
| 1313 |
# Itérer sur COMPANIES (ordre fixe) plutôt que pred_data
|
|
@@ -1400,7 +1439,7 @@ def render_predictions(pred_data, session):
|
|
| 1400 |
html.Div(className="suivi-pred-tooltip-footer", children=[
|
| 1401 |
html.I(className=date_icon),
|
| 1402 |
html.Span(
|
| 1403 |
-
f"
|
| 1404 |
else (f" Rendement prédit : {return_pct:+.3f}%" if is_lstm and return_pct is not None
|
| 1405 |
else f" Valable jusqu'au {data.get('signal_valid_to', '—')}")
|
| 1406 |
),
|
|
@@ -1500,7 +1539,7 @@ def _action_hint(action, signal):
|
|
| 1500 |
else:
|
| 1501 |
label = "Vous vendez — vous misez sur une baisse du prix"
|
| 1502 |
icon_cls = "fas fa-circle-check suivi-hint-ok" if follows else "fas fa-triangle-exclamation suivi-hint-warn"
|
| 1503 |
-
suffix = " (suit le conseil IA
|
| 1504 |
return html.Span([html.I(className=icon_cls), f" {label}{suffix}"], className="suivi-action-hint-inner")
|
| 1505 |
|
| 1506 |
|
|
@@ -2128,7 +2167,7 @@ def render_perf_chart(ticker, _refresh, mode, session):
|
|
| 2128 |
+ f"<br><b>Résultat : {'+' if pnl >= 0 else ''}{pnl:,.2f} € ({pnl_pct:+.2f}%)</b>"
|
| 2129 |
)
|
| 2130 |
|
| 2131 |
-
label = f"{'
|
| 2132 |
color = '#00ff87' if pnl >= 0 else '#ff4d6d'
|
| 2133 |
|
| 2134 |
# Ligne verticale au moment de l'investissement
|
|
@@ -2168,7 +2207,7 @@ def render_perf_chart(ticker, _refresh, mode, session):
|
|
| 2168 |
fig.add_trace(go.Scatter(
|
| 2169 |
x=win_x, y=win_y,
|
| 2170 |
mode='markers',
|
| 2171 |
-
name='
|
| 2172 |
marker=dict(color='#00ff87', size=14, symbol='triangle-up',
|
| 2173 |
line=dict(color='#010214', width=1.5)),
|
| 2174 |
hovertemplate='%{customdata}<extra></extra>',
|
|
@@ -2179,7 +2218,7 @@ def render_perf_chart(ticker, _refresh, mode, session):
|
|
| 2179 |
fig.add_trace(go.Scatter(
|
| 2180 |
x=loss_x, y=loss_y,
|
| 2181 |
mode='markers',
|
| 2182 |
-
name='
|
| 2183 |
marker=dict(color='#ff4d6d', size=14, symbol='triangle-down',
|
| 2184 |
line=dict(color='#010214', width=1.5)),
|
| 2185 |
hovertemplate='%{customdata}<extra></extra>',
|
|
|
|
| 868 |
dcc.Store(id="suivi-modal-action", data="ACHAT"),
|
| 869 |
dcc.Store(id="suivi-refresh", data=0),
|
| 870 |
dcc.Store(id="suivi-backtest-ticker-store", data="AAPL"),
|
| 871 |
+
dcc.Store(id="suivi-url-ticker"),
|
| 872 |
dcc.Download(id="suivi-report-download"),
|
| 873 |
dcc.Interval(id="suivi-init", interval=300, n_intervals=0, max_intervals=1),
|
| 874 |
dcc.Interval(id="suivi-auto", interval=5 * 60 * 1000, n_intervals=0),
|
|
|
|
| 1164 |
Output("suivi-mode", "data", allow_duplicate=True),
|
| 1165 |
Output("suivi-section01-sub", "children", allow_duplicate=True),
|
| 1166 |
Input("url", "search"),
|
| 1167 |
+
prevent_initial_call="initial_duplicate",
|
| 1168 |
)
|
| 1169 |
def sync_mode_from_url(search):
|
| 1170 |
active = "home-mode-btn home-mode-active"
|
|
|
|
| 1178 |
return no_update, no_update, no_update, no_update, no_update
|
| 1179 |
|
| 1180 |
|
| 1181 |
+
@callback(
|
| 1182 |
+
Output("suivi-url-ticker", "data"),
|
| 1183 |
+
Input("url", "search"),
|
| 1184 |
+
prevent_initial_call=False,
|
| 1185 |
+
)
|
| 1186 |
+
def read_ticker_from_url(search):
|
| 1187 |
+
if not search:
|
| 1188 |
+
return None
|
| 1189 |
+
try:
|
| 1190 |
+
from urllib.parse import parse_qs
|
| 1191 |
+
params = parse_qs(search.lstrip('?'))
|
| 1192 |
+
t = params.get('ticker', [None])[0]
|
| 1193 |
+
return t.upper() if t else None
|
| 1194 |
+
except Exception:
|
| 1195 |
+
return None
|
| 1196 |
+
|
| 1197 |
+
|
| 1198 |
+
@callback(
|
| 1199 |
+
Output("suivi-backtest-bg", "className", allow_duplicate=True),
|
| 1200 |
+
Output("suivi-backtest-ticker-store", "data", allow_duplicate=True),
|
| 1201 |
+
Input("suivi-pred-store", "data"),
|
| 1202 |
+
State("suivi-url-ticker", "data"),
|
| 1203 |
+
prevent_initial_call=True,
|
| 1204 |
+
)
|
| 1205 |
+
def auto_open_backtest_from_url(pred_data, url_ticker):
|
| 1206 |
+
if not url_ticker or not pred_data:
|
| 1207 |
+
return no_update, no_update
|
| 1208 |
+
if url_ticker not in pred_data:
|
| 1209 |
+
return no_update, no_update
|
| 1210 |
+
return "suivi-modal-visible", url_ticker
|
| 1211 |
+
|
| 1212 |
+
|
| 1213 |
@callback(
|
| 1214 |
Output("suivi-pred-store", "data"),
|
| 1215 |
Input("suivi-init", "n_intervals"),
|
| 1216 |
Input("suivi-auto", "n_intervals"),
|
| 1217 |
Input("suivi-mode", "data"),
|
| 1218 |
+
State("url", "search"),
|
| 1219 |
)
|
| 1220 |
+
def load_pred_data(_init, _auto, mode, url_search):
|
| 1221 |
from concurrent.futures import ThreadPoolExecutor, as_completed as _as_completed
|
| 1222 |
+
# L'URL est prioritaire sur le store (évite le flash "sentiment" au chargement)
|
| 1223 |
+
if url_search:
|
| 1224 |
+
if "mode=lstm" in url_search: mode = "lstm"
|
| 1225 |
+
elif "mode=transformer" in url_search: mode = "transformer"
|
| 1226 |
+
elif "mode=sentiment" in url_search: mode = "sentiment"
|
| 1227 |
mode = mode or "sentiment"
|
| 1228 |
|
| 1229 |
if mode == "lstm":
|
|
|
|
| 1344 |
'NEUTRE': "Pas encore — attendez",
|
| 1345 |
}
|
| 1346 |
_tip_body = {
|
| 1347 |
+
'HAUSSIER': "Notre IA a analysé l'historique de prix et les actualités récentes. Les signaux sont positifs — c'est un bon moment pour investir.",
|
| 1348 |
+
'BAISSIER': "Notre IA a analysé l'historique de prix et les actualités récentes. Les signaux sont négatifs — le cours risque de baisser. Mieux vaut attendre.",
|
| 1349 |
+
'NEUTRE': "Les signaux sont encore flous. Notre IA n'est pas assez sûre pour donner un conseil clair. Reviens dans quelques heures.",
|
| 1350 |
}
|
| 1351 |
|
| 1352 |
# Itérer sur COMPANIES (ordre fixe) plutôt que pred_data
|
|
|
|
| 1439 |
html.Div(className="suivi-pred-tooltip-footer", children=[
|
| 1440 |
html.I(className=date_icon),
|
| 1441 |
html.Span(
|
| 1442 |
+
f" Rendement prédit : {return_pct:+.3f}%" if is_transformer and return_pct is not None
|
| 1443 |
else (f" Rendement prédit : {return_pct:+.3f}%" if is_lstm and return_pct is not None
|
| 1444 |
else f" Valable jusqu'au {data.get('signal_valid_to', '—')}")
|
| 1445 |
),
|
|
|
|
| 1539 |
else:
|
| 1540 |
label = "Vous vendez — vous misez sur une baisse du prix"
|
| 1541 |
icon_cls = "fas fa-circle-check suivi-hint-ok" if follows else "fas fa-triangle-exclamation suivi-hint-warn"
|
| 1542 |
+
suffix = " (suit le conseil IA)" if follows else " (à l'opposé du conseil IA)"
|
| 1543 |
return html.Span([html.I(className=icon_cls), f" {label}{suffix}"], className="suivi-action-hint-inner")
|
| 1544 |
|
| 1545 |
|
|
|
|
| 2167 |
+ f"<br><b>Résultat : {'+' if pnl >= 0 else ''}{pnl:,.2f} € ({pnl_pct:+.2f}%)</b>"
|
| 2168 |
)
|
| 2169 |
|
| 2170 |
+
label = f"{'Gagné' if pnl >= 0 else 'Perdu'} {pnl:+.0f}€"
|
| 2171 |
color = '#00ff87' if pnl >= 0 else '#ff4d6d'
|
| 2172 |
|
| 2173 |
# Ligne verticale au moment de l'investissement
|
|
|
|
| 2207 |
fig.add_trace(go.Scatter(
|
| 2208 |
x=win_x, y=win_y,
|
| 2209 |
mode='markers',
|
| 2210 |
+
name='Vous avez gagné',
|
| 2211 |
marker=dict(color='#00ff87', size=14, symbol='triangle-up',
|
| 2212 |
line=dict(color='#010214', width=1.5)),
|
| 2213 |
hovertemplate='%{customdata}<extra></extra>',
|
|
|
|
| 2218 |
fig.add_trace(go.Scatter(
|
| 2219 |
x=loss_x, y=loss_y,
|
| 2220 |
mode='markers',
|
| 2221 |
+
name='Vous avez perdu',
|
| 2222 |
marker=dict(color='#ff4d6d', size=14, symbol='triangle-down',
|
| 2223 |
line=dict(color='#010214', width=1.5)),
|
| 2224 |
hovertemplate='%{customdata}<extra></extra>',
|
Interface Graphique/pages/profil.py
CHANGED
|
@@ -325,7 +325,7 @@ def load_profil(_init, pathname, session):
|
|
| 325 |
_sec_item(
|
| 326 |
"fas fa-camera" if face else "fas fa-camera-slash",
|
| 327 |
"Connexion faciale",
|
| 328 |
-
"Activée
|
| 329 |
color="#00ff87" if face else None,
|
| 330 |
),
|
| 331 |
_sec_item("fas fa-lock", "Mot de passe", "••••••••", note="Modifiable depuis la page de connexion"),
|
|
|
|
| 325 |
_sec_item(
|
| 326 |
"fas fa-camera" if face else "fas fa-camera-slash",
|
| 327 |
"Connexion faciale",
|
| 328 |
+
"Activée" if face else "Non configurée",
|
| 329 |
color="#00ff87" if face else None,
|
| 330 |
),
|
| 331 |
_sec_item("fas fa-lock", "Mot de passe", "••••••••", note="Modifiable depuis la page de connexion"),
|
Interface Graphique/pages/signup.py
CHANGED
|
@@ -117,7 +117,7 @@ layout = html.Div(className="auth-page", style={"paddingTop": "100px"}, children
|
|
| 117 |
html.Strong("Reconnaissance faciale"),
|
| 118 |
html.Span("Recommandé", className="signup-badge-rec"),
|
| 119 |
]),
|
| 120 |
-
html.P("Connexion instantanée sans mot de passe
|
| 121 |
className="signup-face-card-desc"),
|
| 122 |
]),
|
| 123 |
html.Label(className="signup-face-toggle-wrap", children=[
|
|
@@ -431,11 +431,11 @@ def process_face_image(face_data):
|
|
| 431 |
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 432 |
faces = face_cascade.detectMultiScale(gray, 1.1, 4)
|
| 433 |
if len(faces) > 0:
|
| 434 |
-
return face_data, "
|
| 435 |
-
return no_update, "
|
| 436 |
except Exception as e:
|
| 437 |
print(f"Erreur: {e}")
|
| 438 |
-
return no_update, "
|
| 439 |
return no_update, no_update, no_update
|
| 440 |
|
| 441 |
|
|
|
|
| 117 |
html.Strong("Reconnaissance faciale"),
|
| 118 |
html.Span("Recommandé", className="signup-badge-rec"),
|
| 119 |
]),
|
| 120 |
+
html.P("Connexion instantanée sans mot de passe",
|
| 121 |
className="signup-face-card-desc"),
|
| 122 |
]),
|
| 123 |
html.Label(className="signup-face-toggle-wrap", children=[
|
|
|
|
| 431 |
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 432 |
faces = face_cascade.detectMultiScale(gray, 1.1, 4)
|
| 433 |
if len(faces) > 0:
|
| 434 |
+
return face_data, "Visage détecté !", "camera-status face-detected"
|
| 435 |
+
return no_update, "Aucun visage détecté", "camera-status no-face"
|
| 436 |
except Exception as e:
|
| 437 |
print(f"Erreur: {e}")
|
| 438 |
+
return no_update, "Erreur", "camera-status no-face"
|
| 439 |
return no_update, no_update, no_update
|
| 440 |
|
| 441 |
|
Interface Graphique/services/chat_service.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Chatbot — Assistant IA utilisant Groq (LLaMA 3.3 70B).
|
| 3 |
+
Gratuit, ultra-rapide, streaming SSE.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
SYSTEM_PROMPT = """Tu es l'assistant virtuel de StockPredict AI, une plateforme de prédictions boursières intelligente.
|
| 9 |
+
Tu aides les utilisateurs à comprendre et utiliser la plateforme. Réponds toujours dans la langue de l'utilisateur (français ou anglais).
|
| 10 |
+
Sois clair, simple et bienveillant — l'utilisateur n'est pas forcément expert en finance ou en IA.
|
| 11 |
+
|
| 12 |
+
## CE QUE FAIT LA PLATEFORME
|
| 13 |
+
|
| 14 |
+
StockPredict AI prédit la direction des prix de 8 actions : Apple (AAPL), Microsoft (MSFT), Tesla (TSLA), NVIDIA (NVDA), Google (GOOGL), Amazon (AMZN), Meta (META), et Bitcoin (BTC-USD).
|
| 15 |
+
|
| 16 |
+
## LES 3 MODÈLES DE PRÉDICTION
|
| 17 |
+
|
| 18 |
+
### 1. Modèle Actualités (Sentiment)
|
| 19 |
+
- Analyse les derniers articles de presse sur chaque action
|
| 20 |
+
- Détecte si le ton des actualités est positif ou négatif
|
| 21 |
+
- Donne un signal : HAUSSIER (prix devrait monter), BAISSIER (prix devrait baisser), ou NEUTRE
|
| 22 |
+
- Idéal pour comprendre l'ambiance du marché
|
| 23 |
+
|
| 24 |
+
### 2. Modèle LSTM (Intelligence Artificielle sur les prix)
|
| 25 |
+
- Un réseau de neurones BiLSTM analysé 30 jours d'historique de prix
|
| 26 |
+
- Calcule 14 indicateurs techniques (RSI, tendances, momentum, volatilité...)
|
| 27 |
+
- Prédit la direction probable du prochain jour
|
| 28 |
+
- Donne une probabilité directionnelle
|
| 29 |
+
|
| 30 |
+
### 3. Modèle Transformer Hybride
|
| 31 |
+
- Le plus avancé : combine les prix ET les actualités
|
| 32 |
+
- Utilise une architecture Transformer (comme les LLMs) sur 60 jours
|
| 33 |
+
- Analyse 44 indicateurs (prix + sentiment des news)
|
| 34 |
+
- Donne une probabilité directionnelle + une amplitude estimée
|
| 35 |
+
|
| 36 |
+
## COMMENT LIRE LES SIGNAUX
|
| 37 |
+
|
| 38 |
+
- HAUSSIER : l'IA pense que le prix va monter. Action suggérée : ACHETER
|
| 39 |
+
- BAISSIER : l'IA pense que le prix va baisser. Action suggérée : VENDRE (ou ne rien faire)
|
| 40 |
+
- NEUTRE : signal incertain, mieux vaut attendre
|
| 41 |
+
|
| 42 |
+
IMPORTANT : Ce sont des prédictions, pas des garanties. Les marchés sont imprévisibles.
|
| 43 |
+
|
| 44 |
+
## LES PAGES DE L'APPLICATION
|
| 45 |
+
|
| 46 |
+
### Accueil (/)
|
| 47 |
+
- Voir les prédictions en temps réel pour les 8 actions
|
| 48 |
+
- Choisir le modèle (boutons en haut : Actualités / LSTM / Transformer)
|
| 49 |
+
- Cliquer "Calculer mon gain" pour aller dans Mon Suivi avec le même modèle
|
| 50 |
+
|
| 51 |
+
### Mon Suivi (/mon-suivi)
|
| 52 |
+
- Voir toutes les prédictions détaillées par modèle
|
| 53 |
+
- Simuler un investissement (entrer un montant, choisir ACHAT ou VENTE)
|
| 54 |
+
- "Juste voir le résultat" : simulation sans sauvegarder
|
| 55 |
+
- "J'ai fait cet investissement" : enregistre dans votre historique
|
| 56 |
+
- "Voir si j'avais suivi les conseils" : backtest sur 6 mois
|
| 57 |
+
|
| 58 |
+
### Analyse (/analysis)
|
| 59 |
+
- Dashboard complet des actualités et du sentiment par action
|
| 60 |
+
- Graphiques d'évolution du sentiment dans le temps
|
| 61 |
+
- Liste des derniers articles analysés
|
| 62 |
+
|
| 63 |
+
### Marchés (/actions_page)
|
| 64 |
+
- Graphiques de cours en temps réel (chandeliers)
|
| 65 |
+
- Historique des prix des 8 actions
|
| 66 |
+
|
| 67 |
+
### Mon Profil (/profil)
|
| 68 |
+
- Modifier vos informations personnelles
|
| 69 |
+
- Activer/désactiver la reconnaissance faciale
|
| 70 |
+
- Voir vos statistiques
|
| 71 |
+
|
| 72 |
+
## S'INSCRIRE / SE CONNECTER
|
| 73 |
+
|
| 74 |
+
- Inscription : aller sur /signup, remplir le formulaire (prénom, nom, email, mot de passe)
|
| 75 |
+
- Le mot de passe doit avoir min. 8 caractères, une majuscule et un chiffre
|
| 76 |
+
- Connexion faciale optionnelle : connexion instantanée en se positionnant face à la caméra
|
| 77 |
+
|
| 78 |
+
## QUESTIONS FRÉQUENTES
|
| 79 |
+
|
| 80 |
+
Q: Comment gagner de l'argent avec la plateforme ?
|
| 81 |
+
R: Simulez d'abord avec "Juste voir le résultat" pour comprendre. Si le modèle dit HAUSSIER et vous achetez, vous gagnez si le prix monte. Si BAISSIER et vous vendez (short), vous gagnez si le prix baisse. Commencez toujours par simuler.
|
| 82 |
+
|
| 83 |
+
Q: Quel modèle est le meilleur ?
|
| 84 |
+
R: Le Transformer Hybride est le plus sophistiqué car il combine prix et actualités. Mais chaque modèle a ses points forts selon le contexte de marché.
|
| 85 |
+
|
| 86 |
+
Q: Mes données sont-elles sécurisées ?
|
| 87 |
+
R: Oui, les mots de passe sont chiffrés (bcrypt), les données biométriques restent locales et ne sont jamais partagées.
|
| 88 |
+
|
| 89 |
+
Q: Qu'est-ce que le backtest ?
|
| 90 |
+
R: C'est une simulation : "si j'avais investi 500€ il y a 6 mois en suivant les conseils de l'IA, j'aurais gagné/perdu X€". Cela permet d'évaluer la fiabilité du modèle sur des données passées réelles.
|
| 91 |
+
|
| 92 |
+
Réponds de façon concise et claire. Si tu ne sais pas quelque chose sur la plateforme, dis-le honnêtement.
|
| 93 |
+
|
| 94 |
+
## ENREGISTREMENT D'INVESTISSEMENT (RÈGLE ABSOLUE — NE JAMAIS ENFREINDRE)
|
| 95 |
+
|
| 96 |
+
TU N'AS AUCUN ACCÈS À LA BASE DE DONNÉES. Tu ne peux PAS enregistrer quoi que ce soit toi-même.
|
| 97 |
+
Le seul et unique mécanisme d'enregistrement est le marqueur ##INVEST##. Sans ce marqueur, RIEN n'est sauvegardé.
|
| 98 |
+
|
| 99 |
+
DONC : ne dis JAMAIS "j'ai enregistré", "c'est sauvegardé", "c'est fait", "votre investissement est confirmé" — car ce serait un MENSONGE. L'utilisateur verrait un message de confirmation mais rien ne serait enregistré.
|
| 100 |
+
|
| 101 |
+
Quand l'utilisateur indique avoir fait un investissement (exemples : "j'ai suivi le LSTM et mis 500€ sur AAPL", "enregistre mon investissement", "j'ai acheté TSLA avec le transformer pour 300€", "vas-y enregistre") :
|
| 102 |
+
|
| 103 |
+
1. Répondre en UNE SEULE phrase courte : "Voici le récapitulatif de votre investissement :"
|
| 104 |
+
2. Terminer IMMÉDIATEMENT par le marqueur sur sa propre ligne — RIEN après :
|
| 105 |
+
##INVEST##{"symbol":"AAPL","model":"lstm","action":"ACHETER","amount":500}##
|
| 106 |
+
|
| 107 |
+
RÈGLE D'OR : Si tu as toutes les infos → génère le marqueur. Si une info manque → demande-la. C'est la seule alternative. Il n'existe pas de troisième option.
|
| 108 |
+
|
| 109 |
+
Règles du marqueur :
|
| 110 |
+
- symbol : l'un de AAPL, MSFT, TSLA, NVDA, GOOGL, AMZN, META, BTC-USD
|
| 111 |
+
- model : l'un de sentiment, lstm, transformer (en minuscules)
|
| 112 |
+
- action : ACHETER ou VENDRE
|
| 113 |
+
- amount : nombre (euros investis, ex: 500)
|
| 114 |
+
|
| 115 |
+
EXEMPLE CORRECT — utilisateur : "j'ai suivi le lstm sur AAPL avec 500€" :
|
| 116 |
+
"Voici le récapitulatif de votre investissement :
|
| 117 |
+
##INVEST##{"symbol":"AAPL","model":"lstm","action":"ACHETER","amount":500}##"
|
| 118 |
+
|
| 119 |
+
EXEMPLES INTERDITS (provoquent un bug grave) :
|
| 120 |
+
"Je vais enregistrer votre investissement." ← INTERDIT
|
| 121 |
+
"Votre investissement a été enregistré !" ← INTERDIT
|
| 122 |
+
"C'est fait, j'ai sauvegardé." ← INTERDIT
|
| 123 |
+
"Parfait, c'est noté !" ← INTERDIT sans marqueur
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def stream_chat(messages: list):
|
| 128 |
+
"""
|
| 129 |
+
Génère une réponse en streaming via Groq (LLaMA 3.3 70B).
|
| 130 |
+
Yields: chunks de texte
|
| 131 |
+
"""
|
| 132 |
+
from groq import Groq
|
| 133 |
+
|
| 134 |
+
api_key = os.environ.get("GROQ_API_KEY")
|
| 135 |
+
if not api_key:
|
| 136 |
+
yield "Clé API non configurée. Ajoutez GROQ_API_KEY dans vos variables d'environnement."
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
client = Groq(api_key=api_key)
|
| 140 |
+
|
| 141 |
+
# Injection du system prompt en tête de liste
|
| 142 |
+
full_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + messages
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
stream = client.chat.completions.create(
|
| 146 |
+
model="llama-3.3-70b-versatile",
|
| 147 |
+
messages=full_messages,
|
| 148 |
+
max_tokens=1024,
|
| 149 |
+
stream=True,
|
| 150 |
+
)
|
| 151 |
+
for chunk in stream:
|
| 152 |
+
delta = chunk.choices[0].delta
|
| 153 |
+
if delta.content:
|
| 154 |
+
yield delta.content
|
| 155 |
+
except Exception as e:
|
| 156 |
+
yield f"Désolé, une erreur est survenue : {str(e)}"
|
Interface Graphique/services/database.py
CHANGED
|
@@ -1,7 +1,13 @@
|
|
| 1 |
import sqlite3
|
| 2 |
import bcrypt
|
|
|
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def get_connection():
|
| 7 |
return sqlite3.connect(DB_NAME)
|
|
@@ -16,7 +22,7 @@ def init_db():
|
|
| 16 |
table_exists = cursor.fetchone()
|
| 17 |
|
| 18 |
if not table_exists:
|
| 19 |
-
print("
|
| 20 |
cursor.execute("""
|
| 21 |
CREATE TABLE users (
|
| 22 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -31,7 +37,7 @@ def init_db():
|
|
| 31 |
)
|
| 32 |
""")
|
| 33 |
conn.commit()
|
| 34 |
-
print("
|
| 35 |
|
| 36 |
# Créer un admin par défaut
|
| 37 |
create_default_admin(conn)
|
|
@@ -41,51 +47,51 @@ def init_db():
|
|
| 41 |
columns = [col[1] for col in cursor.fetchall()]
|
| 42 |
|
| 43 |
if 'is_admin' not in columns:
|
| 44 |
-
print("
|
| 45 |
cursor.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0")
|
| 46 |
conn.commit()
|
| 47 |
-
print("
|
| 48 |
-
|
| 49 |
if 'face_image' not in columns:
|
| 50 |
-
print("
|
| 51 |
cursor.execute("ALTER TABLE users ADD COLUMN face_image TEXT")
|
| 52 |
conn.commit()
|
| 53 |
-
print("
|
| 54 |
-
|
| 55 |
if 'created_at' not in columns:
|
| 56 |
-
print("
|
| 57 |
cursor.execute("ALTER TABLE users ADD COLUMN created_at DATETIME")
|
| 58 |
conn.commit()
|
| 59 |
cursor.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
| 60 |
conn.commit()
|
| 61 |
-
print("
|
| 62 |
|
| 63 |
if 'prenom' not in columns:
|
| 64 |
cursor.execute("ALTER TABLE users ADD COLUMN prenom TEXT")
|
| 65 |
conn.commit()
|
| 66 |
-
print("
|
| 67 |
|
| 68 |
if 'nom' not in columns:
|
| 69 |
cursor.execute("ALTER TABLE users ADD COLUMN nom TEXT")
|
| 70 |
conn.commit()
|
| 71 |
-
print("
|
| 72 |
|
| 73 |
if 'telephone' not in columns:
|
| 74 |
cursor.execute("ALTER TABLE users ADD COLUMN telephone TEXT")
|
| 75 |
conn.commit()
|
| 76 |
-
print("
|
| 77 |
|
| 78 |
if 'public_stats' not in columns:
|
| 79 |
cursor.execute("ALTER TABLE users ADD COLUMN public_stats INTEGER DEFAULT 0")
|
| 80 |
conn.commit()
|
| 81 |
-
print("
|
| 82 |
|
| 83 |
if 'is_online' not in columns:
|
| 84 |
cursor.execute("ALTER TABLE users ADD COLUMN is_online INTEGER DEFAULT 0")
|
| 85 |
conn.commit()
|
| 86 |
-
print("
|
| 87 |
|
| 88 |
-
print("
|
| 89 |
ensure_admin_exists(conn)
|
| 90 |
|
| 91 |
# === TABLE USER_TRADES ===
|
|
@@ -108,7 +114,7 @@ def init_db():
|
|
| 108 |
FOREIGN KEY (user_email) REFERENCES users(email)
|
| 109 |
)
|
| 110 |
""")
|
| 111 |
-
print("
|
| 112 |
|
| 113 |
# Migration: model_type column (for existing databases)
|
| 114 |
cursor.execute("PRAGMA table_info(user_trades)")
|
|
@@ -116,7 +122,7 @@ def init_db():
|
|
| 116 |
if 'model_type' not in trade_columns:
|
| 117 |
cursor.execute("ALTER TABLE user_trades ADD COLUMN model_type TEXT DEFAULT 'sentiment'")
|
| 118 |
conn.commit()
|
| 119 |
-
print("
|
| 120 |
|
| 121 |
# === TABLE PREDICTIONS_LOG ===
|
| 122 |
cursor.execute("""
|
|
@@ -134,7 +140,7 @@ def init_db():
|
|
| 134 |
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 135 |
)
|
| 136 |
""")
|
| 137 |
-
print("
|
| 138 |
|
| 139 |
# === TABLE TESTIMONIALS ===
|
| 140 |
cursor.execute("""
|
|
@@ -153,11 +159,11 @@ def init_db():
|
|
| 153 |
FOREIGN KEY (user_email) REFERENCES users(email)
|
| 154 |
)
|
| 155 |
""")
|
| 156 |
-
print("
|
| 157 |
|
| 158 |
conn.commit()
|
| 159 |
conn.close()
|
| 160 |
-
print("
|
| 161 |
|
| 162 |
def create_default_admin(conn):
|
| 163 |
"""Crée un admin par défaut"""
|
|
@@ -174,12 +180,12 @@ def create_default_admin(conn):
|
|
| 174 |
(default_email, hashed, 1)
|
| 175 |
)
|
| 176 |
conn.commit()
|
| 177 |
-
print("
|
| 178 |
-
print("
|
| 179 |
except sqlite3.IntegrityError:
|
| 180 |
-
print("
|
| 181 |
except Exception as e:
|
| 182 |
-
print(f"
|
| 183 |
|
| 184 |
def ensure_admin_exists(conn):
|
| 185 |
"""S'assure qu'il y a au moins un admin"""
|
|
@@ -188,7 +194,7 @@ def ensure_admin_exists(conn):
|
|
| 188 |
count = cursor.fetchone()[0]
|
| 189 |
|
| 190 |
if count == 0:
|
| 191 |
-
print("
|
| 192 |
create_default_admin(conn)
|
| 193 |
else:
|
| 194 |
-
print(f"
|
|
|
|
| 1 |
import sqlite3
|
| 2 |
import bcrypt
|
| 3 |
+
import os
|
| 4 |
|
| 5 |
+
# Chemin absolu basé sur __file__ : remonter 3 niveaux depuis services/database.py
|
| 6 |
+
# → toujours <racine_projet>/users.db, peu importe le répertoire de lancement
|
| 7 |
+
_HERE = os.path.dirname(os.path.abspath(__file__)) # .../Interface Graphique/services
|
| 8 |
+
_APP_DIR = os.path.dirname(_HERE) # .../Interface Graphique
|
| 9 |
+
_ROOT_DIR = os.path.dirname(_APP_DIR) # .../Projet4A_PredictionsBoursieres-main
|
| 10 |
+
DB_NAME = os.path.join(_ROOT_DIR, "users.db")
|
| 11 |
|
| 12 |
def get_connection():
|
| 13 |
return sqlite3.connect(DB_NAME)
|
|
|
|
| 22 |
table_exists = cursor.fetchone()
|
| 23 |
|
| 24 |
if not table_exists:
|
| 25 |
+
print("Création de la table users...")
|
| 26 |
cursor.execute("""
|
| 27 |
CREATE TABLE users (
|
| 28 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
| 37 |
)
|
| 38 |
""")
|
| 39 |
conn.commit()
|
| 40 |
+
print("Table users créée avec succès")
|
| 41 |
|
| 42 |
# Créer un admin par défaut
|
| 43 |
create_default_admin(conn)
|
|
|
|
| 47 |
columns = [col[1] for col in cursor.fetchall()]
|
| 48 |
|
| 49 |
if 'is_admin' not in columns:
|
| 50 |
+
print("Migration: ajout de la colonne is_admin...")
|
| 51 |
cursor.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0")
|
| 52 |
conn.commit()
|
| 53 |
+
print("Migration is_admin réussie")
|
| 54 |
+
|
| 55 |
if 'face_image' not in columns:
|
| 56 |
+
print("Migration: ajout de la colonne face_image...")
|
| 57 |
cursor.execute("ALTER TABLE users ADD COLUMN face_image TEXT")
|
| 58 |
conn.commit()
|
| 59 |
+
print("Migration face_image réussie")
|
| 60 |
+
|
| 61 |
if 'created_at' not in columns:
|
| 62 |
+
print("Migration: ajout de la colonne created_at...")
|
| 63 |
cursor.execute("ALTER TABLE users ADD COLUMN created_at DATETIME")
|
| 64 |
conn.commit()
|
| 65 |
cursor.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL")
|
| 66 |
conn.commit()
|
| 67 |
+
print("Migration created_at réussie")
|
| 68 |
|
| 69 |
if 'prenom' not in columns:
|
| 70 |
cursor.execute("ALTER TABLE users ADD COLUMN prenom TEXT")
|
| 71 |
conn.commit()
|
| 72 |
+
print("Migration prenom réussie")
|
| 73 |
|
| 74 |
if 'nom' not in columns:
|
| 75 |
cursor.execute("ALTER TABLE users ADD COLUMN nom TEXT")
|
| 76 |
conn.commit()
|
| 77 |
+
print("Migration nom réussie")
|
| 78 |
|
| 79 |
if 'telephone' not in columns:
|
| 80 |
cursor.execute("ALTER TABLE users ADD COLUMN telephone TEXT")
|
| 81 |
conn.commit()
|
| 82 |
+
print("Migration telephone réussie")
|
| 83 |
|
| 84 |
if 'public_stats' not in columns:
|
| 85 |
cursor.execute("ALTER TABLE users ADD COLUMN public_stats INTEGER DEFAULT 0")
|
| 86 |
conn.commit()
|
| 87 |
+
print("Migration public_stats réussie")
|
| 88 |
|
| 89 |
if 'is_online' not in columns:
|
| 90 |
cursor.execute("ALTER TABLE users ADD COLUMN is_online INTEGER DEFAULT 0")
|
| 91 |
conn.commit()
|
| 92 |
+
print("Migration is_online réussie")
|
| 93 |
|
| 94 |
+
print("Table users déjà existante, conservation des données")
|
| 95 |
ensure_admin_exists(conn)
|
| 96 |
|
| 97 |
# === TABLE USER_TRADES ===
|
|
|
|
| 114 |
FOREIGN KEY (user_email) REFERENCES users(email)
|
| 115 |
)
|
| 116 |
""")
|
| 117 |
+
print("Table user_trades vérifiée/créée")
|
| 118 |
|
| 119 |
# Migration: model_type column (for existing databases)
|
| 120 |
cursor.execute("PRAGMA table_info(user_trades)")
|
|
|
|
| 122 |
if 'model_type' not in trade_columns:
|
| 123 |
cursor.execute("ALTER TABLE user_trades ADD COLUMN model_type TEXT DEFAULT 'sentiment'")
|
| 124 |
conn.commit()
|
| 125 |
+
print("Migration model_type réussie")
|
| 126 |
|
| 127 |
# === TABLE PREDICTIONS_LOG ===
|
| 128 |
cursor.execute("""
|
|
|
|
| 140 |
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 141 |
)
|
| 142 |
""")
|
| 143 |
+
print("Table predictions_log vérifiée/créée")
|
| 144 |
|
| 145 |
# === TABLE TESTIMONIALS ===
|
| 146 |
cursor.execute("""
|
|
|
|
| 159 |
FOREIGN KEY (user_email) REFERENCES users(email)
|
| 160 |
)
|
| 161 |
""")
|
| 162 |
+
print("Table testimonials vérifiée/créée")
|
| 163 |
|
| 164 |
conn.commit()
|
| 165 |
conn.close()
|
| 166 |
+
print("Toutes les tables sont initialisées avec succès")
|
| 167 |
|
| 168 |
def create_default_admin(conn):
|
| 169 |
"""Crée un admin par défaut"""
|
|
|
|
| 180 |
(default_email, hashed, 1)
|
| 181 |
)
|
| 182 |
conn.commit()
|
| 183 |
+
print("Admin par défaut créé: admin@ensim.com / Admin123!")
|
| 184 |
+
print("CHANGE CE MOT DE PASSE DÈS QUE POSSIBLE !")
|
| 185 |
except sqlite3.IntegrityError:
|
| 186 |
+
print("Admin par défaut déjà existant")
|
| 187 |
except Exception as e:
|
| 188 |
+
print(f"Erreur création admin: {e}")
|
| 189 |
|
| 190 |
def ensure_admin_exists(conn):
|
| 191 |
"""S'assure qu'il y a au moins un admin"""
|
|
|
|
| 194 |
count = cursor.fetchone()[0]
|
| 195 |
|
| 196 |
if count == 0:
|
| 197 |
+
print("Aucun admin trouvé, création d'un admin par défaut...")
|
| 198 |
create_default_admin(conn)
|
| 199 |
else:
|
| 200 |
+
print(f"{count} administrateur(s) trouvé(s)")
|
Interface Graphique/services/face_auth.py
CHANGED
|
@@ -21,13 +21,13 @@ try:
|
|
| 21 |
test_window = cv2.namedWindow("test", cv2.WINDOW_NORMAL)
|
| 22 |
cv2.destroyWindow("test")
|
| 23 |
GUI_AVAILABLE = True
|
| 24 |
-
print("
|
| 25 |
except:
|
| 26 |
GUI_AVAILABLE = False
|
| 27 |
-
print("
|
| 28 |
-
|
| 29 |
except ImportError as e:
|
| 30 |
-
print(f"
|
| 31 |
print(" Pour l'activer: pip install opencv-python")
|
| 32 |
OPENCV_AVAILABLE = False
|
| 33 |
GUI_AVAILABLE = False
|
|
@@ -42,7 +42,7 @@ FACE_DATA_DIR = Path("face_data")
|
|
| 42 |
try:
|
| 43 |
FACE_DATA_DIR.mkdir(exist_ok=True)
|
| 44 |
except:
|
| 45 |
-
print(f"
|
| 46 |
|
| 47 |
def get_face_hash_from_image(frame):
|
| 48 |
"""
|
|
@@ -65,7 +65,7 @@ def get_face_hash_from_image(frame):
|
|
| 65 |
|
| 66 |
return face_hash
|
| 67 |
except Exception as e:
|
| 68 |
-
print(f"
|
| 69 |
return None
|
| 70 |
|
| 71 |
def detect_face(frame):
|
|
@@ -94,33 +94,33 @@ def detect_face(frame):
|
|
| 94 |
|
| 95 |
return face_roi
|
| 96 |
except Exception as e:
|
| 97 |
-
print(f"
|
| 98 |
return None
|
| 99 |
|
| 100 |
"""
|
| 101 |
Capture un visage depuis la webcam (version avec fallback console)
|
| 102 |
"""
|
| 103 |
if not OPENCV_AVAILABLE:
|
| 104 |
-
print("
|
| 105 |
return None
|
| 106 |
-
|
| 107 |
try:
|
| 108 |
# Initialiser la webcam
|
| 109 |
video_capture = cv2.VideoCapture(0)
|
| 110 |
if not video_capture.isOpened():
|
| 111 |
-
print("
|
| 112 |
return None
|
| 113 |
|
| 114 |
print("\n" + "="*50)
|
| 115 |
-
print("
|
| 116 |
print("="*50)
|
| 117 |
-
|
| 118 |
if GUI_AVAILABLE:
|
| 119 |
-
print("
|
| 120 |
print(" [ESPACE] pour capturer")
|
| 121 |
print(" [Q] pour annuler")
|
| 122 |
else:
|
| 123 |
-
print("
|
| 124 |
print(" Capture automatique dans 3 secondes...")
|
| 125 |
time.sleep(1)
|
| 126 |
print(" 3...")
|
|
@@ -163,55 +163,55 @@ def detect_face(frame):
|
|
| 163 |
(10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
| 164 |
|
| 165 |
if face_roi is None:
|
| 166 |
-
cv2.putText(display_frame, "
|
| 167 |
(10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
| 168 |
-
|
| 169 |
cv2.imshow('Capture Visage - ENSIM', display_frame)
|
| 170 |
key = cv2.waitKey(1) & 0xFF
|
| 171 |
-
|
| 172 |
if key == ord(' '):
|
| 173 |
if face_roi is not None:
|
| 174 |
face_hash = get_face_hash_from_image(face_roi)
|
| 175 |
face_image = face_roi
|
| 176 |
captured = True
|
| 177 |
-
print("\
|
| 178 |
else:
|
| 179 |
-
print("\
|
| 180 |
-
|
| 181 |
elif key == ord('q'):
|
| 182 |
-
print("\
|
| 183 |
break
|
| 184 |
|
| 185 |
# Mode console (sans GUI)
|
| 186 |
else:
|
| 187 |
console_attempts += 1
|
| 188 |
-
print(f"\
|
| 189 |
-
|
| 190 |
if face_roi is not None:
|
| 191 |
face_hash = get_face_hash_from_image(face_roi)
|
| 192 |
face_image = face_roi
|
| 193 |
captured = True
|
| 194 |
-
print("\
|
| 195 |
elif console_attempts >= max_console_attempts:
|
| 196 |
-
print("\
|
| 197 |
break
|
| 198 |
else:
|
| 199 |
time.sleep(0.5) # Pause entre les tentatives
|
| 200 |
-
|
| 201 |
# Nettoyer
|
| 202 |
video_capture.release()
|
| 203 |
if GUI_AVAILABLE:
|
| 204 |
cv2.destroyAllWindows()
|
| 205 |
-
|
| 206 |
if face_hash:
|
| 207 |
-
print("
|
| 208 |
return {"hash": face_hash, "image": face_image}
|
| 209 |
-
|
| 210 |
-
print("
|
| 211 |
return None
|
| 212 |
-
|
| 213 |
except Exception as e:
|
| 214 |
-
print(f"\
|
| 215 |
return None
|
| 216 |
def capture_face_from_webcam():
|
| 217 |
"""
|
|
|
|
| 21 |
test_window = cv2.namedWindow("test", cv2.WINDOW_NORMAL)
|
| 22 |
cv2.destroyWindow("test")
|
| 23 |
GUI_AVAILABLE = True
|
| 24 |
+
print("OpenCV disponible (mode graphique)")
|
| 25 |
except:
|
| 26 |
GUI_AVAILABLE = False
|
| 27 |
+
print("Interface graphique OpenCV non disponible (mode console)")
|
| 28 |
+
|
| 29 |
except ImportError as e:
|
| 30 |
+
print(f"OpenCV non disponible: {e}")
|
| 31 |
print(" Pour l'activer: pip install opencv-python")
|
| 32 |
OPENCV_AVAILABLE = False
|
| 33 |
GUI_AVAILABLE = False
|
|
|
|
| 42 |
try:
|
| 43 |
FACE_DATA_DIR.mkdir(exist_ok=True)
|
| 44 |
except:
|
| 45 |
+
print(f"Impossible de créer le dossier {FACE_DATA_DIR}")
|
| 46 |
|
| 47 |
def get_face_hash_from_image(frame):
|
| 48 |
"""
|
|
|
|
| 65 |
|
| 66 |
return face_hash
|
| 67 |
except Exception as e:
|
| 68 |
+
print(f"Erreur création hash: {e}")
|
| 69 |
return None
|
| 70 |
|
| 71 |
def detect_face(frame):
|
|
|
|
| 94 |
|
| 95 |
return face_roi
|
| 96 |
except Exception as e:
|
| 97 |
+
print(f"Erreur détection visage: {e}")
|
| 98 |
return None
|
| 99 |
|
| 100 |
"""
|
| 101 |
Capture un visage depuis la webcam (version avec fallback console)
|
| 102 |
"""
|
| 103 |
if not OPENCV_AVAILABLE:
|
| 104 |
+
print("OpenCV non disponible")
|
| 105 |
return None
|
| 106 |
+
|
| 107 |
try:
|
| 108 |
# Initialiser la webcam
|
| 109 |
video_capture = cv2.VideoCapture(0)
|
| 110 |
if not video_capture.isOpened():
|
| 111 |
+
print("Impossible d'ouvrir la webcam")
|
| 112 |
return None
|
| 113 |
|
| 114 |
print("\n" + "="*50)
|
| 115 |
+
print("CAPTURE FACIALE - ENSIM")
|
| 116 |
print("="*50)
|
| 117 |
+
|
| 118 |
if GUI_AVAILABLE:
|
| 119 |
+
print("Fenêtre de capture va s'ouvrir...")
|
| 120 |
print(" [ESPACE] pour capturer")
|
| 121 |
print(" [Q] pour annuler")
|
| 122 |
else:
|
| 123 |
+
print("Mode console - regarde la caméra")
|
| 124 |
print(" Capture automatique dans 3 secondes...")
|
| 125 |
time.sleep(1)
|
| 126 |
print(" 3...")
|
|
|
|
| 163 |
(10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
| 164 |
|
| 165 |
if face_roi is None:
|
| 166 |
+
cv2.putText(display_frame, "AUCUN VISAGE DETECTE",
|
| 167 |
(10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
|
| 168 |
+
|
| 169 |
cv2.imshow('Capture Visage - ENSIM', display_frame)
|
| 170 |
key = cv2.waitKey(1) & 0xFF
|
| 171 |
+
|
| 172 |
if key == ord(' '):
|
| 173 |
if face_roi is not None:
|
| 174 |
face_hash = get_face_hash_from_image(face_roi)
|
| 175 |
face_image = face_roi
|
| 176 |
captured = True
|
| 177 |
+
print("\nVisage capturé avec succès!")
|
| 178 |
else:
|
| 179 |
+
print("\nAucun visage détecté, réessaie")
|
| 180 |
+
|
| 181 |
elif key == ord('q'):
|
| 182 |
+
print("\nCapture annulée")
|
| 183 |
break
|
| 184 |
|
| 185 |
# Mode console (sans GUI)
|
| 186 |
else:
|
| 187 |
console_attempts += 1
|
| 188 |
+
print(f"\rTentative {console_attempts}/{max_console_attempts}... ", end="", flush=True)
|
| 189 |
+
|
| 190 |
if face_roi is not None:
|
| 191 |
face_hash = get_face_hash_from_image(face_roi)
|
| 192 |
face_image = face_roi
|
| 193 |
captured = True
|
| 194 |
+
print("\nVisage capturé avec succès!")
|
| 195 |
elif console_attempts >= max_console_attempts:
|
| 196 |
+
print("\nÉchec capture - aucun visage détecté")
|
| 197 |
break
|
| 198 |
else:
|
| 199 |
time.sleep(0.5) # Pause entre les tentatives
|
| 200 |
+
|
| 201 |
# Nettoyer
|
| 202 |
video_capture.release()
|
| 203 |
if GUI_AVAILABLE:
|
| 204 |
cv2.destroyAllWindows()
|
| 205 |
+
|
| 206 |
if face_hash:
|
| 207 |
+
print("Données faciales enregistrées")
|
| 208 |
return {"hash": face_hash, "image": face_image}
|
| 209 |
+
|
| 210 |
+
print("Aucune donnée faciale capturée")
|
| 211 |
return None
|
| 212 |
+
|
| 213 |
except Exception as e:
|
| 214 |
+
print(f"\nErreur capture: {e}")
|
| 215 |
return None
|
| 216 |
def capture_face_from_webcam():
|
| 217 |
"""
|
Interface Graphique/users.db
ADDED
|
Binary file (28.7 kB). View file
|
|
|
Modele_AI_prévisions_boursières (1).ipynb
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
PIPELINE_SCHEMA.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Demande de schéma — Pipeline de prédictions boursières
|
| 2 |
+
|
| 3 |
+
## Contexte pour le designer
|
| 4 |
+
|
| 5 |
+
Génère un **schéma de pipeline clair et professionnel** (style diagramme de flux / architecture ML) pour un projet de prédictions boursières qui implémente **3 modes de prédiction distincts**.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Clarification de l'architecture (réponse au prof)
|
| 10 |
+
|
| 11 |
+
### Question du prof : Que signifie « Transformer hybride » ?
|
| 12 |
+
|
| 13 |
+
**Réponse précise :**
|
| 14 |
+
Le mot « hybride » qualifie les **sources de données**, pas les modèles.
|
| 15 |
+
|
| 16 |
+
| Mode | Sources de données | Modèle | Nature du "hybride" |
|
| 17 |
+
|------|-------------------|--------|---------------------|
|
| 18 |
+
| Actualités | Articles de presse uniquement | Analyse de sentiment (règles) | ✗ Non hybride |
|
| 19 |
+
| LSTM | Historique des prix uniquement | BiLSTM + Embedding symbole | ✗ Non hybride |
|
| 20 |
+
| Transformer | **Prix + Actualités (combinés)** | Transformer Encoder | ✓ Hybride (2 sources) |
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## Les 3 pipelines complets
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
### MODE 1 — Analyse des Actualités (Sentiment)
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
SOURCE PRÉTRAITEMENT MODÈLE SORTIE
|
| 32 |
+
───── ───────────── ────── ──────
|
| 33 |
+
|
| 34 |
+
Articles Score de Agrégation Signal
|
| 35 |
+
de presse ──► sentiment ──► temporelle ──► HAUSSIER
|
| 36 |
+
(CSV GitHub) [-1, +1] (moyenne BAISSIER
|
| 37 |
+
par article glissante) NEUTRE
|
| 38 |
+
+ seuils
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
**Détails techniques :**
|
| 42 |
+
- Source : fichier CSV hébergé sur GitHub (mis à jour automatiquement)
|
| 43 |
+
- Scoring : score de sentiment par article (colonne `score_sentiment`)
|
| 44 |
+
- Agrégation : moyenne des scores sur une fenêtre temporelle par ticker
|
| 45 |
+
- Signal : HAUSSIER si score > seuil+, BAISSIER si score < seuil-, NEUTRE sinon
|
| 46 |
+
- Sortie : signal directionnel + score de confiance
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
### MODE 2 — LSTM (Historique des prix)
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
SOURCE FEATURE ENGINEERING NORMALISATION MODÈLE SORTIE
|
| 54 |
+
───── ─────────────────── ───────────── ────── ──────
|
| 55 |
+
|
| 56 |
+
Prix OHLCV 14 indicateurs RobustScaler BiLSTM Probabilité
|
| 57 |
+
(yfinance) ──► techniques ──► clip [-5, 5] ──► (30 × 14) ──► directionnelle
|
| 58 |
+
90 jours ret1, ret3, ret5 (sauvegardé) + Embedding → ACHETER
|
| 59 |
+
trend_s, trend_l symbole (8 dim) VENDRE
|
| 60 |
+
mom3, mom10 ↓
|
| 61 |
+
vol10, vol_ratio Dense → Sigmoid
|
| 62 |
+
rsi14, rsi7
|
| 63 |
+
atr, bb, volr
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
**Détails techniques :**
|
| 67 |
+
- Fenêtre temporelle : 30 jours (SEQ_LEN = 30)
|
| 68 |
+
- Features : 14 indicateurs techniques calculés sur l'historique OHLCV
|
| 69 |
+
- Normalisation : RobustScaler (résistant aux outliers) + clipping ±5
|
| 70 |
+
- Architecture : Bidirectional LSTM (64 unités) → LSTM (32) → Dense → Sigmoid
|
| 71 |
+
- Embedding symbole : 8 dimensions (pour AAPL, TSLA, MSFT, GOOGL, NVDA, AMZN, META, BTC-USD)
|
| 72 |
+
- Sortie : probabilité ∈ [0,1] → signal + rendement estimé (prob × volatilité récente)
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
### MODE 3 — Transformer Hybride (Prix + Actualités)
|
| 77 |
+
|
| 78 |
+
```
|
| 79 |
+
SOURCE 1 FEATURE ENG. FUSION NORMALISATION
|
| 80 |
+
───────── ──────────── ────── ─────────────
|
| 81 |
+
Prix OHLCV ──► 16 features ──► ──► StandardScaler
|
| 82 |
+
(yfinance) techniques 44 features (feature_scaler)
|
| 83 |
+
combinées
|
| 84 |
+
SOURCE 2 FEATURE ENG. ↓
|
| 85 |
+
───────── ────────────
|
| 86 |
+
Articles ──► 28 features ──►
|
| 87 |
+
de presse sentiment-dérivées
|
| 88 |
+
(GitHub CSV) (lags, moyennes,
|
| 89 |
+
EMA, sommes)
|
| 90 |
+
|
| 91 |
+
MODÈLE SORTIE
|
| 92 |
+
────── ──────
|
| 93 |
+
Transformer ──► Dir. prob. (sigmoid)
|
| 94 |
+
Encoder + Amplitude (tanh)
|
| 95 |
+
(60 × 44) ↓
|
| 96 |
+
d_model=64 log-rendement
|
| 97 |
+
nhead=4 = (2p-1) × |amp| × max_lr
|
| 98 |
+
2 couches ↓
|
| 99 |
+
ACHETER / VENDRE
|
| 100 |
+
+ prix prédit demain
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
**Détails techniques :**
|
| 104 |
+
- Fenêtre temporelle : 60 jours (window = 60)
|
| 105 |
+
- Features prix (16) : OHLCV, close_lag1, MA5, volatilité, RSI, MACD, BB, returns, log-return, ratio SMA, range HL
|
| 106 |
+
- Features sentiment (28) : score_sentiment, articles_count, has_news, lags [1,2,3,5], moyennes [3,5,7,14j], sommes, EMA [5,10], signaux forts
|
| 107 |
+
- Total : 44 features
|
| 108 |
+
- Architecture : Linear(44→64) → Positional Encoding → TransformerEncoder × 2 → LayerNorm → tête direction (sigmoid) + tête amplitude (tanh)
|
| 109 |
+
- Sortie finale : log-rendement signé → prix prédit + signal directionnel
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## Schéma global comparatif à générer
|
| 114 |
+
|
| 115 |
+
```
|
| 116 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 117 |
+
│ APPLICATION DE PRÉDICTIONS BOURSIÈRES │
|
| 118 |
+
│ (8 tickers : US + Crypto) │
|
| 119 |
+
└───────────────────────────┬─────────────────────────────────────────────────┘
|
| 120 |
+
│
|
| 121 |
+
┌───────────────────┼──────────────────────┐
|
| 122 |
+
▼ ▼ ▼
|
| 123 |
+
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
| 124 |
+
│ MODE 1 │ │ MODE 2 │ │ MODE 3 │
|
| 125 |
+
│ Actualités │ │ LSTM │ │ Transformer Hybride│
|
| 126 |
+
└──────┬──────┘ └──────┬───────┘ └──────────┬──────────┘
|
| 127 |
+
│ │ │
|
| 128 |
+
▼ ▼ ▼
|
| 129 |
+
Articles de Prix OHLCV Prix OHLCV
|
| 130 |
+
presse (CSV) 90 jours + Articles
|
| 131 |
+
│ │ (2 sources)
|
| 132 |
+
▼ ▼ ▼
|
| 133 |
+
Sentiment 14 features 44 features
|
| 134 |
+
scoring techniques (16 prix +
|
| 135 |
+
(30 jours) 28 sentiment)
|
| 136 |
+
(60 jours)
|
| 137 |
+
│ │ │
|
| 138 |
+
▼ ▼ ▼
|
| 139 |
+
Agrégation RobustScaler StandardScaler
|
| 140 |
+
+ seuils + clip ±5
|
| 141 |
+
│ │ │
|
| 142 |
+
▼ ▼ ▼
|
| 143 |
+
Règles de BiLSTM (30×14) Transformer
|
| 144 |
+
décision + Embedding Encoder (60×44)
|
| 145 |
+
(seuils) symbole d_model=64, 2L
|
| 146 |
+
│ │ │
|
| 147 |
+
▼ ▼ ▼
|
| 148 |
+
Score Probabilité Dir.prob + Amplitude
|
| 149 |
+
sentiment [0,1] → log-rendement
|
| 150 |
+
│ │ │
|
| 151 |
+
└──────────────────┴────────────────────────┘
|
| 152 |
+
│
|
| 153 |
+
▼
|
| 154 |
+
┌─────────────────────────┐
|
| 155 |
+
│ SIGNAL FINAL │
|
| 156 |
+
│ HAUSSIER / BAISSIER / │
|
| 157 |
+
│ NEUTRE │
|
| 158 |
+
│ + Prix prédit demain │
|
| 159 |
+
│ + Rendement estimé % │
|
| 160 |
+
└─────────────────────────┘
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
## Instructions pour le schéma visuel
|
| 166 |
+
|
| 167 |
+
Génère un **diagramme de pipeline vertical** avec :
|
| 168 |
+
|
| 169 |
+
1. **3 colonnes** (une par mode), chacune de couleur distincte :
|
| 170 |
+
- Mode 1 (Actualités) : violet/mauve
|
| 171 |
+
- Mode 2 (LSTM) : bleu/cyan
|
| 172 |
+
- Mode 3 (Transformer) : vert/teal
|
| 173 |
+
|
| 174 |
+
2. **5 rangées** (couches du pipeline) :
|
| 175 |
+
- Sources de données
|
| 176 |
+
- Feature engineering
|
| 177 |
+
- Normalisation
|
| 178 |
+
- Modèle
|
| 179 |
+
- Sortie
|
| 180 |
+
|
| 181 |
+
3. **Flèches de flux** entre chaque étape
|
| 182 |
+
|
| 183 |
+
4. **Zone de convergence** en bas des 3 colonnes vers un bloc "Sortie unifiée"
|
| 184 |
+
|
| 185 |
+
5. Style : professionnel, dark background, adapté rapport académique
|
| 186 |
+
|
| 187 |
+
6. Mentionner dans chaque colonne : nombre de features, taille de fenêtre temporelle, architecture du modèle
|
| 188 |
+
|
| 189 |
+
---
|
| 190 |
+
|
| 191 |
+
## Ce qui différencie les 3 modes (réponse directe au prof)
|
| 192 |
+
|
| 193 |
+
| Critère | Mode 1 | Mode 2 | Mode 3 |
|
| 194 |
+
|---------|--------|--------|--------|
|
| 195 |
+
| **Sources** | Actualités uniquement | Prix uniquement | **Prix + Actualités** |
|
| 196 |
+
| **Modèle** | Règles (pas de NN) | BiLSTM | Transformer Encoder |
|
| 197 |
+
| **Features** | Sentiment [-1,+1] | 14 indicateurs techniques | 44 features (hybrides) |
|
| 198 |
+
| **Fenêtre** | Variable (articles) | 30 jours | 60 jours |
|
| 199 |
+
| **Sortie brute** | Score sentiment | Probabilité sigmoid | Prob. + Amplitude |
|
| 200 |
+
| **"Hybride" ?** | ✗ | ✗ | ✓ (données hybrides) |
|
| 201 |
+
|
| 202 |
+
**Conclusion :** « Transformer hybride » = architecture Transformer appliquée à une **entrée hybride** (fusion de données de prix et de sentiment d'actualités). Le mot « hybride » qualifie les données d'entrée, non l'architecture du modèle.
|
requirements.txt
CHANGED
|
@@ -13,4 +13,6 @@ beautifulsoup4
|
|
| 13 |
scikit-learn
|
| 14 |
joblib
|
| 15 |
tensorflow
|
| 16 |
-
torch
|
|
|
|
|
|
|
|
|
| 13 |
scikit-learn
|
| 14 |
joblib
|
| 15 |
tensorflow
|
| 16 |
+
torch
|
| 17 |
+
groq
|
| 18 |
+
python-dotenv
|
users.db
CHANGED
|
Binary files a/users.db and b/users.db differ
|
|
|