From 42aaf4a6296a2cdfd640330269f6546591703a6d Mon Sep 17 00:00:00 2001 From: Xavier Date: Tue, 6 Oct 2026 11:22:12 +0200 Subject: [PATCH] =?UTF-8?q?v2.0.0=20-=20Validation=20=C3=89tape=201=20et?= =?UTF-8?q?=20configuration=20des=20fournisseurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 20 - backend/Dockerfile | 5 + backend/config.py | 25 + backend/main.py | 28 + requirements.txt => backend/requirements.txt | 6 +- docker-compose.yml | 24 +- frontend/index.html | 144 +++++ main.py | 647 ------------------- 8 files changed, 213 insertions(+), 686 deletions(-) delete mode 100644 Dockerfile create mode 100644 backend/Dockerfile create mode 100644 backend/config.py create mode 100644 backend/main.py rename requirements.txt => backend/requirements.txt (55%) create mode 100644 frontend/index.html delete mode 100644 main.py diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 5780f00..0000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM python:3.13-slim - -WORKDIR /app - -# Installation des dépendances système de base -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Copie et installation des bibliothèques Python -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copie du code source -COPY . . - -EXPOSE 5001 - -CMD ["python", "main.py"] \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..536a80b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.13-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5001", "--reload"] \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..ab4c8fa --- /dev/null +++ b/backend/config.py @@ -0,0 +1,25 @@ +import json +import os + +# Détecte le dossier parent (racine du projet) dynamiquement +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SESSIONS_DIR = os.path.join(BASE_DIR, "sessions") +CONFIG_FILE = os.path.join(SESSIONS_DIR, "app_config.json") + +def load_config(): + os.makedirs(SESSIONS_DIR, exist_ok=True) + if not os.path.exists(CONFIG_FILE): + default_config = { + "api_keys": {"openrouter": "", "groq": "", "gemini": ""}, + "smtp": {"host": "", "port": 587, "user": "", "password": ""}, + "custom_providers": [] + } + save_config(default_config) + return default_config + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + +def save_config(config_data): + os.makedirs(SESSIONS_DIR, exist_ok=True) + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config_data, f, indent=4, ensure_ascii=False) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..fd722e0 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,28 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse +import os +import config + +app = FastAPI(title="AETHAS38 API") + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FRONTEND_DIR = os.path.join(BASE_DIR, "frontend") + +# Montage dynamique +app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") + +@app.get("/", response_class=HTMLResponse) +async def serve_admin(): + index_path = os.path.join(FRONTEND_DIR, "index.html") + with open(index_path, "r", encoding="utf-8") as f: + return f.read() + +@app.get("/api/config") +async def get_config(): + return config.load_config() + +@app.post("/api/config") +async def update_config(data: dict): + config.save_config(data) + return {"status": "success", "message": "Configuration sauvegardée avec succès."} \ No newline at end of file diff --git a/requirements.txt b/backend/requirements.txt similarity index 55% rename from requirements.txt rename to backend/requirements.txt index 4c47189..47abfe6 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,8 @@ python-dotenv pyotp qrcode psutil -openpyxl \ No newline at end of file +openpyxl +fastapi +uvicorn +pydantic +python-dotenv \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c0e1dd9..8b295c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,11 @@ services: aethas38-core: - build: - context: . - no_cache: true - container_name: aethas38-multi-ia - restart: unless-stopped + build: ./backend + container_name: aethas38-core ports: - "5002:5001" - environment: - - TZ=Europe/Paris - env_file: - - .env volumes: - - ./:/app - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - - dockge-network - -networks: - dockge-network: - external: true \ No newline at end of file + - ./backend:/app + - ./frontend:/frontend + - ./sessions:/sessions + restart: unless-stopped \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d23c345 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,144 @@ + + + + + + AETHAS38 - Administration + + + + + +
+
+

⚙️ Administration AETHAS38

+ v2.0.0 +
+ +
+ {{ notification }} +
+ + +
+

🔑 Clés API Principales

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

📧 Configuration Serveur Mail (SMTP)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

🔌 Fournisseurs Personnalisés

+ +
+
+
+

Fournisseur #{{ index + 1 }}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+

Aucun fournisseur supplémentaire configuré.

