Compare commits

..
5 Commits
Author SHA1 Message Date
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 570 additions and 476 deletions

No files matched your search

+128 -138
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.responses import RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List 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, SessionLocal
from .database import engine, Base, get_db
from .auth import get_password_hash, generate_totp_secret, get_totp_uri, verify_password, verify_totp, create_access_token, verify_token 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 .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse, PasswordChange
from .models import User, Project, Message, SystemSettings from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog
from .orchestrator import run_orchestrator, sync_providers_models from .orchestrator import run_orchestrator, sync_providers_models, sync_finances
from .models import User, Project, Message, SystemSettings, AIModel
# Création des tables dans la base de données
Base.metadata.create_all(bind=engine) 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") 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(assets_path, exist_ok=True)
os.makedirs(avatars_path, exist_ok=True)
app.mount("/assets", StaticFiles(directory=assets_path), name="assets") app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
def is_setup_required(db: Session) -> bool: async def scheduler_task():
admin = db.query(User).filter(User.is_admin == True).first() tz = pytz.timezone('Europe/Paris')
return admin is None 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("/") @app.on_event("startup")
def read_root(db: Session = Depends(get_db)): async def startup_event(): asyncio.create_task(scheduler_task())
if is_setup_required(db):
return RedirectResponse(url="/setup")
return RedirectResponse(url="/login")
@app.get("/setup") def is_setup_required(db: Session) -> bool: return db.query(User).filter(User.is_superadmin == True).first() is None
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 get_current_user(request: Request, db: Session = Depends(get_db)): def get_current_user(request: Request, db: Session = Depends(get_db)):
token = request.cookies.get("session_token") token = request.cookies.get("session_token")
@@ -105,86 +50,131 @@ def get_current_user(request: Request, db: Session = Depends(get_db)):
if not user: raise HTTPException(status_code=401, detail="Utilisateur introuvable") if not user: raise HTTPException(status_code=401, detail="Utilisateur introuvable")
return user 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é.")
# Le créateur initial devient automatiquement Super-Admin ET Admin
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)):
"""Enregistre l'avatar. Le redimensionnement parfait est géré visuellement par CSS (object-cover) dans le frontend."""
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}
# --- GESTION DES PROJETS ET MESSAGES ---
@app.get("/api/projects", response_model=List[ProjectResponse]) @app.get("/api/projects", response_model=List[ProjectResponse])
def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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()
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) @app.post("/api/projects", response_model=ProjectResponse)
def create_project(project: ProjectCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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) p = Project(title=project.title, user_id=current_user.id); db.add(p); db.commit(); db.refresh(p); return p
db.add(new_project)
db.commit()
db.refresh(new_project)
return new_project
@app.put("/api/projects/{project_id}/rename", response_model=ProjectResponse) @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)): 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() p = 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") if not p: raise HTTPException(status_code=404)
project.title = project_data.title p.title = project_data.title; db.commit(); db.refresh(p); return p
db.commit()
db.refresh(project)
return project
@app.put("/api/projects/{project_id}/pin", response_model=ProjectResponse) @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)): 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() p = 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") if not p: raise HTTPException(status_code=404)
project.is_pinned = not project.is_pinned p.is_pinned = not p.is_pinned; db.commit(); db.refresh(p); return p
db.commit()
db.refresh(project)
return project
@app.delete("/api/projects/{project_id}") @app.delete("/api/projects/{project_id}")
def delete_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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() p = 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") if not p: raise HTTPException(status_code=404)
db.delete(project) db.delete(p); db.commit(); return {"message": "Supprimé"}
db.commit()
return {"message": "Projet 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]) @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)): 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()
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()
@app.post("/api/projects/{project_id}/messages", response_model=List[MessageResponse]) @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)): 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.""" db.add(Message(role=message.role, content=message.content, project_id=project_id)); db.commit()
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()
history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
settings = db.query(SystemSettings).first() 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) ai_resp = await run_orchestrator(history, settings, conf)
# Ex pour tester le pipeline complet : {"workers": ["gemini-3.5-flash-lite", "google/gemini-1.5-pro"]} db.add(Message(role="assistant", content=ai_resp, project_id=project_id)); db.commit()
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()
return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() 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.post("/api/models/sync") @app.post("/api/models/sync")
async def trigger_model_sync(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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 admin requis.")
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs.")
settings = db.query(SystemSettings).first() settings = db.query(SystemSettings).first()
return await sync_providers_models(db, settings) return await sync_providers_models(db, settings, "Manuelle")
@app.get("/api/models") @app.get("/api/models/export")
def get_models(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): def export_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.""" if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.")
return db.query(AIModel).order_by(AIModel.name.asc()).all() 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) hashed_password = Column(String)
totp_secret = Column(String) totp_secret = Column(String)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
is_superadmin = Column(Boolean, default=False)
avatar_path = Column(String, nullable=True)
projects = relationship("Project", back_populates="owner") projects = relationship("Project", back_populates="owner")
class Project(Base): class Project(Base):
@@ -48,24 +50,27 @@ class SystemSettings(Base):
cloudflare_account_id = Column(String, nullable=True) cloudflare_account_id = Column(String, nullable=True)
cloudflare_api_token = Column(String, nullable=True) cloudflare_api_token = Column(String, nullable=True)
huggingface_api_key = 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): class AIModel(Base):
"""Stocke la liste des modèles extraits depuis les fournisseurs."""
__tablename__ = "ai_models" __tablename__ = "ai_models"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
provider = Column(String, index=True) provider = Column(String, index=True)
model_id = Column(String, unique=True, index=True) model_id = Column(String, unique=True, index=True)
name = Column(String) name = Column(String)
description_fr = Column(String, nullable=True)
domain = Column(String, default="Texte")
is_free = Column(Boolean, default=False)
context_length = Column(Integer) context_length = Column(Integer)
pricing_prompt = Column(Float) pricing_prompt = Column(Float)
pricing_completion = Column(Float) pricing_completion = Column(Float)
last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc)) last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class FinancialLog(Base): class FinancialLog(Base):
"""Suivi financier par fournisseur."""
__tablename__ = "financial_logs" __tablename__ = "financial_logs"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
provider = Column(String, index=True) provider = Column(String, unique=True, index=True)
balance = Column(Float) balance = Column(Float, default=0.0)
total_usage = Column(Float) total_usage = Column(Float, default=0.0)
checked_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) checked_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+86 -98
View File
@@ -3,125 +3,113 @@ import httpx
from openai import AsyncOpenAI from openai import AsyncOpenAI
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import SystemSettings, AIModel from .models import SystemSettings, AIModel, FinancialLog
from .logger import system_logger
from datetime import datetime, timezone from datetime import datetime, timezone
# --- PARTIE 1 : EXTRACTION DES MODÈLES --- def determine_domain(model_id: str) -> str:
async def sync_providers_models(db: Session, settings: SystemSettings): mid = model_id.lower()
"""Extrait et met à jour les modèles depuis les fournisseurs configurés.""" if "vision" in mid or "vl" in mid: return "Vision & Texte"
added_or_updated = 0 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):
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
resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"})
if resp.status_code == 200:
data = resp.json().get("data", {})
limit = data.get("limit")
usage = data.get("usage", 0)
balance = (limit - usage) if limit is not None else 0.0
update_finance_db(db, "OpenRouter", balance, usage)
except Exception: pass
if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0)
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: async with httpx.AsyncClient() as client:
# 1. OpenRouter (Exemple principal pour l'extraction massive)
if settings.openrouter_api_key: if settings.openrouter_api_key:
try: try:
response = await client.get("https://openrouter.ai/api/v1/models") resp = await client.get("https://openrouter.ai/api/v1/models")
if response.status_code == 200: if resp.status_code == 200:
for item in response.json().get("data", []): for item in resp.json().get("data", []):
model_id = item["id"]
existing = db.query(AIModel).filter(AIModel.model_id == model_id).first()
pricing = item.get("pricing", {}) pricing = item.get("pricing", {})
# Conversion en coût pour 1 Million de tokens pp = float(pricing.get("prompt", 0)) * 1000000 if pricing.get("prompt") else 0.0
p_prompt = float(pricing.get("prompt", 0)) * 1000000 if pricing.get("prompt") else 0.0 pc = float(pricing.get("completion", 0)) * 1000000 if pricing.get("completion") else 0.0
p_comp = float(pricing.get("completion", 0)) * 1000000 if pricing.get("completion") else 0.0 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: pass
if existing: if settings.groq_api_key:
existing.pricing_prompt = p_prompt try:
existing.pricing_completion = p_comp resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"})
existing.last_updated = datetime.now(timezone.utc) if resp.status_code == 200:
else: for item in resp.json().get("data", []):
new_model = AIModel( 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)
provider="openrouter", added += 1
model_id=model_id, except Exception: pass
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}")
settings.last_sync_date = datetime.now(timezone.utc)
settings.last_sync_type = sync_type
db.commit() 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): 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)
if "gemini" in model_id.lower() and 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://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key) return AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key)
elif settings.openrouter_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): async def ask_agent(client, model_id, messages, is_openrouter=False):
"""Appel asynchrone à un modèle IA."""
kwargs = {"model": model_id, "messages": messages} kwargs = {"model": model_id, "messages": messages}
if is_openrouter: if is_openrouter: kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"}
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
response = await client.chat.completions.create(**kwargs)
return response.choices[0].message.content
async def run_orchestrator(history: list, settings: SystemSettings, config: dict) -> str: async def run_orchestrator(history: list, settings: SystemSettings, config: dict) -> str:
""" workers = config.get("workers", ["gemini-3.5-flash-lite"])
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é
user_prompt = history[-1].content user_prompt = history[-1].content
formatted_history = [{"role": msg.role, "content": msg.content} for msg in history[:-1]] 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)
if len(workers) == 1: if len(workers) == 1:
worker_model = workers[0] w_mod = workers[0]
client = get_client_for_model(worker_model, settings) return await ask_agent(get_client_for_model(w_mod, settings), w_mod, formatted_history + [{"role": "user", "content": user_prompt}], "openrouter" in w_mod.lower())
messages = formatted_history + [{"role": "user", "content": user_prompt}]
return await ask_agent(client, worker_model, messages, "openrouter" in worker_model.lower())
# SCÉNARIO 2 : Multi-Travailleurs (Le pipeline complet) p_mod = config.get("prompter", "gemini-3.5-flash-lite")
try: 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}])
# É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})
system_logger.info("Démarrage du Prompteur...") 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]
optimized_prompt = await ask_agent(p_client, prompter_model, p_messages) responses = await asyncio.gather(*w_tasks, return_exceptions=True)
# Étape 2 : Les Travailleurs en parallèle (Magie de l'Asynchrone) c_mod = config.get("concatenator", "gemini-3.5-flash-lite")
system_logger.info(f"Lancement de {len(workers)} travailleurs en parallèle...") 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."
w_tasks = [] return await ask_agent(get_client_for_model(c_mod, settings), c_mod, [{"role": "user", "content": synth}])
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)}")
+15 -12
View File
@@ -1,17 +1,15 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, List
class AdminCreate(BaseModel): class AdminCreate(BaseModel):
email: EmailStr email: EmailStr
username: str username: str
password: str password: str
# Serveur Mail (Obligatoire)
smtp_host: str smtp_host: str
smtp_port: int smtp_port: int
smtp_user: str smtp_user: str
smtp_password: str smtp_password: str
# Clés API (Optionnelles)
openrouter_api_key: Optional[str] = None openrouter_api_key: Optional[str] = None
openrouter_management_key: Optional[str] = None openrouter_management_key: Optional[str] = None
groq_api_key: Optional[str] = None groq_api_key: Optional[str] = None
@@ -27,33 +25,38 @@ class LoginRequest(BaseModel):
password: str password: str
totp_code: str totp_code: str
# --- NOUVEAU : Schémas pour les Projets ---
class ProjectBase(BaseModel): class ProjectBase(BaseModel):
title: str title: str
class ProjectCreate(ProjectBase): class ProjectCreate(ProjectBase): pass
pass
class ProjectResponse(ProjectBase): class ProjectResponse(ProjectBase):
id: int id: int
created_at: datetime created_at: datetime
is_pinned: bool is_pinned: bool
class Config: from_attributes = True
class Config: class ProjectRename(BaseModel):
from_attributes = True 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): class MessageBase(BaseModel):
role: str role: str
content: str content: str
class MessageCreate(MessageBase): class MessageCreate(MessageBase):
pass config: Optional[OrchestratorConfig] = None
class MessageResponse(MessageBase): class MessageResponse(MessageBase):
id: int id: int
created_at: datetime created_at: datetime
project_id: int project_id: int
class Config: from_attributes = True
class ProjectRename(BaseModel): class PasswordChange(BaseModel):
title: str old_password: str
new_password: str
+330 -223
View File
@@ -6,10 +6,7 @@
<title>AETHAS38 - Dashboard</title> <title>AETHAS38 - Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
darkMode: 'class',
theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } }
}
</script> </script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
@@ -17,310 +14,420 @@
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; } 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; } .neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; } ::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
</style> </style>
</head> </head>
<body class="h-screen flex overflow-hidden"> <body class="h-screen flex overflow-hidden">
<div id="app" class="flex w-full h-full relative bg-aethas-dark"> <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>
<!-- BARRE LATÉRALE -->
<div :class="['transition-all duration-300 flex-shrink-0 relative z-50', isSidebarOpen ? 'w-72' : 'w-0']"> <div :class="['transition-all duration-300 flex-shrink-0 relative z-50', 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']"> <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="p-4 border-b border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3"> <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> <span class="font-bold text-lg tracking-wider neon-text">AETHAS38</span>
</div> </div>
<button @click="toggleSidebar" class="md:hidden text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button> <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>
<div class="p-4"> <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.5 px-4 flex items-center justify-center gap-2 transition-colors disabled:opacity-50 shadow-md"> <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-5 h-5 text-aethas-cyan"></i> Nouveau Projet IA <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> </button>
</div> </div>
<div class="flex-1 overflow-y-auto p-4 space-y-6"> <div class="flex-1 overflow-y-auto p-4 space-y-6">
<div v-if="pinnedProjects.length > 0"> <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"> <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']"> <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"> <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>
<i data-lucide="pin" class="w-4 h-4 text-aethas-cyan fill-aethas-cyan flex-shrink-0"></i> <div class="hidden group-hover:flex items-center gap-1 bg-gray-800 pl-2 rounded">
<span class="truncate">{{ project.title }}</span> <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>
</div> <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 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>
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>
<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"> <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', activeView === 'chat' && activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<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-3 h-3 text-gray-500"></i><span class="truncate">{{ project.title }}</span></div>
<div class="flex items-center gap-2 truncate"> <div class="hidden group-hover:flex items-center gap-1 bg-gray-800 pl-2 rounded">
<i data-lucide="message-square" class="w-4 h-4 text-gray-500 flex-shrink-0"></i> <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>
<span class="truncate">{{ project.title }}</span> <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>
</div> <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 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>
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="p-4 border-t border-gray-700 flex justify-between items-center"> <div class="p-4 border-t border-gray-700 flex justify-between items-center cursor-pointer hover:bg-gray-800 transition-colors" @click="activeView = 'settings'">
<div class="flex items-center gap-2"> <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> <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">
<span class="text-sm font-medium">{{ username }}</span> <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>
<span class="text-sm font-medium">
{{ user.username }}
<span v-if="user.is_superadmin" class="text-[10px] bg-purple-900 text-purple-200 px-1.5 py-0.5 rounded ml-1">Super-Admin</span>
<span v-else-if="user.is_admin" class="text-[10px] bg-red-900 text-red-200 px-1.5 py-0.5 rounded ml-1">Admin</span>
</span>
</div> </div>
<button class="text-gray-400 hover:text-white transition-colors" title="Paramètres"> <i data-lucide="settings" class="w-5 h-5 text-gray-400"></i>
<i data-lucide="settings" class="w-5 h-5"></i>
</button>
</div> </div>
</aside> </aside>
</div> </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]"> <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"> <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> <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 transition-opacity duration-300"> <div v-show="!isSidebarOpen" class="flex items-center gap-2">
<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"> <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> <span class="font-bold text-lg neon-text">AETHAS38</span>
</div> </div>
</div> </div>
<div v-if="activeProject" class="text-sm font-medium text-gray-300 flex items-center gap-2"> <div v-if="activeView === 'chat' && activeProject" class="text-sm font-medium text-gray-300 flex items-center gap-4">
{{ activeProject.title }} <span class="truncate max-w-[200px] md:max-w-md">{{ activeProject.title }}</span>
<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 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> </div>
</header> </header>
<div v-if="!activeProject" class="flex-1 overflow-y-auto p-4 md:p-8 flex flex-col items-center justify-center"> <!-- VUE : CONFIGURATION (Par Niveaux) -->
<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"> <div v-if="activeView === 'settings'" class="flex-1 overflow-y-auto p-4 md:p-8">
<h2 class="text-2xl font-bold text-gray-200 mb-2 text-center">Comment puis-je vous aider aujourd'hui ?</h2> <div class="max-w-4xl mx-auto space-y-6">
<p class="text-gray-500 text-sm text-center">Sélectionnez ou créez un projet pour commencer.</p> <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>
<div v-else class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 flex flex-col" id="chat-container"> <div class="flex border-b border-gray-700">
<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> <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>
<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']"> <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 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>
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">{{ msg.role === 'user' ? 'Vous' : 'AETHAS38' }}</span>
</div> </div>
<div class="text-gray-200 whitespace-pre-wrap text-sm leading-relaxed">{{ msg.content }}</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"> <!-- Tab: Utilisateur -->
<div class="flex items-center gap-2 mb-2"> <div v-if="settingsTab === 'user'" class="space-y-6">
<i data-lucide="bot" class="w-4 h-4 text-aethas-cyan"></i> <div class="bg-gray-800 p-6 rounded-xl border border-gray-700">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">AETHAS38</span> <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> </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> <!-- Tab: Admin -->
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.15s"></div> <div v-if="settingsTab === 'admin' && user.is_admin" class="space-y-6">
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.3s"></div> <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> </div>
</div> </div>
<div class="p-4 md:p-6 w-full max-w-4xl mx-auto"> <!-- VUE : MODÈLES ET FINANCES (Admin Only) -->
<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"> <div v-if="activeView === 'models' && user.is_admin" class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6">
<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> <div class="flex justify-between items-end border-b border-gray-700 pb-4">
<textarea <div>
v-model="prompt" <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>
@input="autoResize" <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>
@keydown.enter.exact.prevent="sendMessage" </div>
ref="promptTextarea" <div class="flex gap-2">
:disabled="!activeProject || isAiThinking" <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>
rows="1" </div>
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" </div>
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> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="fin in modelInfo.finances" :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 text-aethas-cyan">${{ fin.balance.toFixed(4) }}</div><div class="text-xs text-gray-400 mt-1">Conso. Totale : ${{ 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">
<table class="w-full text-left text-sm text-gray-300">
<thead class="text-xs uppercase bg-gray-800 text-gray-400"><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>
<p class="text-center text-xs text-gray-500 mt-3">AETHAS38 peut faire des erreurs. Vérifiez toujours les informations importantes.</p>
</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" 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-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">
<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 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"><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 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>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.3s"></div>
</div>
</div>
</div>
<!-- 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>
<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" 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> </main>
</div> </div>
<script> <script>
const { createApp, ref, onMounted, nextTick, computed } = Vue; const { createApp, ref, reactive, onMounted, nextTick, computed } = Vue;
createApp({ createApp({
setup() { 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 isSidebarOpen = ref(true);
const prompt = ref(""); const prompt = ref("");
const promptTextarea = ref(null); const promptTextarea = ref(null);
const projects = ref([]); const projects = ref([]);
const activeProject = ref(null); const activeProject = ref(null);
const messages = ref([]); const messages = ref([]);
const isLoading = ref(false); const isLoading = ref(false);
const isAiThinking = 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: [], finances: [] });
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 pinnedProjects = computed(() => projects.value.filter(p => p.is_pinned));
const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned)); const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned));
const toggleSidebar = () => { isSidebarOpen.value = !isSidebarOpen.value; }; const filteredModels = computed(() => {
const autoResize = () => { return modelInfo.models.filter(m => {
const el = promptTextarea.value; const matchSearch = (m.model_id.toLowerCase().includes(filterSearch.value.toLowerCase()) || m.provider.toLowerCase().includes(filterSearch.value.toLowerCase()));
if (!el) return; const matchDomain = filterDomain.value === "" || m.domain === filterDomain.value;
el.style.height = 'auto'; let matchPrice = true;
el.style.height = el.scrollHeight + 'px'; if (filterPricing.value === 'free') matchPrice = m.is_free;
}; if (filterPricing.value === 'paid') matchPrice = !m.is_free;
const scrollToBottom = () => { return matchSearch && matchDomain && matchPrice;
nextTick(() => {
const container = document.getElementById('chat-container');
if (container) container.scrollTop = container.scrollHeight;
}); });
};
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects');
if (response.ok) {
projects.value = await response.json();
nextTick(() => lucide.createIcons());
}
} catch (error) { console.error(error); }
};
const selectProject = async (project) => {
activeProject.value = project;
messages.value = [];
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 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 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 sendMessage = async () => {
if (!prompt.value.trim() || !activeProject.value) 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());
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 })
});
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) {
messages.value = data;
} 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 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 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); }
};
onMounted(() => {
lucide.createIcons();
fetchProjects();
}); });
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){ 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 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; modelInfo.finances = data.finances; } } catch(e){} };
const syncModels = async () => { isSyncing.value = true; try { const res = await fetch('/api/models/sync', { method: 'POST' }); if(res.ok) { await fetchModelsInfo(); alert("Synchronisation terminée"); } } catch(e) {} isSyncing.value = false; };
const uploadAvatar = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData(); formData.append("file", file);
try {
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 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 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 || 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();
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(payload) });
if (response.ok) { const data = await response.json(); if (Array.isArray(data)) messages.value = data; }
else { const err = await response.json(); alert("Erreur: " + err.detail); }
} catch (error) {} finally { isAiThinking.value = false; scrollToBottom(); nextTick(()=>lucide.createIcons()); }
};
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 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(); fetchUser(); fetchProjects(); fetchModelsInfo(); });
return { return {
username, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea, user, activeView, settingsTab, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea,
projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking, 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, filterSearch, filterDomain, filterPricing, filteredModels, formatDate, syncModels, isSyncing,
selectedWorkers, selectedPrompter, selectedConcatenator, limitWorkers,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage
}; };
} }
}).mount('#app'); }).mount('#app');
</script> </script>
<style>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
</style>
</body> </body>
</html> </html>
+1
View File
@@ -13,3 +13,4 @@ email-validator
PyJWT PyJWT
openai openai
httpx httpx
python-multipart