Test : Intégration bouton PayPal Sandbox et activation du Serveur Mail
Build and Push Docker Image / build-and-push (push) Successful in 1m45s

This commit is contained in:
xavier committed 2026-08-23 09:47:14 +02:00
1 parent 632c23069a
commit 2ea18701b5
12 files changed
+482 -183

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ Echange GEMINY.txt
aethas_logs.txt
Important/
ARCHIVAGE ECHANGE GEMINY.txt
test financier/
+33 -44
View File
@@ -1,85 +1,74 @@
# Aethas38 - Multi-IA Orchestrateur (Minecraft-Tech Edition)
Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécurisé et facturable.
<!-- MAJ 18/08/2026 : URL de production masquée pour raisons de cybersécurité (Information Disclosure) -->
**Architecture réseau :** Déploiement via Docker derrière Nginx Proxy Manager (NPM) sur `https://ia.votre-domaine.com`.
<!-- MAJ 23/08/2026 : URL de production masquée pour raisons de cybersécurité (Information Disclosure) -->
**Architecture réseau :** Déploiement via Docker derrière Nginx Proxy Manager (NPM) sur `https://ia.votre-domaine.com`. Timeout proxy ajusté à 300s pour le calcul lourd.
## 📜 Licence & Droits d'Utilisation (EULA)
Ce projet est distribué sous une licence "Source Available" propriétaire (EULA) rédigée par Xavier Fernandez (Aethas38).
* **Modifications & Partage :** Les utilisateurs sont autorisés à modifier et améliorer le code, à condition de partager ces améliorations avec la communauté.
* **Restrictions strictes :** Il est **strictement interdit** de modifier ou supprimer les crédits de l'auteur originel, de retirer le module de dons dédié au créateur, ou de désactiver la télémétrie de sécurité (notification d'installation avec IP/Nom admin envoyée à `admin@mail.aethas38.duckdns.org`). L'installation requiert l'acceptation explicite de ces termes.
* **Donations obligatoires :** Le système intègre nativement deux modules de dons. L'un est modifiable par l'hébergeur du projet. L'autre (marqué "Soutenir le créateur de ce projet") est inaltérable et relié directement au créateur originel (Xavier Fernandez / Aethas38). Le retrait de ce module est une violation stricte de cette licence.
* **Restrictions strictes :** Il est **strictement interdit** de modifier ou supprimer les crédits de l'auteur originel, de retirer le module de dons dédié au créateur, ou de désactiver la télémétrie de sécurité. L'installation requiert l'acceptation explicite de ces termes.
* **Donations obligatoires :** Le système intègre nativement deux modules de dons. L'un est modifiable par l'hébergeur du projet. L'autre (marqué "Soutenir le créateur de ce projet") est inaltérable et relié directement au créateur originel.
## ⚠️ Pré-requis Matériels (Important)
Pour fonctionner de manière optimale, l'Orchestrateur Local nécessite au minimum **une carte graphique avec 4 Go de VRAM dédiée**. Il est possible de configurer l'application pour utiliser le CPU, mais les performances seront considérablement réduites et les temps de réponse allongés. **L'auteur décline toute responsabilité en cas de surcharge matérielle liée à une utilisation CPU intensive.**
Pour fonctionner de manière optimale, l'Orchestrateur Local nécessite au minimum **une carte graphique avec 4 Go de VRAM dédiée** (ex: GTX 1650). Il est possible de configurer l'application pour utiliser le CPU, mais les performances seront considérablement réduites. **L'auteur décline toute responsabilité en cas de surcharge matérielle liée à une utilisation CPU intensive.**
## 📋 To-Do List (Roadmap)
### Phase 1 & 2 : Infrastructure & Backend de Base ✅
- [x] Installation Docker, Nginx (NPM), Pare-feu, Gitea/GitHub
- [x] Serveur Local Ollama (Qwen 3B) + FastHTML (Adapté aux processeurs limités type AMD Phenom)
- [x] Serveur Local Ollama (Abliterated 3B) + FastHTML
- [x] Routeur Multi-IA (OpenRouter, Gemini, Local) avec système de cascade et directives d'aiguillage strictes
- [x] Continuité de service : IA de secours automatique
- [x] Continuité de service : IA de secours automatique et Timeout à 300s.
### Phase 3 : Refonte UI & Expérience Utilisateur ✅
- [x] Thème "Minecraft-Tech" (Inspiré serveurs/redstone) compatible Mobile (Responsive)
- [x] Historique (Sidebar) avec Renommage, Épinglage, Suppression et Navigation isolée par session
- [x] Zone de saisie intelligente (Textarea auto-adaptatif, Drag & Drop avec pills/miniatures, Collage multi-fichiers jusqu'à 10)
- [x] Zone de saisie intelligente (Drag & Drop, Collage multi-fichiers)
- [x] Export modulaire et propre (PDF, DOC, TXT, JSON, ZIP)
- [x] Boutons de partage direct (WhatsApp, Email)
- [x] Indicateur d'avancement dynamique (Loader Néon) pendant le traitement des IA
- [x] Architecture modulaire propre (Dossier `modules/`)
- [x] Boutons de partage direct (WhatsApp, Email) et Déconnexion.
- [x] Indicateur d'avancement dynamique (Loader Néon) pendant le traitement des IA.
### Phase 4 : Gestion des Utilisateurs & Sécurité Légale
- [ ] Mettre en place une bibliothèque logicielle autonome sur le serveur (création PDF, DOCX, etc.).
- [x] Routage automatique (Gratuit vs Payant) sélectionné par l'utilisateur.
- [X] Système de connexion sécurisé avec configuration du premier lancement (DOB depuis 1900, Email, Clés API). L'installateur a obligatoirement le rôle "Dieu tout puissant" sur son se (Admin global = Super Admin).
- [x] Routage automatique (Gratuit vs Payant) sélectionné par l'utilisateur avec solution de repli.
- [x] Système de cryptage de grade militaire (Fernet) et de Hashing (Bcrypt) mis en place.
- [X] Sécurité Légère : Fail2Ban, protection Bruteforce et DDoS. **Blocage total des accès VPN sauf adresses locales (192.168.xxx.xxx & 10.xxx.xxx.xxx)**.
- [X] Conformité légale (RGPD) : Journalisation stricte des IP par client conservée 1 an.
- [X] Sécurité Légère : Fail2Ban, protection Bruteforce et DDoS. **Blocage total des accès VPN**. Ping silencieux.
- [X] Conformité légale (LCEN/RGPD) : Création de dossiers utilisateurs isolés. Journalisation stricte des IP et de l'activité conservée 365 jours. Purge automatique des comptes inactifs > 1 an.
- [ ] Multilinguisme : Adaptation automatique de la langue du site via la géolocalisation de l'IP.
### Phase 5 : Sécurité Financière & Facturation Exacte 💰
- [x] Pré-calcul local du nombre de tokens (via `tiktoken`) pour estimer le coût avant envoi (Module `billing_manager.py`). Bloquage automatique si coût > 0.
- [x] Synchronisation dynamique du catalogue OpenRouter et calcul de facturation au token réel (`tokens_in` / `tokens_out` via `response.usage`).
- [x] Interface UI : Affichage du Pop-Up OBLIGATOIRE de validation du coût pour TOUS les utilisateurs.
- [ ] Cryptage absolu de toutes les données sensibles et bancaires.
- [x] Pré-calcul local du nombre de tokens (via `tiktoken`) pour estimer le coût avant envoi.
- [x] Catalogue tarifaire dynamique (mise à jour auto) supprimant les coûts écrits en dur.
- [x] Affichage du Pop-Up OBLIGATOIRE de validation du coût pour TOUS les utilisateurs.
- [x] Télémétrie Admin : Suivi en temps réel de la consommation (Jour/Mois) et du solde restant sur l'API distante.
- [ ] Module de conversion des devises en temps réel (Taux du jour) pour un affichage transparent.
- [ ] **Utilisateurs Privilégiés (Zéro Marge) :** A définir par Admin/Super Admin. Autorisation de virements manuels pour la cagnotte.
- [x] **Marge Commerciale :** Majoration automatique via la formule `Prix_Facture_HT = Cout_API_OpenRouter_HT / x.xx` (soit x% de marge) implémentée dans le gestionnaire financier.
- [ ] Export comptable Excel (TCD) par utilisateur via Pandas/Openpyxl et archivage PDF automatisé.
- [ ] **Utilisateurs Privilégiés (Zéro Marge) :** A définir par Admin/Super Admin.
- [x] **Marge Commerciale :** Majoration automatique via la formule `Prix_Facture_HT = Cout_API_OpenRouter_HT / x.xx`.
- [ ] Export comptable Excel (TCD) par utilisateur via Pandas/Openpyxl (Optimisé pour les déclarations URSSAF).
### Phase 6 : Nouvelles Capacités IA (Spécialisations & Modèles) 🤖
*Note : L'infrastructure bascule sur un catalogue dynamique via `modules/model_updater.py` pour pallier les décommissionnements constants des fournisseurs tiers (MAJ toutes les 2h).*
*L'infrastructure bascule sur un catalogue dynamique mis à jour toutes les 2h pour pallier les décommissionnements.*
| Domaine de tâche | Modèle / Moteur | Fournisseur / Hébergement | Rôle principal |
| :--- | :--- | :--- | :--- |
| **Orchestration & Tri** | Llama 3.2 3B Instruct Abliterated | Local (GTX 1650 / Ollama) | Analyse l'intention, juge de sécurité sans censure et route vers la bonne IA |
| **Bureautique & Code** | à définir par le Super Admin | Programmation, scripts, macros VBA, mathématiques |
| **Images HD / Fichiers** | à définir par le Super Admin | Analyse d'images/vidéos et documents joints |
| **Génération Multimédia** | à définir par le Super Admin | Création d'images et vidéos (Text-to-Image / Text-to-Video) |
| **Orchestration & Tri** | Llama 3.2 3B Instruct Abliterated | Local (GTX 1650 4Go) | Analyse l'intention, juge de sécurité sans censure et route vers la bonne IA |
| **Bureautique & Code** | Catalogue Dynamique (ex: Sonnet 3.5) | OpenRouter | Programmation KubeJS, scripts, mathématiques |
| **Images HD / Fichiers** | Gemini / Vision | Google API | Analyse d'images/vidéos et documents joints |
### Phase 7 & 8 : Maintenance & Déploiement
- [ ] Script de surveillance de l'espace disque du serveur (Beszel / Psutil) réservé à l'Admin.
- [ ] Sauvegarde automatique : Compression du site toutes les 6 heures vers un GDrive (dossier à définir lors de l'installation principale et modifiable dans le temps), 4 versions conservées.
- [x] Hébergement sur Gitea/Github des versions Docker. Configuration du fichier `docker-compose.yml` et `Dockerfile` réalisée.
- [x] Script Bash de déploiement automatique via `script_perso.sh`. A éprouver
- [ ] Création d'un Wiki détaillé multilingue pour l'installation personnelle.
- [ ] Script de surveillance de l'espace disque du serveur réservé à l'Admin.
- [ ] File d'attente asynchrone (Queue List) et Timer en temps réel pour gérer la charge serveur.
- [ ] Sauvegarde automatique : Compression du site toutes les 6 heures vers un GDrive.
- [x] Hébergement Gitea Docker. Injection des clés via `env_file`. Volumes persistants configurés.
### Phase 9 : Modération Automatisée & Conformité Pénale 🚨
- [x] Système hiérarchique strict : `Super Admin` (Installateur), `Admin` (Modération/VIP), `VIP` (Grade de filiation sans privilèges d'administration) et Utilisateurs.
- [ ] Validation obligatoire des CGV/CGU incluant une clause de "Ban sans remboursement et signalement aux autorités" en cas de contenu manifestement illicite.
- [x] Modération préventive "Anti-Zèle" : Filtrage par IA Locale des intentions illégales bloquant la requête vers OpenRouter tout en tolérant la fiction et les demandes juridiques.
- [x] Système d'Avertissements (Strikes) automatisé avec alerte E-mail aux Admins.
- [x] Protection anti-abus Admin : Si un Admin enfreint la loi, il est exclu de l'alerte ; seuls les autres Admins statuent.
- [x] Archivage légal sécurisé : Stockage pérenne des logs, preuves d'infractions et **décisions d'audit manuscrites des Admins** dans une arborescence dédiée.
- [x] Système hiérarchique : `Super Admin`, `Admin`, `VIP` (Famille) et `Standard`. Quotas de ressources (60% CPU/RAM) applicables selon le rang.
- [x] Modération préventive "Anti-Zèle" : Filtrage par IA Locale tolérant la fiction et les jeux vidéo.
- [x] Système d'Avertissements (Strikes) automatisé et preuve d'audit manuscrite.
### Phase 10 : Base de Connaissances Intelligente (RAG) 📚
- [x] Initialisation de l'arborescence dynamique (`modules/rag_manager.py`) sur le RAID (`/DATA/AppData/MULTI-IA-AETHAS38/RAG/`).
- [x] Évolution de l'Orchestrateur Local pour la sélection automatique du dossier sémantique pertinent ("minecraft", "droit", etc.).
- [x] Initialisation de l'arborescence dynamique (`RAG/`) sur le stockage RAID.
- [x] Injection asynchrone des documents Markdown et Texte directement dans la fenêtre de contexte des LLMs.
- [x] Auto-Apprentissage : Le système génère automatiquement des fichiers `.md` de son propre savoir après validation.
- [ ] Interface UI : Ajout d'une vue Administrateur pour téléverser de nouveaux documents RAG directement depuis le navigateur.
## 📷 Vidéosurveillance & Détection Intelligente
*(Voir configuration Frigate locale et Google Coral TPU existante)*
- [ ] Interface UI : Ajout d'une vue Administrateur pour téléverser de nouveaux documents RAG.
+48
View File
@@ -11602,5 +11602,53 @@
"action": "Acc\u00e8s \u00e0 /chat",
"date": "2026-08-22 01:59:10",
"timestamp": 1787356750.8306415
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /",
"date": "2026-08-23 05:50:47",
"timestamp": 1787457047.035862
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /",
"date": "2026-08-23 05:50:50",
"timestamp": 1787457050.7357597
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /",
"date": "2026-08-23 05:58:37",
"timestamp": 1787457517.9751449
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /chat",
"date": "2026-08-23 06:28:43",
"timestamp": 1787459323.8286097
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /",
"date": "2026-08-23 08:26:26",
"timestamp": 1787466386.609821
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /",
"date": "2026-08-23 09:28:20",
"timestamp": 1787470100.2071035
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /",
"date": "2026-08-23 09:30:55",
"timestamp": 1787470255.7610068
},
{
"ip": "127.0.0.1",
"action": "Acc\u00e8s \u00e0 /",
"date": "2026-08-23 09:39:21",
"timestamp": 1787470761.3066163
}
]
+15 -10
View File
@@ -9,8 +9,6 @@ load_dotenv()
api_key_openrouter = os.getenv("OPENROUTER_API_KEY") or "sk-or-v1-cle-non-definie"
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key_openrouter)
# Chemin ABSOLU strict
CATALOGUE_FILE = "/DATA/AppData/MULTI-IA-AETHAS38/catalogue_dynamique.json"
def verifier_cle_api():
@@ -19,8 +17,10 @@ def verifier_cle_api():
raise Exception("Clé OPENROUTER_API_KEY absente dans le fichier .env.")
return cle
def obtenir_modele_dynamique(premium: bool):
"""Charge le catalogue dynamiquement selon l'état Premium ou Gratuit."""
def obtenir_modele_dynamique(premium: bool, prompt: str = ""):
"""Charge le catalogue dynamiquement et adapte le modèle si la règle Éducation est détectée."""
est_education = "RÈGLE ÉDUCATION NATIONALE" in prompt
try:
if os.path.exists(CATALOGUE_FILE):
with open(CATALOGUE_FILE, "r", encoding="utf-8") as f:
@@ -30,20 +30,25 @@ def obtenir_modele_dynamique(premium: bool):
claude = [m for m in paid_models if "claude-3.5-sonnet" in m.lower()]
return claude[0] if claude else (paid_models[0] if paid_models else "anthropic/claude-3.5-sonnet")
else:
# En mode gratuit + Education, Mistral est le plus pointu en syntaxe française
if est_education:
return "mistralai/mistral-small-24b-instruct-2501:free"
free_models = data.get("free_models", [])
priorites = [m for m in free_models if "gemma" in m.lower() or "nemotron" in m.lower()]
return priorites[0] if priorites else (free_models[0] if free_models else "google/gemma-2-9b-it:free")
except Exception:
pass
return "anthropic/claude-3.5-sonnet" if premium else "google/gemma-2-9b-it:free"
if premium: return "anthropic/claude-3.5-sonnet"
return "mistralai/mistral-small-24b-instruct-2501:free" if est_education else "google/gemma-2-9b-it:free"
def interroger_openrouter(prompt: str, type_modele: str = "openrouter_chat", premium: bool = False, max_retries: int = 2, chat_id: str = None):
verifier_cle_api()
nom_modele_api = obtenir_modele_dynamique(premium)
nom_modele_api = obtenir_modele_dynamique(premium, prompt) # On passe le prompt pour détecter le marqueur
nom_affichage = f"OpenRouter ({nom_modele_api.split('/')[-1]})"
couleur = "#d97757" if premium else "#4a90e2"
# Injection du Tracker Financier OpenRouter
extra_body_data = {"session_id": chat_id} if chat_id else {}
for tentative in range(max_retries):
@@ -53,7 +58,7 @@ def interroger_openrouter(prompt: str, type_modele: str = "openrouter_chat", pre
messages=[{"role": "user", "content": prompt}],
extra_headers={"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "Aethas38 Multi-IA"},
extra_body=extra_body_data,
timeout=90.0 # TIMEOUT MONTE A 90s
timeout=90.0
)
texte = response.choices[0].message.content
usage = response.usage
@@ -78,7 +83,7 @@ def interroger_openrouter_multi(prompt: str, type_modele: str = "openrouter_code
verifier_cle_api()
extra_body_data = {"session_id": chat_id} if chat_id else {}
modeles = [obtenir_modele_dynamique(premium)]
modeles = [obtenir_modele_dynamique(premium, prompt)]
if premium:
modeles.append("openai/gpt-4o-mini")
else:
@@ -92,7 +97,7 @@ def interroger_openrouter_multi(prompt: str, type_modele: str = "openrouter_code
messages=[{"role": "user", "content": prompt}],
extra_headers={"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "Aethas38 Multi-IA"},
extra_body=extra_body_data,
timeout=90.0 # TIMEOUT MONTE A 90s
timeout=90.0
)
contenu = res.choices[0].message.content
if contenu:
+67 -11
View File
@@ -1,6 +1,7 @@
import os
import json
import time
import requests
from datetime import datetime
import tiktoken
from modules.logger import log_event
@@ -10,19 +11,50 @@ BASE_DIR = "/DATA/AppData/MULTI-IA-AETHAS38"
os.makedirs(BASE_DIR, exist_ok=True)
BALANCES_FILE = os.path.join(BASE_DIR, "aethas_balances.json")
AUDIT_FILE = os.path.join(BASE_DIR, "aethas_financial_audit.json")
TARIFS_OPENROUTER = {
"meta-llama/llama-3.1-8b-instruct:free": 0.0,
"qwen/qwen-2.5-coder-32b-instruct:free": 0.0,
"mistralai/mistral-small-24b-instruct-2501:free": 0.0,
"openai/gpt-4o-mini": 0.0000015,
"anthropic/claude-3.5-sonnet": 0.000015,
"mistralai/mistral-large-2411": 0.000006,
"google/gemini-2.5-pro": 0.000005
}
TARIFS_FILE = os.path.join(BASE_DIR, "tarifs_openrouter.json")
VIP_ADMIN_USERS = ["xavier", "céline", "celine", "anaëlle", "anaelle", "renan", "audric"]
def obtenir_tarifs_dynamiques() -> dict:
"""Récupère les tarifs via API toutes les 2 heures maximum pour éviter le hardcoding."""
maintenant = time.time()
if os.path.exists(TARIFS_FILE):
if maintenant - os.path.getmtime(TARIFS_FILE) < 7200:
try:
with open(TARIFS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
tarifs = {}
try:
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10)
if resp.status_code == 200:
data = resp.json().get("data", [])
for m in data:
pricing = m.get("pricing", {})
p_prompt = float(pricing.get("prompt", 0.0))
p_comp = float(pricing.get("completion", 0.0))
# On sécurise l'estimation en prenant la valeur la plus haute entre tokens in et out
tarifs[m["id"]] = max(p_prompt, p_comp)
with open(TARIFS_FILE, "w", encoding="utf-8") as f:
json.dump(tarifs, f, indent=4)
log_event("INFO", "BILLING", "Tarifs OpenRouter mis à jour dynamiquement.")
return tarifs
except Exception as e:
log_event("ERROR", "BILLING", f"Échec récupération des tarifs : {e}")
# Repli sur le cache si l'API est injoignable
if os.path.exists(TARIFS_FILE):
try:
with open(TARIFS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def charger_soldes() -> dict:
if os.path.exists(BALANCES_FILE):
try:
@@ -71,7 +103,8 @@ def compter_tokens(texte: str, modele: str = "cl100k_base") -> int:
def estimer_cout_facture(prompt: str, modele_id: str, rang_utilisateur: str = "standard", multi_ia: bool = False) -> tuple[float, float, int]:
tokens = compter_tokens(prompt)
cout_unitaire = TARIFS_OPENROUTER.get(modele_id, 0.0)
tarifs = obtenir_tarifs_dynamiques()
cout_unitaire = tarifs.get(modele_id, 0.0)
cout_brut = tokens * cout_unitaire
if multi_ia and cout_brut > 0:
@@ -145,3 +178,26 @@ def generer_dossier_litige(utilisateur_nom: str, est_admin: bool = False) -> lis
"montant_facture_ttc": item.get("prix_facture_client")
})
return dossier
def calculer_prix_recharge_ttc(montant_credits_usd: float, methode: str = "paypal") -> float:
"""
Calcule le coût final pour l'utilisateur qui souhaite acheter une recharge de crédits.
Le coût final répercute les frais bancaires pour que l'entreprise soit à l'équilibre.
Frais estimés (Variables selon devises, basés sur des profils Sandbox/Standard) :
- PayPal : 2.9% + 0.35$ fixes par transaction
- Revolut Business : 1.0% + 0.20$ fixes par transaction en ligne
Formule d'absorption des frais : Montant_Final = (Credits + Frais_Fixes) / (1 - Pourcentage_Frais)
"""
if methode.lower() == "paypal":
frais_fixes = 0.35
frais_variable = 0.029
elif methode.lower() == "revolut":
frais_fixes = 0.20
frais_variable = 0.010
else:
return montant_credits_usd # Aucune méthode bancaire spécifiée
montant_a_payer = (montant_credits_usd + frais_fixes) / (1 - frais_variable)
return round(montant_a_payer, 2)
+52 -38
View File
@@ -1,6 +1,10 @@
import os
import json
from datetime import date
import uuid
import time
import re
import psutil
from fasthtml.common import *
from modules.database import load_history, save_history
from modules.scraper import rechercher_sur_google, extraire_contenu_urls
@@ -13,10 +17,27 @@ from modules.billing_manager import verifier_et_valider_budget, obtenir_solde
from modules.rag_manager import rechercher_contexte_rag, auto_apprendre_rag
from modules.moderator import analyser_intention_illicite, appliquer_sanction_et_archiver
def check_and_update_resource_quota(utilisateur_nom, role_utilisateur):
quota_file = "/DATA/AppData/MULTI-IA-AETHAS38/Utilisateurs/quotas_ressources.json"
today = str(date.today())
limites = {"super admin": 10, "admin": 10, "vip": 5, "famille": 5, "standard": 3}
limite_max = limites.get(role_utilisateur.lower(), 3)
try:
if os.path.exists(quota_file):
with open(quota_file, "r") as f: data = json.load(f)
else: data = {}
except: data = {}
if data.get("date") != today: data = {"date": today, "users": {}}
user_uses = data["users"].get(utilisateur_nom, 0)
if user_uses >= limite_max: return False
data["users"][utilisateur_nom] = user_uses + 1
os.makedirs(os.path.dirname(quota_file), exist_ok=True)
with open(quota_file, "w") as f: json.dump(data, f)
return True
async def handle_chat_request(request, session):
start_time = time.time()
try:
# Psutil et la gestion de surcharge retirés comme demandé
form = await request.form()
prompt = form.get("prompt", "")
chat_id = form.get("chat_id", str(uuid.uuid4()))
@@ -24,6 +45,7 @@ async def handle_chat_request(request, session):
mode_payant = form.get("mode_payant") == "on"
mode_multi_ia = form.get("mode_multi_ia") == "on"
mode_education = form.get("mode_education") == "on" # INTERCEPTION DU MODE EDU
validation_acquise = form.get("validation_acquise") == "true"
choix_utilisateur = form.get("choix_utilisateur", "aucun")
@@ -39,10 +61,16 @@ async def handle_chat_request(request, session):
role_utilisateur = "Super Admin" if utilisateur_nom.lower() == "xavier" else ("VIP" if utilisateur_nom.lower() in ["céline", "anaëlle", "renan", "audric"] else "standard")
ip_client = request.client.host if request.client else "Inconnue"
cpu_usage = psutil.cpu_percent(interval=0.1)
ram_usage = psutil.virtual_memory().percent
if cpu_usage > 60.0 or ram_usage > 60.0:
if not check_and_update_resource_quota(utilisateur_nom, role_utilisateur):
return Div(H3("⚠️ Limite de requêtes atteintes", style="color:#ff9800;"), P(f"Serveur très sollicité (>60%). Quota épuisé."), style="background:#333; padding:15px; border-radius:10px; border: 1px solid #ff9800;")
est_illicite, raison = analyser_intention_illicite(prompt)
if est_illicite:
sanction = appliquer_sanction_et_archiver(utilisateur_nom, prompt, raison, ip_client)
return Div(H3("⚠️ Avertissement de Sécurité"), P("Votre requête enfreint les conditions d'utilisation de la plateforme."), P(f"Sanction : {sanction}"), style="color:red; background:#300; padding:15px; border-radius:10px;")
return Div(H3("⚠️ Avertissement de Sécurité"), P(f"Sanction : {sanction}"), style="color:red; background:#300; padding:15px; border-radius:10px;")
file_summary = []
file_names = []
@@ -53,24 +81,14 @@ async def handle_chat_request(request, session):
file_summary.append(f"\n--- Fichier : {f.filename} ---\n{content.decode('utf-8', errors='ignore')}")
liste_fichiers_str = ", ".join(file_names) if file_names else "Aucun fichier"
log_mode = "PAYANT 👑" if mode_payant else "GRATUIT 🆓"
log_event("INFO", "SYSTEM", f"Requête reçue [{log_mode}]. Fichiers joints : {liste_fichiers_str}")
sec_prompt = f"""Tu es l'Orchestrateur système. Analyse la requête et choisis le meilleur fournisseur IA :
- "gemini" : (OBLIGATOIRE si fichiers joints), rédaction complexe, comparaisons.
- "openrouter_chat" : requêtes générales, texte rapide, traduction.
- "openrouter_code" : programmation, Minecraft/KubeJS, scripts, mathématiques.
- "openrouter_droit" : droit français, CGV, CGU, RGPD.
- "local" : salutations basiques, questions simples.
Tu dois aussi déterminer si la requête nécessite de lire la documentation interne (RAG). Thèmes : "minecraft", "droit", "serveur", "regles" ou "aucun".
Renvoie STRICTEMENT un JSON : {{"intention": "Résumé...", "fournisseur": "gemini|openrouter_chat|openrouter_code|openrouter_droit|local", "theme_rag": "minecraft|droit|serveur|regles|aucun"}}
Requête : {prompt}"""
log_event("INFO", "SYSTEM", f"Requête reçue (Edu: {mode_education} / Payant: {mode_payant})")
sec_prompt = f"""Tu es l'Orchestrateur système. Analyse la requête et choisis le meilleur fournisseur IA..."""
try:
texte_routeur, _, _, _ = interroger_local_ollama(sec_prompt)
decision = nettoyer_decision_router(texte_routeur)
except Exception:
decision = {"intention": "Analyse de secours", "fournisseur": "openrouter_code" if "minecraft" in prompt.lower() else "openrouter_chat", "theme_rag": "aucun"}
except:
decision = {"intention": "Analyse", "fournisseur": "openrouter_chat", "theme_rag": "aucun"}
intention = decision.get("intention", "Analyse")
fournisseur = decision.get("fournisseur", "openrouter_chat").lower()
@@ -78,28 +96,32 @@ async def handle_chat_request(request, session):
if mode_payant or mode_multi_ia:
fournisseur = "openrouter_code"
intention += " (Forcé API Premium/Multi via UI)"
intention += " (API Premium/Multi)"
if file_names:
fournisseur = "gemini"
intention += " (Forcé Gemini via Sécurité Fichiers)"
# --- LOGIQUE EDUCATION NATIONALE ---
if mode_education:
fournisseur = "openrouter_chat"
thematique_rag = "Education_Nationale"
intention = "Éducation Nationale " + ("(Payant)" if mode_payant else "(IA Gratuite)")
# Marqueur secret pour ai_openrouter.py
instruction_edu = "[SYSTÈME] RÈGLE ÉDUCATION NATIONALE : Agis comme un expert de l'Éducation Nationale française. Pédagogie stricte, respect du B.O.\n\n"
prompt = instruction_edu + prompt
contexte_rag = ""
if thematique_rag != "aucun":
contexte_rag = rechercher_contexte_rag(prompt, categorie=thematique_rag)
urls = re.findall(r"https?://[^\s]+", prompt)
mots_cles = ["cherche", "recherche", "sur internet", "sur le web", "sur le net", "trouve", "site"]
if any(m in prompt.lower() for m in mots_cles):
if any(m in prompt.lower() for m in ["cherche", "recherche", "sur le web"]):
urls.extend(rechercher_sur_google(prompt))
web_text = extraire_contenu_urls(list(set(urls))) if urls else ""
full_prompt = prompt + ("\n" + "\n".join(file_summary) if file_summary else "")
if contexte_rag:
full_prompt += f"\n\n[Contexte Documentaire RAG (Base de connaissances interne) :\n{contexte_rag}]"
if web_text:
full_prompt += f"\n\n[Contexte web :\n{web_text}]"
if contexte_rag: full_prompt += f"\n\n[Contexte RAG :\n{contexte_rag}]"
if web_text: full_prompt += f"\n\n[Contexte web :\n{web_text}]"
if "openrouter" in fournisseur:
modele_estime = "anthropic/claude-3.5-sonnet" if mode_payant else "cohere/north-mini-code:free"
@@ -108,19 +130,16 @@ async def handle_chat_request(request, session):
from modules.billing_manager import estimer_cout_facture
_, prix_estime, _ = estimer_cout_facture(full_prompt, modele_estime, rang_utilisateur=role_utilisateur, multi_ia=mode_multi_ia)
if prix_estime > 0:
return render_popup_paiement(prix_estime, prompt, chat_id, mode_multi_ia)
return render_popup_paiement(prix_estime, prompt, chat_id, mode_multi_ia, mode_education)
# Empêche un utilisateur classique avec un solde à zéro de consommer du Premium
if mode_payant and role_utilisateur.lower() not in ["super admin", "admin", "vip", "famille"]:
from modules.billing_manager import estimer_cout_facture
_, prix_estime, _ = estimer_cout_facture(full_prompt, modele_estime, rang_utilisateur=role_utilisateur, multi_ia=mode_multi_ia)
if prix_estime > obtenir_solde(utilisateur_nom):
return Div(H3("❌ Solde Insuffisant", style="color:red;"), P("Veuillez recharger votre compte."), style="background:#300; padding:15px; border-radius:10px;")
return Div(H3("❌ Solde Insuffisant", style="color:red;"), P("Rechargez votre compte."), style="background:#300; padding:15px;")
# CRUCIAL : Transmission du chat_id à l'Engine
reponse, couleur, fournisseur_nom, modele = executer_requete_ia(fournisseur, full_prompt, mode_payant=mode_payant, multi_ia=mode_multi_ia, chat_id=chat_id)
# Le paiement véritable s'exécute APRÈS la réponse de l'IA OpenRouter
if "openrouter" in fournisseur_nom.lower() and mode_payant:
verifier_et_valider_budget(full_prompt, modele, utilisateur_nom=utilisateur_nom, rang_utilisateur=role_utilisateur, multi_ia=mode_multi_ia)
@@ -131,20 +150,15 @@ async def handle_chat_request(request, session):
history[chat_id] = {"title": (prompt[:30] if prompt else "Discussion") + "...", "pinned": False, "timestamp": time.time(), "messages": []}
execution_time = round(time.time() - start_time, 1)
display_msg = prompt
if file_names:
display_msg += f"\n\n📎 **Fichiers joints :** {', '.join(file_names)}\n> *🗑️ Note : Fichiers traités de manière éphémère en mémoire vive.*"
display_msg = prompt.replace("[SYSTÈME] RÈGLE ÉDUCATION NATIONALE : Agis comme un expert de l'Éducation Nationale française. Pédagogie stricte, respect du B.O.\n\n", "")
if file_names: display_msg += f"\n\n📎 **Fichiers joints :** {', '.join(file_names)}"
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, "execution_time": execution_time})
save_history(history)
log_event("INFO", fournisseur_nom, f"Réponse générée en {execution_time}s.")
return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur, execution_time), Script("renderMarkdown();"), render_sidebar()
except Exception as e:
if "SOLDE_INSUFFISANT" in str(e):
details = str(e).split("|")
return Div(H3("❌ Solde Insuffisant", style="color:red;"), P(f"Cette requête coûte {details[1]} $, mais il ne vous reste que {details[2]} $."), P("Veuillez recharger votre compte ou désactiver le mode Payant."), style="background:#300; padding:15px; border-radius:10px; border: 1px solid red;")
log_event("CRITICAL", "SYSTEM", f"Erreur dans handle_chat_request : {e}")
log_event("CRITICAL", "SYSTEM", f"Erreur : {e}")
return Div(f"Erreur système : {e}", style="color:red; background:#300; padding:15px; border-radius:10px;")
+6 -12
View File
@@ -2,29 +2,23 @@ import os
import json
import time
# FORCAGE DU CHEMIN ABSOLU
BASE_DIR = "/DATA/AppData/MULTI-IA-AETHAS38"
os.makedirs(BASE_DIR, exist_ok=True)
HISTORY_FILE = os.path.join(BASE_DIR, "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)
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)
with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump(history, f, indent=4)
def nettoyer_vieilles_discussions():
history = load_history()
now = time.time()
un_an_en_secondes = 365 * 24 * 60 * 60
a_supprimer = [cid for cid, data in history.items() if not data.get('pinned', False) and now - data.get('timestamp', now) > un_an_en_secondes]
un_an = 365 * 24 * 60 * 60
a_supprimer = [cid for cid, data in history.items() if not data.get('pinned', False) and now - data.get('timestamp', now) > un_an]
if a_supprimer:
for cid in a_supprimer:
del history[cid]
for cid in a_supprimer: del history[cid]
save_history(history)
print(f"🧹 AETHAS CORE : Nettoyage auto terminé. {len(a_supprimer)} discussion(s) supprimée(s).")
+45
View File
@@ -0,0 +1,45 @@
import os
import requests
import xml.etree.ElementTree as ET
from datetime import datetime
from modules.logger import log_event
RAG_DIR = "/DATA/AppData/MULTI-IA-AETHAS38/RAG/Education_Nationale"
def maj_base_education():
"""Scrape le flux RSS du Bulletin Officiel pour maintenir la base documentaire à jour."""
os.makedirs(RAG_DIR, exist_ok=True)
url_rss = "https://www.education.gouv.fr/bo/rss.xml"
try:
response = requests.get(url_rss, timeout=15)
if response.status_code == 200:
root = ET.fromstring(response.content)
# Formatage pour une ingestion parfaite par le RAG
contenu_md = f"# Veille Pédagogique - Bulletin Officiel (B.O.)\n"
contenu_md += f"*Date de mise à jour RAG : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n"
# On extrait les 20 dernières circulaires/textes de loi
for item in root.findall('.//item')[:20]:
titre = item.find('title').text if item.find('title') is not None else "Document sans titre"
lien = item.find('link').text if item.find('link') is not None else "Aucun lien"
desc = item.find('description').text if item.find('description') is not None else "Pas de résumé disponible."
contenu_md += f"## {titre}\n"
contenu_md += f"**Source officielle :** {lien}\n"
contenu_md += f"> {desc}\n\n---\n"
fichier_bo = os.path.join(RAG_DIR, "bulletin_officiel_recent.md")
with open(fichier_bo, "w", encoding="utf-8") as f:
f.write(contenu_md)
log_event("INFO", "SCRAPER_EDU", "Base RAG Éducation (B.O.) mise à jour avec succès.")
else:
log_event("WARNING", "SCRAPER_EDU", f"Le site du ministère a retourné le code {response.status_code}.")
except Exception as e:
log_event("ERROR", "SCRAPER_EDU", f"Erreur lors de la MAJ de la base Éducation : {e}")
if __name__ == "__main__":
# Permet de tester le script directement en ligne de commande : python modules/eduscol_scraper.py
maj_base_education()
-11
View File
@@ -3,7 +3,6 @@ import logging
from logging.handlers import TimedRotatingFileHandler
from datetime import datetime
# Chemin absolu obligatoire pour correspondre au volume Docker
LOG_DIR = "/DATA/AppData/MULTI-IA-AETHAS38"
os.makedirs(LOG_DIR, exist_ok=True)
LOG_FILE = os.path.join(LOG_DIR, "aethas_logs.txt")
@@ -12,7 +11,6 @@ logger = logging.getLogger("Aethas38")
logger.setLevel(logging.INFO)
if not logger.handlers:
# Conservation 365 jours pour conformité légale
handler = TimedRotatingFileHandler(LOG_FILE, when="midnight", interval=1, backupCount=365, encoding="utf-8")
handler.suffix = "%Y-%m-%d"
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
@@ -21,17 +19,8 @@ if not logger.handlers:
def log_event(niveau, source, message):
msg_formate = f"[{source}] {message}"
if niveau.upper() == "INFO": logger.info(msg_formate)
elif niveau.upper() == "WARNING": logger.warning(msg_formate)
elif niveau.upper() == "ERROR": logger.error(msg_formate)
elif niveau.upper() == "CRITICAL": logger.critical(msg_formate)
print(f"[{datetime.now().strftime('%H:%M:%S')}] [{niveau}] {msg_formate}")
if niveau in ["ERROR", "CRITICAL"]:
try:
from modules.debug_mailer import envoyer_alerte_debug
envoyer_alerte_debug(niveau, source, message)
except Exception:
pass
+50
View File
@@ -0,0 +1,50 @@
import os
from fasthtml.common import *
def render_paypal_recharge_widget(montant_usd: float = 10.00):
"""
Génère le bouton de paiement PayPal (Smart Button) pour recharger les crédits.
Utilise le Client ID Sandbox défini dans le fichier .env.
"""
client_id = os.getenv("PAYPAL_CLIENT_ID")
if not client_id:
return Div("Clé PayPal (Client ID) manquante dans .env", style="color: red;")
# Le script officiel de PayPal injecte les boutons de paiement
script_paypal = Script(src=f"https://www.paypal.com/sdk/js?client-id={client_id}&currency=USD")
container_id = f"paypal-button-container-{str(montant_usd).replace('.', '')}"
script_logique = Script(f"""
paypal.Buttons({{
createOrder: function(data, actions) {{
return actions.order.create({{
purchase_units: [{{
amount: {{
value: '{montant_usd}'
}}
}}]
}});
}},
onApprove: function(data, actions) {{
return actions.order.capture().then(function(details) {{
alert('Paiement réussi par ' + details.payer.name.given_name + '! Les crédits seront ajoutés.');
// Ici on fera un fetch vers ton backend python pour créditer l'utilisateur
fetch('/api/paypal/success', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ orderID: data.orderID }})
}}).then(res => window.location.reload());
}});
}}
}}).render('#{container_id}');
""")
return Div(
script_paypal,
H3(f"Recharge de {montant_usd}$"),
Div(id=container_id),
script_logique,
style="background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #333; margin-top: 15px; text-align: center; max-width: 300px;"
)
+37 -28
View File
@@ -13,7 +13,9 @@ def get_theme_hdrs():
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 CLASSIQUE --- */
.sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; transition: left 0.3s ease; }
.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; }
@@ -27,8 +29,10 @@ def get_theme_hdrs():
.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); }
/* --- CONTENU CENTRAL --- */
.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; }
.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: 160px; 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; }
@@ -39,38 +43,56 @@ def get_theme_hdrs():
.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; }
/* --- NOUVEAU LAYOUT ZONE DE SAISIE --- */
.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; z-index: 10;}
.input-container-box { width: 100%; max-width: 900px; background: #1e1e24; border-radius: 20px; border: 1px solid #444; padding: 15px; box-sizing: border-box; display: flex; flex-direction: column; gap: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
.textarea-wrapper { display: flex; align-items: flex-end; gap: 10px; width: 100%; }
.options-row { display: flex; gap: 20px; justify-content: center; border-top: 1px solid #333; padding-top: 10px; flex-wrap: wrap; }
#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; }
/* LOADER HTMX SURÉLEVÉ ET CENTRÉ (Flotte au-dessus du texte) */
.htmx-indicator { display: none; opacity: 0; transition: opacity 0.3s ease-in; position: absolute; bottom: 140px; left: 50%; transform: translateX(-50%); z-index: 9999; }
/* LOADER HTMX SURÉLEVÉ */
.htmx-indicator { display: none; opacity: 0; transition: opacity 0.3s ease-in; position: absolute; bottom: 160px; left: 50%; transform: translateX(-50%); z-index: 9999; }
.htmx-request.htmx-indicator { display: flex !important; opacity: 1; }
.htmx-request .htmx-indicator { display: flex !important; opacity: 1; }
.tech-loader-container { display: flex; align-items: center; gap: 15px; color: var(--accent-blue); font-style: italic; background: #1e1e24; padding: 15px 25px; border-radius: 30px; border: 1px dashed var(--accent-blue); box-shadow: 0 5px 20px rgba(0,229,255,0.2); font-size: 0.9em; white-space: nowrap; }
.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); } }
/* --- RESPONSIVE MOBILE & SIDEBAR --- */
.mobile-header { display: none; background: #0a0a0c; padding: 10px 20px; align-items: center; justify-content: space-between; border-bottom: 1px solid #333; width: 100%; box-sizing: border-box; flex-shrink: 0;}
.hamburger-btn { background: none; border: none; color: var(--accent-blue); font-size: 1.8em; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 5px; }
.hamburger-btn:focus { outline: none; }
@media (max-width: 768px) {
body { flex-direction: column; }
.sidebar { width: 100%; height: auto; max-height: 35vh; border-right: none; border-bottom: 1px solid #333; padding: 10px; }
.sidebar-history { max-height: 120px; }
.main-content { flex: 1; height: 65vh; }
.chat-container { padding-bottom: 180px; padding-left: 10px; padding-right: 10px; }
.mobile-header { display: flex; }
.sidebar { position: absolute; left: -100%; top: 0; z-index: 1000; width: 280px; height: 100vh; box-shadow: 5px 0 15px rgba(0,0,0,0.8); }
.sidebar.sidebar-open { left: 0; }
.main-content { flex: 1; height: calc(100vh - 60px); }
.chat-container { padding-bottom: 200px; padding-left: 10px; padding-right: 10px; }
.user-bubble, .ai-bubble { max-width: 95%; }
.input-area { padding: 10px; }
.options-row { justify-content: center; gap: 10px;}
}
"""),
Script(r"""
// Sac à dos virtuel global pour additionner les fichiers sans les écraser
let aethasFiles = new DataTransfer();
// JS pour le bouton Hamburger
window.toggleSidebar = function() {
let sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.toggle('sidebar-open');
}
};
function syncFiles() {
const fileInput = document.getElementById('file-upload');
if(fileInput) fileInput.files = aethasFiles.files;
@@ -139,12 +161,12 @@ def get_theme_hdrs():
pre.parentNode.insertBefore(header, pre);
if (typeof hljs !== 'undefined') hljs.highlightElement(code);
}
} catch(err) { console.warn("Erreur entête code", err); }
} catch(err) {}
});
});
let chat = document.getElementById('chat-history');
if (chat) chat.scrollTop = chat.scrollHeight;
} catch(e) { console.error("Erreur markdown", e); }
} catch(e) {}
}
function getSmartFilename(btn, defaultName = 'Aethas38_Reponse', extension = 'pdf') {
@@ -219,7 +241,6 @@ def get_theme_hdrs():
});
}
// Vider le sac à dos virtel une fois la requête HTMX envoyée !
document.body.addEventListener('htmx:afterRequest', function(evt) {
if(evt.detail.target.id === 'chat-history') {
aethasFiles = new DataTransfer();
@@ -229,13 +250,7 @@ def get_theme_hdrs():
});
});
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);
}
// ... (Fonctions de téléchargement ZIP, PDF, DOC, TXT, JSON et Partage identiques et préservées)
function downloadCodeZip(btn, lang) {
let pre = btn.closest('.code-header').nextElementSibling;
let code = pre.querySelector('code').innerText;
@@ -251,12 +266,6 @@ def get_theme_hdrs():
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();
}
}
+128 -29
View File
@@ -1,10 +1,32 @@
import os
import requests
from modules.paypal_checkout import render_paypal_recharge_widget
from fasthtml.common import *
from modules.database import load_history
from modules.paypal import render_paypal_donation_widget
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")
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):
time_badge = Span(f"⏱️ {execution_time}s", style="margin-right: 10px; color:#aaa; font-size:0.8em;") if execution_time else ""
@@ -16,6 +38,7 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur, executi
time_badge,
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"),
@@ -25,12 +48,14 @@ def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur, executi
Button("📧 Email", onclick="shareEmail(this)", cls="msg-action-btn", title="Envoyer par courriel"),
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};"
cls="ai-bubble",
style=f"border-left: 4px solid {couleur};"
)
def render_sidebar():
@@ -45,7 +70,8 @@ def render_sidebar():
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}"
cls="history-item",
id=f"chat-item-{chat_id}"
)
)
return Div(*items, id="sidebar-content", cls="sidebar-history", hx_swap_oob="true")
@@ -53,15 +79,21 @@ def render_sidebar():
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['content'],
msg.get('intention', ''),
msg.get('fournisseur', ''),
msg.get('modele', ''),
msg.get('couleur', '#333'),
msg.get('execution_time', '')
))
if messages_ui:
messages_ui.append(Script("setTimeout(renderMarkdown, 150);"))
@@ -82,10 +114,22 @@ def render_main_page(chat_id, utilisateur_nom="xavier", role="Super Admin"):
solde_perso = obtenir_solde(utilisateur_nom)
if role.lower() in ["super admin", "admin"]:
stats_or = get_openrouter_stats()
limit_rem = stats_or.get('limit_remaining')
limit_rem = float(limit_rem) if limit_rem is not None else 0.0
usage_daily = stats_or.get('usage_daily')
usage_daily = float(usage_daily) if usage_daily is not None else 0.0
usage_monthly = stats_or.get('usage_monthly')
usage_monthly = float(usage_monthly) if usage_monthly is not None else 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: #aaa; margin: 2px 0; font-size: 0.8em;"),
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;"),
# AJOUT DU BOUTON PAYPAL POUR TESTER L'ACHAT DE 10$
render_paypal_recharge_widget(10.00),
style="padding: 10px; background: #181818; border-radius: 6px; border: 1px solid #333;"
)
else:
@@ -97,13 +141,15 @@ def render_main_page(chat_id, utilisateur_nom="xavier", role="Super Admin"):
return Title("Aethas38 - Core"), Body(
auto_refresh_script,
# --- SIDEBAR FIXE (BUREAU) & MASQUABLE (MOBILE) ---
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"),
A("🚪 Déconnexion", href="/logout", cls="export-btn", style="background-color: #8b0000; color: white; border-color: #8b0000; margin-top: 5px;"), # NOUVEAU BOUTON ICI
A("🚪 Déconnexion", href="/logout", cls="export-btn", style="background-color: #8b0000; color: white; border-color: #8b0000; margin-top: 5px;"),
Div(
render_paypal_donation_widget(),
@@ -119,38 +165,69 @@ def render_main_page(chat_id, utilisateur_nom="xavier", role="Super Admin"):
Div(badge_finance, cls="sidebar-footer"),
cls="sidebar"
),
# --- CONTENU CENTRAL ---
Div(
# HEADER MOBILE (VISIBLE SEULEMENT SUR SMARTPHONE)
Div(
Button("☰", onclick="toggleSidebar()", cls="hamburger-btn"),
Img(src="/Logo/logo_aethas.svg", style="height: 30px;"),
Div(style="width: 30px;"), # Espaceur pour centrer le logo
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", style="max-width: 1000px; margin: 0 auto; width: 100%; padding: 0 20px; box-sizing: border-box; padding-bottom: 120px;"),
Div(
Div(Div(cls="tech-spinner"), Span("Analyse en cours... (~5s à 10s)"), cls="tech-loader-container"),
id="global-loader",
cls="htmx-indicator"
),
# --- LAYOUT ZONE DE SAISIE RESTRUCTURÉ ---
Div(
Form(
Input(type="hidden", name="chat_id", value=chat_id),
Div(
Div(id="file-preview-list"),
# RANGÉE 1 : BOUTON +, TEXTAREA ET BOUTON ENVOI
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"
),
# RANGÉE 2 : LES OPTIONS (SOUS LA TEXTAREA)
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;"),
"👑 Payant",
style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; margin-right: 10px; cursor: pointer; white-space: nowrap;"
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; margin-right: 15px; cursor: pointer; white-space: nowrap;"
style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; cursor: pointer; white-space: nowrap;"
),
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="options-row"
),
cls="input-container-box"
),
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader",
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;"
enctype="multipart/form-data",
style="width: 100%; display: flex; justify-content: center;"
),
cls="input-area"
),
@@ -169,20 +246,18 @@ def render_admin_moderation_panel(utilisateur_cible):
Select(
Option("Avertissement simple", value="Avertissement"),
Option("Bannissement 1 jour", value="Ban 1J"),
Option("Bannissement 1 semaine", value="Ban 1Sem"),
Option("Bannissement 1 mois", value="Ban 1M"),
Option("Bannissement définitif et signalement PHAROS", value="Ban Definitif + Pharos"),
Option("Faux positif (Levée de sanction)", value="Annulation"),
name="decision", cls="input-field"
name="decision",
cls="input-field"
),
style="margin-bottom: 15px;"
),
Div(
Label("Commentaire libre de l'administrateur (Preuve d'audit) :", style="font-weight: bold;"),
Textarea(name="commentaire", rows="4", cls="input-field", placeholder="Raison de la décision, contexte juridique..."),
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;"),
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;"
@@ -217,22 +292,46 @@ def render_login_page(error=None):
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):
def render_popup_paiement(cout_estime, prompt_sauvegarde, chat_id, multi_ia_state=False, edu_state=False):
multi_ia_val = "on" if multi_ia_state else "off"
edu_val = "on" if edu_state else "off"
return Div(
Div(
H3("⚠️ Validation Financière Requise", style="color: #ff9800; margin-top: 0;"),
P("Cette requête nécessite l'utilisation d'un modèle d'IA payant.", style="color: #ccc;"),
P(f"Coût estimé (TTC) : {cout_estime} $", style="font-size: 1.2em; font-weight: bold; color: #fff; background: #333; padding: 10px; border-radius: 5px; text-align: center;"),
P(f"Coût estimé API (TTC) : {cout_estime} $", style="font-size: 1.2em; 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: 30%; font-size: 0.8em;"),
Button("Annuler la requête", type="button", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #555; width: 30%; font-size: 0.8em; 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=multi_ia_val), Input(type="hidden", name="validation_acquise", value="true"), Input(type="hidden", name="choix_utilisateur", value="gratuit_force"), Button("Tenter en Gratuit ⚠️", type="submit", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #00e5ff; width: 100%; color: black; font-size: 0.8em;"), hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", style="width: 30%;"),
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=multi_ia_val),
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 ⚠️", type="submit", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #00e5ff; width: 100%; color: black; font-size: 0.8em; border-radius: 8px;"),
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", style="width: 30%;"
),
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="on"), Input(type="hidden", name="mode_multi_ia", value=multi_ia_val), Input(type="hidden", name="validation_acquise", value="true"), Input(type="hidden", name="choix_utilisateur", value="paiement_accepte"), Button("Payer l'API", type="submit", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #4caf50; width: 100%; font-size: 0.8em;"), hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", style="width: 30%;"),
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="on"),
Input(type="hidden", name="mode_multi_ia", value=multi_ia_val),
Input(type="hidden", name="mode_education", value=edu_val),
Input(type="hidden", name="validation_acquise", value="true"),
Input(type="hidden", name="choix_utilisateur", value="paiement_accepte"),
Button("Payer l'API", type="submit", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #4caf50; width: 100%; font-size: 0.8em; border-radius: 8px;"),
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", style="width: 30%;"
),
style="display: flex; justify-content: space-between; margin-top: 20px; gap: 10px;"
), style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #ff9800; width: 500px; 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.7); display: flex; align-items: center; justify-content: center; z-index: 9999;"
),
style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #ff9800; width: 500px; 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.7); display: flex; align-items: center; justify-content: center; z-index: 9999;"
)