import os
import requests
from fasthtml.common import *
from modules.database import load_history
from modules.billing_manager import obtenir_solde
def get_openrouter_stats():
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key: return {}
try:
resp = requests.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {api_key}"}, timeout=3)
if resp.status_code == 200:
return resp.json().get("data", {})
except: pass
return {}
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, execution_time=None, trace=None, prompt_associe="", thematique="general", msg_id=""):
time_badge = Span(f"⏱️ {execution_time}s", style="margin-right: 10px; color:#aaa; font-size:0.8em;") if execution_time else ""
trace_html = ""
if trace and isinstance(trace, list):
badges = [Span(t, style="font-size:0.75em; background:#222; padding:2px 6px; border-radius:4px; border:1px solid #444;") for t in trace]
trace_html = Div(Span("Route : ", style="color:#888; font-size:0.75em;"), *badges, style="display:flex; gap:5px; align-items:center; margin-bottom:8px; flex-wrap:wrap;")
encart_info = Div(
Span(f"🎯 {intention}", style="margin-right: 10px; color:#aaa; font-size:0.8em;"),
Span(f"🏢 {nom_fournisseur}", style="margin-right: 10px; color:#aaa; font-size:0.8em;"),
Span(f"🧠 {nom_modele}", style=f"margin-right: 10px; color:{couleur}; font-weight:bold; font-size:0.8em;"),
time_badge, style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 10px; display: flex; flex-wrap: wrap; gap: 5px;"
)
bouton_forcer = ""
if msg_id and prompt_associe and thematique != "aucun":
safe_prompt = prompt_associe.replace('"', '\\"').replace('\n', ' ')
safe_reponse = text.replace('"', '\\"').replace('\n', ' ')
valeurs_htmx = f'{{"prompt": "{safe_prompt}", "reponse": "{safe_reponse}", "theme": "{thematique}"}}'
bouton_forcer = Button("💾 Mémoriser",
hx_post="/api/forcer_rag",
hx_vals=valeurs_htmx,
hx_target=f"#badge-rag-{msg_id}",
hx_swap="outerHTML",
id=f"badge-rag-{msg_id}",
cls="msg-action-btn",
style="color: #4caf50; border-color: #4caf50;"
)
actions = Div(
bouton_forcer,
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"),
Button("🗄️ JSON", onclick="downloadResponseJSON(this)", cls="msg-action-btn", title="Télécharger en JSON"),
Button("💬 WhatsApp", onclick="shareWhatsApp(this)", cls="msg-action-btn", title="Partager sur WhatsApp"),
Button("📧 Email", onclick="shareEmail(this)", cls="msg-action-btn", title="Envoyer par courriel"),
cls="msg-actions"
)
return Div(
trace_html,
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(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")
def render_main_page(chat_id, utilisateur_nom="xavier", role="Super Admin"):
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'),
msg.get('execution_time', ''),
msg.get('trace', []),
msg.get('prompt_associe', ''),
msg.get('thematique', 'general'),
msg.get('msg_id', '')
))
if messages_ui: messages_ui.append(Script("setTimeout(renderMarkdown, 150);"))
auto_refresh_script = Script('''
let serverDown = false;
setInterval(() => {
fetch('/ping', {cache: 'no-store'})
.then(response => { if (serverDown && response.ok) { window.location.reload(); } })
.catch(() => { serverDown = true; console.log("Serveur hors ligne..."); });
}, 5000);
function checkMediaPaymentWarning() {
const mediaMode = document.querySelector('input[name="media_mode"]:checked').value;
const isPayant = document.getElementById('mode-payant-toggle').checked;
const warningDiv = document.getElementById('media-warning');
if (mediaMode === 'video' && !isPayant) {
warningDiv.style.display = 'block';
warningDiv.innerHTML = '⚠️ La génération vidéo gratuite est très limitée ou inexistante. Cochez "Payant" pour de meilleurs résultats.';
} else if (mediaMode === 'image' && !isPayant) {
warningDiv.style.display = 'block';
warningDiv.innerHTML = 'ℹ️ Mode image gratuit : Qualité standard (HuggingFace). Pour la HD, cochez "Payant".';
} else {
warningDiv.style.display = 'none';
}
}
''')
solde_perso = obtenir_solde(utilisateur_nom)
if role.lower() in ["super admin", "admin"]:
stats_or = get_openrouter_stats()
limit_rem = float(stats_or.get('limit_remaining') or 0.0)
usage_daily = float(stats_or.get('usage_daily') or 0.0)
usage_monthly = float(stats_or.get('usage_monthly') or 0.0)
badge_finance = Div(
P(f"👤 {utilisateur_nom.capitalize()} ({role})", style="color: #00e5ff; margin: 0; font-weight: bold; font-size: 0.9em;"),
P(f"💳 Solde personnel : {solde_perso:.2f} $", style="color: #00e5ff; margin: 2px 0; font-size: 0.85em; font-weight: bold;"),
P(f"🏦 Cagnotte globale : {obtenir_solde('xavier'):.2f} $", style="color: #4caf50; margin: 0; font-size: 0.8em; font-weight: bold;"),
P(f"🌐 OR Restant : {limit_rem:.4f} $", style="color: #ff9800; margin: 2px 0 0; font-size: 0.8em; font-weight: bold;"),
P(f"📊 OR Conso (Jour/Mois) : {usage_daily:.4f}$ / {usage_monthly:.4f}$", style="color: #8bc34a; margin: 0; font-size: 0.75em;"),
style="padding: 10px; background: #181818; border-radius: 6px; border: 1px solid #333;"
)
else:
badge_finance = Div(P(f"👤 {utilisateur_nom.capitalize()} ({role})", style="color: #fff; margin: 0; font-weight: bold; font-size: 0.9em;"), P(f"💳 Solde disponible : {solde_perso:.2f} $", style="color: #00e5ff; margin: 2px 0 0; font-size: 0.85em;"), style="padding: 10px; background: #181818; border-radius: 6px; border: 1px solid #333;")
return Title("Aethas38 - Core"), Body(
auto_refresh_script,
Div(
Div(
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"),
# Bouton d'accès rapide au Back Office (FO -> BO)
A("⚙️ Back Office", href="/backoffice", cls="export-btn", style="background-color: #3f51b5; color: white; border-color: #3f51b5; margin-top: 5px; text-decoration: none; text-align: center; font-weight: bold;"),
# Bouton de recharger les crédits
Button("💳 Recharger des crédits", onclick="document.getElementById('modal-recharge-paypal').style.display='flex'", cls="export-btn", style="background-color: #00e5ff; color: black; border: none; margin-top: 5px; font-weight: bold; cursor: pointer;"),
# Lien de don direct basé sur le .env
A("❤️ Faire un don", href=os.getenv("PAYPAL_DONATION_LINK", "#"), target="_blank", cls="export-btn", style="background-color: #ff4081; color: white; border-color: #ff4081; margin-top: 5px; text-decoration: none; text-align: center; font-weight: bold;"),
Button("📚 Gestion RAG", hx_get="/admin/rag_modal", hx_target="body", hx_swap="beforeend", cls="export-btn", style="background-color: #2196f3; color: white; border-color: #2196f3; margin-top: 5px; cursor: pointer;") if role.lower() in ["super admin", "admin"] else "",
A("📊 Export URSSAF (Excel)", href="/admin/export_comptable", cls="export-btn", style="background-color: #4caf50; color: white; border-color: #4caf50; margin-top: 5px; text-decoration: none; text-align: center;") if role.lower() in ["super admin", "admin"] else "",
A("🚪 Déconnexion", href="/logout", cls="export-btn", style="background-color: #8b0000; color: white; border-color: #8b0000; margin-top: 5px;"),
cls="nav-buttons", style="display: flex; flex-direction: column; gap: 10px;"
), cls="sidebar-top"
),
render_sidebar(), Div(badge_finance, cls="sidebar-footer"), cls="sidebar"
),
Div(
Div(Button("☰", onclick="toggleSidebar()", cls="hamburger-btn"), Img(src="/Logo/logo_aethas.svg", style="height: 30px;"), Div(style="width: 30px;"), cls="mobile-header"),
Div(*messages_ui, id="chat-history", cls="chat-container"),
Div(Div(Div(cls="tech-spinner"), Span("Analyse en cours... (~5s à 10s)"), cls="tech-loader-container"), id="global-loader", cls="htmx-indicator"),
Div(
Form(
Input(type="hidden", name="chat_id", value=chat_id),
Div(
Div(
Label(Input(type="radio", name="media_mode", value="texte", checked=True, onchange="checkMediaPaymentWarning()"), "📝 Texte", cls="media-btn"),
Label(Input(type="radio", name="media_mode", value="image", onchange="checkMediaPaymentWarning()"), "🎨 Image", cls="media-btn"),
Label(Input(type="radio", name="media_mode", value="video", onchange="checkMediaPaymentWarning()"), "🎬 Vidéo", cls="media-btn"),
style="display: flex; gap: 15px; justify-content: center; margin-bottom: 10px;"
),
Div(id="media-warning", style="display:none; color: #ff9800; font-size: 0.8em; text-align: center; margin-bottom: 10px;"),
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="textarea-wrapper"
),
Div(
Label(Input(type="checkbox", name="mode_education", id="mode-education-toggle", style="margin-right: 6px; accent-color: #8bc34a; cursor: pointer;"), "🎓 Éducation", style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; cursor: pointer; white-space: nowrap;"),
Label(Input(type="checkbox", name="mode_payant", id="mode-payant-toggle", style="margin-right: 6px; accent-color: #00e5ff; cursor: pointer;", onchange="checkMediaPaymentWarning()"), "👑 Payant", style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; cursor: pointer; white-space: nowrap;"),
Label(Input(type="checkbox", name="mode_multi_ia", id="mode-multi-toggle", style="margin-right: 6px; accent-color: #d97757; cursor: pointer;"), "⚡ Multi-IA", style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; cursor: pointer; white-space: nowrap;"),
cls="options-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'; checkMediaPaymentWarning();", enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;"
), cls="input-area"
), cls="main-content"
)
)
def render_admin_moderation_panel(utilisateur_cible):
return Div(
H3(f"⚖️ Tribunal d'administration - Dossier de : {utilisateur_cible}", style="color: #ff4444;"),
P("Loi applicable : Aucun utilisateur n'est dispensé du respect de la législation en vigueur."),
Form(
Input(type="hidden", name="cible", value=utilisateur_cible),
Div(Label("Action décidée :", style="font-weight: bold;"), Select(Option("Avertissement simple", value="Avertissement"), Option("Bannissement 1 jour", value="Ban 1J"), Option("Bannissement définitif et signalement PHAROS", value="Ban Definitif + Pharos"), name="decision", cls="input-field"), style="margin-bottom: 15px;"),
Div(Label("Commentaire (Preuve d'audit) :", style="font-weight: bold;"), Textarea(name="commentaire", rows="4", cls="input-field", placeholder="Raison..."), style="margin-bottom: 15px;"),
Button("Valider la décision et archiver", type="submit", cls="btn-submit", style="background-color: #ff4444; color: white; width: 100%;"), hx_post="/admin_decision", hx_target="#admin-result", style="background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #333;"
), Div(id="admin-result", style="margin-top: 15px; color: #00e5ff;"), cls="admin-panel"
)
def render_login_page(error=None):
error_msg = P(error, style="color: #ff4444; text-align: center; font-weight: bold;") if error else ""
return Title("Connexion - Aethas38"), Body(
Div(
Img(src="/Logo/logo_aethas.svg", style="max-width: 180px; margin: 0 auto 20px; display: block;"),
H2("Accès Restreint", style="text-align: center; color: #00e5ff;"),
error_msg,
Form(
Div(Label("Email :", style="color: #aaa; font-size: 0.9em;"), Input(type="email", name="email", placeholder="admin@mail.aethas38.duckdns.org", cls="input-field", required=True), style="margin-bottom: 15px;"),
Div(Label("Mot de passe :", style="color: #aaa; font-size: 0.9em;"), Input(type="password", name="password", placeholder="••••••••", cls="input-field", required=True), style="margin-bottom: 20px;"),
Button("Déverrouiller le système", type="submit", cls="btn-submit", style="width: 100%;"), method="post", action="/login",
), style="max-width: 400px; margin: 100px auto; padding: 30px; background: #1a1a1a; border-radius: 10px; border: 1px solid #333; box-shadow: 0 4px 15px rgba(0,229,255,0.1);"
), style="background-color: #0d0d0d; color: #fff; font-family: monospace; height: 100vh; display: flex; align-items: center; justify-content: center;"
)
def render_popup_paiement(cout_estime, prompt_sauvegarde, chat_id, multi_ia_state=False, edu_state=False):
edu_val = "on" if edu_state else "off"
return Div(
Div(
H3("⚠️ Crédits insuffisants", style="color: #ff4444; margin-top: 0;"),
P("Cette requête nécessite un modèle d'IA dont le coût dépasse votre solde actuel.", style="color: #ccc;"),
P(f"Coût estimé de la requête : {cout_estime} $", style="font-size: 1.1em; font-weight: bold; color: #fff; background: #333; padding: 10px; border-radius: 5px; text-align: center;"),
Div(
Button("Annuler la requête", type="button", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #555; width: 48%; font-size: 0.85em; border-radius: 8px;"),
Form(
Input(type="hidden", name="prompt", value=prompt_sauvegarde),
Input(type="hidden", name="chat_id", value=chat_id),
Input(type="hidden", name="mode_payant", value="off"),
Input(type="hidden", name="mode_multi_ia", value="off"),
Input(type="hidden", name="mode_education", value=edu_val),
Input(type="hidden", name="validation_acquise", value="true"),
Input(type="hidden", name="choix_utilisateur", value="gratuit_force"),
Button("Tenter en Gratuit (Local)", type="submit", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #00e5ff; width: 100%; color: black; font-size: 0.85em; border-radius: 8px;"),
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", style="width: 48%;"
),
style="display: flex; justify-content: space-between; margin-top: 20px; gap: 10px;"
),
style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #ff4444; width: 450px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,0.8);"
),
id="popup-paiement",
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 9999;"
)
def render_rag_upload_modal():
themes = [
"Minecraft_KubeJS", "Juridique_Loi_Francaise", "Serveur_Debian",
"Bureautique_Excel", "Regles_Aethas38", "Education_Nationale", "Connaissances_Generales"
]
options = [Option(t.replace("_", " "), value=t, style="color: black;") for t in themes]
return Div(
Div(
H3("📚 Administration RAG (Base Vectorielle)", style="color: #2196f3; margin-top: 0;"),
P("Formats supportés : PDF, MD, TXT, JS, JSON, YML, CSV", style="color: #ccc; font-size: 0.9em;"),
Form(
Div(
Label("1. Choisir la thématique :", style="font-weight: bold; color: white;"),
Select(*options, name="theme", cls="input-field", style="margin-bottom: 15px;")
),
Div(
Label("2. Sélectionner les fichiers :", style="font-weight: bold; color: white;"),
Input(type="file", name="fichiers_rag", multiple=True, required=True, cls="input-field", style="margin-bottom: 15px; background: #222; padding: 10px; border: 1px dashed #2196f3; cursor: pointer;")
),
Div(
Button("Annuler", type="button", onclick="document.getElementById('rag-modal').remove();", cls="btn-submit", style="background-color: #555; width: 48%;"),
Button("Téléverser & Indexer", type="submit", cls="btn-submit", style="background-color: #2196f3; color: white; width: 48%;"),
style="display: flex; justify-content: space-between;"
),
hx_post="/api/upload_rag",
hx_target="#rag-upload-result",
enctype="multipart/form-data"
),
Div(id="rag-upload-result", style="margin-top: 15px; text-align: center; font-weight: bold;"),
style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #2196f3; width: 500px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,0.8);"
),
id="rag-modal",
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 9999;"
)
def render_backoffice_page(utilisateur, role, tous_les_utilisateurs, stats_serveur):
sections = [
H2(f"⚙️ Paramètres du compte - {utilisateur.capitalize()} ({role})", style="color: #00e5ff;"),
Form(
Div(Label("Modifier mon pseudonyme :"), Input(type="text", name="nouveau_pseudo", value=utilisateur, cls="input-field"), style="margin-bottom: 10px;"),
Div(Label("Nouveau mot de passe :"), Input(type="password", name="nouveau_mdp", placeholder="Laisser vide si inchangé", cls="input-field"), style="margin-bottom: 15px;"),
Button("Enregistrer les modifications", type="submit", cls="btn-submit"),
method="post", action="/backoffice/update_profile", style="background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #333; margin-bottom: 20px;"
)
]
# Spécificités Admin & Super Admin (Héritage des privilèges inférieurs)
if role in ["Admin", "Super Admin"]:
lignes_utilisateurs = [Li(f"{pseudo} — Rôle : {data.get('role', 'Membre')}") for pseudo, data in tous_les_utilisateurs.items()]
sections.append(Div(
H3("👥 Gestion des utilisateurs", style="color: #4caf50;"),
Ul(*lignes_utilisateurs),
style="background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #333; margin-bottom: 20px;"
))
# Spécificités Super Admin uniquement (Supervision Système, RAID & Agent Vectoriel + Bouton de Vectorisation Globale)
if role == "Super Admin" and stats_serveur:
data_st = stats_serveur.get('data_stats', {})
sections.append(Div(
H3("🖥️ Supervision Système, RAID & Agent Vectoriel", style="color: #ff9800;"),
P(f"💾 Espace disque racine : {stats_serveur['disque_utilise_gb']} Go / {stats_serveur['disque_total_gb']} Go ({stats_serveur['pourcentage_utilise']}%)"),
(P(f"🗄️ Périphérique RAID ({data_st.get('device', '/DATA')} sur {data_st.get('mountpoint', '/DATA')}) : {data_st.get('utilise_gb', 0)} Go / {data_st.get('total_gb', 0)} Go ({data_st.get('pourcentage', 0)}% utilisés, {data_st.get('libre_gb', 0)} Go libres)") if data_st else P("🗄️ Partition RAID (/DATA) : Non montée ou non détectée")),
Div(
P("📡 État du RAID (mdstat) :", style="margin-bottom: 5px; font-weight: bold;"),
Pre(stats_serveur.get('raid_status', 'N/A'), style="font-size: 0.8em; background: #111; color: #00ffcc; padding: 10px; border-radius: 4px; overflow-x: auto; max-height: 180px; overflow-y: auto; white-space: pre-wrap; font-family: monospace; margin: 0;"),
style="margin-bottom: 15px;"
),
P(f"🤖 Agent Vectoriel (Ollama) : {stats_serveur.get('agent_status', 'N/A')}"),
Hr(style="border-color: #444; margin: 15px 0;"),
H4("⚡ Actions Globales RAG", style="color: #00e5ff; font-size: 1em;"),
Form(
Button("Forcer la vectorisation globale de tout le RAG", type="submit", cls="btn-submit", style="background-color: #e91e63; color: white; width: 100%; font-weight: bold; cursor: pointer;"),
method="post", action="/backoffice/forcer_vectorisation"
),
style="background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #333;"
))
sections.append(A("← Retour au Chat", href="/", style="display: block; margin-top: 20px; color: #00e5ff; text-decoration: none;"))
return Title("Back Office - Aethas38"), Body(
Div(*sections, style="max-width: 600px; margin: 50px auto; font-family: monospace; color: #fff;"),
style="background-color: #0d0d0d; padding: 20px;"
)