import os import json import uuid from dotenv import load_dotenv from fasthtml.common import * from openai import OpenAI from groq import Groq from google import genai # ========================================== # SECTION 1. INITIALISATION, SÉCURITÉ & HISTORIQUE # ========================================== load_dotenv() # --- GESTION DE L'HISTORIQUE (JSON) --- HISTORY_FILE = "aethas_history.json" def load_history(): if not os.path.exists(HISTORY_FILE): with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump({}, f) with open(HISTORY_FILE, 'r', encoding='utf-8') as f: return json.load(f) def save_history(history): with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump(history, f, indent=4) # --- CSS & JAVASCRIPT (Thème, Markdown, ZIP et Copie) --- theme_hdrs = ( # Bibliothèques externes pour le Markdown et la création de ZIP 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/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"), 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; } /* SIDEBAR */ .sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; } .new-chat-btn { background: transparent; border: 1px solid var(--accent-blue); color: var(--accent-blue); padding: 10px; border-radius: 5px; cursor: pointer; font-weight: bold; width: 100%; margin-bottom: 20px;} .new-chat-btn:hover { background: rgba(0, 229, 255, 0.1); } .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-btn { background: none; border: none; color: #888; cursor: pointer; padding: 2px 5px; font-size: 1.1em;} .history-btn:hover { color: var(--accent-blue); } /* CHAT AREA */ .main-content { flex: 1; display: flex; flex-direction: column; height: 100%; position: relative; } .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: 120px;} /* BULLES DE CHAT */ .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; box-shadow: 0 4px 6px rgba(0,0,0,0.3);} .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; box-shadow: 0 4px 6px rgba(0,0,0,0.3);} /* BLOCS DE CODE (Copie & ZIP) */ .code-header { background: #111; 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-header button { background: #333; border: 1px solid #444; color: white; cursor: pointer; padding: 4px 10px; border-radius: 5px; margin-left: 8px; 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;} /* INPUT AREA */ .input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 80%, transparent); display: flex; justify-content: center; } .input-wrapper { display: flex; flex-direction: row; gap: 15px; width: 100%; max-width: 900px; background: #1e1e24; padding: 15px; border-radius: 15px; border: 1px solid #444; align-items: center;} .input-field { background: #121212; border: 1px solid #333; color: white; padding: 15px; flex-grow: 1; min-width: 50%; font-size: 1em; border-radius: 10px; } .input-field:focus { outline: none; border-color: var(--accent-blue); } .btn-submit { background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); color: white; border: none; padding: 15px 30px; border-radius: 10px; font-weight: bold; cursor: pointer; white-space: nowrap; } """), # Script Magique : Convertit le Markdown, ajoute les boutons Copier/Télécharger en ZIP Script(""" function renderMarkdown() { document.querySelectorAll('.markdown-raw').forEach(el => { let container = el.nextElementSibling; container.innerHTML = marked.parse(el.textContent); el.classList.remove('markdown-raw'); // Ajout de la barre de code avec boutons container.querySelectorAll('pre').forEach(pre => { let code = pre.querySelector('code'); let lang = code.className.replace('language-', '') || 'texte'; let header = document.createElement('div'); header.className = 'code-header'; header.innerHTML = ` ${lang.toUpperCase()}
`; pre.parentNode.insertBefore(header, pre); hljs.highlightElement(code); }); }); // Auto-scroll vers le bas let chat = document.getElementById('chat-history'); chat.scrollTop = chat.scrollHeight; } function copyCode(btn) { let code = btn.parentElement.parentElement.nextElementSibling.querySelector('code').innerText; navigator.clipboard.writeText(code); btn.innerText = "✅ Copié !"; setTimeout(() => btn.innerText = "📋 Copier", 2000); } function downloadCode(btn, lang) { let code = btn.parentElement.parentElement.nextElementSibling.querySelector('code').innerText; let zip = new JSZip(); let ext = lang === 'python' ? 'py' : lang === 'javascript' ? 'js' : lang === 'html' ? 'html' : 'txt'; // Création du fichier dans le ZIP zip.file(`Aethas38_Code.${ext}`, code); zip.generateAsync({type:"blob"}).then(function(content) { let link = document.createElement('a'); link.href = URL.createObjectURL(content); link.download = `Aethas38_Export_${lang}.zip`; link.click(); }); } """) ) # Activation des sessions pour se souvenir du chat actuel app, rt = fast_app(hdrs=theme_hdrs, secret_key="super_secret_aethas") # ========================================== # SECTION 2. CONNEXION AUX IA # ========================================== local_client = OpenAI(base_url="http://192.168.1.20:11434/v1", api_key="ollama") groq_client = Groq(api_key=os.getenv("GROQ_API_KEY")) gemini_client = genai.Client(api_key=os.getenv("GEMINI_API_KEY")) def interroger_gemini_cascade(prompt): modeles_a_tester = ["gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini-2.5-pro", "gemini-3.7-flash", "gemini-3.6-flash"] for nom_modele in modeles_a_tester: try: ia_response = gemini_client.models.generate_content( model=nom_modele, contents=prompt, config={"system_instruction": "Réponds toujours dans la même langue que celle utilisée par l'utilisateur."} ) return ia_response.text, nom_modele except Exception: continue raise Exception("Tous les modèles Gemini sont indisponibles actuellement.") # ========================================== # SECTION 3. INTERFACE UTILISATEUR & HISTORIQUE # ========================================== def render_sidebar(): history = load_history() # Trier par épinglé d'abord, puis par ID (ordre de création) sorted_chats = sorted(history.items(), key=lambda x: (not x[1].get('pinned', False), x[0])) items = [] for chat_id, data in sorted_chats: pin_icon = "📌" if data.get('pinned', False) else "📍" items.append( Div( Span(data['title'], style="flex:1; cursor:pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"), # Bouton Épingler Button(pin_icon, cls="history-btn", hx_post=f"/pin/{chat_id}", hx_target="#sidebar-content", hx_swap="innerHTML"), # Bouton Renommer Button("✏️", cls="history-btn", hx_post=f"/rename/{chat_id}", hx_prompt="Nouveau nom :", hx_target="#sidebar-content", hx_swap="innerHTML"), # Bouton Supprimer Button("🗑️", cls="history-btn", hx_post=f"/delete/{chat_id}", hx_target="#sidebar-content", hx_swap="innerHTML"), cls="history-item", id=f"chat-item-{chat_id}" ) ) return items @rt('/') def get(session): # Démarrer une nouvelle session de chat si aucune n'existe if 'current_chat_id' not in session: session['current_chat_id'] = str(uuid.uuid4()) return Title("Aethas38 - Mine_AI"), Body( # --- COLONNE GAUCHE (Historique) --- 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"), Form(Button("+ Nouvelle discussion", cls="new-chat-btn"), hx_post="/new_chat", hx_target="body"), Div(*render_sidebar(), id="sidebar-content", style="flex:1; overflow-y:auto;"), Div(P("👤 Admin (Xavier)", style="color: #aaa; margin: 0; text-align:center;"), style="border-top: 1px solid #333; padding-top: 15px;"), cls="sidebar" ), # --- COLONNE DROITE (Chat) --- Div( # Zone d'affichage des messages (Append 'beforeend' ici) Div(id="chat-history", cls="chat-container"), # Zone de saisie Div( Form( Div( Input(type="text", name="prompt", id="prompt-input", placeholder="Entrez votre requête ou commande...", cls="input-field"), Button("Exécuter", type="submit", cls="btn-submit"), cls="input-wrapper" ), hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", # AJOUTE à la suite au lieu de remplacer ! hx_on__after_request="this.reset()", # Vide le champ de texte style="width: 100%; display: flex; justify-content: center;" ), cls="input-area" ), cls="main-content" ) ) # ========================================== # SECTION 4. LE MOTEUR DE ROUTAGE (L'Aiguilleur) # ========================================== @rt('/chat') def post(prompt: str, session: dict): try: # 1. Gestion du Titre dans l'historique chat_id = session.get('current_chat_id') history = load_history() if chat_id not in history: history[chat_id] = {"title": prompt[:30] + "...", "pinned": False, "messages": []} save_history(history) # 2. Création de la bulle VISUELLE Utilisateur (Alignée à droite) user_bubble = Div(P(prompt, style="margin:0; font-weight:bold;"), cls="user-bubble") # 3. Analyse de l'Aiguilleur router_prompt = """Tu es le Routeur. Choisis : "groq" (code/maths), "gemini" (création/rédaction), "local" (salutations). Format strict : {"intention": "...", "fournisseur": "..."}. Ne dis rien d'autre.""" router_response = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": router_prompt}, {"role": "user", "content": prompt}], temperature=0.1) raw_decision = router_response.choices[0].message.content start_idx = raw_decision.find('{') end_idx = raw_decision.rfind('}') + 1 decision = json.loads(raw_decision[start_idx:end_idx]) if start_idx != -1 and end_idx != 0 else {"intention": "Secours", "fournisseur": "gemini"} fournisseur = decision.get("fournisseur", "local").lower() # 4. Requête IA if fournisseur == "groq": ia_response = groq_client.chat.completions.create(messages=[{"role": "system", "content": "Réponds dans la langue de l'utilisateur."}, {"role": "user", "content": prompt}], model="llama-3.3-70b-versatile") reponse_finale, couleur, nom_fournisseur, nom_modele = ia_response.choices[0].message.content, "#f97316", "GROQ", "llama-3.3-70b" elif fournisseur == "gemini": reponse_finale, nom_modele = interroger_gemini_cascade(prompt) couleur, nom_fournisseur = "#3b82f6", "GOOGLE GEMINI" else: ia_response = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": "Réponds dans la langue de l'utilisateur."}, {"role": "user", "content": prompt}]) reponse_finale, couleur, nom_fournisseur, nom_modele = ia_response.choices[0].message.content, "#10b981", "SERVEUR LOCAL", "qwen2.5:3b" # 5. Création de la bulle VISUELLE IA (Alignée à gauche avec Encart) encart_info = Div( Span(f"🎯 {decision.get('intention', '')}", style="margin-right: 15px; color:#aaa; font-size:0.85em;"), Span(f"🏢 {nom_fournisseur}", style="margin-right: 15px; color:#aaa; font-size:0.85em;"), Span(f"🧠 {nom_modele}", style=f"color:{couleur}; font-weight:bold; font-size:0.85em;"), style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px;" ) ai_bubble = Div( encart_info, # Le texte brut caché Div(reponse_finale, cls="markdown-raw", style="display:none;"), # Le conteneur final rendu par JS Div(cls="markdown-rendered", style="line-height: 1.6;"), cls="ai-bubble", style=f"border-left: 4px solid {couleur};" ) # On retourne l'ensemble (User + IA) et on déclenche le rendu Markdown JS return user_bubble, ai_bubble, Script("renderMarkdown();"), Div(*render_sidebar(), id="sidebar-content", hx_swap_oob="true") except Exception as e: return Div(f"Erreur : {e}", style="color:red; background:#300; padding:15px; border-radius:10px;") # ========================================== # SECTION 5. ROUTES DE L'HISTORIQUE (Renommer, Épingler, Supprimer) # ========================================== @rt('/new_chat') def post(session): session['current_chat_id'] = str(uuid.uuid4()) return Script("window.location.reload();") # Recharge la page à zéro @rt('/rename/{chat_id}') def post(chat_id: str, req): # htmx envoie la valeur tapée dans la popup via l'en-tête 'HX-Prompt' nouveau_nom = req.headers.get("HX-Prompt") if nouveau_nom: history = load_history() if chat_id in history: history[chat_id]['title'] = nouveau_nom save_history(history) return render_sidebar() @rt('/pin/{chat_id}') def post(chat_id: str): history = load_history() if chat_id in history: history[chat_id]['pinned'] = not history[chat_id].get('pinned', False) save_history(history) return render_sidebar() @rt('/delete/{chat_id}') def post(chat_id: str): history = load_history() if chat_id in history: del history[chat_id] save_history(history) return render_sidebar() if __name__ == '__main__': serve(port=5001) @rt('/load_chat/{chat_id}') def post(chat_id: str, session): session['current_chat_id'] = chat_id return Script("window.location.reload();") # Recharge la page avec la bonne session