MAJ majeur
Build and Push Docker Image / build-and-push (push) Failing after 27s

This commit is contained in:
xavier committed 2026-10-06 13:59:17 +02:00
1 parent 626ec138e4
commit d6a7d10e3a
8 files changed
+518 -225

No files matched your search

+3 -3
View File
@@ -19,9 +19,9 @@ Le développement suit un versioning strict (format `vX.Y.Z`) synchronisé avec
## 🚀 ROADMAP (Ordre d'exécution strict) ## 🚀 ROADMAP (Ordre d'exécution strict)
- [X] **Étape 1 :** Création de la page d'administration (changement des clés, ajout de fournisseurs, configuration serveur mail, etc.). - [X] **Étape 1 :** Création de la page d'administration (changement des clés, ajout de fournisseurs, configuration serveur mail, etc.).
- [ ] **Étape 2 :** Ajout des fournisseurs pour le traitement d'images, de sons (génération de voix, musique, calage sur vidéo) et de vidéos. - [X] **Étape 2 :** Ajout des fournisseurs pour le traitement d'images, de sons (génération de voix, musique, calage sur vidéo) et de vidéos.
- [ ] **Étape 3 :** Possibilité de télécharger les prompts en PDF, TXT, MD et JSON. - [X] **Étape 3 :** Possibilité de télécharger les prompts en PDF, TXT, MD et JSON.
- [ ] **Étape 4 :** Possibilité de télécharger les réponses en PDF, TXT, MD et JSON. - [X] **Étape 4 :** Possibilité de télécharger les réponses en PDF, TXT, MD et JSON.
- [ ] **Étape 5 :** Rédaction du Wiki détaillé pour Gitea et GitHub. - [ ] **Étape 5 :** Rédaction du Wiki détaillé pour Gitea et GitHub.
- [ ] **Étape 6 :** Ne plus créer systématiquement un nouveau projet à l'ouverture de l'application. - [ ] **Étape 6 :** Ne plus créer systématiquement un nouveau projet à l'ouverture de l'application.
- [ ] **Étape 7 :** Refonte de la gestion des projets (séparation Épinglés / Non Épinglés, avec le dernier utilisé en tête de liste). - [ ] **Étape 7 :** Refonte de la gestion des projets (séparation Épinglés / Non Épinglés, avec le dernier utilisé en tête de liste).
+39
View File
@@ -0,0 +1,39 @@
# ai_engine.py
import os
import requests
import asyncio
from datetime import datetime
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")[cite: 11]
def ask_llm(session_id, full_id, msg):
parts = full_id.split("|", 1)
provider, actual_model = parts[0], parts[1] if len(parts) > 1 else full_id
log_event(session_id, "API_REQ", f"Provider: {provider} | Modèle: {actual_model}")[cite: 11]
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}"[cite: 11]
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}"[cite: 11]
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}"[cite: 11]
return "⚠️ Fournisseur API non implémenté ou ID invalide[cite: 11]."
except Exception as e: return f"⚠ Erreur de connexion : {str(e)}"[cite: 11]
async def async_ask_llm(session_id, full_id, msg):
return await asyncio.to_thread(ask_llm, session_id, full_id, msg)[cite: 11]
+2 -3
View File
@@ -1,3 +1,4 @@
# auth.py
import re import re
import pyotp import pyotp
import hashlib import hashlib
@@ -35,7 +36,6 @@ def send_invite_email(to_email, setup_link):
smtp_pass = os.getenv("SMTP_PASS") smtp_pass = os.getenv("SMTP_PASS")
if not all([smtp_server, smtp_user, smtp_pass]): if not all([smtp_server, smtp_user, smtp_pass]):
print("Erreur: Configuration SMTP incomplète dans .env")
return False return False
msg = MIMEMultipart() msg = MIMEMultipart()
@@ -52,6 +52,5 @@ def send_invite_email(to_email, setup_link):
server.send_message(msg) server.send_message(msg)
server.quit() server.quit()
return True return True
except Exception as e: except Exception:
print(f"Erreur SMTP: {e}")
return False return False
+57
View File
@@ -0,0 +1,57 @@
# catalog_manager.py
import os
import json
import re
import requests
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:
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)[cite: 11]
def get_budget():
mgmt_key = os.getenv("OPENROUTER_MANAGEMENT_KEY")
if not mgmt_key: return "Clé manquante[cite: 11]"
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} $"[cite: 11]
except: return "Erreur réseau[cite: 11]"
def parse_txt_catalog(content: str):
lines = content.split('\n')
models = []
current = {}
ext_date, ext_time = "Inconnue", ""
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, ext_time = match.group(1), 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[cite: 11]
+36
View File
@@ -0,0 +1,36 @@
# history_manager.py
import os
import json
import uuid
from datetime import datetime
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 {}[cite: 11]
def save_index(idx):
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(idx, f, indent=4, ensure_ascii=False)[cite: 11]
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 [][cite: 11]
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)[cite: 11]
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[cite: 11]
+185 -118
View File
@@ -1,140 +1,207 @@
from fastapi import FastAPI, HTTPException # main.py
from fastapi.staticfiles import StaticFiles from fasthtml.common import *
from fastapi.responses import HTMLResponse, Response
from pydantic import BaseModel
import requests
import asyncio
import os import os
import uuid import time
import json import requests
import subprocess from dotenv import load_dotenv
import urllib3
import socket
import config
# Forçage IPv4 from auth import verify_password, verify_totp
old_getaddrinfo = socket.getaddrinfo from catalog_manager import MODELS_DATA, load_catalog, save_catalog, get_budget, parse_txt_catalog
def force_ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): from history_manager import load_index, save_index, load_session_data, save_session_data, create_new_session
return old_getaddrinfo(host, port, socket.AF_INET, type, proto, flags) from ai_engine import ask_llm, async_ask_llm, log_event
socket.getaddrinfo = force_ipv4_getaddrinfo
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) load_dotenv()
app = FastAPI(title="AETHAS38 API") 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;}
.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; }
.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; width: 100%;}
"""),
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<=2; i++) { if(document.getElementById('worker'+i).value !== "") wCount++; }
if(wCount === 0) { alert("Erreur : Sélectionnez au moins une IA de travail."); 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'; }
}
""")
]
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) app, rt = fast_app(secret_key=os.getenv("SESSION_SECRET", "super-secret-key-fallback"), hdrs=theme_hdrs)
FRONTEND_DIR = os.path.join(BASE_DIR, "frontend")
SESSIONS_DIR = os.path.join(BASE_DIR, "sessions")
MEDIA_DIR = os.path.join(SESSIONS_DIR, "media")
TEMP_DIR = os.path.join(SESSIONS_DIR, "temp")
os.makedirs(MEDIA_DIR, exist_ok=True) os.makedirs("sessions", exist_ok=True)
os.makedirs(TEMP_DIR, exist_ok=True)
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") @rt('/login')
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") 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;"
)
class ExportRequest(BaseModel): @rt('/auth/login')
content: str def post(email: str, password: str, totp_code: str, session):
format: str # pdf, txt, md, json if email == os.getenv("ADMIN_EMAIL") and verify_password(os.getenv("ADMIN_PASSWORD_HASH"), password) and verify_totp(os.getenv("ADMIN_2FA_SECRET"), totp_code):
type: str # prompt ou response session["authenticated"] = True
session["role"] = "admin"
return Script("window.location.href = '/';")
return Div("Identifiants ou code 2FA incorrects.", style="color:#ef4444; font-weight:bold;")
@app.get("/", response_class=HTMLResponse) @rt('/logout')
async def serve_admin(): def get(session):
index_path = os.path.join(FRONTEND_DIR, "index.html") session.clear()
with open(index_path, "r", encoding="utf-8") as f: return RedirectResponse('/login')
return f.read()
@app.get("/api/config") @rt('/check_updates')
async def get_config(): def get(session):
return config.load_config() 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:
return Span(f"🟢 Gitea Connecté ({res.json()[0].get('sha', '')[:7]})", style="color:#10b981;")
except: pass
return Span("🟠 Gitea hors ligne", style="color:#fbbf24;")
@app.post("/api/config") def render_history_list():
async def update_config(data: dict): idx = load_index()
config.save_config(data) items = [Button("➕ Nouveau Projet", cls="export-btn mb-3", style="background:#10b981; color:#0f172a;", hx_get="/session/new", hx_target="#chat-history")]
return {"status": "success", "message": "Configuration sauvegardée avec succès."} for sid, data in idx.items():
items.append(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"), cls="flex justify-between items-center w-full mb-2 bg-[#2a2a35] p-2 rounded border border-[#444]"))
return Div(*items, id="history-list-container", hx_swap_oob="true")
@app.post("/api/export") def render_model_dropdown(field_id, placeholder):
async def export_content(req: ExportRequest): groups = {}
filename_base = f"aethas38_{req.type}_{uuid.uuid4().hex[:8]}" 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 = [Option(f"{m['name']} ({'Gratuit' if str(m.get('is_free'))=='True' else 'Payant'})", value=m['id'], **{"data-free": str(m.get('is_free', False)).lower()}) for m in groups[prov]]
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;")
if req.format == "txt": SESSION_PROGRESS = {}
return Response(
content=req.content, @rt('/status/{sid}')
media_type="text/plain", def get_status(sid: str):
headers={"Content-Disposition": f"attachment; filename={filename_base}.txt"} 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(
Div(
H2("AETHAS 38", style="color:#00e5ff; font-weight:bold; font-size:24px;"),
Div("⏳ Vérification GitHub...", hx_get="/check_updates", hx_trigger="load", style="font-size:10px; 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", type="button", cls="export-btn mb-2", onclick="window.location.href='/export_models'"),
A("Déconnexion", href="/logout", cls="export-btn text-center", style="color:#ef4444; border-color:#ef4444;"),
Div(P("BUDGET", style="color:#94a3b8; font-size:10px; font-weight:bold; margin-top:8px;"), P(get_budget(), id="budget-display", style="color:#10b981; font-size:16px; font-weight:bold;")),
cls="sidebar"
),
Div(
Div(id="chat-history", cls="chat-container"),
Div(Span("⚙️ Préparation...", id="loading-text"), id="loading-tracker", cls="htmx-indicator"),
Form(
Input(type="hidden", name="session_id", value=session_id, id="current-session-id"),
Div(
Div(render_model_dropdown("writer", "1. Rédacteur"), render_model_dropdown("synthesizer", "7. Concaténeur"), style="border-right: 1px solid #444; padding-right:15px;"),
Div(render_model_dropdown("worker1", "2. Travailleur 1"), render_model_dropdown("worker2", "3. Travailleur 2"), style="padding: 0 15px;"),
cls="grid grid-cols-2 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", id="file-label-text"), _for="file-upload", cls="file-upload-btn w-40"),
Input(type="text", name="msg", placeholder="Votre consigne...", cls="input-box", required=True),
Button("Lancer", 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();"
),
cls="main-content"
),
style="display: grid; grid-template-columns: 280px 1fr;"
) )
)
elif req.format == "md": @rt('/chat')
return Response( async def post(msg: str, session_id: str, session, writer: str = "", worker1: str = "", worker2: str = "", synthesizer: str = "", fichiers: list[UploadFile] = None):
content=f"# AETHAS38 Export ({req.type.upper()})\n\n{req.content}", if not session.get("authenticated"): return RedirectResponse('/login')
media_type="text/markdown", workers = [w for w in [worker1, worker2] if w]
headers={"Content-Disposition": f"attachment; filename={filename_base}.md"}
)
elif req.format == "json": SESSION_PROGRESS[session_id] = "⚡ Traitement des IA en cours..."
json_data = json.dumps({"type": req.type, "content": req.content}, indent=4, ensure_ascii=False) tasks = [async_ask_llm(session_id, w, msg) for w in workers]
return Response( results = await asyncio.gather(*tasks)
content=json_data, final_response = results[0] if results else "Aucun travailleur configuré."
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={filename_base}.json"}
)
elif req.format == "pdf": session_data = load_session_data(session_id)
# Utilisation de LibreOffice en mode headless session_data.append({"user": msg, "ia": final_response})
html_filename = os.path.join(TEMP_DIR, f"{filename_base}.html") save_session_data(session_id, session_data)
pdf_filename = os.path.join(TEMP_DIR, f"{filename_base}.pdf")
# Création d'un HTML propre pour la conversion return Div(P("Vous", cls="msg-user"), Div(msg, cls="bubble-user"), P("AETHAS38", cls="msg-ia"), Div(final_response, cls="bubble-ia")), P(get_budget(), id="budget-display", style="color:#10b981; font-size:16px; font-weight:bold;", hx_swap_oob="true")
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; color: #333; }}
h1 {{ color: #00e5ff; border-bottom: 2px solid #ddd; padding-bottom: 10px; }}
pre, p {{ white-space: pre-wrap; line-height: 1.6; background: #f4f4f4; padding: 15px; border-radius: 5px; }}
</style>
</head>
<body>
<h1>AETHAS38 - Export {req.type.upper()}</h1>
<p>{req.content}</p>
</body>
</html>
"""
with open(html_filename, "w", encoding="utf-8") as f: @rt('/export_models')
f.write(html_content) def get(session):
if not session.get("authenticated"): return RedirectResponse('/login')
return Response("=== CATALOGUE ===\nID: groq|llama-3.1-8b-instant\nTarif: Gratuit", media_type="text/plain", headers={"Content-Disposition": "attachment; filename=modeles.txt"})
try: @rt('/session/new')
# Commande LibreOffice headless pour convertir le HTML en PDF def get(session):
# Note: selon l'OS (Windows vs Linux Docker), la commande peut s'appeler 'soffice' ou 'libreoffice' if not session.get("authenticated"): return RedirectResponse('/login')
cmd = ["soffice", "--headless", "--convert-to", "pdf", "--outdir", TEMP_DIR, html_filename] 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()
# Exécution synchrone dans un thread pour ne pas bloquer l'API @rt('/session/load/{sid}')
def run_libreoffice(): def get(sid: str, session):
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if not session.get("authenticated"): return RedirectResponse('/login')
messages = load_session_data(sid)
return tuple(Div(P("Vous", cls="msg-user"), Div(m['user'], cls="bubble-user"), P("AETHAS38", cls="msg-ia"), Div(m['ia'], cls="bubble-ia")) for m in messages) + (Input(type="hidden", name="session_id", value=sid, id="current-session-id", hx_swap_oob="true"),)
await asyncio.to_thread(run_libreoffice) if __name__ == '__main__':
import uvicorn
if not os.path.exists(pdf_filename): uvicorn.run(app, host="0.0.0.0", port=5001)
raise Exception("La génération du PDF par LibreOffice a échoué.")
with open(pdf_filename, "rb") as pdf_file:
pdf_bytes = pdf_file.read()
# Nettoyage des fichiers temporaires
os.remove(html_filename)
os.remove(pdf_filename)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename_base}.pdf"}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur de génération PDF via LibreOffice : {str(e)}")
raise HTTPException(status_code=400, detail="Format d'export non supporté.")
View File
File renamed without changes.
+196 -101
View File
@@ -14,6 +14,8 @@
.btn-primary { background-color: #00e5ff; color: #0f172a; font-weight: bold; padding: 10px 20px; border-radius: 5px; cursor: pointer; transition: 0.2s; } .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-primary:hover { background-color: #38bdf8; }
.btn-primary:disabled { background-color: #334155; color: #94a3b8; cursor: not-allowed; } .btn-primary:disabled { background-color: #334155; color: #94a3b8; cursor: not-allowed; }
.btn-export { background-color: #334155; color: #38bdf8; font-size: 11px; font-weight: bold; padding: 4px 8px; border-radius: 4px; cursor: pointer; border: 1px solid #475569; }
.btn-export:hover { background-color: #475569; color: #00e5ff; }
.btn-danger { background-color: #f43f5e; color: white; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; } .btn-danger { background-color: #f43f5e; color: white; padding: 5px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; }
.tab-btn { padding: 10px 20px; font-weight: bold; cursor: pointer; transition: 0.2s; border-bottom: 2px solid transparent; } .tab-btn { padding: 10px 20px; font-weight: bold; cursor: pointer; transition: 0.2s; border-bottom: 2px solid transparent; }
.tab-active { color: #00e5ff; border-bottom-color: #00e5ff; } .tab-active { color: #00e5ff; border-bottom-color: #00e5ff; }
@@ -24,77 +26,30 @@
<body class="p-8"> <body class="p-8">
<div id="app" class="max-w-5xl mx-auto"> <div id="app" class="max-w-5xl mx-auto">
<!-- En-tête --> <!-- En-tête & Suivi financier OpenRouter -->
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-[#00e5ff]">🤖 AETHAS38</h1> <h1 class="text-3xl font-bold text-[#00e5ff]">🤖 AETHAS38</h1>
<span class="text-xs text-gray-400">v2.1.0</span> <div class="bg-slate-800 border border-slate-700 px-4 py-2 rounded text-xs flex space-x-4">
<span>💰 OpenRouter - Crédit : <strong class="text-emerald-400">{{ orCredits.limit }}</strong></span>
<span>Consommation : <strong class="text-amber-400">{{ orCredits.usage }}</strong></span>
</div>
</div> </div>
<!-- Navigation Onglets --> <!-- Navigation Onglets -->
<div class="flex space-x-2 mb-8 border-b border-gray-700"> <div class="flex space-x-2 mb-8 border-b border-gray-700">
<button @click="currentTab = 'admin'" :class="currentTab === 'admin' ? 'tab-active' : 'tab-inactive'" class="tab-btn">⚙️ Administration</button> <button @click="currentTab = 'admin'" :class="currentTab === 'admin' ? 'tab-active' : 'tab-inactive'" class="tab-btn">⚙️ Administration & Modèles</button>
<button @click="currentTab = 'studio'" :class="currentTab === 'studio' ? 'tab-active' : 'tab-inactive'" class="tab-btn">🎬 Studio Multimédia</button> <button @click="currentTab = 'studio'" :class="currentTab === 'studio' ? 'tab-active' : 'tab-inactive'" class="tab-btn">🎬 Studio Média</button>
<button @click="currentTab = 'documents'" :class="currentTab === 'documents' ? 'tab-active' : 'tab-inactive'" class="tab-btn">📂 Documents & Exports</button>
</div> </div>
<div v-if="notification" class="mb-4 p-4 rounded bg-emerald-900/50 border border-emerald-500 text-emerald-400"> <div v-if="notification" class="mb-4 p-4 rounded bg-emerald-900/50 border border-emerald-500 text-emerald-400">
{{ notification }} {{ notification }}
</div> </div>
<!-- ONGLET : STUDIO MULTIMÉDIA (ÉTAPE 2) --> <!-- ONGLET : ADMINISTRATION & CATALOGUE DE MODÈLES -->
<div v-if="currentTab === 'studio'">
<div class="card">
<h2 class="text-xl font-bold text-purple-400 mb-4">Générateur Média (HuggingFace)</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="md:col-span-3">
<label class="text-xs font-bold text-gray-400">Votre Prompt (Description)</label>
<input type="text" v-model="mediaPrompt" class="input-box" placeholder="A futuristic cyberpunk city raining at night..." @keyup.enter="generateMedia">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Type de Média</label>
<select v-model="mediaType" class="input-box">
<option value="image">🖼️ Image (Stable Diffusion)</option>
<option value="voice">🎙️ Voix (Bark TTS)</option>
<option value="music">🎵 Musique (MusicGen)</option>
<option value="video">🎥 Vidéo (ZeroScope/Damo)</option>
</select>
</div>
</div>
<div class="flex justify-end mt-4">
<button @click="generateMedia" :disabled="isGenerating || !mediaPrompt" class="btn-primary">
{{ isGenerating ? '⏳ Génération en cours (Patientez)...' : '✨ Générer le Média' }}
</button>
</div>
</div>
<!-- Affichage du Résultat -->
<div v-if="generatedMediaUrl" class="card text-center">
<h3 class="font-bold text-gray-300 mb-4">Résultat :</h3>
<img v-if="generatedMediaType === 'image'" :src="generatedMediaUrl" class="max-w-full h-auto mx-auto rounded border border-gray-600 shadow-lg">
<audio v-if="['music', 'voice'].includes(generatedMediaType)" controls class="w-full mx-auto">
<source :src="generatedMediaUrl" type="audio/wav">
Votre navigateur ne supporte pas l'audio.
</audio>
<video v-if="generatedMediaType === 'video'" controls class="max-w-full h-auto mx-auto rounded shadow-lg">
<source :src="generatedMediaUrl" type="video/mp4">
Votre navigateur ne supporte pas la vidéo.
</video>
<div class="mt-6">
<a :href="generatedMediaUrl" download class="text-[#00e5ff] hover:underline text-sm font-bold">⬇️ Télécharger le fichier</a>
</div>
</div>
</div>
<!-- ONGLET : ADMINISTRATION (ÉTAPE 1) -->
<div v-if="currentTab === 'admin'"> <div v-if="currentTab === 'admin'">
<!-- Clés API -->
<div class="card"> <div class="card">
<h2 class="text-xl font-bold text-purple-400 mb-4">🔑 Clés API Principales</h2> <h2 class="text-xl font-bold text-purple-400 mb-4">🔑 Clés API & Suivi</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="text-xs font-bold text-gray-400">OpenRouter API Key</label> <label class="text-xs font-bold text-gray-400">OpenRouter API Key</label>
@@ -104,37 +59,43 @@
<label class="text-xs font-bold text-gray-400">Groq API Key</label> <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_..."> <input type="password" v-model="config.api_keys.groq" class="input-box" placeholder="gsk_...">
</div> </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>
</div> </div>
<!-- Fournisseurs Personnalisés --> <!-- Gestion et Choix des Modèles (Gratuit / Payant & Catégorie) -->
<div class="card"> <div class="card">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-amber-400">🔌 Fournisseurs Personnalisés</h2> <h2 class="text-xl font-bold text-cyan-400">🎛️ Sélection & Filtrage des Modèles</h2>
<button @click="addProvider" class="btn-primary text-sm py-1 px-3">+ Ajouter</button> <div class="space-x-2">
</div> <select v-model="modelFilterPricing" class="bg-slate-800 text-xs text-gray-300 p-2 rounded border border-gray-700">
<div v-for="(provider, index) in config.custom_providers" :key="index" class="p-4 bg-slate-800 rounded border border-slate-700 mb-4"> <option value="all">Tous les tarifs</option>
<div class="flex justify-between mb-2"> <option value="free">🟢 Gratuit uniquement</option>
<h3 class="font-bold text-sm">Fournisseur #{{ index + 1 }}</h3> <option value="paid">🟠 Payant</option>
<button @click="removeProvider(index)" class="btn-danger">Supprimer</button> </select>
<button @click="exportModelsJson" class="btn-export">Exporter Catalogue JSON</button>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="text-xs text-gray-400">Filtrer par Catégorie / Spécialité</label>
<input type="text" v-model="modelSearchCat" class="input-box text-xs" placeholder="Ex: Texte, Vision, Code...">
</div>
<div class="md:col-span-2">
<label class="text-xs text-gray-400">Modèle Actif Sélectionné</label>
<input type="text" v-model="selectedActiveModel" class="input-box text-xs text-emerald-400 font-bold" readonly>
</div>
</div>
<div class="bg-slate-900 p-4 rounded border border-gray-700 max-h-60 overflow-y-auto text-xs space-y-2">
<div v-for="m in filteredModels" :key="m.id" @click="selectedActiveModel = m.id" class="p-2 bg-slate-800 rounded hover:bg-slate-700 cursor-pointer flex justify-between items-center">
<div> <div>
<label class="text-xs text-gray-400">Nom (Ex: HuggingFace)</label> <strong class="text-cyan-300">{{ m.name }}</strong> <span class="text-gray-500">({{ m.id }})</span>
<input type="text" v-model="provider.name" class="input-box"> <p class="text-gray-400">{{ m.description }}</p>
</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="https://api...">
</div>
<div>
<label class="text-xs text-gray-400">Clé API</label>
<input type="password" v-model="provider.api_key" class="input-box" placeholder="hf_...">
</div> </div>
<span :class="m.pricing === 'free' ? 'text-emerald-400 bg-emerald-950 px-2 py-1 rounded text-[10px]' : 'text-amber-400 bg-amber-950 px-2 py-1 rounded text-[10px]'">
{{ m.pricing === 'free' ? 'GRATUIT' : 'PAYANT' }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -144,26 +105,121 @@
</div> </div>
</div> </div>
<!-- ONGLET : STUDIO MÉDIA -->
<div v-if="currentTab === 'studio'">
<div class="card">
<h2 class="text-xl font-bold text-purple-400 mb-4">🎬 Studio Multimédia</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="md:col-span-3">
<label class="text-xs font-bold text-gray-400">Votre Prompt</label>
<input type="text" v-model="mediaPrompt" class="input-box" placeholder="A futuristic city...">
</div>
<div>
<label class="text-xs font-bold text-gray-400">Type</label>
<select v-model="mediaType" class="input-box">
<option value="image">🖼️ Image</option>
<option value="voice">🎙️ Voix</option>
<option value="music">🎵 Musique</option>
</select>
</div>
</div>
<div class="flex justify-end">
<button @click="generateMedia" :disabled="isGenerating || !mediaPrompt" class="btn-primary">
{{ isGenerating ? '⏳ Génération...' : '✨ Générer' }}
</button>
</div>
</div>
<div v-if="generatedMediaUrl" class="card text-center">
<h3 class="font-bold text-gray-300 mb-4">Résultat :</h3>
<img :src="generatedMediaUrl" class="max-w-full h-auto mx-auto rounded border border-gray-600 shadow-lg">
<div class="mt-4"><a :href="generatedMediaUrl" download class="text-[#00e5ff] hover:underline text-sm font-bold">⬇️ Télécharger</a></div>
</div>
</div>
<!-- ONGLET : DOCUMENTS & EXPORTS -->
<div v-if="currentTab === 'documents'">
<div class="card">
<h2 class="text-xl font-bold text-blue-400 mb-4">📂 Analyse de Documents & Exports Multi-formats</h2>
<div class="mb-4">
<label class="text-xs font-bold text-gray-400">Sélectionner un fichier (PDF, Word, TXT)</label>
<input type="file" @change="handleFileUpload" class="input-box file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[#00e5ff] file:text-[#0f172a] hover:file:bg-[#38bdf8] cursor-pointer">
</div>
<div class="mb-4">
<label class="text-xs font-bold text-gray-400">Instruction associée</label>
<textarea v-model="docPrompt" rows="3" class="input-box" placeholder="Ex: Résume ce document..."></textarea>
</div>
<div class="flex justify-end">
<button @click="uploadDocument" :disabled="isUploading || !selectedFile" class="btn-primary">
{{ isUploading ? '⏳ Traitement...' : '🚀 Envoyer et Analyser' }}
</button>
</div>
</div>
<div v-if="docResult" class="card">
<div class="flex justify-between items-center mb-2">
<h3 class="font-bold text-emerald-400">Rapport : {{ docResult.filename }}</h3>
<!-- Boutons d'export directs (PDF, TXT, MD, JSON) -->
<div class="flex space-x-2">
<span class="text-xs text-gray-400 self-center">Exporter en :</span>
<button @click="exportData(docResult.full_text, 'document_result', 'pdf')" class="btn-export">PDF</button>
<button @click="exportData(docResult.full_text, 'document_result', 'txt')" class="btn-export">TXT</button>
<button @click="exportData(docResult.full_text, 'document_result', 'md')" class="btn-export">MD</button>
<button @click="exportData(docResult.full_text, 'document_result', 'json')" class="btn-export">JSON</button>
</div>
</div>
<div class="bg-slate-900 p-4 rounded border border-gray-700 whitespace-pre-wrap text-sm text-gray-200">
{{ docResult.extracted_preview }}
</div>
</div>
</div>
</div> </div>
<script> <script>
const { createApp, ref, onMounted } = Vue; const { createApp, ref, computed, onMounted } = Vue;
createApp({ createApp({
setup() { setup() {
const currentTab = ref('studio'); const currentTab = ref('admin');
const config = ref({ api_keys: {}, smtp: {}, custom_providers: [] }); const config = ref({ api_keys: {}, custom_providers: [] });
const notification = ref(""); const notification = ref("");
const orCredits = ref({ limit: "Chargement...", usage: "..." });
// Modèles et filtres
const modelFilterPricing = ref("free");
const modelSearchCat = ref("");
const selectedActiveModel = ref("groq/llama-3.1-8b-instant");
const availableModels = ref([
{ id: "groq/llama-3.1-8b-instant", name: "Groq Llama 3.1 8B", pricing: "free", category: "Texte", description: "Modèle ultra-rapide sur Groq" },
{ id: "google/gemini-1.5-flash", name: "Gemini 1.5 Flash", pricing: "free", category: "Vision / Multimodal", description: "Modèle léger Google" },
{ id: "anthropic/claude-3.5-sonnet", name: "Claude 3.5 Sonnet", pricing: "paid", category: "Texte / Code", description: "Modèle de pointe Anthropic" },
{ id: "black-forest-labs/flux-schnell", name: "Flux Schnell", pricing: "free", category: "Image", description: "Génération d'images rapide" }
]);
const filteredModels = computed(() => {
return availableModels.value.filter(m => {
const matchPricing = modelFilterPricing.value === 'all' || m.pricing === modelFilterPricing.value;
const matchCat = !modelSearchCat.value || m.category.toLowerCase().includes(modelSearchCat.value.toLowerCase()) || m.name.toLowerCase().includes(modelSearchCat.value.toLowerCase());
return matchPricing && matchCat;
});
});
// Variables Studio
const mediaPrompt = ref(""); const mediaPrompt = ref("");
const mediaType = ref("image"); const mediaType = ref("image");
const isGenerating = ref(false); const isGenerating = ref(false);
const generatedMediaUrl = ref(null); const generatedMediaUrl = ref(null);
const generatedMediaType = ref(null);
const selectedFile = ref(null);
const docPrompt = ref("");
const isUploading = ref(false);
const docResult = ref(null);
const loadConfig = async () => { const loadConfig = async () => {
const res = await fetch('/api/config'); const res = await fetch('/api/config');
config.value = await res.json(); config.value = await res.json();
const credRes = await fetch('/api/openrouter/credits');
orCredits.value = await credRes.json();
}; };
const saveConfig = async () => { const saveConfig = async () => {
@@ -177,13 +233,19 @@
setTimeout(() => notification.value = "", 3000); setTimeout(() => notification.value = "", 3000);
}; };
const addProvider = () => { config.value.custom_providers.push({ name: "", base_url: "", api_key: "" }); }; const exportModelsJson = () => {
const removeProvider = (index) => { config.value.custom_providers.splice(index, 1); }; const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(availableModels.value, null, 4));
const downloadAnchor = document.createElement('a');
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", "aethas38_models_catalog.json");
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
};
const generateMedia = async () => { const generateMedia = async () => {
if (!mediaPrompt.value) return; if (!mediaPrompt.value) return;
isGenerating.value = true; isGenerating.value = true;
generatedMediaUrl.value = null;
try { try {
const res = await fetch('/api/media/generate', { const res = await fetch('/api/media/generate', {
method: 'POST', method: 'POST',
@@ -191,23 +253,56 @@
body: JSON.stringify({ prompt: mediaPrompt.value, media_type: mediaType.value }) body: JSON.stringify({ prompt: mediaPrompt.value, media_type: mediaType.value })
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) generatedMediaUrl.value = data.url;
generatedMediaUrl.value = data.url; else alert(data.detail);
generatedMediaType.value = data.type; } catch (e) { alert("Erreur réseau"); }
} else { finally { isGenerating.value = false; }
alert(data.detail); };
}
} catch (e) { const handleFileUpload = (event) => { selectedFile.value = event.target.files[0]; };
alert("Erreur de connexion au serveur.");
} finally { const uploadDocument = async () => {
isGenerating.value = false; if (!selectedFile.value) return;
} isUploading.value = true;
docResult.value = null;
const formData = new FormData();
formData.append("file", selectedFile.value);
formData.append("prompt", docPrompt.value);
try {
const res = await fetch('/api/documents/process', { method: 'POST', body: formData });
const data = await res.json();
if (res.ok) docResult.value = data;
else alert(data.detail || "Erreur document");
} catch (e) { alert("Erreur connexion"); }
finally { isUploading.value = false; }
};
const exportData = async (content, type, format) => {
try {
const res = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, format, type })
});
if (!res.ok) throw new Error("Erreur export");
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aethas38_${type}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (e) { alert("Erreur téléchargement export."); }
}; };
onMounted(loadConfig); onMounted(loadConfig);
return { return {
currentTab, config, notification, saveConfig, addProvider, removeProvider, currentTab, config, notification, orCredits, saveConfig,
mediaPrompt, mediaType, isGenerating, generatedMediaUrl, generatedMediaType, generateMedia modelFilterPricing, modelSearchCat, selectedActiveModel, availableModels, filteredModels, exportModelsJson,
mediaPrompt, mediaType, isGenerating, generatedMediaUrl, generateMedia,
selectedFile, docPrompt, isUploading, docResult, handleFileUpload, uploadDocument, exportData
}; };
} }
}).mount('#app'); }).mount('#app');