diff --git a/.gitignore b/.gitignore index 7505227..67519cc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ venv/ .env .sesskey aethas_history.json -Echange GEMINY.txt \ No newline at end of file +Echange GEMINY.txt +.vscode/ \ No newline at end of file diff --git a/Logo/logo_aethas.jpg b/Logo/logo_aethas.jpg new file mode 100644 index 0000000..f600187 Binary files /dev/null and b/Logo/logo_aethas.jpg differ diff --git a/Logo/logo_aethas.svg b/Logo/logo_aethas.svg new file mode 100644 index 0000000..d4f7af6 --- /dev/null +++ b/Logo/logo_aethas.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AETHAS + 38 + + + TECH CORE + diff --git a/README.md b/README.md index a9e6f34..892820f 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,14 @@ Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécu - [x] Architecture modulaire propre (Dossier `modules/` isolant Database, Scraper, AI Engine, Router et UI) ### Phase 4 : Gestion des Utilisateurs & Sécurité -- [ ] Mettre en place une bibliothèque lociel à installer avec le serveur +- [ ] Céer un lien Paypal pour les dons des utilisateurs. +- [ ] Mettre en place une bibliothèque logiciel à installer avec le serveur (déporter tout ce qui peut l'être comme la création de pdf, docx, html, etc.) - [ ] Choix entre l'utilisation de la version payante ou gratuite. Routage suivant le choix. - [ ] Système de connexion sécurisé (Mots de passe cryptés) - [ ] Fail2Ban (Blocage 15 mins après 5 erreurs) - [ ] Intégration SMTP (`mail.aethas38.duckdns.org`) pour mot de passe oublié et notifications de thèmes spécifiques - [ ] Gestion des rôles : Admin (Xavier, Céline), Famille (Anaëlle, Audric, Renan), Testeur (Nogapra) et Externes. Testeur juste accès à la partie gratuite. +- [ ] Prévoir un script automatique envoyant un mail sympathique aux utilisateurs lors de leur anniversaire. ### Phase 5 : Sécurité Financière & Facturation Exacte 💰 - [ ] Modèle Excel de calcul des coûts serveur (1x 1000W H24, Internet 60€/mois). Prendre en compte le tarif réglemanté de l'électricité en France et la consommation réel du serveur. @@ -51,7 +53,7 @@ Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécu ### Phase 7 : RAG & Maintenance - [ ] "RAG" : Base de données vectorielle (ChromaDB) pour permettre l'apprentissage et la mémorisation de l'IA locale -- [ ] Script de surveillance de l'espace disque du serveur +- [ ] Script de surveillance de l'espace disque du serveur. Affichage d'une barre d'état (statuts des disques, remplissage, utilisations pour Xavier) - [ ] Création de logs centralisés consultables et téléchargeables par l'Admin ### Phase 8 : Déploiement Public & Configuration diff --git a/main.py b/main.py index f6a3151..ef3ed01 100644 --- a/main.py +++ b/main.py @@ -1,446 +1,62 @@ -import os -import json import uuid -import time -import re -from dotenv import load_dotenv from fasthtml.common import * +from starlette.staticfiles import StaticFiles from starlette.responses import FileResponse -# Import des modules spécialisés -from modules.database import load_history, save_history, nettoyer_vieilles_discussions, HISTORY_FILE -from modules.scraper import rechercher_sur_google, extraire_contenu_urls -from modules.ai_engine import local_client, interroger_gemini_cascade, appeler_groq_api -from modules.router import nettoyer_decision_router -from modules.ui_components import render_user_msg, render_ai_msg, render_sidebar +# Imports ultra-propres depuis les modules dédiés +from modules.theme import get_theme_hdrs +from modules.database import nettoyer_vieilles_discussions, HISTORY_FILE, load_history, save_history +from modules.ui_components import render_main_page, render_sidebar +from modules.chat_handler import handle_chat_request -load_dotenv() +# On exécute le nettoyage au démarrage nettoyer_vieilles_discussions() -svg_favicon = "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cstyle%3E@keyframes pulse { 0% { opacity: 1; stroke-width: 5; } 50% { opacity: 0.5; stroke-width: 7; } 100% { opacity: 1; stroke-width: 5; } } .neon { animation: pulse 2s infinite; transform-origin: 50px 50px; transform: rotate(45deg); }%3C/style%3E%3Crect width='100' height='100' fill='%23121212'/%3E%3Cg class='neon'%3E%3Crect x='25' y='25' width='50' height='50' fill='none' stroke='%2300e5ff'/%3E%3Cpath d='M25 50 L75 50 M50 25 L50 75' stroke='%23b300ff'/%3E%3Ccircle cx='50' cy='50' r='8' fill='%23e0e0e0'/%3E%3C/g%3E%3C/svg%3E" +# Initialisation du serveur FastHTML +app, rt = fast_app(hdrs=get_theme_hdrs(), secret_key="super_secret_aethas") -theme_hdrs = ( - Script(src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"), - Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"), - Script(src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"), - Script(src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"), - Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"), - Link(rel="icon", type="image/svg+xml", href=f"data:image/svg+xml;utf8,{svg_favicon}"), - - Style(""" - :root { --bg-dark: #121212; --bg-panel: #1e1e24; --accent-blue: #00e5ff; --accent-purple: #b300ff; --text-main: #e0e0e0; } - body { background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, sans-serif; margin: 0; display: flex; height: 100vh; overflow: hidden; box-sizing: border-box; } - - .sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; } - .sidebar-top { flex-shrink: 0; } - .sidebar-history { flex: 1; overflow-y: auto; margin-top: 15px; margin-bottom: 15px; } - .sidebar-footer { flex-shrink: 0; border-top: 1px solid #333; padding-top: 15px; text-align: center; } - - .nav-buttons { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; } - .new-chat-btn { display:block; text-align:center; text-decoration:none; background: transparent; border: 1px solid var(--accent-blue); color: var(--accent-blue); padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; transition: 0.2s; } - .new-chat-btn:hover { background: rgba(0, 229, 255, 0.1); } - .export-btn { background: #2a2a35; border: 1px solid #444; color: white; padding: 10px; border-radius: 5px; cursor: pointer; transition: 0.2s; font-size: 1em; text-align:center; text-decoration:none; display:block;} - .export-btn:hover { background: #444; } - - .history-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #222; font-size: 0.9em; transition: 0.2s; } - .history-item:hover { background: #1a1a20; } - .history-link { flex: 1; text-decoration: none; color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; padding-right: 10px; } - .history-btn { background: none; border: none; color: #888; cursor: pointer; padding: 2px 5px; font-size: 1.1em; } - .history-btn:hover { color: var(--accent-blue); } - - .main-content { flex: 1; display: flex; flex-direction: column; height: 100vh; position: relative; box-sizing: border-box; } - .chat-container { flex: 1; padding: 20px; max-width: 1000px; margin: 0 auto; width: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; padding-bottom: 140px; box-sizing: border-box; } - - .user-bubble { background-color: #2a2a35; padding: 15px 20px; border-radius: 15px 15px 0 15px; align-self: flex-end; max-width: 80%; border: 1px solid #444; margin-left: auto; word-break: break-word; overflow-wrap: break-word; } - .ai-bubble { background-color: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; align-self: flex-start; max-width: 90%; width: 100%; border: 1px solid #333; word-break: break-word; overflow-wrap: break-word; } - - .msg-actions { display: flex; gap: 10px; margin-top: 15px; border-top: 1px dashed #333; padding-top: 10px; justify-content: flex-end; flex-wrap: wrap; } - .msg-action-btn { background: transparent; border: 1px solid #444; color: #888; border-radius: 15px; padding: 5px 12px; cursor: pointer; font-size: 0.85em; transition: 0.2s; } - .msg-action-btn:hover { background: #2a2a35; color: var(--accent-blue); border-color: var(--accent-blue); } - - .code-header { background: #16161a; padding: 8px 15px; display: flex; justify-content: space-between; align-items: center; border-radius: 8px 8px 0 0; font-family: monospace; font-size: 0.85em; border: 1px solid #333; border-bottom: none; } - .code-actions-group { display: flex; gap: 8px; } - .code-header button { background: #2a2a35; border: 1px solid #444; color: #e0e0e0; cursor: pointer; padding: 4px 10px; border-radius: 5px; font-size: 0.85em; transition: 0.2s; } - .code-header button:hover { background: var(--accent-blue); color: black; border-color: var(--accent-blue); } - .markdown-rendered pre { margin-top: 0; border-radius: 0 0 8px 8px; border: 1px solid #333; padding: 15px; overflow-x: auto; background-color: #0d0d0f !important; } - - .input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 85%, transparent); display: flex; justify-content: center; box-sizing: border-box; } - .input-container-box { width: 100%; max-width: 900px; background: #1e1e24; border-radius: 24px; border: 1px solid #444; padding: 10px 15px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; } - - #file-preview-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 80px; overflow-y: auto; padding-bottom: 5px; } - .file-pill { background: #2a2a35; border: 1px solid #555; color: var(--accent-blue); padding: 5px 12px; border-radius: 12px; font-size: 0.8em; display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } - - .input-row { display: flex; align-items: flex-end; gap: 10px; width: 100%; } - .input-field { background: transparent; border: none; color: white; resize: none; flex: 1; font-size: 1em; height: 36px; max-height: 140px; overflow-y: hidden; padding: 8px; font-family: inherit; box-sizing: border-box; line-height: 20px; } - .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; } - .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); } } - """), - - Script(r""" - function renderMarkdown() { - try { - document.querySelectorAll('.markdown-raw').forEach(el => { - if (el.dataset.rendered) return; - let container = el.nextElementSibling; - container.innerHTML = (typeof marked !== 'undefined') ? marked.parse(el.textContent) : el.textContent; - el.dataset.rendered = "true"; - el.classList.remove('markdown-raw'); - - container.querySelectorAll('pre').forEach(pre => { - try { - let code = pre.querySelector('code'); - if (code && !pre.previousElementSibling?.classList.contains('code-header')) { - let rawLang = code.className.replace('language-', '') || 'snippet'; - let header = document.createElement('div'); - header.className = 'code-header'; - header.innerHTML = ` - ${rawLang.toUpperCase()} -
- - -
`; - pre.parentNode.insertBefore(header, pre); - if (typeof hljs !== 'undefined') hljs.highlightElement(code); - } - } catch(err) { console.warn("Erreur entête code", err); } - }); - }); - let chat = document.getElementById('chat-history'); - if (chat) chat.scrollTop = chat.scrollHeight; - } catch(e) { console.error("Erreur markdown", e); } - } - - function getSmartFilename(btn, defaultName = 'Aethas38_Reponse', extension = 'pdf') { - try { - let bubble = btn.closest('.ai-bubble'); - let prevUserMsg = bubble.previousElementSibling; - if (prevUserMsg && prevUserMsg.classList.contains('user-bubble')) { - let text = prevUserMsg.innerText.replace(/📎.*$/, '').trim(); - let slug = text.toLowerCase() - .replace(/[^a-z0-9àâäéèêëîïôöùûüç\s]/g, '') - .trim() - .replace(/\s+/g, '_') - .substring(0, 35); - if (slug.length > 3) return slug + '.' + extension; - } - } catch(e) {} - return defaultName + '.' + extension; - } - - function getCleanPlainText(btn) { - let rendered = btn.closest('.ai-bubble').querySelector('.markdown-rendered'); - let raw = btn.closest('.ai-bubble').querySelector('.markdown-raw'); - let source = (rendered && rendered.innerHTML.trim() !== "") ? rendered : raw; - return source.innerText || source.textContent || ""; - } - - function autoResize(textarea) { - if (!textarea) return; - textarea.style.height = '36px'; - let scrollH = textarea.scrollHeight; - if (scrollH > 100) { - textarea.style.height = '100px'; - textarea.style.overflowY = 'auto'; - } else { - textarea.style.height = scrollH + 'px'; - textarea.style.overflowY = 'hidden'; - } - } - - 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 (hasFiles) { - e.preventDefault(); - fileInput.files = dt.files; - handleFileSelection(fileInput); - } else { - setTimeout(() => autoResize(ta), 20); - } - }); - } - - if (dropZone && fileInput) { - ['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); - } - fileInput.files = dt.files; - handleFileSelection(fileInput); - }); - } - }); - - function copyCodeBlock(btn) { - let pre = btn.closest('.code-header').nextElementSibling; - navigator.clipboard.writeText(pre.querySelector('code').innerText); - btn.innerText = "✅ Copié !"; - setTimeout(() => btn.innerText = "📋 Copier", 2000); - } - - function downloadCodeZip(btn, lang) { - let pre = btn.closest('.code-header').nextElementSibling; - let code = pre.querySelector('code').innerText; - let ext = lang === 'python' ? 'py' : (lang === 'javascript' ? 'js' : (lang === 'html' ? 'html' : 'txt')); - let filename = getSmartFilename(btn, 'aethas_snippet', ext); - - if (typeof JSZip !== 'undefined') { - let zip = new JSZip(); - zip.file(filename, code); - zip.generateAsync({type: "blob"}).then(function(content) { - let link = document.createElement('a'); - link.href = URL.createObjectURL(content); - link.download = filename.replace('.'+ext, '_snippet.zip'); - link.click(); - }); - } else { - let blob = new Blob([code], {type: 'text/plain;charset=utf-8'}); - let link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = filename; - link.click(); - } - } - - 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'); - let originalBg = content.style.backgroundColor; - let originalColor = content.style.color; - content.style.backgroundColor = '#ffffff'; - content.style.color = '#000000'; - content.style.padding = '15px'; - html2pdf().set({ margin: 10, filename: filename, html2canvas: { scale: 2, backgroundColor: '#ffffff' }, jsPDF: { unit: 'mm', format: 'a4' } }).from(content).save().then(() => { - content.style.backgroundColor = originalBg; - content.style.color = originalColor; - content.style.padding = ''; - }); - } - function downloadResponseTXT(btn) { - let text = getCleanPlainText(btn); - let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'txt'); - let blob = new Blob([text], {type: 'text/plain;charset=utf-8'}); - let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); - } - function downloadResponseDOC(btn) { - let html = btn.closest('.ai-bubble').querySelector('.markdown-rendered').innerHTML; - let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'doc'); - let preHtml = "Aethas Export"; - let blob = new Blob(['\ufeff', preHtml + html + ""], { type: 'application/msword' }); - let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); - } - function downloadResponseJSON(btn) { - let text = getCleanPlainText(btn); - let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'json'); - let blob = new Blob([JSON.stringify({content: text}, null, 2)], {type: 'application/json;charset=utf-8'}); - let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); - } - function shareWhatsApp(btn) { - let text = getCleanPlainText(btn); - window.open('https://api.whatsapp.com/send?text=' + encodeURIComponent("🤖 *Aethas38 (IA) :*\n\n" + text), '_blank'); - } - function shareEmail(btn) { - let text = getCleanPlainText(btn); - window.open('mailto:?subject=Aethas38 - Réponse IA&body=' + encodeURIComponent(text), '_self'); - } - """) -) - -app, rt = fast_app(hdrs=theme_hdrs, secret_key="super_secret_aethas") +# Montage du dossier statique pour rendre accessible le logo +app.mount("/Logo", StaticFiles(directory="Logo"), name="Logo") @rt('/') def get(session): if 'current_chat_id' not in session or not session['current_chat_id']: session['current_chat_id'] = str(uuid.uuid4()) - chat_id = session['current_chat_id'] - history = load_history() - - msgs = [render_user_msg(m['content']) if m['role']=='user' else render_ai_msg(m['content'], m.get('intention',''), m.get('fournisseur',''), m.get('modele',''), m.get('couleur','#333')) for m in history.get(chat_id, {}).get('messages', [])] - if msgs: msgs.append(Script("setTimeout(renderMarkdown, 150);")) - - return Title("Aethas38 - Core"), Body( - Div( - Div( - Div(H2("AETHAS 38", style="color:#00e5ff; margin:0; text-align:center;"), P("Tech Core", style="font-size:0.7em; color:#888; text-align:center;"), margin_bottom="20px"), - Div(A("+ Nouvelle discussion", href="/new_chat", cls="new-chat-btn"), A("💾 Exporter JSON", href="/export_json", target="_blank", cls="export-btn"), cls="nav-buttons"), - cls="sidebar-top" - ), - render_sidebar(), - Div(P("👤 Admin (Xavier)", style="color: #aaa; margin: 0; font-size: 0.9em;"), cls="sidebar-footer"), - cls="sidebar" - ), - Div( - Div(*msgs, 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;"), - Div( - Form( - Input(type="hidden", name="chat_id", value=chat_id), - Div( - Div(id="file-preview-list"), - Div( - Label("+", _for="file-upload", cls="file-btn"), - Input(type="file", name="files", id="file-upload", multiple=True, style="display:none;", onchange="handleFileSelection(this)"), - Textarea(name="prompt", id="prompt-input", placeholder="Requête, lien ou glissez vos fichiers ici...", cls="input-field", rows="1", oninput="autoResize(this)"), - Button("↑", type="submit", cls="btn-submit"), - cls="input-row" - ), - cls="input-container-box" - ), - hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", - hx_on__after_request="this.reset(); document.getElementById('file-preview-list').innerHTML=''; document.getElementById('prompt-input').style.height='36px';", - enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;" - ), - cls="input-area" - ), - cls="main-content" - ) - ) + return render_main_page(session['current_chat_id']) @rt('/chat') async def post(request, session: dict): - try: - form = await request.form() - prompt = form.get("prompt", "") - chat_id = form.get("chat_id", str(uuid.uuid4())) - files = form.getlist("files") - session['current_chat_id'] = chat_id - - history = load_history() - - # Router Qwen - 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 "" - - file_summary = [] - file_names = [] - for f in files[:10]: - if f and f.filename: - file_names.append(f.filename) - content = await f.read() - file_summary.append(f"\n--- Fichier: {f.filename} ---\n{content.decode('utf-8', errors='ignore')}") - - 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.]" - - if fournisseur == "gemini": - try: - reponse, modele = interroger_gemini_cascade(full_prompt) - couleur, fournisseur_nom = "#3b82f6", "GOOGLE GEMINI" - except Exception: - 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) - 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" - - 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 "") - 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}) - save_history(history) - - return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur), Script("renderMarkdown();"), render_sidebar() - - except Exception as e: - return Div(f"Erreur : {e}", style="color:red; background:#300; padding:15px; border-radius:10px;") + return await handle_chat_request(request, session) @rt('/favicon.ico') -def get(): return HTTPResponse(status_code=204) +def get(): return HTTPResponse(status_code=204) + @rt('/new_chat') def get(session): session['current_chat_id'] = str(uuid.uuid4()); return RedirectResponse('/', status_code=303) + @rt('/load_chat/{cid}') def get(cid: str, session): if cid and cid != "null": session['current_chat_id'] = cid return RedirectResponse('/', status_code=303) + @rt('/rename/{cid}') def post(cid: str, req): h = load_history() if cid in h and (nom := req.headers.get("HX-Prompt")): h[cid]['title'] = nom; save_history(h) return render_sidebar() + @rt('/pin/{cid}') def post(cid: str): h = load_history() if cid in h: h[cid]['pinned'] = not h[cid].get('pinned', False); save_history(h) return render_sidebar() + @rt('/delete/{cid}') def post(cid: str): h, cid_str = load_history(), str(cid) if cid_str in h: del h[cid_str]; save_history(h) return render_sidebar() + @rt('/export_json') def get(): return FileResponse(HISTORY_FILE, media_type='application/json', filename='Aethas38_Discussion_Globale.json') diff --git a/modules/chat_handler.py b/modules/chat_handler.py new file mode 100644 index 0000000..d6e5804 --- /dev/null +++ b/modules/chat_handler.py @@ -0,0 +1,72 @@ +import uuid +import time +import re +from fasthtml.common import * +from modules.database import load_history, save_history +from modules.scraper import rechercher_sur_google, extraire_contenu_urls +from modules.router import nettoyer_decision_router +from modules.ai_engine import local_client, interroger_gemini_cascade, appeler_groq_api +from modules.ui_components import render_user_msg, render_ai_msg, render_sidebar + +async def handle_chat_request(request, session): + try: + form = await request.form() + prompt = form.get("prompt", "") + chat_id = form.get("chat_id", str(uuid.uuid4())) + files = form.getlist("files") + 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 "" + + file_summary = [] + file_names = [] + for f in files[:10]: + if f and f.filename: + file_names.append(f.filename) + content = await f.read() + file_summary.append(f"\n--- Fichier: {f.filename} ---\n{content.decode('utf-8', errors='ignore')}") + + 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.]" + + if fournisseur == "gemini": + try: + reponse, modele = interroger_gemini_cascade(full_prompt) + couleur, fournisseur_nom = "#3b82f6", "GOOGLE GEMINI" + except Exception: + 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) + 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" + + 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 "") + 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}) + save_history(history) + + return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur), 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 new file mode 100644 index 0000000..3157c52 --- /dev/null +++ b/modules/paypal.py @@ -0,0 +1,18 @@ +# modules/paypal.py +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" + + return Div( + A( + "☕ Faire un don PayPal", + href=paypal_url, + target="_blank", + cls="export-btn", + style="text-align: center; color: #00e5ff; border-color: #00e5ff; display: block; text-decoration: none;" + ), + style="margin-top: 10px;" + ) \ No newline at end of file diff --git a/modules/theme.py b/modules/theme.py new file mode 100644 index 0000000..d680ef8 --- /dev/null +++ b/modules/theme.py @@ -0,0 +1,270 @@ +from fasthtml.common import * + +def get_theme_hdrs(): + svg_favicon = "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cstyle%3E@keyframes pulse { 0% { opacity: 1; stroke-width: 5; } 50% { opacity: 0.5; stroke-width: 7; } 100% { opacity: 1; stroke-width: 5; } } .neon { animation: pulse 2s infinite; transform-origin: 50px 50px; transform: rotate(45deg); }%3C/style%3E%3Crect width='100' height='100' fill='%23121212'/%3E%3Cg class='neon'%3E%3Crect x='25' y='25' width='50' height='50' fill='none' stroke='%2300e5ff'/%3E%3Cpath d='M25 50 L75 50 M50 25 L50 75' stroke='%23b300ff'/%3E%3Ccircle cx='50' cy='50' r='8' fill='%23e0e0e0'/%3E%3C/g%3E%3C/svg%3E" + + return ( + Script(src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"), + Script(src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"), + Script(src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"), + Script(src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"), + Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"), + Link(rel="icon", type="image/svg+xml", href=f"data:image/svg+xml;utf8,{svg_favicon}"), + Style(""" + :root { --bg-dark: #121212; --bg-panel: #1e1e24; --accent-blue: #00e5ff; --accent-purple: #b300ff; --text-main: #e0e0e0; } + body { background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, sans-serif; margin: 0; display: flex; height: 100vh; overflow: hidden; box-sizing: border-box; } + .sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; } + .sidebar-top { flex-shrink: 0; } + .sidebar-history { flex: 1; overflow-y: auto; margin-top: 15px; margin-bottom: 15px; } + .sidebar-footer { flex-shrink: 0; border-top: 1px solid #333; padding-top: 15px; text-align: center; } + .nav-buttons { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; } + .new-chat-btn { display:block; text-align:center; text-decoration:none; background: transparent; border: 1px solid var(--accent-blue); color: var(--accent-blue); padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; transition: 0.2s; } + .new-chat-btn:hover { background: rgba(0, 229, 255, 0.1); } + .export-btn { background: #2a2a35; border: 1px solid #444; color: white; padding: 10px; border-radius: 5px; cursor: pointer; transition: 0.2s; font-size: 1em; text-align:center; text-decoration:none; display:block;} + .export-btn:hover { background: #444; } + .history-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #222; font-size: 0.9em; transition: 0.2s; } + .history-item:hover { background: #1a1a20; } + .history-link { flex: 1; text-decoration: none; color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; padding-right: 10px; } + .history-btn { background: none; border: none; color: #888; cursor: pointer; padding: 2px 5px; font-size: 1.1em; } + .history-btn:hover { color: var(--accent-blue); } + .main-content { flex: 1; display: flex; flex-direction: column; height: 100vh; position: relative; box-sizing: border-box; } + .chat-container { flex: 1; padding: 20px; max-width: 1000px; margin: 0 auto; width: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; padding-bottom: 140px; box-sizing: border-box; } + .user-bubble { background-color: #2a2a35; padding: 15px 20px; border-radius: 15px 15px 0 15px; align-self: flex-end; max-width: 80%; border: 1px solid #444; margin-left: auto; word-break: break-word; overflow-wrap: break-word; } + .ai-bubble { background-color: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; align-self: flex-start; max-width: 90%; width: 100%; border: 1px solid #333; word-break: break-word; overflow-wrap: break-word; } + .msg-actions { display: flex; gap: 10px; margin-top: 15px; border-top: 1px dashed #333; padding-top: 10px; justify-content: flex-end; flex-wrap: wrap; } + .msg-action-btn { background: transparent; border: 1px solid #444; color: #888; border-radius: 15px; padding: 5px 12px; cursor: pointer; font-size: 0.85em; transition: 0.2s; } + .msg-action-btn:hover { background: #2a2a35; color: var(--accent-blue); border-color: var(--accent-blue); } + .code-header { background: #16161a; padding: 8px 15px; display: flex; justify-content: space-between; align-items: center; border-radius: 8px 8px 0 0; font-family: monospace; font-size: 0.85em; border: 1px solid #333; border-bottom: none; } + .code-actions-group { display: flex; gap: 8px; } + .code-header button { background: #2a2a35; border: 1px solid #444; color: #e0e0e0; cursor: pointer; padding: 4px 10px; border-radius: 5px; font-size: 0.85em; transition: 0.2s; } + .code-header button:hover { background: var(--accent-blue); color: black; border-color: var(--accent-blue); } + .markdown-rendered pre { margin-top: 0; border-radius: 0 0 8px 8px; border: 1px solid #333; padding: 15px; overflow-x: auto; background-color: #0d0d0f !important; } + .input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 85%, transparent); display: flex; justify-content: center; box-sizing: border-box; } + .input-container-box { width: 100%; max-width: 900px; background: #1e1e24; border-radius: 24px; border: 1px solid #444; padding: 10px 15px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; } + #file-preview-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 80px; overflow-y: auto; padding-bottom: 5px; } + .file-pill { background: #2a2a35; border: 1px solid #555; color: var(--accent-blue); padding: 5px 12px; border-radius: 12px; font-size: 0.8em; display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } + .input-row { display: flex; align-items: flex-end; gap: 10px; width: 100%; } + .input-field { background: transparent; border: none; color: white; resize: none; flex: 1; font-size: 1em; height: 36px; max-height: 140px; overflow-y: hidden; padding: 8px; font-family: inherit; box-sizing: border-box; line-height: 20px; } + .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; } + .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); } } + """), + Script(r""" + function renderMarkdown() { + try { + document.querySelectorAll('.markdown-raw').forEach(el => { + if (el.dataset.rendered) return; + let container = el.nextElementSibling; + container.innerHTML = (typeof marked !== 'undefined') ? marked.parse(el.textContent) : el.textContent; + el.dataset.rendered = "true"; + el.classList.remove('markdown-raw'); + + container.querySelectorAll('pre').forEach(pre => { + try { + let code = pre.querySelector('code'); + if (code && !pre.previousElementSibling?.classList.contains('code-header')) { + let rawLang = code.className.replace('language-', '') || 'snippet'; + let header = document.createElement('div'); + header.className = 'code-header'; + header.innerHTML = ` + ${rawLang.toUpperCase()} +
+ + +
`; + pre.parentNode.insertBefore(header, pre); + if (typeof hljs !== 'undefined') hljs.highlightElement(code); + } + } catch(err) { console.warn("Erreur entête code", err); } + }); + }); + let chat = document.getElementById('chat-history'); + if (chat) chat.scrollTop = chat.scrollHeight; + } catch(e) { console.error("Erreur markdown", e); } + } + + function getSmartFilename(btn, defaultName = 'Aethas38_Reponse', extension = 'pdf') { + try { + let bubble = btn.closest('.ai-bubble'); + let prevUserMsg = bubble.previousElementSibling; + if (prevUserMsg && prevUserMsg.classList.contains('user-bubble')) { + let text = prevUserMsg.innerText.replace(/📎.*$/, '').trim(); + let slug = text.toLowerCase().replace(/[^a-z0-9àâäéèêëîïôöùûüç\s]/g, '').trim().replace(/\s+/g, '_').substring(0, 35); + if (slug.length > 3) return slug + '.' + extension; + } + } catch(e) {} + return defaultName + '.' + extension; + } + + function getCleanPlainText(btn) { + let rendered = btn.closest('.ai-bubble').querySelector('.markdown-rendered'); + let raw = btn.closest('.ai-bubble').querySelector('.markdown-raw'); + let source = (rendered && rendered.innerHTML.trim() !== "") ? rendered : raw; + return source.innerText || source.textContent || ""; + } + + function autoResize(textarea) { + if (!textarea) return; + textarea.style.height = '36px'; + let scrollH = textarea.scrollHeight; + if (scrollH > 100) { + textarea.style.height = '100px'; + textarea.style.overflowY = 'auto'; + } else { + textarea.style.height = scrollH + 'px'; + textarea.style.overflowY = 'hidden'; + } + } + + 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 (hasFiles) { + e.preventDefault(); + fileInput.files = dt.files; + handleFileSelection(fileInput); + } else { + setTimeout(() => autoResize(ta), 20); + } + }); + } + + if (dropZone && fileInput) { + ['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); + } + fileInput.files = dt.files; + handleFileSelection(fileInput); + }); + } + }); + + function copyCodeBlock(btn) { + let pre = btn.closest('.code-header').nextElementSibling; + navigator.clipboard.writeText(pre.querySelector('code').innerText); + btn.innerText = "✅ Copié !"; + setTimeout(() => btn.innerText = "📋 Copier", 2000); + } + + function downloadCodeZip(btn, lang) { + let pre = btn.closest('.code-header').nextElementSibling; + let code = pre.querySelector('code').innerText; + let ext = lang === 'python' ? 'py' : (lang === 'javascript' ? 'js' : (lang === 'html' ? 'html' : 'txt')); + let filename = getSmartFilename(btn, 'aethas_snippet', ext); + + if (typeof JSZip !== 'undefined') { + let zip = new JSZip(); + zip.file(filename, code); + zip.generateAsync({type: "blob"}).then(function(content) { + let link = document.createElement('a'); + link.href = URL.createObjectURL(content); + link.download = filename.replace('.'+ext, '_snippet.zip'); + link.click(); + }); + } else { + let blob = new Blob([code], {type: 'text/plain;charset=utf-8'}); + let link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + } + } + + 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'); + let originalBg = content.style.backgroundColor; + let originalColor = content.style.color; + content.style.backgroundColor = '#ffffff'; + content.style.color = '#000000'; + content.style.padding = '15px'; + html2pdf().set({ margin: 10, filename: filename, html2canvas: { scale: 2, backgroundColor: '#ffffff' }, jsPDF: { unit: 'mm', format: 'a4' } }).from(content).save().then(() => { + content.style.backgroundColor = originalBg; + content.style.color = originalColor; + content.style.padding = ''; + }); + } + function downloadResponseTXT(btn) { + let text = getCleanPlainText(btn); + let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'txt'); + let blob = new Blob([text], {type: 'text/plain;charset=utf-8'}); + let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); + } + function downloadResponseDOC(btn) { + let html = btn.closest('.ai-bubble').querySelector('.markdown-rendered').innerHTML; + let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'doc'); + let preHtml = "Aethas Export"; + let blob = new Blob(['\\ufeff', preHtml + html + ""], { type: 'application/msword' }); + let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); + } + function downloadResponseJSON(btn) { + let text = getCleanPlainText(btn); + let filename = getSmartFilename(btn, 'Aethas38_Reponse', 'json'); + let blob = new Blob([JSON.stringify({content: text}, null, 2)], {type: 'application/json;charset=utf-8'}); + let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); + } + function shareWhatsApp(btn) { + let text = getCleanPlainText(btn); + window.open('https://api.whatsapp.com/send?text=' + encodeURIComponent("🤖 *Aethas38 (IA) :*\\n\\n" + text), '_blank'); + } + function shareEmail(btn) { + let text = getCleanPlainText(btn); + window.open('mailto:?subject=Aethas38 - Réponse IA&body=' + encodeURIComponent(text), '_self'); + } + """) + ) \ No newline at end of file diff --git a/modules/ui_components.py b/modules/ui_components.py index 9e1027f..de24c6d 100644 --- a/modules/ui_components.py +++ b/modules/ui_components.py @@ -1,5 +1,6 @@ from fasthtml.common import * from modules.database import load_history +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") @@ -11,7 +12,6 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur): Span(f"🧠 {nom_modele}", style=f"margin-right: 10px; color:{couleur}; font-weight:bold; font-size:0.8em;"), style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 5px;" ) - actions = Div( Button("📄 PDF", onclick="downloadResponsePDF(this)", cls="msg-action-btn", title="Télécharger en PDF"), Button("📘 DOC", onclick="downloadResponseDOC(this)", cls="msg-action-btn", title="Télécharger en Word"), @@ -21,7 +21,6 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur): Button("📧 Email", onclick="shareEmail(this)", cls="msg-action-btn", title="Envoyer par Email"), cls="msg-actions" ) - return Div( encart_info, Div(text, cls="markdown-raw", style="display:none;"), @@ -33,7 +32,6 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur): def render_sidebar(): history = load_history() sorted_chats = sorted(history.items(), key=lambda x: (not x[1].get('pinned', False), -x[1].get('timestamp', 0))) - items = [] for chat_id, data in sorted_chats: pin_icon = "📌" if data.get('pinned', False) else "📍" @@ -47,3 +45,59 @@ def render_sidebar(): ) ) return Div(*items, id="sidebar-content", cls="sidebar-history", hx_swap_oob="true") + +def render_main_page(chat_id): + history = load_history() + messages_ui = [] + if chat_id in history: + for msg in history[chat_id].get('messages', []): + 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'))) + 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"), + A("💾 Exporter JSON", href="/export_json", target="_blank", cls="export-btn"), + render_paypal_donation_widget(), + cls="nav-buttons" + ), + cls="sidebar-top" + ), + render_sidebar(), + Div(P("👤 Admin (Xavier)", style="color: #aaa; margin: 0; font-size: 0.9em;"), cls="sidebar-footer"), + cls="sidebar" + ), + 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;"), + Div( + Form( + Input(type="hidden", name="chat_id", value=chat_id), + Div( + Div(id="file-preview-list"), + Div( + Label("+", _for="file-upload", cls="file-btn"), + Input(type="file", name="files", id="file-upload", multiple=True, style="display:none;", onchange="handleFileSelection(this)"), + Textarea(name="prompt", id="prompt-input", placeholder="Requête, lien ou glissez vos fichiers ici...", cls="input-field", rows="1", oninput="autoResize(this)"), + Button("↑", type="submit", cls="btn-submit"), + cls="input-row" + ), + cls="input-container-box" + ), + hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", + hx_on__after_request="this.reset(); document.getElementById('file-preview-list').innerHTML=''; document.getElementById('prompt-input').style.height='36px';", + enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;" + ), + cls="input-area" + ), + cls="main-content" + ) + ) \ No newline at end of file