import os import json import uuid import time import re import requests from bs4 import BeautifulSoup 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() 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) # SVG Animé (Rotation 45° + Scintillement Pulsé) injecté en Base64 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" 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"), 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; 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; flex: 2; 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; flex: 1; transition: 0.2s; font-size: 1.1em;} .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; 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); } .msg-actions { display: flex; gap: 10px; margin-top: 15px; border-top: 1px dashed #333; padding-top: 10px; justify-content: flex-end; } .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; display: flex; align-items: center; gap: 5px;} .msg-action-btn:hover { background: #2a2a35; color: var(--accent-blue); border-color: var(--accent-blue); } .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; } /* ZONE DE SAISIE GEMINI WEB STYLE */ .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: 60px; overflow-y: auto; } .file-pill { background: #2a2a35; border: 1px solid #555; color: var(--accent-blue); padding: 4px 10px; border-radius: 12px; font-size: 0.8em; display: flex; align-items: center; gap: 6px; } .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; max-height: 120px; overflow-y: auto; padding: 8px; font-family: inherit; box-sizing: border-box; } .input-field:focus { outline: none; } .file-btn { background: #2a2a35; border: 1px solid #444; color: var(--accent-blue); width: 36px !important; height: 36px !important; min-width: 36px !important; max-width: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.3em; flex-shrink: 0 !important; box-sizing: border-box; text-decoration: none; } .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: 36px !important; height: 36px !important; min-width: 36px !important; max-width: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1em; flex-shrink: 0 !important; box-sizing: border-box; } .btn-submit:active { transform: scale(0.95); } """), Script(""" function renderMarkdown() { document.querySelectorAll('.markdown-raw').forEach(el => { let container = el.nextElementSibling; container.innerHTML = marked.parse(el.textContent); el.classList.remove('markdown-raw'); 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); }); }); let chat = document.getElementById('chat-history'); chat.scrollTop = chat.scrollHeight; } /* FONCTIONS DE GÉNÉRATION LOCALE SUR LE PC CLIENT (0 Mo RAM SERVEUR) */ function downloadResponsePDF(btn) { let content = btn.closest('.ai-bubble').querySelector('.markdown-rendered'); let opt = { margin: 10, filename: 'Aethas38_Reponse.pdf', image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2, backgroundColor: '#1e1e24' }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' } }; let originalColor = content.style.color; content.style.color = '#ffffff'; html2pdf().set(opt).from(content).save().then(() => { content.style.color = originalColor; }); } function downloadResponseTXT(btn) { let content = btn.closest('.ai-bubble').querySelector('.markdown-rendered'); let blob = new Blob([content.innerText], {type: 'text/plain;charset=utf-8'}); let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'Aethas38_Reponse.txt'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } function downloadResponseDOC(btn) { let content = btn.closest('.ai-bubble').querySelector('.markdown-rendered').innerHTML; let preHtml = "Aethas Export"; let postHtml = ""; let blob = new Blob(['\\ufeff', preHtml + content + postHtml], { type: 'application/msword' }); let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'Aethas38_Reponse.doc'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } function copyCode(btn) { let pre = btn.closest('.code-header').nextElementSibling; let code = pre.querySelector('code').innerText; navigator.clipboard.writeText(code); btn.innerText = "✅ Copié !"; setTimeout(() => btn.innerText = "📋 Copier", 2000); } function exportChat() { let chat = document.getElementById('chat-history'); if(!chat) return; let blob = new Blob([chat.innerText], {type: 'text/plain;charset=utf-8'}); let link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'Aethas38_Discussion_Globale.txt'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } function handleFileSelection(input) { let previewList = document.getElementById('file-preview-list'); previewList.innerHTML = ''; if (input.files) { for (let i = 0; i < input.files.length; i++) { let pill = document.createElement('div'); pill.className = 'file-pill'; pill.innerHTML = `📎 ${input.files[i].name}`; previewList.appendChild(pill); } } } function autoResize(textarea) { textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; } """) ) app, rt = fast_app(hdrs=theme_hdrs, secret_key="super_secret_aethas") # ========================================== # SECTION 2. CONNEXION AUX IA & RECHERCHE WEB # ========================================== 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: chat = gemini_client.chats.create(model=nom_modele, config={"system_instruction": "Réponds toujours dans la langue de l'utilisateur."}) ia_response = chat.send_message(prompt) return ia_response.text, nom_modele except Exception: continue raise Exception("Tous les modèles Gemini sont indisponibles actuellement.") def rechercher_sur_google(requete): """Effectue une recherche Google silencieuse si le module est installé.""" try: from googlesearch import search urls = [] for url in search(requete, num_results=3, lang="fr"): urls.append(url) return " ".join(urls) # Retourne les liens qui seront scrapés par extraire_contenu_urls except ImportError: return "" def extraire_contenu_urls(prompt): urls = re.findall(r'https?://[^\s]+', prompt) scraped_text = "" for url in urls: try: resp = requests.get(url, timeout=5, headers={"User-Agent": "Mozilla/5.0"}) soup = BeautifulSoup(resp.text, 'html.parser') for script in soup(["script", "style", "nav", "footer"]): script.extract() scraped_text += f"\n\n--- Contenu extrait de l'URL ({url}) ---\n{soup.get_text(separator=' ', strip=True)[:4000]}" except Exception as e: scraped_text += f"\n\n[Impossible de récupérer le contenu de {url}: {e}]" return scraped_text def nettoyer_decision_router(raw_text): try: clean = re.sub(r'```(?:json)?\s*', '', raw_text) clean = clean.replace('```', '').strip() start = clean.find('{') end = clean.rfind('}') + 1 if start != -1 and end != 0: return json.loads(clean[start:end]) except Exception: pass return {"intention": "Analyse", "fournisseur": "gemini"} 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;" ) # BOUTONS D'EXPORTATION CLIENT (0 RAM Serveur) 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"), Button("📝 TXT", onclick="downloadResponseTXT(this)", cls="msg-action-btn", title="Télécharger en Texte"), cls="msg-actions" ) return Div( encart_info, Div(text, cls="markdown-raw", style="display:none;"), Div(cls="markdown-rendered", style="line-height: 1.6;"), actions, cls="ai-bubble", style=f"border-left: 4px solid {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 "📍" items.append( Div( # CORRECTION : Utilisation de vrais liens pour la navigation (Robuste, n'écrase plus rien !) A(Span(data['title'], style="color:inherit;"), href=f"/load_chat/{chat_id}", cls="history-link"), 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 Div(*items, id="sidebar-content", cls="sidebar-history", hx_swap_oob="true") @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() 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( 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( # CORRECTION NAVIGATION : Vrai lien HTTP pour forcer un nettoyage complet propre A("+ Nouvelle discussion", href="/new_chat", cls="new-chat-btn"), Button("💾 Exporter", cls="export-btn", onclick="exportChat()", title="Télécharger la conversation Globale"), 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( Form( Input(type="hidden", name="chat_id", value=chat_id), Div( Div(id="file-preview-list"), Div( 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;", onchange="handleFileSelection(this)"), Textarea(name="prompt", id="prompt-input", placeholder="Entrez votre requête ou déposez vos fichiers / liens ici...", cls="input-field", rows="1", oninput="autoResize(this)"), Button("↑", type="submit", cls="btn-submit", title="Exécuter"), cls="input-row" ), cls="input-container-box" ), hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_on__after_request="this.reset(); document.getElementById('file-preview-list').innerHTML=''; document.getElementById('prompt-input').style.height='auto';", enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;" ), cls="input-area" ), cls="main-content" ) ) # ========================================== # SECTION 4. LE MOTEUR DE ROUTAGE (IA + WEB + FICHIERS) # ========================================== @rt('/chat') async def post(request, session: dict): try: form = await request.form() prompt = form.get("prompt", "") chat_id = form.get("chat_id") files = form.getlist("files") if not chat_id: chat_id = str(uuid.uuid4()) session['current_chat_id'] = chat_id history = load_history() # 1. Scraping Moteur Google (Si l'intention est détectée) mots_cles = ["cherche sur", "recherche sur", "sur internet", "sur le web", "sur le net"] if any(mot in prompt.lower() for mot in mots_cles): liens_google = rechercher_sur_google(prompt) prompt += f"\n {liens_google}" # Ajoute les liens invisibles au prompt pour les scraper juste après # 2. Scraping automatique des liens web web_content = extraire_contenu_urls(prompt) # 3. Traitement des fichiers joints file_contents_summary = [] file_names = [] if files: for file in files[:10]: 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]" file_contents_summary.append(f"\n--- Fichier joint: {file.filename} ---\n{text_content}") # 4. Construction du prompt complet full_prompt = prompt if prompt else f"Analyse ces fichiers : {', '.join(file_names)}" if file_contents_summary: full_prompt += "\n".join(file_contents_summary) if web_content: full_prompt += web_content if chat_id not in history: titre_brut = prompt.replace("cherche sur internet", "").strip() history[chat_id] = { "title": (titre_brut[:30] if titre_brut else "Recherche / Fichiers") + "...", "pinned": False, "timestamp": time.time(), "messages": [] } # 5. Analyse par l'Aiguilleur (Qwen) router_prompt = """Tu es le Routeur. Choisis : "groq" (code/maths), "gemini" (création/rédaction), "local" (salutations). Format strict (JSON uniquement) : {"intention": "...", "fournisseur": "..."}""" 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) decision = nettoyer_decision_router(router_response.choices[0].message.content) fournisseur = decision.get("fournisseur", "local").lower() # 6. 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": 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(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": 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 # 7. 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) 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 (Navigation native) # ========================================== @rt('/favicon.ico') 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/{chat_id}') def get(chat_id: str, session): if chat_id and chat_id != "null": session['current_chat_id'] = chat_id return RedirectResponse('/', status_code=303) @rt('/rename/{chat_id}') def post(chat_id: str, req): if not chat_id or chat_id == "null": return render_sidebar() 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): if not chat_id or chat_id == "null": return render_sidebar() 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): if not chat_id or chat_id == "null": return render_sidebar() history = load_history() if chat_id in history: del history[chat_id] save_history(history) return render_sidebar() if __name__ == '__main__': serve(port=5001)