diff --git a/modules/ai_engine.py b/modules/ai_engine.py index 28fca26..5529da3 100644 --- a/modules/ai_engine.py +++ b/modules/ai_engine.py @@ -27,6 +27,6 @@ def interroger_gemini_cascade(prompt): def appeler_groq_api(prompt_text): resp = groq_client.chat.completions.create( messages=[{"role": "system", "content": "Réponds dans la langue de l'utilisateur. Tu as accès aux recherches web fournies. Utilise uniquement Markdown. Pas de code LaTeX."}, {"role": "user", "content": prompt_text}], - model="llama-3.3-70b-versatile" + model="mixtral-8x7b-32768" # 🛠️ CORRECTION : Modèle Groq stable avec 32 768 tokens de contexte ) - return resp.choices[0].message.content, "#f97316", "GROQ", "llama-3.3-70b" + return resp.choices[0].message.content, "#f97316", "GROQ", "mixtral-8x7b-32768" \ No newline at end of file diff --git a/modules/chat_handler.py b/modules/chat_handler.py index d6e5804..1ffb4ff 100644 --- a/modules/chat_handler.py +++ b/modules/chat_handler.py @@ -9,6 +9,7 @@ from modules.ai_engine import local_client, interroger_gemini_cascade, appeler_g from modules.ui_components import render_user_msg, render_ai_msg, render_sidebar async def handle_chat_request(request, session): + start_time = time.time() try: form = await request.form() prompt = form.get("prompt", "") @@ -17,22 +18,8 @@ async def handle_chat_request(request, session): session['current_chat_id'] = chat_id history = load_history() - - sec_prompt = f"""Analyse et route cette demande. Si recherche web nécessaire ("va sur", "site", "historique", "version", "cherche", etc.) -> choisis "gemini". Si code/technique -> "groq". Sinon -> "local". - Renvoie STRICTEMENT un JSON : {{"intention": "...", "fournisseur": "gemini|groq|local"}} - Requête : {prompt}""" - - router_res = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "JSON uniquement."}, {"role": "user", "content": sec_prompt}], temperature=0.1) - decision = nettoyer_decision_router(router_res.choices[0].message.content) - intention, fournisseur = decision.get('intention', 'Analyse'), decision.get('fournisseur', 'gemini').lower() - urls = re.findall(r'https?://[^\s]+', prompt) - mots_cles = ["cherche", "recherche", "sur internet", "sur le web", "sur le net", "va sur", "trouve", "site", "donne", "historique", "version"] - if any(m in prompt.lower() for m in mots_cles): - urls.extend(rechercher_sur_google(prompt)) - - web_text = extraire_contenu_urls(list(set(urls))) if urls else "" - + # Extraction des fichiers file_summary = [] file_names = [] for f in files[:10]: @@ -41,10 +28,37 @@ async def handle_chat_request(request, session): content = await f.read() file_summary.append(f"\n--- Fichier: {f.filename} ---\n{content.decode('utf-8', errors='ignore')}") + liste_fichiers_str = ', '.join(file_names) if file_names else 'Aucun fichier' + + sec_prompt = f"""Tu es l'Aiguilleur système. + RÈGLES STRICTES DE ROUTAGE : + 1. "gemini" : OBLIGATOIRE si des fichiers sont joints à analyser (ex: {liste_fichiers_str}), ou pour des requêtes de rédaction complexe, comparaisons, ou recherche web. + 2. "groq" : Pour le code, la programmation, l'architecture système ou les mathématiques. + 3. "local" : UNIQUEMENT pour les salutations, blagues ou questions très simples sans fichiers. NE JAMAIS utiliser pour analyser des documents. + Renvoie STRICTEMENT un JSON : {{"intention": "Résumé de l'intention...", "fournisseur": "gemini|groq|local"}} + Requête : {prompt}""" + + router_res = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "JSON uniquement."}, {"role": "user", "content": sec_prompt}], temperature=0.1) + decision = nettoyer_decision_router(router_res.choices[0].message.content) + intention, fournisseur = decision.get('intention', 'Analyse'), decision.get('fournisseur', 'gemini').lower() + + # 🛡️ PROTECTION HARDCODÉE : Si des fichiers sont présents, forcer Gemini + if file_names: + fournisseur = "gemini" + intention += " (Forcé Gemini via Sécurité)" + + urls = re.findall(r'https?://[^\s]+', prompt) + mots_cles = ["cherche", "recherche", "sur internet", "sur le web", "sur le net", "va sur", "trouve", "site", "donne", "historique", "version"] + if any(m in prompt.lower() for m in mots_cles): + urls.extend(rechercher_sur_google(prompt)) + + web_text = extraire_contenu_urls(list(set(urls))) if urls else "" + full_prompt = prompt + ("\n" + "\n".join(file_summary) if file_summary else "") if web_text: full_prompt += f"\n\n[Contexte web extrait :\n{web_text}\nUtilise ces informations pour répondre précisément.]" + # Routage avec Fallback robuste if fournisseur == "gemini": try: reponse, modele = interroger_gemini_cascade(full_prompt) @@ -53,7 +67,13 @@ async def handle_chat_request(request, session): reponse, couleur, fournisseur_nom, modele = appeler_groq_api(full_prompt) fournisseur_nom, intention = "GROQ (Secours)", intention + " (Secours)" elif fournisseur == "groq": - reponse, couleur, fournisseur_nom, modele = appeler_groq_api(full_prompt) + try: + reponse, couleur, fournisseur_nom, modele = appeler_groq_api(full_prompt) + except Exception as e: + # 🛡️ FALLBACK GROQ -> GEMINI EN CAS DE DÉPASSEMENT DE CONTEXTE OU ERREUR + reponse, modele = interroger_gemini_cascade(full_prompt) + couleur, fournisseur_nom = "#3b82f6", "GOOGLE GEMINI" + intention += " (Secours suite Erreur Groq)" else: res_local = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "Réponds en français."}, {"role": "user", "content": full_prompt}]) reponse, couleur, fournisseur_nom, modele = res_local.choices[0].message.content, "#10b981", "SERVEUR LOCAL", "qwen2.5:3b" @@ -61,12 +81,18 @@ async def handle_chat_request(request, session): if chat_id not in history: history[chat_id] = {"title": (prompt[:30] if prompt else "Discussion") + "...", "pinned": False, "timestamp": time.time(), "messages": []} - display_msg = prompt + (f"\n📎 [Fichiers: {', '.join(file_names)}]" if file_names else "") + execution_time = round(time.time() - start_time, 1) + + display_msg = prompt + if file_names: + display_msg += f"\n\n📎 **Fichiers joints :** {', '.join(file_names)}" + display_msg += "\n> *🗑️ Note : Fichiers traités de manière éphémère en mémoire vive et détruits instantanément après analyse conformément aux règles de confidentialité.*" + history[chat_id]['messages'].append({"role": "user", "content": display_msg}) - history[chat_id]['messages'].append({"role": "ai", "content": reponse, "intention": intention, "fournisseur": fournisseur_nom, "modele": modele, "couleur": couleur}) + history[chat_id]['messages'].append({"role": "ai", "content": reponse, "intention": intention, "fournisseur": fournisseur_nom, "modele": modele, "couleur": couleur, "execution_time": execution_time}) save_history(history) - return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur), Script("renderMarkdown();"), render_sidebar() + return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur, execution_time), Script("renderMarkdown();"), render_sidebar() except Exception as e: return Div(f"Erreur : {e}", style="color:red; background:#300; padding:15px; border-radius:10px;") \ No newline at end of file diff --git a/modules/paypal.py b/modules/paypal.py index 3157c52..864d2b5 100644 --- a/modules/paypal.py +++ b/modules/paypal.py @@ -1,14 +1,18 @@ -# modules/paypal.py +import os from fasthtml.common import * def render_paypal_donation_widget(): """Génère le bouton de don PayPal pour l'interface sidebar.""" - # Remplacez l'URL ci-dessous par votre véritable lien ou bouton de don PayPal - paypal_url = "https://www.paypal.com/donate?hosted_button_id=VOTRE_ID_PAYPAL" + + # Lecture forcée de la variable d'environnement + paypal_url = os.environ.get("PAYPAL_DONATION_LINK", "") + + if not paypal_url: + return Div(id="paypal-widget-placeholder") return Div( A( - "☕ Faire un don PayPal", + "☕ Faire un don", href=paypal_url, target="_blank", cls="export-btn", diff --git a/modules/theme.py b/modules/theme.py index d680ef8..2fb2239 100644 --- a/modules/theme.py +++ b/modules/theme.py @@ -48,13 +48,72 @@ def get_theme_hdrs(): .input-field:focus { outline: none; } .file-btn { background: #2a2a35; border: 1px solid #444; color: var(--accent-blue); width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.3em; text-decoration: none; flex-shrink: 0; } .btn-submit { background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); color: white; border: none; width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1em; flex-shrink: 0; } - .htmx-indicator { display: none; } - .htmx-request .htmx-indicator { display: flex; } - .tech-loader-container { display: flex; align-items: center; gap: 15px; color: var(--accent-blue); font-style: italic; background: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; border: 1px dashed var(--accent-blue); width: fit-content; margin-bottom: 20px; font-size: 0.9em; } + + /* LOADER HTMX SURÉLEVÉ ET CENTRÉ (Flotte au-dessus du texte) */ + .htmx-indicator { display: none; opacity: 0; transition: opacity 0.3s ease-in; position: absolute; bottom: 140px; left: 50%; transform: translateX(-50%); z-index: 9999; } + .htmx-request.htmx-indicator { display: flex !important; opacity: 1; } + .htmx-request .htmx-indicator { display: flex !important; opacity: 1; } + .tech-loader-container { display: flex; align-items: center; gap: 15px; color: var(--accent-blue); font-style: italic; background: #1e1e24; padding: 15px 25px; border-radius: 30px; border: 1px dashed var(--accent-blue); box-shadow: 0 5px 20px rgba(0,229,255,0.2); font-size: 0.9em; white-space: nowrap; } .tech-spinner { width: 20px; height: 20px; border: 3px solid rgba(0, 229, 255, 0.2); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } + + @media (max-width: 768px) { + body { flex-direction: column; } + .sidebar { width: 100%; height: auto; max-height: 35vh; border-right: none; border-bottom: 1px solid #333; padding: 10px; } + .sidebar-history { max-height: 120px; } + .main-content { flex: 1; height: 65vh; } + .chat-container { padding-bottom: 180px; padding-left: 10px; padding-right: 10px; } + .user-bubble, .ai-bubble { max-width: 95%; } + .input-area { padding: 10px; } + } """), Script(r""" + // Sac à dos virtuel global pour additionner les fichiers sans les écraser + let aethasFiles = new DataTransfer(); + + function syncFiles() { + const fileInput = document.getElementById('file-upload'); + if(fileInput) fileInput.files = aethasFiles.files; + renderFilePills(); + } + + function renderFilePills() { + let previewList = document.getElementById('file-preview-list'); + if(!previewList) return; + previewList.innerHTML = ''; + for (let i = 0; i < aethasFiles.files.length; i++) { + let file = aethasFiles.files[i]; + let pill = document.createElement('div'); + pill.className = 'file-pill'; + pill.innerHTML = `📎 ${file.name} ✕`; + previewList.appendChild(pill); + } + } + + function removeFile(index) { + let dt = new DataTransfer(); + for (let i = 0; i < aethasFiles.files.length; i++) { + if (i !== index) dt.items.add(aethasFiles.files[i]); + } + aethasFiles = dt; + syncFiles(); + } + + function handleFileSelection(input) { + if (input.files) { + for (let f of input.files) { + let exists = false; + for (let existing of aethasFiles.files) { + if (existing.name === f.name) exists = true; + } + if (!exists && aethasFiles.files.length < 10) { + aethasFiles.items.add(f); + } + } + } + syncFiles(); + } + function renderMarkdown() { try { document.querySelectorAll('.markdown-raw').forEach(el => { @@ -124,47 +183,50 @@ def get_theme_hdrs(): document.addEventListener('DOMContentLoaded', () => { const ta = document.getElementById('prompt-input'); const dropZone = document.querySelector('.input-container-box'); - const fileInput = document.getElementById('file-upload'); if (ta) { ta.addEventListener('input', () => autoResize(ta)); ta.addEventListener('paste', (e) => { let items = (e.clipboardData || e.originalEvent.clipboardData).items; - let dt = new DataTransfer(); - if (fileInput && fileInput.files) { - for (let f of fileInput.files) dt.items.add(f); - } let hasFiles = false; for (let i = 0; i < items.length; i++) { if (items[i].kind === 'file') { let blob = items[i].getAsFile(); - if (blob) { dt.items.add(blob); hasFiles = true; } + if (blob && aethasFiles.files.length < 10) { + aethasFiles.items.add(blob); + hasFiles = true; + } } } if (hasFiles) { e.preventDefault(); - fileInput.files = dt.files; - handleFileSelection(fileInput); + syncFiles(); } else { setTimeout(() => autoResize(ta), 20); } }); } - if (dropZone && fileInput) { + if (dropZone) { ['dragenter', 'dragover'].forEach(eName => dropZone.addEventListener(eName, e => { e.preventDefault(); dropZone.style.borderColor = 'var(--accent-blue)'; })); ['dragleave', 'drop'].forEach(eName => dropZone.addEventListener(eName, e => { e.preventDefault(); dropZone.style.borderColor = '#444'; })); dropZone.addEventListener('drop', (e) => { e.preventDefault(); - let dt = new DataTransfer(); - if (fileInput.files) { for (let f of fileInput.files) dt.items.add(f); } for (let f of e.dataTransfer.files) { - if (dt.files.length < 10) dt.items.add(f); + if (aethasFiles.files.length < 10) aethasFiles.items.add(f); } - fileInput.files = dt.files; - handleFileSelection(fileInput); + syncFiles(); }); } + + // Vider le sac à dos virtel une fois la requête HTMX envoyée ! + document.body.addEventListener('htmx:afterRequest', function(evt) { + if(evt.detail.target.id === 'chat-history') { + aethasFiles = new DataTransfer(); + syncFiles(); + if(ta) ta.style.height = '36px'; + } + }); }); function copyCodeBlock(btn) { @@ -198,33 +260,6 @@ def get_theme_hdrs(): } } - function handleFileSelection(input) { - let previewList = document.getElementById('file-preview-list'); - previewList.innerHTML = ''; - if (input.files) { - if (input.files.length > 10) alert("Maximum 10 fichiers autorisés."); - let count = Math.min(input.files.length, 10); - for (let i = 0; i < count; i++) { - let file = input.files[i]; - let pill = document.createElement('div'); - pill.className = 'file-pill'; - pill.innerHTML = `📎 ${file.name} ✕`; - previewList.appendChild(pill); - } - } - } - - function removeFile(index) { - let fileInput = document.getElementById('file-upload'); - let dt = new DataTransfer(); - let { files } = fileInput; - for (let i = 0; i < files.length; i++) { - if (i !== index) dt.items.add(files[i]); - } - fileInput.files = dt.files; - handleFileSelection(fileInput); - } - function downloadResponsePDF(btn) { let content = btn.closest('.ai-bubble').querySelector('.markdown-rendered'); let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'pdf'); diff --git a/modules/ui_components.py b/modules/ui_components.py index de24c6d..103e817 100644 --- a/modules/ui_components.py +++ b/modules/ui_components.py @@ -5,11 +5,15 @@ from modules.paypal import render_paypal_donation_widget def render_user_msg(text): return Div(P(text, style="margin:0; font-weight:bold; white-space: pre-wrap;"), cls="user-bubble") -def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur): +# Ajout du paramètre execution_time +def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur, execution_time=None): + time_badge = Span(f"⏱️ {execution_time}s", style="margin-right: 10px; color:#aaa; font-size:0.8em;") if execution_time else "" + encart_info = Div( Span(f"🎯 {intention}", style="margin-right: 10px; color:#aaa; font-size:0.8em;"), Span(f"🏢 {nom_fournisseur}", style="margin-right: 10px; color:#aaa; font-size:0.8em;"), Span(f"🧠 {nom_modele}", style=f"margin-right: 10px; color:{couleur}; font-weight:bold; font-size:0.8em;"), + time_badge, # ⏱️ Injection du chronomètre ici style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 5px;" ) actions = Div( @@ -54,14 +58,13 @@ def render_main_page(chat_id): if msg['role'] == 'user': messages_ui.append(render_user_msg(msg['content'])) else: - messages_ui.append(render_ai_msg(msg['content'], msg.get('intention', ''), msg.get('fournisseur', ''), msg.get('modele', ''), msg.get('couleur', '#333'))) + messages_ui.append(render_ai_msg(msg['content'], msg.get('intention', ''), msg.get('fournisseur', ''), msg.get('modele', ''), msg.get('couleur', '#333'), msg.get('execution_time', ''))) if messages_ui: messages_ui.append(Script("setTimeout(renderMarkdown, 150);")) return Title("Aethas38 - Core"), Body( Div( Div( - # L'image est appelée depuis le dossier local "Logo" monté dans FastHTML Img(src="/Logo/logo_aethas.svg", style="max-width: 80%; height: auto; display: block; margin: 0 auto 20px;"), Div( A("+ Nouvelle discussion", href="/new_chat", cls="new-chat-btn"), @@ -77,7 +80,8 @@ def render_main_page(chat_id): ), Div( Div(*messages_ui, id="chat-history", cls="chat-container"), - Div(Div(Div(cls="tech-spinner"), Span("Aethas Core analyse la requête..."), cls="tech-loader-container"), id="global-loader", cls="htmx-indicator", style="max-width: 1000px; margin: 0 auto; width: 100%; padding: 0 20px; box-sizing: border-box; padding-bottom: 120px;"), + # 🔄 Ajout de l'estimation de temps dans le Loader + Div(Div(Div(cls="tech-spinner"), Span("Analyse en cours... (~5s à 10s)"), cls="tech-loader-container"), id="global-loader", cls="htmx-indicator", style="max-width: 1000px; margin: 0 auto; width: 100%; padding: 0 20px; box-sizing: border-box; padding-bottom: 120px;"), Div( Form( Input(type="hidden", name="chat_id", value=chat_id),