diff --git a/README.md b/README.md index 4755e68..644b72f 100644 --- a/README.md +++ b/README.md @@ -11,37 +11,44 @@ Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécu ### Phase 3 : Refonte UI & Expérience Utilisateur (En cours) ⏳ - [x] Thème "Minecraft-Tech" (Inspiré serveurs/redstone) compatible Mobile (Responsive) -- [x] Historique (Sidebar) avec Renommage, Épinglage, Suppression -- [ ] Maintien et chargement de l'historique complet des messages dans la zone de chat -- [ ] Export de la conversation complète (PDF, DOC, ou JSON) pour l'utilisateur +- [x] Historique (Sidebar) avec Renommage, Épinglage, Suppression et Navigation isolée par session +- [x] Zone de saisie inspirée de Gemini Web (Textarea avec ascenseur, bouton "+" pour import multi-fichiers jusqu'à 10 éléments, et bouton flèche "↑") +- [x] Export du code en ZIP et copie rapide +- [ ] Export global de la conversation (PDF, DOC, JSON) - [ ] Script de nettoyage automatique : Suppression des discussions de > 365 jours (avec notification) -- [ ] Indicateur d'avancement (Loader dynamique) pendant le travail des IA -- [ ] Envoi de fichiers (Upload) pour analyse +- [ ] Indicateur d'avancement dynamique (Loader) pendant le traitement des IA ### Phase 4 : Gestion des Utilisateurs & Sécurité - [ ] 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é/notifications +- [ ] 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), et Externes ### Phase 5 : Sécurité Financière & Facturation Exacte 💰 -- [ ] Simulateur de coût avant requête : L'estimation doit être exacte -- [ ] Gestion des comptes et provisions (CB) avec test du restant financier -- [ ] Règles d'exceptions Administrateurs & Famille (Pas de provision) -- [ ] Majoration invisible de 15% pour les comptes Externes -- [ ] Compteur en temps réel mis à jour après la requête +- [ ] Modèle Excel de calcul des coûts serveur (2x 1000W H24, Écrans 18/24h, Internet 60€/mois)[cite: 3] +- [ ] Simulateur de coût avant requête : L'estimation doit être exacte pour éviter les défauts de paiement[cite: 3] +- [ ] Gestion des comptes et provisions (CB) avec test du restant financier avant validation[cite: 3] +- [ ] Règles d'exceptions Administrateurs & Famille (Pas de provision nécessaire, rattachés au master-compte Xavier/Céline)[cite: 3] +- [ ] Majoration invisible de 15% appliquée automatiquement pour les comptes Externes[cite: 3] +- [ ] Compteur en temps réel mis à jour après la requête basé sur le prix annoncé[cite: 3] +- [ ] Routage Conditionnel Payant : Utiliser *toutes* les IA en adéquation avec le thème de la requête[cite: 3] ### Phase 6 : Nouvelles Capacités IA (Spécialisations & Modèles) 🤖 -*Intégration technique des nouvelles API gratuites et payantes.* -- [ ] **Mistral, DeepSeek, HuggingFace, Cloudflare** : Implémentation des clients API -- [ ] **Multi-Agent (Concaténation)** : L'IA locale synthétise les retours de plusieurs IA pour une réponse optimale. -- [ ] Code : Claude 3.5 Sonnet / Qwen Coder (Anthropic / OpenRouter) -- [ ] Logique & Quêtes : DeepSeek R1 -- [ ] Multimédia : Gemini 2.0 Flash / FLUX.1 (Image/Vidéo) +*La mise en forme finale des retours est assurée par l'abonnement Gemini.* + +| Domaine de tâche | Modèle / Moteur | Fournisseur / Hébergement | Rôle principal | +| :--- | :--- | :--- | :--- | +| **Orchestration & Tri** | Qwen 2.5 (3B/7B quantizé) | Local (GTX 1650 / Ollama) | Analyse l'intention, gère le chat rapide et route vers la bonne IA | +| **Prog. & Java 21** | Claude 3.5 Sonnet / Qwen Coder | Anthropic / OpenRouter | Correction bugs, KubeJS, modernisation de mods (Pay-as-you-go) | +| **Serveur / Logs** | Llama 3.3 (8B / 70B) | Groq API | Analyse crashs Prism Launcher, Bash, Docker (Gratuit / Rapide) | +| **Logique & Quêtes** | DeepSeek R1 (Distillé) | DeepSeek API / Gratuit | Structuration logique des modpacks et FTB Quests | +| **Images HD / Vidéo** | Gemini 2.0 Flash / FLUX.1 | Google AI Studio & Fal.ai | Analyse d'images/vidéos et génération d'assets 2K | +| **Bureautique** | À définir | À définir | Excel (Macros, formules complexes) | ### Phase 7 : RAG & Maintenance -- [ ] "RAG" : Base de données vectorielle (ChromaDB) +- [ ] "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 +- [ ] Création de logs centralisés consultables et téléchargeables par l'Admin ### Phase 8 : Déploiement Public & Configuration - [ ] Assistant de configuration (Wizard) au premier lancement pour insérer les clés API, configurer le SMTP, etc. diff --git a/main.py b/main.py index ceb0a97..04d4220 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,6 @@ from google import genai # ========================================== load_dotenv() -# --- GESTION DE L'HISTORIQUE (JSON) --- HISTORY_FILE = "aethas_history.json" def load_history(): if not os.path.exists(HISTORY_FILE): @@ -22,9 +21,7 @@ def load_history(): 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"), @@ -34,7 +31,6 @@ theme_hdrs = ( :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); } @@ -43,29 +39,30 @@ theme_hdrs = ( .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;} + .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: 130px;} - /* 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; } + /* GEMINI WEB STYLE INPUT BAR */ + .input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 85%, transparent); display: flex; justify-content: center; } + .input-wrapper { display: flex; align-items: flex-end; gap: 12px; width: 100%; max-width: 900px; background: #1e1e24; padding: 12px 15px; border-radius: 24px; border: 1px solid #444; } + .input-field { background: transparent; border: none; color: white; resize: none; flex-grow: 1; font-size: 1em; max-height: 150px; overflow-y: auto; padding: 5px; font-family: inherit; } + .input-field:focus { outline: none; } + + .file-btn { background: #2a2a35; border: 1px solid #444; color: var(--accent-blue); width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.4em; transition: 0.2s; flex-shrink: 0; } + .file-btn:hover { background: rgba(0, 229, 255, 0.2); border-color: var(--accent-blue); } + + .btn-submit { background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); color: white; border: none; width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.2em; flex-shrink: 0; transition: transform 0.1s; } + .btn-submit:active { transform: scale(0.95); } """), - # Script Magique : Convertit le Markdown, ajoute les boutons Copier/Télécharger en ZIP Script(""" function renderMarkdown() { document.querySelectorAll('.markdown-raw').forEach(el => { @@ -73,7 +70,6 @@ theme_hdrs = ( 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'; @@ -91,7 +87,6 @@ theme_hdrs = ( hljs.highlightElement(code); }); }); - // Auto-scroll vers le bas let chat = document.getElementById('chat-history'); chat.scrollTop = chat.scrollHeight; } @@ -107,8 +102,6 @@ theme_hdrs = ( 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'); @@ -120,11 +113,10 @@ theme_hdrs = ( """) ) -# 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 +# SECTION 2. CONNEXION AUX IA & CALCUL COÛTS # ========================================== 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")) @@ -144,12 +136,37 @@ def interroger_gemini_cascade(prompt): continue raise Exception("Tous les modèles Gemini sont indisponibles actuellement.") +def calculer_cout_ia(fournisseur, prompt_tokens, completion_tokens): + if fournisseur == "GROQ": + input_cost_per_m, output_cost_per_m = 0.59, 0.79 + elif fournisseur == "GOOGLE GEMINI": + input_cost_per_m, output_cost_per_m = 0.075, 0.30 + else: + return 0.00 + return round(((prompt_tokens * input_cost_per_m) + (completion_tokens * output_cost_per_m)) / 1_000_000, 6) + # ========================================== # SECTION 3. INTERFACE UTILISATEUR & HISTORIQUE # ========================================== +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): + 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;"), + style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 5px;" + ) + return Div( + encart_info, + Div(text, cls="markdown-raw", style="display:none;"), + Div(cls="markdown-rendered", style="line-height: 1.6;"), + cls="ai-bubble", style=f"border-left: 4px solid {couleur};" + ) + 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 = [] @@ -158,50 +175,61 @@ def render_sidebar(): items.append( Div( Span(data['title'], hx_post=f"/load_chat/{chat_id}", hx_target="body", 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"), + Button(pin_icon, cls="history-btn", hx_post=f"/pin/{chat_id}", hx_target="#sidebar-content", hx_swap="outerHTML"), + Button("✏️", cls="history-btn", hx_post=f"/rename/{chat_id}", hx_prompt="Nouveau nom :", hx_target="#sidebar-content", hx_swap="outerHTML"), + Button("🗑️", cls="history-btn", hx_post=f"/delete/{chat_id}", hx_target="#sidebar-content", hx_swap="outerHTML"), cls="history-item", id=f"chat-item-{chat_id}" ) ) - return items + return Div(*items, id="sidebar-content", style="flex:1; overflow-y:auto;", hx_swap_oob="true") @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()) + + chat_id = session['current_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, 100);")) 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;"), + render_sidebar(), 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(*messages_ui, id="chat-history", cls="chat-container"), 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"), + # Bouton + (Label déclenchant l'input file caché supportant le drag & drop et jusqu'à 10 fichiers) + Label("+", _for="file-upload", cls="file-btn", title="Importer des fichiers (max 10)"), + Input(type="file", name="files", id="file-upload", multiple=True, style="display:none;"), + # Textarea Gemini Web style avec ascenseur automatique + Textarea(name="prompt", id="prompt-input", placeholder="Entrez votre requête ou déposez vos fichiers ici...", cls="input-field", rows="1"), + # Bouton Flèche de validation + Button("↑", type="submit", cls="btn-submit", title="Exécuter"), 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 + hx_swap="beforeend", + hx_on__after_request="this.reset()", + enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;" ), cls="input-area" @@ -211,87 +239,106 @@ def get(session): ) # ========================================== -# SECTION 4. LE MOTEUR DE ROUTAGE (L'Aiguilleur) +# SECTION 4. LE MOTEUR DE ROUTAGE & UPLOAD MULTI-FICHIERS # ========================================== @rt('/chat') -def post(prompt: str, session: dict): +async def post(request, session: dict): try: - # 1. Gestion du Titre dans l'historique + form = await request.form() + prompt = form.get("prompt", "") + files = form.getlist("files") # Récupération de la liste des fichiers (jusqu'à 10) + 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) + history[chat_id] = {"title": (prompt[:30] if prompt else "Fichiers joints") + "...", "pinned": False, "messages": []} - # 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") + file_contents_summary = [] + file_names = [] + + if files: + for file in files[:10]: # Limitation stricte à 10 fichiers + if file and file.filename: + file_names.append(file.filename) + content_bytes = await file.read() + try: + text_content = content_bytes.decode('utf-8', errors='ignore') + except Exception: + text_content = "[Fichier binaire ou non textuel]" + file_contents_summary.append(f"\n--- Fichier: {file.filename} ---\n{text_content}") - # 3. Analyse de l'Aiguilleur + full_prompt = prompt if prompt else f"Analyse ces fichiers : {', '.join(file_names)}" + if file_contents_summary: + full_prompt += "\n".join(file_contents_summary) + + # Analyse par 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) + router_response = local_client.chat.completions.create(model="qwen2.5:3b", messages=[{"role": "system", "content": router_prompt}, {"role": "user", "content": full_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 + # Requête IA & Tokens + prompt_tokens = len(full_prompt.split()) * 2 + completion_tokens = 0 + 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") + ia_response = groq_client.chat.completions.create(messages=[{"role": "system", "content": "Réponds dans la langue de l'utilisateur."}, {"role": "user", "content": full_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" + if hasattr(ia_response, 'usage') and ia_response.usage: + prompt_tokens = ia_response.usage.prompt_tokens + completion_tokens = ia_response.usage.completion_tokens elif fournisseur == "gemini": - reponse_finale, nom_modele = interroger_gemini_cascade(prompt) + reponse_finale, nom_modele = interroger_gemini_cascade(full_prompt) couleur, nom_fournisseur = "#3b82f6", "GOOGLE GEMINI" + completion_tokens = len(reponse_finale.split()) * 2 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}]) + 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": full_prompt}]) reponse_finale, couleur, nom_fournisseur, nom_modele = ia_response.choices[0].message.content, "#10b981", "SERVEUR LOCAL", "qwen2.5:3b" + if hasattr(ia_response, 'usage') and ia_response.usage: + prompt_tokens = ia_response.usage.prompt_tokens + completion_tokens = ia_response.usage.completion_tokens - # 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};" - ) + # Sauvegarde en JSON + display_msg = f"{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_finale, "intention": decision.get('intention', ''), "fournisseur": nom_fournisseur, "modele": nom_modele, "couleur": couleur}) + save_history(history) - # 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") + user_bubble = render_user_msg(display_msg) + ai_bubble = render_ai_msg(reponse_finale, decision.get('intention', ''), nom_fournisseur, nom_modele, couleur) + + return user_bubble, ai_bubble, Script("renderMarkdown();"), render_sidebar() 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) +# SECTION 5. ROUTES DE L'HISTORIQUE & NAVIGATION (RedirectResponse FIX) # ========================================== @rt('/new_chat') def post(session): session['current_chat_id'] = str(uuid.uuid4()) - return Script("window.location.reload();") # Recharge la page à zéro + return RedirectResponse('/', status_code=303) + +@rt('/load_chat/{chat_id}') +def post(chat_id: str, session): + session['current_chat_id'] = chat_id + return RedirectResponse('/', status_code=303) @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}') @@ -311,9 +358,4 @@ def post(chat_id: str): 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 \ No newline at end of file + serve(port=5001) \ No newline at end of file