v2.0.0 - Validation Étape 1 et configuration des fournisseurs
Build and Push Docker Image / build-and-push (push) Failing after 27s

This commit is contained in:
xavier committed 2026-10-06 11:22:12 +02:00
1 parent 1e7b07452b
commit 42aaf4a629
8 files changed
+212 -685

No files matched your search

-20
View File
@@ -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"]
+5
View File
@@ -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"]
+25
View File
@@ -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)
+28
View File
@@ -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."}
@@ -5,3 +5,7 @@ pyotp
qrcode
psutil
openpyxl
fastapi
uvicorn
pydantic
python-dotenv
+6 -18
View File
@@ -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
- ./backend:/app
- ./frontend:/frontend
- ./sessions:/sessions
restart: unless-stopped
+144
View File
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AETHAS38 - Administration</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
body { background-color: #0f172a; color: #f8fafc; }
.card { background-color: #1e293b; border: 1px solid #333; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.input-box { width: 100%; padding: 10px; border-radius: 5px; background: #2a2a35; border: 1px solid #444; color: white; margin-top: 5px; outline: none; }
.input-box:focus { border-color: #00e5ff; }
.btn-primary { background-color: #00e5ff; color: #0f172a; font-weight: bold; padding: 10px 20px; border-radius: 5px; cursor: pointer; transition: 0.2s; }
.btn-primary:hover { background-color: #38bdf8; }
.btn-danger { background-color: #f43f5e; color: white; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; }
</style>
</head>
<body class="p-8">
<div id="app" class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-[#00e5ff]">⚙️ Administration AETHAS38</h1>
<span class="text-xs text-gray-400">v2.0.0</span>
</div>
<div v-if="notification" class="mb-4 p-4 rounded bg-emerald-900/50 border border-emerald-500 text-emerald-400">
{{ notification }}
</div>
<!-- Clés API -->
<div class="card">
<h2 class="text-xl font-bold text-purple-400 mb-4">🔑 Clés API Principales</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-xs font-bold text-gray-400">OpenRouter API Key</label>
<input type="password" v-model="config.api_keys.openrouter" class="input-box" placeholder="sk-or-v1-...">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Groq API Key</label>
<input type="password" v-model="config.api_keys.groq" class="input-box" placeholder="gsk_...">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Gemini API Key</label>
<input type="password" v-model="config.api_keys.gemini" class="input-box" placeholder="AIzaSy...">
</div>
</div>
</div>
<!-- Serveur SMTP -->
<div class="card">
<h2 class="text-xl font-bold text-blue-400 mb-4">📧 Configuration Serveur Mail (SMTP)</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-xs font-bold text-gray-400">Hôte (Serveur)</label>
<input type="text" v-model="config.smtp.host" class="input-box" placeholder="smtp.mail.com">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Port</label>
<input type="number" v-model="config.smtp.port" class="input-box" placeholder="587">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Utilisateur / Email</label>
<input type="text" v-model="config.smtp.user" class="input-box" placeholder="admin@aethas38.com">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Mot de passe</label>
<input type="password" v-model="config.smtp.password" class="input-box" placeholder="••••••••">
</div>
</div>
</div>
<!-- Fournisseurs Personnalisés -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-amber-400">🔌 Fournisseurs Personnalisés</h2>
<button @click="addProvider" class="btn-primary text-sm py-1 px-3">+ Ajouter</button>
</div>
<div v-for="(provider, index) in config.custom_providers" :key="index" class="p-4 bg-slate-800 rounded border border-slate-700 mb-4">
<div class="flex justify-between mb-2">
<h3 class="font-bold text-sm">Fournisseur #{{ index + 1 }}</h3>
<button @click="removeProvider(index)" class="btn-danger">Supprimer</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="text-xs text-gray-400">Nom</label>
<input type="text" v-model="provider.name" class="input-box" placeholder="Ex: Ollama Local">
</div>
<div>
<label class="text-xs text-gray-400">URL de Base</label>
<input type="text" v-model="provider.base_url" class="input-box" placeholder="http://192.168.1.X:11434/v1">
</div>
<div>
<label class="text-xs text-gray-400">Clé API (Optionnel)</label>
<input type="password" v-model="provider.api_key" class="input-box" placeholder="...">
</div>
</div>
</div>
<p v-if="config.custom_providers.length === 0" class="text-sm text-gray-500 italic">Aucun fournisseur supplémentaire configuré.</p>
</div>
<!-- Sauvegarde -->
<div class="flex justify-end mt-6">
<button @click="saveConfig" class="btn-primary text-lg">💾 Sauvegarder la Configuration</button>
</div>
</div>
<script>
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
const config = ref({ api_keys: {}, smtp: {}, custom_providers: [] });
const notification = ref("");
const loadConfig = async () => {
const res = await fetch('/api/config');
config.value = await res.json();
};
const saveConfig = async () => {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config.value)
});
const data = await res.json();
notification.value = data.message;
setTimeout(() => notification.value = "", 3000);
};
const addProvider = () => {
config.value.custom_providers.push({ name: "", base_url: "", api_key: "" });
};
const removeProvider = (index) => {
config.value.custom_providers.splice(index, 1);
};
onMounted(loadConfig);
return { config, notification, saveConfig, addProvider, removeProvider };
}
}).mount('#app');
</script>
</body>
</html>
-647
View File
@@ -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"<br><span style='color:#38bdf8; font-size:10px;'>⚙️ {len(workers)} Travailleur(s) | 📎 {len(noms_fichiers)} fichier(s)</span>"
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)