+
+ + +
+ +
+
+ + + + \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index 11344df..0000000 --- a/main.py +++ /dev/null @@ -1,647 +0,0 @@ -from fasthtml.common import * -import os -import requests -import json -import uuid -import asyncio -import time -import re -from datetime import datetime -from dotenv import load_dotenv -from auth import validate_password_strength, hash_password, verify_password, generate_totp_secret, verify_totp - -load_dotenv() - -# --- THEME & SCRIPTS --- -theme_hdrs = [ - Script(src="https://cdn.tailwindcss.com"), - 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"), - Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/tokyo-night-dark.min.css"), - Style(""" - body { background-color: #0f172a; color: #f8fafc; font-family: system-ui, sans-serif; margin: 0;} - .sidebar { background-color: #1e293b; border-right: 1px solid #333; height: 100vh; padding: 20px; display: flex; flex-direction: column;} - .main-content { padding: 20px; height: 100vh; display: flex; flex-direction: column; position: relative;} - .chat-container { flex-grow: 1; overflow-y: auto; margin-bottom: 20px; border: 1px solid #333; padding: 15px; border-radius: 8px; background: #151e2e; } - .input-row { display: flex; gap: 10px; align-items: flex-end; width: 100%; background: #1e293b; padding: 10px; border-radius: 10px; border: 1px solid #333;} - .input-box { flex-grow: 1; padding: 12px; border-radius: 5px; background: #2a2a35; border: 1px solid #444; color: white; outline: none; } - .file-upload-btn { background: #2a2a35; border: 1px solid #444; color: #94a3b8; padding: 12px; border-radius: 5px; cursor: pointer; font-size:14px; display: flex; align-items:center; justify-content:center;} - .model-selector { padding: 8px; border-radius: 5px; background: #1e293b; border: 1px solid #444; color: #00e5ff; width: 100%; font-size: 12px; outline:none; } - .send-btn { padding: 12px 24px; border-radius: 5px; background: #00e5ff; color: #0f172a; font-weight: bold; cursor: pointer; border: none; } - .msg-user { color: #00e5ff; font-size: 12px; font-weight: bold; margin-bottom: 2px; margin-top: 15px;} - .msg-ia { color: #a855f7; font-size: 12px; font-weight: bold; margin-bottom: 2px; margin-top: 15px;} - .bubble-user { background: #1e293b; padding: 10px; border-radius: 5px; display: inline-block; color: white; border: 1px solid #333;} - .bubble-ia { background: #1e293b; padding: 15px; border-radius: 8px; display: block; color: #e2e8f0; border: 1px solid #333;} - .bubble-ia pre { background: #111827; padding: 15px; border-radius: 8px; margin-top: 10px; position: relative; border: 1px solid #444;} - .htmx-indicator { display: none; position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%); background: rgba(15, 23, 42, 0.9); border: 1px solid #00e5ff; padding: 15px 30px; border-radius: 50px; color: #00e5ff; font-weight: bold; z-index: 50;} - .htmx-request .htmx-indicator { display: block; } - .history-btn { background: #2a2a35; color: #e2e8f0; border: 1px solid #444; padding: 8px; border-radius: 5px; cursor: pointer; text-align: left; } - .history-btn:hover { background: #38bdf8; color: #0f172a; } - .export-btn { display: block; background: #0f172a; border: 1px solid #00e5ff; color: #00e5ff; font-weight: bold; padding: 10px; border-radius: 5px; text-align: center; cursor: pointer; transition: 0.2s; width: 100%;} - .export-btn:hover { background: #00e5ff; color: #0f172a; } - """), - Script(""" - function filterModels() { - let filter = document.querySelector('input[name="filter_type"]:checked').value; - document.querySelectorAll('.model-selector option').forEach(opt => { - if(opt.value === "") return; - let isFree = opt.getAttribute('data-free') === 'true'; - if(filter === 'free' && !isFree) opt.style.display = 'none'; - else if(filter === 'paid' && isFree) opt.style.display = 'none'; - else opt.style.display = 'block'; - }); - } - function validateMultiIA() { - let wCount = 0; - for(let i=1; i<=5; i++) { if(document.getElementById('worker'+i).value !== "") wCount++; } - if(wCount === 0) { - alert("Erreur : Vous devez sélectionner au moins une IA de Travail."); - return false; - } - if(wCount > 1) { - if(document.getElementById('writer').value === "" || document.getElementById('synthesizer').value === "") { - alert("Erreur : Puisque vous avez sélectionné plusieurs travailleurs, le Rédacteur et le Concaténeur sont obligatoires."); - return false; - } - } - return true; - } - function updateFileCount(input) { - let label = document.getElementById('file-label-text'); - if(input.files && input.files.length > 0) { label.innerText = `📎 ${input.files.length} fichier(s)`; label.style.color = '#00e5ff'; } - else { label.innerText = '📎 Fichiers Joints'; label.style.color = '#94a3b8'; } - } - htmx.onLoad(function(content) { - content.querySelectorAll('.bubble-ia:not(.rendered)').forEach(function(el) { - el.innerHTML = marked.parse(el.textContent); - el.classList.add('rendered'); - el.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); }); - let chat = document.getElementById('chat-history'); chat.scrollTop = chat.scrollHeight; - }); - }); - """) -] - -app, rt = fast_app(secret_key=os.getenv("SESSION_SECRET", "super-secret-key-fallback"), hdrs=theme_hdrs) - -# --- AUTHENTIFICATION --- -@rt('/login') -def get(): - return Title("Connexion - AETHAS38"), Body( - Div( - H2("AETHAS 38 - Accès Sécurisé", style="color:#00e5ff; margin-bottom:20px; text-align:center;"), - Form( - Input(type="email", name="email", placeholder="E-mail", cls="input-box mb-3 w-full", required=True), - Input(type="password", name="password", placeholder="Mot de passe", cls="input-box mb-3 w-full", required=True), - Input(type="text", name="totp_code", placeholder="Code 2FA (6 chiffres)", cls="input-box mb-4 w-full", required=True), - Button("Se connecter", type="submit", cls="send-btn w-full"), - hx_post="/auth/login", hx_target="#auth-response" - ), - Div(id="auth-response", style="color:red; margin-top:10px; text-align:center;"), - cls="sidebar", style="margin: auto; width: 400px; height: auto; border-radius: 10px; margin-top: 15vh;" - ), style="background-color: #0f172a; height: 100vh; display: flex;" - ) - -@rt('/auth/login') -def post(email: str, password: str, totp_code: str, session): - admin_email = os.getenv("ADMIN_EMAIL") - admin_hash = os.getenv("ADMIN_PASSWORD_HASH") - totp_secret = os.getenv("ADMIN_2FA_SECRET") - - if not admin_email or not admin_hash or not totp_secret: - return Div("Configuration système incomplète (.env).", style="color:red; font-weight:bold;") - - if email == admin_email and verify_password(admin_hash, password) and verify_totp(totp_secret, totp_code): - session["authenticated"] = True - session["role"] = "admin" - return Script("window.location.href = '/';") - else: - return Div("Identifiants ou code 2FA incorrects.", style="color:#ef4444; font-weight:bold;") - -@rt('/logout') -def get(session): - session.clear() - return RedirectResponse('/login') - -# --- SYSTEM & LOGS --- -@rt('/check_updates') -def get(session): - if not session.get("authenticated"): return Span("") - try: - res = requests.get("https://gitea.aethas38.duckdns.org/api/v1/repos/xavier/MULTI-IA-CODAGE/commits?limit=1", timeout=3) - if res.status_code == 200: - remote_commit = res.json()[0].get("sha", "")[:7] - return Span(f"🟢 Gitea Connecté (Dernier commit : {remote_commit})", style="color:#10b981;") - return Span("🟠 Gitea injoignable", style="color:#fbbf24;") - except Exception as e: - return Span("⚪ Statut réseau inconnu", style="color:#94a3b8;") - -# Création des dossiers qui seront mappés sur le RAID -os.makedirs("sessions", exist_ok=True) -os.makedirs("logs", exist_ok=True) -APP_LAUNCH_TIME = datetime.now().strftime("%Y%m%d_%H%M%S") -LOG_FILE = f"logs/AETHAS38_Run_{APP_LAUNCH_TIME}.log" - -def log_event(session_id, category, action): - sid_short = session_id[:8] if session_id and session_id != "system" else "SYSTEM" - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with open(LOG_FILE, "a", encoding="utf-8") as f: - f.write(f"[{timestamp}] [Session:{sid_short}] [{category}] {action}\n") - -# --- CATALOGUE MANAGER (Sécurisé sur le RAID) --- -CATALOG_FILE = "sessions/catalog.json" -MODELS_DATA = [] - -def load_catalog(): - global MODELS_DATA - if os.path.exists(CATALOG_FILE): - with open(CATALOG_FILE, "r", encoding="utf-8") as f: - try: MODELS_DATA = json.load(f) - except: MODELS_DATA = [] - if not MODELS_DATA: - # Fallback de sécurité mis à jour - MODELS_DATA = [ - {"id": "groq|llama-3.1-8b-instant", "name": "Groq - Llama 3.1 (8B)", "is_free": True, "provider": "Général / Texte", "description": "Modèle par défaut."}, - {"id": "gemini|gemini-1.5-flash", "name": "Google - Gemini 1.5 Flash", "is_free": True, "provider": "Vision", "description": "Modèle par défaut."} - ] -load_catalog() - -def save_catalog(data): - global MODELS_DATA - MODELS_DATA = data - with open(CATALOG_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - -def get_budget(): - mgmt_key = os.getenv("OPENROUTER_MANAGEMENT_KEY") - if not mgmt_key: return "Clé manquante" - try: - data = requests.get("https://openrouter.ai/api/v1/credits", headers={"Authorization": f"Bearer {mgmt_key}"}, timeout=5).json().get("data", {}) - return f"{data.get('total_credits', 0) - data.get('total_usage', 0):.4f} $" - except: return "Erreur réseau" - -# --- PERSISTENCE HISTORIQUE (Sécurisé sur le RAID) --- -HISTORY_FILE = "sessions/history.json" - -def load_index(): - if not os.path.exists(HISTORY_FILE): return {} - with open(HISTORY_FILE, "r", encoding="utf-8") as f: - try: return json.load(f) - except: return {} -def save_index(idx): - with open(HISTORY_FILE, "w", encoding="utf-8") as f: - json.dump(idx, f, indent=4, ensure_ascii=False) -def load_session_data(session_id): - path = f"sessions/{session_id}.json" - if not os.path.exists(path): return [] - with open(path, "r", encoding="utf-8") as f: - try: return json.load(f) - except: return [] -def save_session_data(session_id, messages): - with open(f"sessions/{session_id}.json", "w", encoding="utf-8") as f: - json.dump(messages, f, indent=4, ensure_ascii=False) -def create_new_session(): - session_id = str(uuid.uuid4()) - idx = load_index() - idx[session_id] = {"title": f"Projet du {datetime.now().strftime('%d/%m %H:%M')}", "pinned": False} - save_index(idx) - save_session_data(session_id, []) - return session_id - -def render_history_list(): - idx = load_index() - sorted_sessions = sorted(idx.items(), key=lambda x: (not x[1].get('pinned', False), x[1]['title']), reverse=False) - items = [Button("➕ Nouveau Projet", cls="export-btn mb-3", style="background:#10b981; color:#0f172a;", hx_get="/session/new", hx_target="#chat-history")] - for sid, data in sorted_sessions: - pin_icon, pin_color = ("📍", "#10b981") if data.get('pinned') else ("📌", "#94a3b8") - item = Div( - Div(data['title'], cls="truncate flex-grow cursor-pointer text-sm hover:text-cyan-400", hx_get=f"/session/load/{sid}", hx_target="#chat-history"), - Div( - Button("✏", cls="text-xs mx-1 hover:text-white", onclick=f"let name = prompt('Nouveau nom:'); if(name) {{ htmx.ajax('POST', '/history/rename/{sid}', {{values: {{title: name}}, target: '#history-list-container'}}); }}"), - Button(pin_icon, style=f"color:{pin_color};", cls="text-xs mx-1 hover:text-white", hx_post=f"/history/pin/{sid}", hx_target="#history-list-container"), - Button("🗑️", cls="text-xs hover:text-red-500", onclick=f"if(confirm('Supprimer ce projet ?')) {{ htmx.ajax('POST', '/history/delete/{sid}', {{target: '#history-list-container'}}); }}"), - cls="flex-shrink-0" - ), - cls="flex justify-between items-center w-full mb-2 bg-[#2a2a35] p-2 rounded border border-[#444]" - ) - items.append(item) - return Div(*items, id="history-list-container", hx_swap_oob="true") - -# --- UI COMPONENTS --- -def render_model_dropdown(field_id, placeholder): - groups = {} - for m in MODELS_DATA: - prov = m.get('provider', 'Général') - if prov not in groups: groups[prov] = [] - groups[prov].append(m) - - opts = [Option("--- Laisser vide ---", value="")] - for prov in sorted(groups.keys()): - grp_opts = [] - for m in sorted(groups[prov], key=lambda x: x['name']): - is_free = str(m.get('is_free', False)).lower() - label = f"{m['name']} ({'Gratuit' if is_free=='true' else 'Payant'})" - grp_opts.append(Option(label, value=m['id'], **{"data-free": is_free})) - opts.append(Optgroup(label=prov)(*grp_opts)) - - return Div( - P(placeholder, style="color:#94a3b8; font-size:10px; font-weight:bold; margin-bottom:2px;"), - Select(*opts, name=field_id, id=field_id, cls="model-selector", onchange="filterModels()"), - style="margin-bottom: 8px;" - ) - -def parse_txt_catalog(content: str): - """ Logique VBA traduite en Python pour le fallback du fichier TXT """ - lines = content.split('\n') - models = [] - current = {} - ext_date = "Inconnue" - ext_time = "" - - for line in lines: - line_trim = line.strip() - - if "Généré le" in line_trim: - match = re.search(r"Généré le (\d{2}/\d{2}/\d{4}) à (\d{2}:\d{2}:\d{2})", line_trim) - if match: - ext_date = match.group(1) - ext_time = match.group(2) - - if line_trim.startswith("Nom : "): current['name'] = line_trim.replace("Nom : ", "").strip() - elif line_trim.startswith("ID : "): current['id'] = line_trim.replace("ID : ", "").strip() - elif line_trim.startswith("Tarif : "): current['is_free'] = ("Gratuit" in line_trim) - elif line_trim.startswith("Spécialité : "): current['provider'] = line_trim.replace("Spécialité : ", "").strip() - elif line_trim.startswith("Description : "): current['description'] = line_trim.replace("Description : ", "").strip() - elif line_trim.startswith("---"): - if 'id' in current and 'name' in current: models.append(current) - current = {} - - return models, ext_date, ext_time - -# --- API DISTANTE --- -def ask_llm(session_id, full_id, msg): - parts = full_id.split("|", 1) - provider = parts[0] - actual_model = parts[1] if len(parts) > 1 else full_id - - log_event(session_id, "API_REQ", f"Provider: {provider} | Modèle: {actual_model}") - try: - if provider == "openrouter": - res = requests.post("https://openrouter.ai/api/v1/chat/completions", headers={"Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}"}, json={"model": actual_model, "messages": [{"role": "user", "content": msg}]}) - if res.status_code == 200: return res.json()["choices"][0]["message"]["content"] - return f"⚠️ Erreur API ({provider}) : {res.text}" - elif provider == "groq": - res = requests.post("https://api.groq.com/openai/v1/chat/completions", headers={"Authorization": f"Bearer {os.getenv('GROQ_API_KEY')}"}, json={"model": actual_model, "messages": [{"role": "user", "content": msg}]}) - if res.status_code == 200: return res.json()["choices"][0]["message"]["content"] - return f"⚠️ Erreur API ({provider}) : {res.text}" - elif provider == "gemini": - res = requests.post(f"https://generativelanguage.googleapis.com/v1beta/models/{actual_model}:generateContent?key={os.getenv('GEMINI_API_KEY')}", json={"contents": [{"parts": [{"text": msg}]}]}) - if res.status_code == 200: return res.json()["candidates"][0]["content"]["parts"][0]["text"] - return f"⚠️ Erreur API ({provider}) : {res.text}" - return "⚠️ Fournisseur API non implémenté ou ID invalide." - except Exception as e: return f"⚠️️ Erreur de connexion : {str(e)}" - -async def async_ask_llm(session_id, full_id, msg): - return await asyncio.to_thread(ask_llm, session_id, full_id, msg) - -# --- ROUTES & VIEWS --- -SESSION_PROGRESS = {} - -@rt('/status/{sid}') -def get_status(sid: str): - return SESSION_PROGRESS.get(sid, "⚙️ Traitement en cours...") - -@rt('/') -def get(session): - if not session.get("authenticated"): return RedirectResponse('/login') - session_id = create_new_session() - return Title("AETHAS38 Orchestrateur"), Body( - Div( - # --- SIDEBAR GAUCHE --- - Div( - H2("AETHAS 38", style="color:#00e5ff; font-weight:bold; font-size:24px;"), - P("Orchestrateur Distant", style="color:#a855f7; margin-bottom: 10px; font-size:12px;"), - Div("⏳ Vérification GitHub...", hx_get="/check_updates", hx_trigger="load", style="font-size:10px; margin-bottom:15px;"), - - Div( - H3("Importer Nouveaux Modèles", style="color:#94a3b8; font-size:12px; font-weight:bold; margin-bottom:5px;"), - Form( - Input(type="file", name="catalog_file", accept=".txt,.xlsm,.xlsx", required=True, id="cat-upload", style="display:none;", onchange="document.getElementById('cat-label').innerText = '📎 Fichier Sélectionné';"), - Label(Span("📎 Sélectionner (.txt ou .xlsm)", id="cat-label"), _for="cat-upload", cls="file-upload-btn mb-2"), - Button("Importer le Catalogue", type="submit", cls="export-btn", style="background:#a855f7; color:#fff; border:none;"), - hx_post="/import_catalog", hx_target="#catalog-status", enctype="multipart/form-data" - ), - Div(id="catalog-status", style="font-size:10px; color:#10b981; margin-top:5px; text-align:center;"), - style="background:#151e2e; padding:10px; border-radius:5px; border:1px solid #333; margin-bottom:15px;" - ), - - Div(H3("Historique", style="color:#94a3b8; font-size:12px; font-weight:bold; margin-bottom:10px;"), render_history_list(), style="flex-grow:1; overflow:hidden;"), - - Button("📥 Exporter Modèles Actuels (.txt)", type="button", cls="export-btn", onclick="window.location.href='/export_models'"), - A("Déconnexion", href="/logout", cls="export-btn w-full mt-2", style="color:#ef4444; border-color:#ef4444; margin-bottom:10px;"), - Div(P("BUDGET", style="color:#94a3b8; font-size:10px; font-weight:bold;"), P(get_budget(), id="budget-display", style="color:#10b981; font-size:16px; font-weight:bold;")), - cls="sidebar" - ), - # --- ZONE PRINCIPALE --- - Div( - Div(id="chat-history", cls="chat-container"), - Div(Span("⚙️ Préparation du Pipeline...", id="loading-text"), id="loading-tracker", cls="htmx-indicator"), - - Form( - Input(type="hidden", name="session_id", value=session_id, id="current-session-id"), - - Div( - P("Filtre d'affichage :", style="color:#00e5ff; font-weight:bold; font-size:12px; margin-right:15px;"), - Label(Input(type="radio", name="filter_type", value="all", checked=True, onchange="filterModels()"), " Tous"), - Label(Input(type="radio", name="filter_type", value="free", onchange="filterModels()"), " Gratuits Uniquement"), - Label(Input(type="radio", name="filter_type", value="paid", onchange="filterModels()"), " Payants Uniquement"), - cls="flex items-center text-sm text-white gap-4 mb-4 bg-[#1e293b] p-2 rounded border border-[#333] w-fit" - ), - - Div( - Div(render_model_dropdown("writer", "1. Rédacteur de Prompt"), render_model_dropdown("synthesizer", "7. Concaténeur Final"), style="border-right: 1px solid #444; padding-right:15px;"), - Div( - render_model_dropdown("worker1", "2. IA Travailleur 1 (Obligatoire)"), - render_model_dropdown("worker2", "3. IA Travailleur 2"), - render_model_dropdown("worker3", "4. IA Travailleur 3"), - style="padding: 0 15px;" - ), - Div( - render_model_dropdown("worker4", "5. IA Travailleur 4"), - render_model_dropdown("worker5", "6. IA Travailleur 5"), - style="padding-left: 15px;" - ), - cls="grid grid-cols-3 gap-4 mb-4 bg-[#151e2e] p-4 rounded border border-[#333]" - ), - - Div( - Input(type="file", name="fichiers", id="file-upload", multiple=True, style="display:none;", onchange="updateFileCount(this)"), - Label(Span("📎 Fichiers Joints", id="file-label-text"), _for="file-upload", cls="file-upload-btn w-40"), - Input(type="text", name="msg", placeholder="Détaillez le travail à effectuer...", cls="input-box", required=True), - Button("Lancer le Pipeline", type="submit", cls="send-btn"), - cls="input-row" - ), - hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#loading-tracker", enctype="multipart/form-data", - onsubmit="return validateMultiIA();", - **{"hx-on:htmx:afterRequest": "if(event.detail.successful) { this.reset(); document.getElementById('file-label-text').innerText='📎 Fichiers Joints'; filterModels(); }"} - ), - cls="main-content" - ), - style="display: grid; grid-template-columns: 280px 1fr;" - ), - Script(""" - let statusInterval; - document.addEventListener('htmx:beforeRequest', function(evt) { - if(evt.detail.target.id === 'chat-history') { - let sid = document.getElementById('current-session-id').value; - document.getElementById('loading-text').innerText = "⚙️ Envoi des données..."; - statusInterval = setInterval(async () => { - try { let res = await fetch('/status/' + sid); if(res.ok) document.getElementById('loading-text').innerText = await res.text(); } catch(e) {} - }, 800); - } - }); - document.addEventListener('htmx:afterRequest', function(evt) { if(evt.detail.target.id === 'chat-history') clearInterval(statusInterval); }); - filterModels(); - """) - ) - -# --- PIPELINE DE TRAITEMENT --- -@rt('/chat') -async def post(msg: str, session_id: str, session, writer: str = "", worker1: str = "", worker2: str = "", worker3: str = "", worker4: str = "", worker5: str = "", synthesizer: str = "", fichiers: list[UploadFile] = None): - if not session.get("authenticated"): return RedirectResponse('/login') - t_start = time.time() - - files_context, noms_fichiers = "", [] - if fichiers: - for f in fichiers: - if f.filename: - noms_fichiers.append(f.filename) - try: files_context += f"\n--- {f.filename} ---\n{(await f.read()).decode('utf-8')}\n" - except: files_context += f"\n--- {f.filename} (Binaire ignoré) ---\n" - - base_prompt = f"Fichiers fournis :\n{files_context}\n\nConsigne de l'utilisateur : {msg}" if files_context else msg - workers = [w for w in [worker1, worker2, worker3, worker4, worker5] if w] - - t_w0 = time.time() - if writer: - SESSION_PROGRESS[session_id] = "🧠 Étape 1 : Le Rédacteur prépare l'invite..." - prompt_to_work = ask_llm(session_id, writer, "Tu es un Ingénieur Prompt Senior. Reformule et optimise techniquement cette demande pour des LLM spécialisés. Retourne uniquement l'invite optimisée sans salutations :\n\n" + base_prompt) - else: - prompt_to_work = base_prompt - t_w1 = time.time() - - SESSION_PROGRESS[session_id] = f"⚡ Étape 2 : Travail en cours ({len(workers)} IA en parallèle)..." - tasks = [async_ask_llm(session_id, w, prompt_to_work) for w in workers] - results = await asyncio.gather(*tasks) - t_w2 = time.time() - - if synthesizer and len(workers) > 0: - SESSION_PROGRESS[session_id] = "🏗️ Étape 3 : Synthèse et assemblage final..." - synth_input = "Tu es l'Architecte Final. Voici la même tâche effectuée par plusieurs intelligences artificielles :\n\n" - for i, res in enumerate(results): synth_input += f"--- Proposition IA {i+1} ---\n{res}\n\n" - synth_input += "Analyse ces propositions, garde le meilleur code, corrige les erreurs potentielles et génère la solution finale absolue et complète en français." - final_response = ask_llm(session_id, synthesizer, synth_input) - else: - final_response = results[0] - t_w3 = time.time() - SESSION_PROGRESS[session_id] = "✅ Pipeline Terminé" - - rapport = ( - f"\n\n---\n⏱️ **Rapport de Performance (Pipeline Distant)**\n" - f"- **Rédacteur** : {t_w1 - t_w0:.2f} s\n" - f"- **Travailleurs (Parallèle)** : {t_w2 - t_w1:.2f} s\n" - f"- **Concaténeur** : {t_w3 - t_w2:.2f} s\n" - f"- **Temps Total du Cycle** : {t_w3 - t_start:.2f} s" - ) - - info_lbl = f"
⚙️ {len(workers)} Travailleur(s) | 📎 {len(noms_fichiers)} fichier(s)" - - session_data = load_session_data(session_id) - session_data.append({"user": msg, "ia": final_response + rapport, "info": info_lbl}) - save_session_data(session_id, session_data) - - return Div(P("Vous", cls="msg-user"), Div(NotStr(f"{msg}{info_lbl}"), cls="bubble-user"), P("AETHAS38", cls="msg-ia"), Div(final_response + rapport, cls="bubble-ia")), P(get_budget(), id="budget-display", style="color:#10b981; font-size:16px; font-weight:bold;", hx_swap_oob="true") - -# --- IMPORT / EXPORT CATALOGUE --- -# --- IMPORT / EXPORT CATALOGUE --- -@rt('/export_models') -def get(session): - if not session.get("authenticated"): return RedirectResponse('/login') - log_event("system", "SYSTEM", "Demande d'exportation du catalogue depuis OpenRouter.") - now = datetime.now() - txt_content = f"=== CATALOGUE COMPLET DES MODÈLES (Généré le {now.strftime('%d/%m/%Y à %H:%M:%S')}) ===\n\n" - - # 1. Ajout des modèles de base gratuits - models_to_export = [ - {"id": "groq|llama-3.1-8b-instant", "name": "Groq - Llama 3.1 (8B)", "pricing": {"prompt": 0, "completion": 0}, "architecture": {"modality": "text"}, "description": "Modèle ultra-rapide hébergé par Groq."}, - {"id": "gemini|gemini-1.5-flash", "name": "Google - Gemini 1.5 Flash", "pricing": {"prompt": 0, "completion": 0}, "architecture": {"modality": "text/vision"}, "description": "Modèle multimodal léger de Google."} - ] - - # 2. Téléchargement en direct de tous les modèles d'OpenRouter - try: - res = requests.get("https://openrouter.ai/api/v1/models", timeout=10) - if res.status_code == 200: - for m in res.json().get("data", []): - m["id"] = f"openrouter|{m['id']}" - m["name"] = f"OR - {m['name']}" - models_to_export.append(m) - except Exception as e: - log_event("system", "ERROR", f"Erreur API OpenRouter lors de l'export : {e}") - - google_banned = False - - for m in models_to_export: - name = m.get('name', 'Inconnu') - m_id = m.get('id', 'Inconnu') - - usage = "Général / Texte" - arch_mod = str(m.get('architecture', {}).get('modality', '')).lower() - m_id_lower = m_id.lower() - - if "vision" in arch_mod or "vision" in m_id_lower or "vl" in m_id_lower or "image" in arch_mod: - usage = "Vision (Analyse d'images & Multimodal)" - elif "coder" in m_id_lower or "code" in m_id_lower or "math" in m_id_lower: - usage = "Codage & Mathématiques" - elif "video" in m_id_lower: - usage = "Vidéo" - elif "rp" in m_id_lower or "uncensored" in m_id_lower or "roleplay" in m_id_lower: - usage = "Non-censuré / Roleplay" - - is_free = m.get("is_free") - if is_free is None: - pricing = m.get('pricing', {}) - p_prompt = float(pricing.get('prompt', -1)) - p_comp = float(pricing.get('completion', -1)) - cout = "Gratuit" if p_prompt == 0.0 and p_comp == 0.0 else f"Payant (In: {p_prompt}, Out: {p_comp})" - else: - cout = "Gratuit" if is_free else "Payant" - - desc_courte = str(m.get('description', '')).replace('\n', ' ')[:150] - desc_fr = desc_courte - - if "openrouter" in m_id and desc_courte: - if not google_banned: - try: - url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=fr&dt=t&q=" + requests.utils.quote(desc_courte) - trans_res = requests.get(url, timeout=1.5) - if trans_res.status_code == 200: - desc_fr = "".join([x[0] for x in trans_res.json()[0]]) - else: - google_banned = True - desc_fr += " [Traduction interrompue: Sécurité anti-spam]" - except: - google_banned = True - desc_fr += " [Traduction interrompue: Délai d'attente]" - else: - desc_fr = desc_courte - - if not desc_fr: desc_fr = "Aucune description fournie." - - txt_content += ( - f"Nom : {name}\n" - f"ID : {m_id}\n" - f"Tarif : {cout}\n" - f"Spécialité : {usage}\n" - f"Description : {desc_fr}\n" - f"{'-'*60}\n" - ) - - filename = f"modeles_ia_{datetime.now().strftime('%Y%m%d_%H%M')}.txt" - return Response(txt_content, media_type="text/plain", headers={"Content-Disposition": f"attachment; filename={filename}"}) - -@rt('/import_catalog') -async def post(catalog_file: UploadFile, session): - if not session.get("authenticated"): return RedirectResponse('/login') - filename = catalog_file.filename.lower() - new_models = [] - ext_date, ext_time = "Inconnue", "" - - if filename.endswith(".txt"): - content = (await catalog_file.read()).decode('utf-8').replace('\r\n', '\n') - new_models, ext_date, ext_time = parse_txt_catalog(content) - - elif filename.endswith((".xlsx", ".xlsm")): - try: - import openpyxl - from io import BytesIO - content = await catalog_file.read() - wb = openpyxl.load_workbook(BytesIO(content), data_only=True) - ws = wb.active - - val_b2 = ws["B2"].value - val_c2 = ws["C2"].value - - if val_b2 and hasattr(val_b2, 'strftime'): ext_date = val_b2.strftime('%d/%m/%Y') - else: ext_date = str(val_b2) if val_b2 else "Inconnue" - - if val_c2 and hasattr(val_c2, 'strftime'): ext_time = val_c2.strftime('%H:%M:%S') - else: ext_time = str(val_c2) if val_c2 else "" - - for row in ws.iter_rows(min_row=5, values_only=True): - if row[0] and row[1]: - new_models.append({ - 'name': str(row[0]).strip(), - 'id': str(row[1]).strip(), - 'is_free': ("Gratuit" in str(row[2])) if row[2] else False, - 'provider': str(row[3]).strip() if row[3] else "Général", - 'description': str(row[4]).strip() if row[4] else "" - }) - except ImportError: - return Div("❌ Erreur : 'openpyxl' n'est pas installé sur le serveur. Veuillez uploader le fichier .txt.", style="color:red;") - except Exception as e: - return Div(f"❌ Erreur Excel : {str(e)}", style="color:red;") - else: - return Div("❌ Format invalide (.txt ou .xlsm attendu)", style="color:red;") - - if new_models: - save_catalog(new_models) - date_str = f"Extrait le {ext_date} à {ext_time}." if ext_date != "Inconnue" else "" - return Div(f"✅ Catalogue mis à jour ({len(new_models)} modèles). {date_str}", Script("setTimeout(()=>window.location.reload(), 2000);"), style="color:#10b981;") - else: - return Div("❌ Aucun modèle valide trouvé.", style="color:red;") - -# --- HISTORY SYSTEM --- -@rt('/session/new') -def get(session): - if not session.get("authenticated"): return RedirectResponse('/login') - sid = create_new_session() - return Div(), Input(type="hidden", name="session_id", value=sid, id="current-session-id", hx_swap_oob="true"), render_history_list() - -@rt('/session/load/{sid}') -def get(sid: str, session): - if not session.get("authenticated"): return RedirectResponse('/login') - messages = load_session_data(sid) - bubbles = [Div(P("Vous", cls="msg-user"), Div(NotStr(f"{msg['user']}{msg.get('info', '')}"), cls="bubble-user"), P("AETHAS38", cls="msg-ia"), Div(msg['ia'], cls="bubble-ia")) for msg in messages] - return tuple(bubbles) + (Input(type="hidden", name="session_id", value=sid, id="current-session-id", hx_swap_oob="true"),) - -@rt('/history/pin/{sid}') -def post_pin(sid: str, session): - if not session.get("authenticated"): return RedirectResponse('/login') - idx = load_index() - if sid in idx: - idx[sid]['pinned'] = not idx[sid].get('pinned', False) - save_index(idx) - return render_history_list() - -@rt('/history/rename/{sid}') -def post_rename(sid: str, title: str, session): - if not session.get("authenticated"): return RedirectResponse('/login') - idx = load_index() - if sid in idx: - idx[sid]['title'] = title - save_index(idx) - return render_history_list() - -@rt('/history/delete/{sid}') -def post_delete(sid: str, session): - if not session.get("authenticated"): return RedirectResponse('/login') - idx = load_index() - if sid in idx: - del idx[sid] - save_index(idx) - if os.path.exists(f"sessions/{sid}.json"): os.remove(f"sessions/{sid}.json") - return render_history_list() - -if __name__ == '__main__': - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=5001) \ No newline at end of file