Compare commits

..
7 Commits
Author SHA1 Message Date
xavier 95125df3b2 fix: sécurisation extraction API, affichage permanent du solde dans le menu latéral et maj financière post-requête
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-07 19:16:52 +02:00
xavier 36f205a56e fix: ajout de la dépendance pytz manquante pour le planificateur CRON
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-07 18:45:37 +02:00
xavier 2eb3acceea feat: refonte bdd, ajout super-admin, pages de config par niveau, avatar natif et sélecteur de modèles par requête
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 18:39:11 +02:00
xavier 4d5046e8d0 feat: ajout des exports de conversation globaux et du parseur de code pour l'extraction ciblée
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 18:10:32 +02:00
xavier ba2f37cf59 fix: réorganisation des fonctions dans main.py pour corriger le NameError sur get_current_user
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-07 17:14:04 +02:00
xavier 4805228f28 fix: résolution des conflits d'imports et complétion des interfaces manquantes pour la v0.12.0
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 16:59:14 +02:00
xavier 6e4aee1984 feat: intégration du formulaire de modification du mot de passe utilisateur via le tableau de bord
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 16:42:38 +02:00
6 changed files with 614 additions and 446 deletions

No files matched your search

+128 -136
View File
@@ -1,100 +1,45 @@
from fastapi import FastAPI, Depends, HTTPException, status, Request, Response
from fastapi import FastAPI, Depends, HTTPException, status, Request, Response, UploadFile, File
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from typing import List
import os
import os, json, asyncio, shutil
from datetime import datetime
import pytz
# Importation de nos modules locaux
from .database import engine, Base, get_db
from .database import engine, Base, get_db, SessionLocal
from .auth import get_password_hash, generate_totp_secret, get_totp_uri, verify_password, verify_totp, create_access_token, verify_token
from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse
from .models import User, Project, Message, SystemSettings
from .orchestrator import run_orchestrator, sync_providers_models
from .models import User, Project, Message, SystemSettings, AIModel
from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse, PasswordChange
from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog
from .orchestrator import run_orchestrator, sync_providers_models, sync_finances
# Création des tables dans la base de données
Base.metadata.create_all(bind=engine)
app = FastAPI(title="AETHAS38")
app = FastAPI(title="AETHAS38 - Orchestrateur Multi-IA")
# Configuration des fichiers statiques
assets_path = os.path.join(os.getcwd(), "frontend", "assets")
avatars_path = os.path.join(assets_path, "avatars")
os.makedirs(assets_path, exist_ok=True)
os.makedirs(avatars_path, exist_ok=True)
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
def is_setup_required(db: Session) -> bool:
admin = db.query(User).filter(User.is_admin == True).first()
return admin is None
async def scheduler_task():
tz = pytz.timezone('Europe/Paris')
while True:
now = datetime.now(tz)
if (now.hour == 0 or now.hour == 12) and now.minute == 0:
db = SessionLocal()
settings = db.query(SystemSettings).first()
if settings:
try: await sync_providers_models(db, settings, "Automatique")
except: pass
db.close()
await asyncio.sleep(60)
await asyncio.sleep(30)
@app.get("/")
def read_root(db: Session = Depends(get_db)):
if is_setup_required(db):
return RedirectResponse(url="/setup")
return RedirectResponse(url="/login")
@app.on_event("startup")
async def startup_event(): asyncio.create_task(scheduler_task())
@app.get("/setup")
def setup_page(db: Session = Depends(get_db)):
if not is_setup_required(db):
return RedirectResponse(url="/login")
frontend_path = os.path.join(os.getcwd(), "frontend", "index.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface introuvable.")
return FileResponse(frontend_path)
@app.get("/login")
def login_page(db: Session = Depends(get_db)):
if is_setup_required(db):
return RedirectResponse(url="/setup")
frontend_path = os.path.join(os.getcwd(), "frontend", "login.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface de connexion introuvable.")
return FileResponse(frontend_path)
@app.post("/api/setup")
def create_admin(admin_data: AdminCreate, db: Session = Depends(get_db)):
if not is_setup_required(db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="L'installation a déjà été effectuée.")
hashed_pw = get_password_hash(admin_data.password)
totp_secret = generate_totp_secret()
new_admin = User(email=admin_data.email, username=admin_data.username, hashed_password=hashed_pw, totp_secret=totp_secret, is_admin=True)
db.add(new_admin)
new_settings = SystemSettings(
smtp_host=admin_data.smtp_host, smtp_port=admin_data.smtp_port, smtp_user=admin_data.smtp_user, smtp_password=admin_data.smtp_password,
openrouter_api_key=admin_data.openrouter_api_key, openrouter_management_key=admin_data.openrouter_management_key,
groq_api_key=admin_data.groq_api_key, gemini_api_key=admin_data.gemini_api_key, deepseek_api_key=admin_data.deepseek_api_key,
mistral_api_key=admin_data.mistral_api_key, cloudflare_account_id=admin_data.cloudflare_account_id, cloudflare_api_token=admin_data.cloudflare_api_token,
huggingface_api_key=admin_data.huggingface_api_key
)
db.add(new_settings)
db.commit()
db.refresh(new_admin)
return {"message": "Configuration terminée avec succès.", "totp_secret": totp_secret, "totp_uri": get_totp_uri(totp_secret, new_admin.username)}
@app.post("/api/login")
def login(login_data: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == login_data.username).first()
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Identifiants incorrects.")
if not verify_totp(user.totp_secret, login_data.totp_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Code 2FA invalide.")
access_token = create_access_token(data={"sub": user.username})
response.set_cookie(key="session_token", value=access_token, httponly=True, max_age=3600, samesite="lax")
return {"message": "Connexion réussie"}
@app.get("/dashboard")
def dashboard(request: Request):
token = request.cookies.get("session_token")
if not token or not verify_token(token):
return RedirectResponse(url="/login")
frontend_path = os.path.join(os.getcwd(), "frontend", "dashboard.html")
return FileResponse(frontend_path)
# --- GESTION DES PROJETS ---
def is_setup_required(db: Session) -> bool: return db.query(User).filter(User.is_superadmin == True).first() is None
def get_current_user(request: Request, db: Session = Depends(get_db)):
token = request.cookies.get("session_token")
@@ -105,86 +50,133 @@ def get_current_user(request: Request, db: Session = Depends(get_db)):
if not user: raise HTTPException(status_code=401, detail="Utilisateur introuvable")
return user
@app.get("/")
def read_root(db: Session = Depends(get_db)): return RedirectResponse(url="/setup") if is_setup_required(db) else RedirectResponse(url="/login")
@app.get("/setup")
def setup_page(db: Session = Depends(get_db)): return RedirectResponse(url="/login") if not is_setup_required(db) else FileResponse(os.path.join(os.getcwd(), "frontend", "index.html"))
@app.get("/login")
def login_page(db: Session = Depends(get_db)): return RedirectResponse(url="/setup") if is_setup_required(db) else FileResponse(os.path.join(os.getcwd(), "frontend", "login.html"))
@app.post("/api/setup")
def create_admin(admin_data: AdminCreate, db: Session = Depends(get_db)):
if not is_setup_required(db): raise HTTPException(status_code=403, detail="Déjà installé.")
new_admin = User(email=admin_data.email, username=admin_data.username, hashed_password=get_password_hash(admin_data.password), totp_secret=generate_totp_secret(), is_admin=True, is_superadmin=True)
db.add(new_admin)
new_settings = SystemSettings(smtp_host=admin_data.smtp_host, smtp_port=admin_data.smtp_port, smtp_user=admin_data.smtp_user, smtp_password=admin_data.smtp_password, openrouter_api_key=admin_data.openrouter_api_key, openrouter_management_key=admin_data.openrouter_management_key, groq_api_key=admin_data.groq_api_key, gemini_api_key=admin_data.gemini_api_key, deepseek_api_key=admin_data.deepseek_api_key, mistral_api_key=admin_data.mistral_api_key, cloudflare_account_id=admin_data.cloudflare_account_id, cloudflare_api_token=admin_data.cloudflare_api_token, huggingface_api_key=admin_data.huggingface_api_key)
db.add(new_settings)
db.commit()
db.refresh(new_admin)
return {"message": "Succès", "totp_secret": new_admin.totp_secret, "totp_uri": get_totp_uri(new_admin.totp_secret, new_admin.username)}
@app.post("/api/login")
def login(login_data: LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == login_data.username).first()
if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Identifiants incorrects.")
if not verify_totp(user.totp_secret, login_data.totp_code): raise HTTPException(status_code=401, detail="2FA invalide.")
response.set_cookie(key="session_token", value=create_access_token(data={"sub": user.username}), httponly=True, max_age=3600, samesite="lax")
return {"message": "Connexion réussie"}
@app.get("/dashboard")
def dashboard(request: Request):
token = request.cookies.get("session_token")
if not token or not verify_token(token): return RedirectResponse(url="/login")
return FileResponse(os.path.join(os.getcwd(), "frontend", "dashboard.html"))
@app.get("/api/users/me")
def get_me(current_user: User = Depends(get_current_user)):
return {"username": current_user.username, "is_admin": current_user.is_admin, "is_superadmin": current_user.is_superadmin, "avatar_path": current_user.avatar_path}
@app.put("/api/users/me/password")
def change_password(passwords: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not verify_password(passwords.old_password, current_user.hashed_password): raise HTTPException(status_code=400, detail="L'ancien mot de passe est incorrect.")
if len(passwords.new_password) < 8: raise HTTPException(status_code=400, detail="8 caractères minimum.")
current_user.hashed_password = get_password_hash(passwords.new_password)
db.commit()
return {"message": "Mot de passe mis à jour."}
@app.post("/api/users/me/avatar")
async def upload_avatar(file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
file_location = os.path.join(avatars_path, f"user_{current_user.id}.jpg")
with open(file_location, "wb") as buffer: shutil.copyfileobj(file.file, buffer)
current_user.avatar_path = f"/assets/avatars/user_{current_user.id}.jpg?v={int(datetime.now().timestamp())}"
db.commit()
return {"message": "Avatar mis à jour", "avatar_path": current_user.avatar_path}
@app.get("/api/projects", response_model=List[ProjectResponse])
def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(Project).filter(Project.user_id == current_user.id).order_by(Project.created_at.desc()).all()
def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): return db.query(Project).filter(Project.user_id == current_user.id).order_by(Project.created_at.desc()).all()
@app.post("/api/projects", response_model=ProjectResponse)
def create_project(project: ProjectCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
new_project = Project(title=project.title, user_id=current_user.id)
db.add(new_project)
db.commit()
db.refresh(new_project)
return new_project
p = Project(title=project.title, user_id=current_user.id); db.add(p); db.commit(); db.refresh(p); return p
@app.put("/api/projects/{project_id}/rename", response_model=ProjectResponse)
def rename_project(project_id: int, project_data: ProjectRename, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project: raise HTTPException(status_code=404, detail="Projet introuvable")
project.title = project_data.title
db.commit()
db.refresh(project)
return project
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not p: raise HTTPException(status_code=404)
p.title = project_data.title; db.commit(); db.refresh(p); return p
@app.put("/api/projects/{project_id}/pin", response_model=ProjectResponse)
def toggle_pin_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project: raise HTTPException(status_code=404, detail="Projet introuvable")
project.is_pinned = not project.is_pinned
db.commit()
db.refresh(project)
return project
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not p: raise HTTPException(status_code=404)
p.is_pinned = not p.is_pinned; db.commit(); db.refresh(p); return p
@app.delete("/api/projects/{project_id}")
def delete_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project: raise HTTPException(status_code=404, detail="Projet introuvable")
db.delete(project)
db.commit()
return {"message": "Projet supprimé"}
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not p: raise HTTPException(status_code=404)
db.delete(p); db.commit(); return {"message": "Supprimé"}
# --- GESTION DES MESSAGES ---
@app.get("/api/projects/{project_id}/export")
def export_project(project_id: int, format: str = "txt", db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
messages = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
if format == "json":
data = [{"role": m.role, "content": m.content, "date": m.created_at.isoformat()} for m in messages]
return Response(content=json.dumps(data, indent=2), media_type="application/json", headers={"Content-Disposition": f"attachment; filename=export_{project_id}.json"})
text = f"--- HISTORIQUE : {p.title} ---\n\n"
for m in messages: text += f"[{m.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {'VOUS' if m.role == 'user' else 'AETHAS38'}:\n{m.content}\n\n{'-'*50}\n\n"
return Response(content=text, media_type="text/plain;charset=utf-8", headers={"Content-Disposition": f"attachment; filename=export_{project_id}.txt"})
@app.get("/api/projects/{project_id}/messages", response_model=List[MessageResponse])
def get_messages(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project: raise HTTPException(status_code=404, detail="Projet introuvable")
return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
def get_messages(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
@app.post("/api/projects/{project_id}/messages", response_model=List[MessageResponse])
async def create_message(project_id: int, message: MessageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Ajoute un message, lance l'Orchestrateur asynchrone et retourne l'historique."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project: raise HTTPException(status_code=404, detail="Projet introuvable")
user_message = Message(role=message.role, content=message.content, project_id=project_id)
db.add(user_message)
db.commit()
db.add(Message(role=message.role, content=message.content, project_id=project_id)); db.commit()
history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
settings = db.query(SystemSettings).first()
conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]}
# Configuration temporaire (sera remplacée par les choix du Frontend à la Phase 2)
# Ex pour tester le pipeline complet : {"workers": ["gemini-3.5-flash-lite", "google/gemini-1.5-pro"]}
orchestrator_config = {"workers": ["gemini-3.5-flash-lite"]}
ai_response_text = await run_orchestrator(history, settings, orchestrator_config)
ai_message = Message(role="assistant", content=ai_response_text, project_id=project_id)
db.add(ai_message)
db.commit()
ai_resp = await run_orchestrator(db, history, settings, conf) # Transmission de DB
db.add(Message(role="assistant", content=ai_resp, project_id=project_id)); db.commit()
return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
# --- ROUTES MODÈLES & FINANCES ---
@app.get("/api/models/info")
def get_models_info(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
settings = db.query(SystemSettings).first()
return {
"last_sync_date": settings.last_sync_date.isoformat() if settings and settings.last_sync_date else None,
"last_sync_type": settings.last_sync_type if settings else None,
"models": db.query(AIModel).order_by(AIModel.name.asc()).all(),
"finances": db.query(FinancialLog).all()
}
@app.get("/api/finances")
def get_finances(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(FinancialLog).all()
@app.post("/api/models/sync")
async def trigger_model_sync(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Déclenche manuellement l'extraction et la mise à jour des modèles depuis les API."""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs.")
if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.")
settings = db.query(SystemSettings).first()
return await sync_providers_models(db, settings)
return await sync_providers_models(db, settings, "Manuelle")
@app.get("/api/models")
def get_models(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Renvoie la liste complète des modèles stockés en base de données."""
return db.query(AIModel).order_by(AIModel.name.asc()).all()
@app.get("/api/models/export")
def export_models(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.")
models = db.query(AIModel).all()
data = [{"provider": m.provider, "model_id": m.model_id, "name": m.name, "description_fr": m.description_fr, "domain": m.domain, "is_free": m.is_free, "context_length": m.context_length, "pricing_prompt": m.pricing_prompt, "pricing_completion": m.pricing_completion} for m in models]
return Response(content=json.dumps(data), media_type="application/json", headers={"Content-Disposition": "attachment; filename=aethas38_models.json"})
+10 -5
View File
@@ -11,6 +11,8 @@ class User(Base):
hashed_password = Column(String)
totp_secret = Column(String)
is_admin = Column(Boolean, default=False)
is_superadmin = Column(Boolean, default=False)
avatar_path = Column(String, nullable=True)
projects = relationship("Project", back_populates="owner")
class Project(Base):
@@ -48,24 +50,27 @@ class SystemSettings(Base):
cloudflare_account_id = Column(String, nullable=True)
cloudflare_api_token = Column(String, nullable=True)
huggingface_api_key = Column(String, nullable=True)
last_sync_date = Column(DateTime, nullable=True)
last_sync_type = Column(String, nullable=True)
class AIModel(Base):
"""Stocke la liste des modèles extraits depuis les fournisseurs."""
__tablename__ = "ai_models"
id = Column(Integer, primary_key=True, index=True)
provider = Column(String, index=True)
model_id = Column(String, unique=True, index=True)
name = Column(String)
description_fr = Column(String, nullable=True)
domain = Column(String, default="Texte")
is_free = Column(Boolean, default=False)
context_length = Column(Integer)
pricing_prompt = Column(Float)
pricing_completion = Column(Float)
last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class FinancialLog(Base):
"""Suivi financier par fournisseur."""
__tablename__ = "financial_logs"
id = Column(Integer, primary_key=True, index=True)
provider = Column(String, index=True)
balance = Column(Float)
total_usage = Column(Float)
provider = Column(String, unique=True, index=True)
balance = Column(Float, default=0.0)
total_usage = Column(Float, default=0.0)
checked_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+105 -99
View File
@@ -3,125 +3,131 @@ import httpx
from openai import AsyncOpenAI
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import SystemSettings, AIModel
from .logger import system_logger
from .models import SystemSettings, AIModel, FinancialLog
from datetime import datetime, timezone
# --- PARTIE 1 : EXTRACTION DES MODÈLES ---
async def sync_providers_models(db: Session, settings: SystemSettings):
"""Extrait et met à jour les modèles depuis les fournisseurs configurés."""
added_or_updated = 0
def determine_domain(model_id: str) -> str:
mid = model_id.lower()
if "vision" in mid or "vl" in mid: return "Vision & Texte"
if "coder" in mid or "code" in mid or "math" in mid: return "Code & Logique"
if "audio" in mid or "whisper" in mid: return "Audio"
return "Texte Polyvalent"
# httpx.AsyncClient permet des requêtes non-bloquantes (ultra rapide)
async def sync_finances(db: Session, settings: SystemSettings):
"""Interroge les fournisseurs pour récupérer le solde financier exact."""
async with httpx.AsyncClient() as client:
if settings.openrouter_management_key or settings.openrouter_api_key:
try:
key = settings.openrouter_management_key or settings.openrouter_api_key
# Test de l'endpoint des crédits prépayés en priorité
cred_resp = await client.get("https://openrouter.ai/api/v1/credits", headers={"Authorization": f"Bearer {key}"})
balance = 0.0
usage = 0.0
if cred_resp.status_code == 200 and cred_resp.json().get("data"):
data = cred_resp.json().get("data", {})
balance = float(data.get("total_credits") or 0.0) - float(data.get("total_usage") or 0.0)
usage = float(data.get("total_usage") or 0.0)
else:
# Fallback sur l'usage de la clé si pas de crédits prépayés
key_resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"})
if key_resp.status_code == 200:
data = key_resp.json().get("data", {})
limit = data.get("limit")
usage = float(data.get("usage") or 0.0)
balance = (float(limit) - usage) if limit is not None else -usage
update_finance_db(db, "OpenRouter", balance, usage)
except Exception as e: print(f"Erreur Finance OR: {e}")
if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0) # Gratuit en Beta
if settings.deepseek_api_key:
try:
resp = await client.get("https://api.deepseek.com/user/balance", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"})
if resp.status_code == 200:
infos = resp.json().get("balance_infos", [{}])[0]
update_finance_db(db, "DeepSeek", float(infos.get("total_balance", 0)), 0.0)
except Exception: pass
db.commit()
def update_finance_db(db, provider, balance, usage):
log = db.query(FinancialLog).filter(FinancialLog.provider == provider).first()
if log:
log.balance = balance; log.total_usage = usage; log.checked_at = datetime.now(timezone.utc)
else:
db.add(FinancialLog(provider=provider, balance=balance, total_usage=usage))
async def sync_providers_models(db: Session, settings: SystemSettings, sync_type: str = "Automatique"):
added = 0
async with httpx.AsyncClient() as client:
# 1. OpenRouter (Exemple principal pour l'extraction massive)
if settings.openrouter_api_key:
try:
response = await client.get("https://openrouter.ai/api/v1/models")
if response.status_code == 200:
for item in response.json().get("data", []):
model_id = item["id"]
existing = db.query(AIModel).filter(AIModel.model_id == model_id).first()
resp = await client.get("https://openrouter.ai/api/v1/models")
if resp.status_code == 200:
for item in resp.json().get("data", []):
pricing = item.get("pricing") or {}
pp = float(pricing.get("prompt") or 0.0) * 1000000
pc = float(pricing.get("completion") or 0.0) * 1000000
is_free = (pp == 0.0 and pc == 0.0)
desc = item.get("description", "Modèle IA générique.")[:200] + "..."
process_model(db, "openrouter", item["id"], item["name"], desc, determine_domain(item["id"]), is_free, item.get("context_length", 0), pp, pc)
added += 1
except Exception as e: print(f"Erreur Modèles OR: {e}")
pricing = item.get("pricing", {})
# Conversion en coût pour 1 Million de tokens
p_prompt = float(pricing.get("prompt", 0)) * 1000000 if pricing.get("prompt") else 0.0
p_comp = float(pricing.get("completion", 0)) * 1000000 if pricing.get("completion") else 0.0
if existing:
existing.pricing_prompt = p_prompt
existing.pricing_completion = p_comp
existing.last_updated = datetime.now(timezone.utc)
else:
new_model = AIModel(
provider="openrouter",
model_id=model_id,
name=item["name"],
context_length=item.get("context_length", 0),
pricing_prompt=p_prompt,
pricing_completion=p_comp
)
db.add(new_model)
added_or_updated += 1
except Exception as e:
system_logger.error(f"Erreur Sync OpenRouter: {e}")
if settings.groq_api_key:
try:
resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"})
if resp.status_code == 200:
for item in resp.json().get("data", []):
process_model(db, "groq", item["id"], item["id"].capitalize(), "Modèle ultra-rapide exécuté sur LPU Groq.", determine_domain(item["id"]), True, 8192, 0.0, 0.0)
added += 1
except Exception: pass
settings.last_sync_date = datetime.now(timezone.utc)
settings.last_sync_type = sync_type
db.commit()
return {"status": "success", "models_processed": added_or_updated}
await sync_finances(db, settings)
return {"status": "success", "models_processed": added}
def process_model(db, provider, mod_id, name, desc, domain, is_free, ctx, pp, pc):
existing = db.query(AIModel).filter(AIModel.model_id == mod_id).first()
if existing:
existing.pricing_prompt = pp; existing.pricing_completion = pc; existing.is_free = is_free; existing.last_updated = datetime.now(timezone.utc)
else:
db.add(AIModel(provider=provider, model_id=mod_id, name=name, description_fr=desc, domain=domain, is_free=is_free, context_length=ctx, pricing_prompt=pp, pricing_completion=pc))
# --- PARTIE 2 : MOTEUR MULTI-AGENTS ---
def get_client_for_model(model_id: str, settings: SystemSettings):
"""Retourne le client AsyncOpenAI approprié selon le modèle sélectionné."""
if "gemini" in model_id.lower() and settings.gemini_api_key:
return AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key)
elif settings.openrouter_api_key:
if "gemini" in model_id.lower() and settings.gemini_api_key: return AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key)
elif "groq" in model_id.lower() or "llama" in model_id.lower(): return AsyncOpenAI(base_url="https://api.groq.com/openai/v1", api_key=settings.groq_api_key)
return AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key)
raise ValueError(f"Aucun fournisseur configuré pour {model_id}")
async def ask_agent(client, model_id, messages, is_openrouter=False):
"""Appel asynchrone à un modèle IA."""
kwargs = {"model": model_id, "messages": messages}
if is_openrouter:
kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"}
response = await client.chat.completions.create(**kwargs)
return response.choices[0].message.content
async def run_orchestrator(history: list, settings: SystemSettings, config: dict) -> str:
"""
Gère la logique : 1 Prompteur -> N Travailleurs -> 1 Concaténeur.
Le dictionnaire 'config' proviendra de l'interface graphique.
"""
workers = config.get("workers", [])
if not workers:
workers = ["gemini-3.5-flash-lite"] # Fallback de sécurité
if is_openrouter: kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"}
resp = await client.chat.completions.create(**kwargs)
return resp.choices[0].message.content
async def run_orchestrator(db: Session, history: list, settings: SystemSettings, config: dict) -> str:
workers = config.get("workers", ["gemini-3.5-flash-lite"])
user_prompt = history[-1].content
formatted_history = [{"role": msg.role, "content": msg.content} for msg in history[:-1]]
# SCÉNARIO 1 : Un seul travailleur (Pas besoin de prompteur/concaténeur)
final_response = ""
if len(workers) == 1:
worker_model = workers[0]
client = get_client_for_model(worker_model, settings)
messages = formatted_history + [{"role": "user", "content": user_prompt}]
return await ask_agent(client, worker_model, messages, "openrouter" in worker_model.lower())
w_mod = workers[0]
final_response = await ask_agent(get_client_for_model(w_mod, settings), w_mod, formatted_history + [{"role": "user", "content": user_prompt}], "openrouter" in w_mod.lower())
else:
p_mod = config.get("prompter", "gemini-3.5-flash-lite")
optimized = await ask_agent(get_client_for_model(p_mod, settings), p_mod, [{"role": "system", "content": "Optimise cette requête."}, {"role": "user", "content": user_prompt}])
# SCÉNARIO 2 : Multi-Travailleurs (Le pipeline complet)
try:
# Étape 1 : Le Prompteur améliore la requête
prompter_model = config.get("prompter", "gemini-3.5-flash-lite")
p_client = get_client_for_model(prompter_model, settings)
p_messages = [{"role": "system", "content": "Tu es un expert en Prompt Engineering. Optimise la requête de l'utilisateur pour qu'elle soit claire, directive et parfaite pour des IAs de génération. Retourne UNIQUEMENT le prompt optimisé."}]
p_messages.append({"role": "user", "content": user_prompt})
w_tasks = [ask_agent(get_client_for_model(w, settings), w, formatted_history + [{"role": "user", "content": optimized}], "openrouter" in w.lower()) for w in workers]
responses = await asyncio.gather(*w_tasks, return_exceptions=True)
system_logger.info("Démarrage du Prompteur...")
optimized_prompt = await ask_agent(p_client, prompter_model, p_messages)
c_mod = config.get("concatenator", "gemini-3.5-flash-lite")
synth = f"Requête: {user_prompt}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{r}" for i, r in enumerate(responses)]) + "\n\nFais une synthèse finale."
final_response = await ask_agent(get_client_for_model(c_mod, settings), c_mod, [{"role": "user", "content": synth}])
# Étape 2 : Les Travailleurs en parallèle (Magie de l'Asynchrone)
system_logger.info(f"Lancement de {len(workers)} travailleurs en parallèle...")
w_tasks = []
for w_model in workers:
w_client = get_client_for_model(w_model, settings)
w_messages = formatted_history + [{"role": "user", "content": optimized_prompt}]
# On stocke les tâches sans les attendre immédiatement
w_tasks.append(ask_agent(w_client, w_model, w_messages, "openrouter" in w_model.lower()))
# 'gather' exécute toutes les requêtes en même temps !
workers_responses = await asyncio.gather(*w_tasks, return_exceptions=True)
# Étape 3 : Le Concaténeur synthétise
concat_model = config.get("concatenator", "gemini-3.5-flash-lite")
c_client = get_client_for_model(concat_model, settings)
synthesis_prompt = f"Voici la requête initiale : {user_prompt}\n\nVoici les réponses de {len(workers)} experts IA différents :\n"
for i, resp in enumerate(workers_responses):
synthesis_prompt += f"--- EXPERT {i+1} ---\n{resp if not isinstance(resp, Exception) else 'Erreur de génération'}\n\n"
synthesis_prompt += "Fais une synthèse finale parfaite, complète et structurée de ces réponses, en gardant le meilleur de chacune."
system_logger.info("Démarrage du Concaténeur...")
c_messages = [{"role": "user", "content": synthesis_prompt}]
return await ask_agent(c_client, concat_model, c_messages)
except Exception as e:
system_logger.error(f"Erreur Pipeline Multi-Agents: {e}")
raise HTTPException(status_code=502, detail=f"Échec de l'orchestration : {str(e)}")
# MAJ Financière après requête
await sync_finances(db, settings)
return final_response
+15 -12
View File
@@ -1,17 +1,15 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional
from typing import Optional, List
class AdminCreate(BaseModel):
email: EmailStr
username: str
password: str
# Serveur Mail (Obligatoire)
smtp_host: str
smtp_port: int
smtp_user: str
smtp_password: str
# Clés API (Optionnelles)
openrouter_api_key: Optional[str] = None
openrouter_management_key: Optional[str] = None
groq_api_key: Optional[str] = None
@@ -27,33 +25,38 @@ class LoginRequest(BaseModel):
password: str
totp_code: str
# --- NOUVEAU : Schémas pour les Projets ---
class ProjectBase(BaseModel):
title: str
class ProjectCreate(ProjectBase):
pass
class ProjectCreate(ProjectBase): pass
class ProjectResponse(ProjectBase):
id: int
created_at: datetime
is_pinned: bool
class Config: from_attributes = True
class Config:
from_attributes = True
class ProjectRename(BaseModel):
title: str
class OrchestratorConfig(BaseModel):
workers: List[str]
prompter: Optional[str] = "gemini-3.5-flash-lite"
concatenator: Optional[str] = "gemini-3.5-flash-lite"
# --- NOUVEAU : Schémas pour les Messages ---
class MessageBase(BaseModel):
role: str
content: str
class MessageCreate(MessageBase):
pass
config: Optional[OrchestratorConfig] = None
class MessageResponse(MessageBase):
id: int
created_at: datetime
project_id: int
class Config: from_attributes = True
class ProjectRename(BaseModel):
title: str
class PasswordChange(BaseModel):
old_password: str
new_password: str
+354 -194
View File
@@ -6,10 +6,7 @@
<title>AETHAS38 - Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } }
}
tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
@@ -17,118 +14,281 @@
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
</style>
</head>
<body class="h-screen flex overflow-hidden">
<div id="app" class="flex w-full h-full relative bg-aethas-dark">
<div v-if="isSidebarOpen" @click="toggleSidebar" class="md:hidden fixed inset-0 bg-black/60 z-40 transition-opacity"></div>
<div :class="['transition-all duration-300 flex-shrink-0 relative z-50', isSidebarOpen ? 'w-72' : 'w-0']">
<!-- MODAL PARAMÈTRES (Sécurité) -->
<div v-if="isSettingsModalOpen" class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
<div class="bg-aethas-panel border border-gray-600 rounded-2xl w-full max-w-md overflow-hidden shadow-[0_0_30px_rgba(0,0,0,0.7)]">
<div class="p-4 border-b border-gray-700 flex justify-between items-center bg-gray-800/50">
<h2 class="text-lg font-bold text-white flex items-center gap-2"><i data-lucide="settings" class="w-5 h-5 text-aethas-cyan"></i> Paramètres</h2>
<button @click="closeSettings" class="text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-6 space-y-4">
<h3 class="text-sm font-semibold text-aethas-cyan uppercase tracking-wider mb-2">Sécurité du compte</h3>
<div v-if="settingsMessage" :class="['p-3 rounded text-sm text-center', settingsMessage.includes('succès') ? 'bg-green-900/50 text-green-200 border border-green-500' : 'bg-red-900/50 text-red-200 border border-red-500']">{{ settingsMessage }}</div>
<div><label class="block text-sm text-gray-300 mb-1">Ancien mot de passe</label><input v-model="passForm.old" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none"></div>
<div><label class="block text-sm text-gray-300 mb-1">Nouveau mot de passe</label><input v-model="passForm.new" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none"></div>
<div><label class="block text-sm text-gray-300 mb-1">Confirmer</label><input v-model="passForm.confirm" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none"></div>
<button @click="changePassword" :disabled="isSavingSettings" class="w-full mt-4 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2.5 px-4 rounded-lg transition-colors border border-gray-500 disabled:opacity-50">
<span v-if="isSavingSettings">Mise à jour...</span><span v-else>Modifier</span>
</button>
</div>
</div>
</div>
<!-- BARRE LATÉRALE -->
<div :class="['transition-all duration-300 flex-shrink-0 relative z-50 flex flex-col', isSidebarOpen ? 'w-72' : 'w-0']">
<aside :class="['absolute top-0 left-0 h-full w-72 bg-aethas-panel border-r border-gray-700 flex flex-col transition-transform duration-300', isSidebarOpen ? 'translate-x-0' : '-translate-x-full']">
<div class="p-4 border-b border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="/assets/logo.png" alt="Logo" class="w-10 h-10 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<img src="/assets/logo.png" class="w-10 h-10 rounded-full border border-aethas-cyan object-cover bg-gray-800">
<span class="font-bold text-lg tracking-wider neon-text">AETHAS38</span>
</div>
<button @click="toggleSidebar" class="md:hidden text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-4">
<button @click="createNewProject" :disabled="isLoading" class="w-full bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg py-2.5 px-4 flex items-center justify-center gap-2 transition-colors disabled:opacity-50 shadow-md">
<i data-lucide="plus" class="w-5 h-5 text-aethas-cyan"></i> Nouveau Projet IA
<div class="p-4 space-y-2">
<button @click="createNewProject" :disabled="isLoading" class="w-full bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg py-2 px-4 flex items-center justify-center gap-2 shadow-md">
<i data-lucide="plus" class="w-4 h-4 text-aethas-cyan"></i> Nouveau Projet
</button>
<button v-if="user.is_admin" @click="activeView = 'models'" :class="['w-full border rounded-lg py-2 px-4 flex items-center justify-center gap-2 transition-colors', activeView === 'models' ? 'bg-aethas-cyan/10 border-aethas-cyan text-aethas-cyan' : 'bg-aethas-dark hover:bg-gray-800 text-gray-300 border-gray-700']">
<i data-lucide="database" class="w-4 h-4"></i> Modèles & Finances
</button>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<div v-if="pinnedProjects.length > 0">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Épinglés</h3>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Épinglés</h3>
<ul class="space-y-1">
<li v-for="project in pinnedProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm transition-colors', activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate">
<i data-lucide="pin" class="w-4 h-4 text-aethas-cyan fill-aethas-cyan flex-shrink-0"></i>
<span class="truncate">{{ project.title }}</span>
</div>
<div class="hidden group-hover:flex items-center gap-1 flex-shrink-0 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan p-1 rounded hover:bg-gray-700" title="Renommer"><i data-lucide="edit-2" class="w-3.5 h-3.5"></i></button>
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700" title="Désépingler"><i data-lucide="pin-off" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
<li v-for="project in pinnedProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm', activeView === 'chat' && activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate"><i data-lucide="pin" class="w-3 h-3 text-aethas-cyan"></i><span class="truncate">{{ project.title }}</span></div>
<div class="hidden group-hover:flex items-center gap-1 bg-gray-800 pl-2 rounded">
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white"><i data-lucide="pin-off" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-red-500 hover:text-red-400"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
</div>
</li>
</ul>
</div>
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Récents</h3>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Récents</h3>
<ul class="space-y-1">
<li v-if="recentProjects.length === 0 && pinnedProjects.length === 0" class="text-sm text-gray-500 italic p-2">Aucun projet.</li>
<li v-for="project in recentProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm transition-colors', activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate">
<i data-lucide="message-square" class="w-4 h-4 text-gray-500 flex-shrink-0"></i>
<span class="truncate">{{ project.title }}</span>
</div>
<div class="hidden group-hover:flex items-center gap-1 flex-shrink-0 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan p-1 rounded hover:bg-gray-700" title="Renommer"><i data-lucide="edit-2" class="w-3.5 h-3.5"></i></button>
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700" title="Épingler"><i data-lucide="pin" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
<li v-for="project in recentProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm', activeView === 'chat' && activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate"><i data-lucide="message-square" class="w-3 h-3 text-gray-500"></i><span class="truncate">{{ project.title }}</span></div>
<div class="hidden group-hover:flex items-center gap-1 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan"><i data-lucide="edit-2" class="w-3.5 h-3.5"></i></button>
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white"><i data-lucide="pin" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
</div>
</li>
</ul>
</div>
</div>
<div class="p-4 border-t border-gray-700 flex justify-between items-center">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-sm font-bold shadow-md">{{ username.charAt(0).toUpperCase() }}</div>
<span class="text-sm font-medium">{{ username }}</span>
<!-- NOUVEAU : ENCART FINANCIER SOUS LE MENU RÉCENTS -->
<div class="px-4 pb-2 border-b border-gray-700">
<div v-if="financesList.length > 0" class="flex flex-col gap-1.5 mb-2">
<div v-for="fin in financesList" :key="fin.provider" class="text-[10px] bg-gray-800/80 border border-gray-600/50 px-2 py-1.5 rounded flex justify-between items-center shadow-inner">
<span class="text-gray-400 capitalize">{{ fin.provider }}</span>
<span :class="fin.balance > 0 ? 'text-green-400 font-bold' : 'text-aethas-cyan'">${{ fin.balance > 0 ? fin.balance.toFixed(4) : Math.abs(fin.total_usage).toFixed(4) }}</span>
</div>
<button class="text-gray-400 hover:text-white transition-colors" title="Paramètres">
<i data-lucide="settings" class="w-5 h-5"></i>
</button>
</div>
</div>
<div class="p-4 flex justify-between items-center cursor-pointer hover:bg-gray-800 transition-colors" @click="activeView = 'settings'">
<div class="flex items-center gap-2">
<img v-if="user.avatar_path" :src="user.avatar_path" class="w-8 h-8 rounded-full object-cover border border-gray-600 bg-gray-900">
<div v-else class="w-8 h-8 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-sm font-bold shadow-md">{{ user.username.charAt(0).toUpperCase() }}</div>
<div class="flex flex-col leading-tight">
<span class="text-sm font-medium">{{ user.username }}</span>
<span v-if="user.is_superadmin" class="text-[9px] text-purple-300">SUPER-ADMIN</span>
<span v-else-if="user.is_admin" class="text-[9px] text-red-300">ADMINISTRATEUR</span>
</div>
</div>
<i data-lucide="settings" class="w-5 h-5 text-gray-400"></i>
</div>
</aside>
</div>
<main class="flex-1 flex flex-col min-w-0 z-10 transition-all duration-300">
<!-- ZONE PRINCIPALE -->
<main class="flex-1 flex flex-col min-w-0 z-10 transition-all duration-300 bg-aethas-dark relative">
<header class="flex items-center justify-between p-4 border-b border-gray-700 bg-aethas-panel h-[73px]">
<div class="flex items-center gap-3">
<button @click="toggleSidebar" class="text-gray-400 hover:text-white transition-colors p-1.5 rounded hover:bg-gray-700"><i data-lucide="menu" class="w-6 h-6"></i></button>
<div v-show="!isSidebarOpen" class="flex items-center gap-2 transition-opacity duration-300">
<img src="/assets/logo.png" alt="Logo" class="w-8 h-8 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<button @click="toggleSidebar" class="text-gray-400 hover:text-white p-1.5 rounded hover:bg-gray-700"><i data-lucide="menu" class="w-6 h-6"></i></button>
<div v-show="!isSidebarOpen" class="flex items-center gap-2">
<img src="/assets/logo.png" class="w-8 h-8 rounded-full border border-aethas-cyan">
<span class="font-bold text-lg neon-text">AETHAS38</span>
</div>
</div>
<div v-if="activeProject" class="text-sm font-medium text-gray-300 flex items-center gap-2">
{{ activeProject.title }}
<button @click="renameProject(activeProject)" class="text-gray-500 hover:text-aethas-cyan"><i data-lucide="edit-2" class="w-4 h-4"></i></button>
<div v-if="activeView === 'chat' && activeProject" class="text-sm font-medium text-gray-300 flex items-center gap-4">
<span class="truncate max-w-[200px] md:max-w-md">{{ activeProject.title }}</span>
<div class="flex items-center gap-2 border-l border-gray-600 pl-4">
<button @click="exportProject('txt')" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 border border-gray-600 px-2 py-1 rounded flex items-center gap-1"><i data-lucide="download" class="w-3 h-3"></i> TXT</button>
<button @click="exportProject('json')" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 border border-gray-600 px-2 py-1 rounded flex items-center gap-1"><i data-lucide="file-json" class="w-3 h-3"></i> JSON</button>
</div>
</div>
</header>
<div v-if="!activeProject" class="flex-1 overflow-y-auto p-4 md:p-8 flex flex-col items-center justify-center">
<img src="/assets/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mb-6 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800 flex-shrink-0">
<h2 class="text-2xl font-bold text-gray-200 mb-2 text-center">Comment puis-je vous aider aujourd'hui ?</h2>
<p class="text-gray-500 text-sm text-center">Sélectionnez ou créez un projet pour commencer.</p>
<!-- VUE : CONFIGURATION (Par Niveaux) -->
<div v-if="activeView === 'settings'" class="flex-1 overflow-y-auto p-4 md:p-8">
<div class="max-w-4xl mx-auto space-y-6">
<h2 class="text-2xl font-bold text-white flex items-center gap-2 border-b border-gray-700 pb-4"><i data-lucide="settings-2" class="w-6 h-6 text-aethas-cyan"></i> Configuration Système</h2>
<div class="flex border-b border-gray-700">
<button @click="settingsTab = 'user'" :class="['py-2 px-4 text-sm font-semibold border-b-2 transition-colors', settingsTab === 'user' ? 'border-aethas-cyan text-aethas-cyan' : 'border-transparent text-gray-400 hover:text-white']">Mon Profil (Utilisateur)</button>
<button v-if="user.is_admin" @click="settingsTab = 'admin'" :class="['py-2 px-4 text-sm font-semibold border-b-2 transition-colors', settingsTab === 'admin' ? 'border-red-500 text-red-400' : 'border-transparent text-gray-400 hover:text-white']">Administration</button>
<button v-if="user.is_superadmin" @click="settingsTab = 'super'" :class="['py-2 px-4 text-sm font-semibold border-b-2 transition-colors', settingsTab === 'super' ? 'border-aethas-purple text-purple-400' : 'border-transparent text-gray-400 hover:text-white']">Super-Admin</button>
</div>
<div v-else class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 flex flex-col" id="chat-container">
<!-- Tab: Utilisateur -->
<div v-if="settingsTab === 'user'" class="space-y-6">
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700">
<h3 class="text-lg font-semibold text-white mb-4">Personnalisation du Profil</h3>
<div class="flex items-center gap-6">
<img v-if="user.avatar_path" :src="user.avatar_path" class="w-20 h-20 rounded-full object-cover border-2 border-aethas-cyan bg-gray-900 shadow-lg">
<div v-else class="w-20 h-20 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-2xl font-bold shadow-lg">{{ user.username.charAt(0).toUpperCase() }}</div>
<div class="flex-1">
<label class="bg-aethas-panel hover:bg-gray-700 text-white border border-gray-600 px-4 py-2 rounded-lg cursor-pointer transition-colors text-sm inline-block">
<i data-lucide="upload" class="w-4 h-4 inline mr-1"></i> Uploader un nouvel Avatar
<input type="file" accept="image/*" @change="uploadAvatar" class="hidden">
</label>
<p class="text-xs text-gray-500 mt-2">L'image sera automatiquement recadrée et redimensionnée à l'affichage pour s'adapter parfaitement à l'interface.</p>
</div>
</div>
</div>
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700">
<h3 class="text-lg font-semibold text-white mb-4">Sécurité</h3>
<div v-if="settingsMessage" class="p-3 rounded text-sm mb-4 bg-gray-900 border border-aethas-cyan text-aethas-cyan">{{ settingsMessage }}</div>
<div class="grid gap-4 max-w-md">
<input v-model="passForm.old" type="password" placeholder="Ancien mot de passe" class="bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none">
<input v-model="passForm.new" type="password" placeholder="Nouveau mot de passe" class="bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none">
<input v-model="passForm.confirm" type="password" placeholder="Confirmer" class="bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none">
<button @click="changePassword" :disabled="isSavingSettings" class="bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg disabled:opacity-50">Modifier le mot de passe</button>
</div>
</div>
</div>
<!-- Tab: Admin -->
<div v-if="settingsTab === 'admin' && user.is_admin" class="space-y-6">
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700">
<h3 class="text-lg font-semibold text-red-400 mb-4">Outils Administrateur</h3>
<p class="text-sm text-gray-400 mb-4">Fonctionnalités réservées aux administrateurs pour la gestion des modèles et le suivi de base.</p>
<div class="flex gap-4">
<button @click="activeView = 'models'" class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg text-sm border border-gray-600">Ouvrir le panneau Modèles IA</button>
<a href="/api/models/export" target="_blank" class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg text-sm border border-gray-600">Exporter DB Modèles (JSON)</a>
</div>
</div>
</div>
<!-- Tab: Super-Admin -->
<div v-if="settingsTab === 'super' && user.is_superadmin" class="space-y-6">
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700 border-l-4 border-l-aethas-purple">
<h3 class="text-lg font-semibold text-purple-400 mb-4">Espace Réservé Super-Admin</h3>
<p class="text-sm text-gray-400">La gestion complète du serveur mail, des clés API et de l'élévation des droits utilisateurs sera disponible ici lors du prochain patch de développement.</p>
</div>
</div>
</div>
</div>
<!-- VUE : MODÈLES ET FINANCES (Admin Only) -->
<div v-if="activeView === 'models' && user.is_admin" class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6">
<div class="flex justify-between items-end border-b border-gray-700 pb-4">
<div>
<h2 class="text-2xl font-bold text-white flex items-center gap-2"><i data-lucide="cpu" class="w-6 h-6 text-aethas-cyan"></i> Modèles & Finances API</h2>
<p class="text-sm text-gray-400 mt-1">Dernière extraction : <span class="text-gray-200 font-mono">{{ formatDate(modelInfo.last_sync_date) }}</span> <span class="text-xs bg-gray-800 px-2 py-0.5 rounded border border-gray-600 ml-2 uppercase">{{ modelInfo.last_sync_type || 'Aucune' }}</span></p>
</div>
<div class="flex gap-2">
<button @click="syncModels" :disabled="isSyncing" class="bg-aethas-cyan hover:bg-cyan-600 text-aethas-dark font-semibold py-2 px-4 rounded flex items-center gap-2 text-sm disabled:opacity-50"><i data-lucide="refresh-cw" :class="{'animate-spin': isSyncing}" class="w-4 h-4"></i> Forcer Extraction</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="fin in financesList" :key="fin.provider" class="bg-gray-800 border border-gray-700 rounded-xl p-4 flex flex-col justify-between">
<div class="flex justify-between items-start mb-2"><span class="font-bold text-gray-200 capitalize">{{ fin.provider }}</span><i data-lucide="wallet" class="w-5 h-5 text-gray-500"></i></div>
<div>
<div class="text-2xl font-mono" :class="fin.balance > 0 ? 'text-green-400' : 'text-aethas-cyan'">${{ fin.balance > 0 ? fin.balance.toFixed(4) : Math.abs(fin.total_usage).toFixed(4) }}</div>
<div class="text-xs text-gray-400 mt-1">Conso. API : ${{ fin.total_usage.toFixed(4) }}</div>
</div>
</div>
</div>
<div class="bg-aethas-panel border border-gray-700 rounded-xl overflow-hidden flex flex-col">
<div class="p-4 bg-gray-800/50 border-b border-gray-700 flex flex-wrap gap-4 items-center">
<input v-model="filterSearch" type="text" placeholder="Rechercher..." class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 w-full md:w-64 focus:border-aethas-cyan focus:outline-none">
<select v-model="filterDomain" class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none">
<option value="">Tous les domaines</option>
<option value="Texte Polyvalent">Texte Polyvalent</option>
<option value="Code & Logique">Code & Logique</option>
<option value="Vision & Texte">Vision & Texte</option>
<option value="Audio">Audio</option>
</select>
<select v-model="filterPricing" class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none">
<option value="">Tous les prix</option>
<option value="free">Gratuits uniquement</option>
<option value="paid">Payants</option>
</select>
</div>
<div class="overflow-x-auto max-h-[500px] custom-scrollbar">
<table class="w-full text-left text-sm text-gray-300">
<thead class="text-xs uppercase bg-gray-800 text-gray-400 sticky top-0"><tr><th class="px-4 py-3">Modèle</th><th class="px-4 py-3">Fournisseur</th><th class="px-4 py-3">Domaine</th><th class="px-4 py-3">Coût (1M Tokens)</th><th class="px-4 py-3">Contexte</th><th class="px-4 py-3 w-1/3">Description</th></tr></thead>
<tbody>
<tr v-for="model in filteredModels" :key="model.model_id" class="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-aethas-cyan break-all">{{ model.model_id }}</td>
<td class="px-4 py-3 capitalize">{{ model.provider }}</td>
<td class="px-4 py-3"><span class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs">{{ model.domain }}</span></td>
<td class="px-4 py-3 font-mono text-xs"><span v-if="model.is_free" class="text-green-400 font-bold">GRATUIT</span><span v-else>In: ${{ model.pricing_prompt.toFixed(2) }}<br>Out: ${{ model.pricing_completion.toFixed(2) }}</span></td>
<td class="px-4 py-3">{{ (model.context_length / 1000).toFixed(0) }}k</td>
<td class="px-4 py-3 text-xs text-gray-400 truncate max-w-[250px]" :title="model.description_fr">{{ model.description_fr }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- VUE : CHAT PAR DÉFAUT -->
<template v-if="activeView === 'chat'">
<div v-if="!activeProject" class="flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center">
<img src="/assets/logo.png" class="w-24 h-24 mb-6 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)]">
<h2 class="text-2xl font-bold text-gray-200 mb-2">Comment puis-je vous aider aujourd'hui ?</h2>
</div>
<div v-else class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 flex flex-col custom-scrollbar" id="chat-container">
<div v-if="messages.length === 0" class="flex-1 flex items-center justify-center text-gray-500 italic">Démarrez la conversation ci-dessous.</div>
<div v-for="msg in messages" :key="msg.id" :class="['max-w-3xl rounded-2xl p-4', msg.role === 'user' ? 'bg-gray-800 ml-auto border border-gray-700' : 'bg-aethas-panel mr-auto border border-aethas-cyan/30']">
<div v-for="msg in messages" :key="msg.id" :class="['max-w-4xl rounded-2xl p-4', msg.role === 'user' ? 'bg-gray-800 ml-auto border border-gray-700' : 'bg-aethas-panel mr-auto border border-aethas-cyan/30 flex flex-col']">
<div class="flex items-center gap-2 mb-2">
<i v-if="msg.role === 'user'" data-lucide="user" class="w-4 h-4 text-gray-400"></i>
<i v-else data-lucide="bot" class="w-4 h-4 text-aethas-cyan"></i>
<img v-if="msg.role === 'user' && user.avatar_path" :src="user.avatar_path" class="w-5 h-5 rounded-full object-cover">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">{{ msg.role === 'user' ? 'Vous' : 'AETHAS38' }}</span>
</div>
<div class="text-gray-200 whitespace-pre-wrap text-sm leading-relaxed">{{ msg.content }}</div>
<div class="text-gray-200 text-sm leading-relaxed w-full">
<template v-for="(part, index) in parseMessage(msg.content)" :key="index">
<div v-if="part.type === 'text'" class="whitespace-pre-wrap font-sans">{{ part.content }}</div>
<div v-if="part.type === 'code'" class="my-4 bg-gray-900 rounded-lg border border-gray-700 overflow-hidden shadow-inner">
<div class="flex justify-between items-center px-4 py-2 bg-gray-800 border-b border-gray-700">
<span class="text-xs text-gray-400 font-mono flex items-center gap-2"><i data-lucide="file-code-2" class="w-4 h-4"></i> {{ part.language || 'code' }}</span>
<button @click="downloadFile(part.content, part.language)" class="text-aethas-cyan hover:text-white flex items-center gap-1 text-xs transition-colors bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded"><i data-lucide="download" class="w-3 h-3"></i> Fichier</button>
</div>
<pre class="p-4 text-xs font-mono text-gray-300 overflow-x-auto custom-scrollbar"><code>{{ part.content }}</code></pre>
</div>
</template>
</div>
<div v-if="msg.role !== 'user'" class="mt-3 flex items-center gap-3 border-t border-gray-700/50 pt-2 opacity-50 hover:opacity-100 transition-opacity">
<button @click="copyToClipboard(msg.content)" class="text-xs text-gray-400 hover:text-aethas-cyan flex items-center gap-1"><i data-lucide="copy" class="w-3 h-3"></i> Copier</button>
<button @click="downloadMessage(msg.content, msg.created_at)" class="text-xs text-gray-400 hover:text-aethas-cyan flex items-center gap-1"><i data-lucide="file-down" class="w-3 h-3"></i> Extraire</button>
</div>
</div>
<div v-if="isAiThinking" class="max-w-3xl rounded-2xl p-4 bg-aethas-panel mr-auto border border-aethas-cyan/30 w-24">
<div class="flex items-center gap-2 mb-2">
<i data-lucide="bot" class="w-4 h-4 text-aethas-cyan"></i>
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">AETHAS38</span>
</div>
<div class="flex space-x-1.5 h-4 items-center">
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.15s"></div>
@@ -137,187 +297,187 @@
</div>
</div>
<div class="p-4 md:p-6 w-full max-w-4xl mx-auto">
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan focus-within:ring-1 focus-within:ring-aethas-cyan transition-all">
<button class="p-3 text-gray-400 hover:text-aethas-cyan transition-colors flex-shrink-0" :disabled="!activeProject || isAiThinking"><i data-lucide="paperclip" class="w-5 h-5"></i></button>
<textarea
v-model="prompt"
@input="autoResize"
@keydown.enter.exact.prevent="sendMessage"
ref="promptTextarea"
:disabled="!activeProject || isAiThinking"
rows="1"
class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none leading-relaxed disabled:opacity-50"
placeholder="Posez votre question à l'Orchestrateur..."></textarea>
<button @click="sendMessage" :disabled="!activeProject || !prompt.trim() || isAiThinking" class="p-3 text-aethas-cyan hover:text-white transition-colors flex-shrink-0 disabled:opacity-50 disabled:text-gray-500"><i data-lucide="send" class="w-5 h-5"></i></button>
<!-- ZONE SÉLECTION MODÈLES PAR REQUÊTE -->
<div v-if="activeProject" class="p-4 md:p-6 w-full max-w-5xl mx-auto space-y-2">
<div class="bg-gray-800/80 border border-gray-700 rounded-xl p-3 flex flex-wrap gap-4 items-center text-xs shadow-md">
<div class="flex items-center gap-2 flex-1 min-w-[200px]">
<span class="text-aethas-cyan font-semibold w-20"><i data-lucide="users" class="w-3 h-3 inline"></i> Travailleurs:</span>
<select multiple v-model="selectedWorkers" @change="limitWorkers" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 w-full h-16 outline-none focus:border-aethas-cyan custom-scrollbar">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }} ({{ m.domain }})</option>
</select>
<span class="text-gray-500 ml-1">{{ selectedWorkers.length }}/5</span>
</div>
<p class="text-center text-xs text-gray-500 mt-3">AETHAS38 peut faire des erreurs. Vérifiez toujours les informations importantes.</p>
<div class="flex flex-col gap-2 flex-1 min-w-[200px]" :class="{'opacity-30 pointer-events-none': selectedWorkers.length <= 1}">
<div class="flex items-center gap-2">
<span class="text-gray-400 font-semibold w-24"><i data-lucide="edit-3" class="w-3 h-3 inline"></i> Prompteur:</span>
<select v-model="selectedPrompter" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 flex-1 outline-none truncate">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-aethas-purple font-semibold w-24"><i data-lucide="git-merge" class="w-3 h-3 inline"></i> Concaténeur:</span>
<select v-model="selectedConcatenator" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 flex-1 outline-none truncate">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
</div>
</div>
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan">
<textarea v-model="prompt" @input="autoResize" @keydown.enter.exact.prevent="sendMessage" ref="promptTextarea" :disabled="isAiThinking" rows="1" class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none disabled:opacity-50 custom-scrollbar" placeholder="Sélectionnez vos modèles ci-dessus, puis posez votre question..."></textarea>
<button @click="sendMessage" :disabled="!prompt.trim() || isAiThinking" class="p-3 text-aethas-cyan hover:text-white disabled:opacity-50"><i data-lucide="send" class="w-5 h-5"></i></button>
</div>
</div>
</template>
</main>
</div>
<script>
const { createApp, ref, onMounted, nextTick, computed } = Vue;
const { createApp, ref, reactive, onMounted, nextTick, computed } = Vue;
createApp({
setup() {
const username = ref("Admin");
const user = ref({ username: "Guest", is_admin: false, is_superadmin: false, avatar_path: null });
const activeView = ref('chat');
const settingsTab = ref('user');
const isSidebarOpen = ref(true);
const prompt = ref("");
const promptTextarea = ref(null);
const projects = ref([]);
const activeProject = ref(null);
const messages = ref([]);
const isLoading = ref(false);
const isAiThinking = ref(false);
const isSyncing = ref(false);
const isSavingSettings = ref(false);
const settingsMessage = ref("");
const passForm = reactive({ old: '', new: '', confirm: '' });
const modelInfo = reactive({ last_sync_date: null, last_sync_type: null, models: [] });
const financesList = ref([]);
const filterSearch = ref("");
const filterDomain = ref("");
const filterPricing = ref("");
const selectedWorkers = ref(["gemini-3.5-flash-lite"]);
const selectedPrompter = ref("gemini-3.5-flash-lite");
const selectedConcatenator = ref("gemini-3.5-flash-lite");
const pinnedProjects = computed(() => projects.value.filter(p => p.is_pinned));
const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned));
const filteredModels = computed(() => {
return modelInfo.models.filter(m => {
const matchSearch = (m.model_id.toLowerCase().includes(filterSearch.value.toLowerCase()) || m.provider.toLowerCase().includes(filterSearch.value.toLowerCase()));
const matchDomain = filterDomain.value === "" || m.domain === filterDomain.value;
let matchPrice = true;
if (filterPricing.value === 'free') matchPrice = m.is_free;
if (filterPricing.value === 'paid') matchPrice = !m.is_free;
return matchSearch && matchDomain && matchPrice;
});
});
const formatDate = (isoString) => {
if (!isoString) return "Jamais";
const d = new Date(isoString);
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + " à " + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
};
const toggleSidebar = () => { isSidebarOpen.value = !isSidebarOpen.value; };
const autoResize = () => {
const el = promptTextarea.value;
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
};
const scrollToBottom = () => {
nextTick(() => {
const container = document.getElementById('chat-container');
if (container) container.scrollTop = container.scrollHeight;
});
};
const autoResize = () => { const el = promptTextarea.value; if(el){ el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }};
const scrollToBottom = () => { nextTick(() => { const c = document.getElementById('chat-container'); if (c) c.scrollTop = c.scrollHeight; }); };
const fetchProjects = async () => {
const fetchUser = async () => { try { const res = await fetch('/api/users/me'); if(res.ok) user.value = await res.json(); } catch(e){} };
const fetchModelsInfo = async () => { try { const res = await fetch('/api/models/info'); if(res.ok) { const data = await res.json(); modelInfo.last_sync_date = data.last_sync_date; modelInfo.last_sync_type = data.last_sync_type; modelInfo.models = data.models; } } catch(e){} };
const fetchFinances = async () => { try { const res = await fetch('/api/finances'); if(res.ok) financesList.value = await res.json(); } catch(e){} };
const syncModels = async () => {
isSyncing.value = true;
try {
const response = await fetch('/api/projects');
if (response.ok) {
projects.value = await response.json();
nextTick(() => lucide.createIcons());
const res = await fetch('/api/models/sync', { method: 'POST' });
if(res.ok) {
await fetchModelsInfo();
await fetchFinances();
alert("Synchronisation terminée !");
} else {
const err = await res.json();
alert("Erreur: " + err.detail);
}
} catch (error) { console.error(error); }
} catch(e) { alert("Erreur réseau de synchronisation."); }
isSyncing.value = false;
};
const selectProject = async (project) => {
activeProject.value = project;
messages.value = [];
const uploadAvatar = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData(); formData.append("file", file);
try {
const response = await fetch(`/api/projects/${project.id}/messages`);
if (response.ok) {
messages.value = await response.json();
scrollToBottom();
nextTick(() => lucide.createIcons());
}
} catch (error) { console.error(error); }
const res = await fetch('/api/users/me/avatar', { method: 'POST', body: formData });
const data = await res.json();
if(res.ok) user.value.avatar_path = data.avatar_path;
else alert(data.detail);
} catch(e) { alert("Erreur d'upload"); }
event.target.value = null;
};
const createNewProject = async () => {
isLoading.value = true;
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: "Nouvelle discussion" })
});
if (response.ok) {
const newProject = await response.json();
projects.value.unshift(newProject);
selectProject(newProject);
}
} catch (error) { console.error(error); }
finally { isLoading.value = false; }
};
const exportProject = (format) => { if(activeProject.value) window.open(`/api/projects/${activeProject.value.id}/export?format=${format}`, '_blank'); };
const copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text); alert("Copié !"); } catch (e) {} };
const downloadFile = (content, hint) => { let fname = hint ? hint.trim() : 'code.txt'; if (!fname.includes('.')) fname += '.txt'; const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fname; a.click(); URL.revokeObjectURL(url); };
const downloadMessage = (content, dateString) => { downloadFile(content, `message_${dateString.replace(/[:T.Z]/g, '-')}.txt`); };
const parseMessage = (content) => { const regex = /```([\w.-]*)\n([\s\S]*?)```/g; let parts = [], last = 0, m; while ((m = regex.exec(content)) !== null) { if (m.index > last) parts.push({ type: 'text', content: content.slice(last, m.index) }); parts.push({ type: 'code', language: m[1], content: m[2] }); last = m.index + m[0].length; } if (last < content.length) parts.push({ type: 'text', content: content.slice(last) }); return parts.length > 0 ? parts : [{ type: 'text', content: content }]; };
const renameProject = async (project) => {
const newTitle = window.prompt("Nouveau nom du projet :", project.title);
if (!newTitle || newTitle.trim() === "" || newTitle === project.title) return;
try {
const response = await fetch(`/api/projects/${project.id}/rename`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle.trim() })
});
if (response.ok) {
const updatedProject = await response.json();
const index = projects.value.findIndex(p => p.id === project.id);
if (index !== -1) projects.value[index] = updatedProject;
if (activeProject.value && activeProject.value.id === project.id) {
activeProject.value.title = updatedProject.title;
}
}
} catch (error) { console.error("Erreur de renommage", error); }
};
const fetchProjects = async () => { try { const res = await fetch('/api/projects'); if(res.ok) projects.value = await res.json(); nextTick(()=>lucide.createIcons()); } catch(e){} };
const selectProject = async (project) => { activeView.value = 'chat'; activeProject.value = project; messages.value = []; try { const res = await fetch(`/api/projects/${project.id}/messages`); if(res.ok) { messages.value = await res.json(); scrollToBottom(); nextTick(()=>lucide.createIcons()); } } catch(e){} };
const createNewProject = async () => { activeView.value = 'chat'; isLoading.value = true; try { const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({title: "Nouvelle discussion"})}); if(res.ok){ const np = await res.json(); projects.value.unshift(np); selectProject(np); } } catch(e){} finally{isLoading.value = false;} };
const sendMessage = async () => {
if (!prompt.value.trim() || !activeProject.value) return;
const userText = prompt.value.trim();
prompt.value = "";
autoResize();
if (!prompt.value.trim() || !activeProject.value || selectedWorkers.value.length === 0) return;
const userText = prompt.value.trim(); prompt.value = ""; autoResize();
messages.value.push({ id: Date.now(), role: 'user', content: userText });
isAiThinking.value = true;
scrollToBottom();
nextTick(() => lucide.createIcons());
isAiThinking.value = true; scrollToBottom();
const payload = { role: 'user', content: userText, config: { workers: selectedWorkers.value, prompter: selectedPrompter.value, concatenator: selectedConcatenator.value } };
try {
const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'user', content: userText })
});
const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) {
messages.value = data;
if (Array.isArray(data)) messages.value = data;
await fetchFinances(); // MAJ du budget sous le nom
} else {
selectProject(activeProject.value);
}
} else {
const err = await response.json();
alert("Erreur de l'IA: " + err.detail);
}
} catch (error) {
console.error("Erreur envoi message", error);
} finally {
isAiThinking.value = false;
scrollToBottom();
nextTick(() => lucide.createIcons());
const err = await response.json(); alert("Erreur API IA : " + err.detail);
}
} catch (error) {} finally { isAiThinking.value = false; scrollToBottom(); nextTick(()=>lucide.createIcons()); }
};
const togglePin = async (projectId) => {
try {
const response = await fetch(`/api/projects/${projectId}/pin`, { method: 'PUT' });
if (response.ok) {
const updatedProject = await response.json();
const index = projects.value.findIndex(p => p.id === projectId);
if (index !== -1) projects.value[index] = updatedProject;
}
} catch (error) { console.error(error); }
const changePassword = async () => {
settingsMessage.value = "";
if (passForm.new !== passForm.confirm) { settingsMessage.value = "Mots de passe différents."; return; }
if (passForm.new.length < 8) { settingsMessage.value = "8 caractères minimum."; return; }
isSavingSettings.value = true;
try { const res = await fetch('/api/users/me/password', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old_password: passForm.old, new_password: passForm.new }) }); const data = await res.json(); if (res.ok) { settingsMessage.value = data.message; passForm.old=''; passForm.new=''; passForm.confirm=''; } else { settingsMessage.value = data.detail; } } catch (e) {} finally { isSavingSettings.value = false; }
};
const deleteProject = async (projectId) => {
if (!confirm("Voulez-vous vraiment supprimer ce projet ?")) return;
try {
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (response.ok) {
projects.value = projects.value.filter(p => p.id !== projectId);
if (activeProject.value && activeProject.value.id === projectId) activeProject.value = null;
}
} catch (error) { console.error(error); }
};
const renameProject = async (project) => { const nt = window.prompt("Nom :", project.title); if (!nt || nt.trim() === "" || nt === project.title) return; try { const res = await fetch(`/api/projects/${project.id}/rename`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nt.trim() }) }); if (res.ok) { const up = await res.json(); const i = projects.value.findIndex(p => p.id === project.id); if (i !== -1) projects.value[i] = up; if (activeProject.value && activeProject.value.id === project.id) activeProject.value.title = up.title; } } catch (e) {} };
const togglePin = async (pid) => { try { const res = await fetch(`/api/projects/${pid}/pin`, { method: 'PUT' }); if (res.ok) { const up = await res.json(); const i = projects.value.findIndex(p => p.id === pid); if (i !== -1) projects.value[i] = up; } } catch (e) {} };
const deleteProject = async (pid) => { if (!confirm("Supprimer ce projet ?")) return; try { const res = await fetch(`/api/projects/${pid}`, { method: 'DELETE' }); if (res.ok) { projects.value = projects.value.filter(p => p.id !== pid); if (activeProject.value && activeProject.value.id === pid) activeProject.value = null; } } catch (e) {} };
const limitWorkers = () => { if(selectedWorkers.value.length > 5) { selectedWorkers.value.pop(); alert("Max 5 travailleurs."); } };
onMounted(() => {
lucide.createIcons();
fetchProjects();
});
onMounted(() => { lucide.createIcons(); fetchUser(); fetchProjects(); fetchModelsInfo(); fetchFinances(); });
return {
username, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea,
user, activeView, settingsTab, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea,
projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking,
createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject
createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject,
passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar,
modelInfo, financesList, filterSearch, filterDomain, filterPricing, filteredModels, formatDate, syncModels, isSyncing,
selectedWorkers, selectedPrompter, selectedConcatenator, limitWorkers,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage
};
}
}).mount('#app');
+2
View File
@@ -13,3 +13,5 @@ email-validator
PyJWT
openai
httpx
python-multipart
pytz