From 2eb3acceea9a09d1dd5658efdc7e1e7b9f5f19a3 Mon Sep 17 00:00:00 2001 From: Xavier Date: Wed, 7 Oct 2026 18:39:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20refonte=20bdd,=20ajout=20super-admin,?= =?UTF-8?q?=20pages=20de=20config=20par=20niveau,=20avatar=20natif=20et=20?= =?UTF-8?q?s=C3=A9lecteur=20de=20mod=C3=A8les=20par=20requ=C3=AAte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 227 +++++++----------- backend/models.py | 15 +- backend/orchestrator.py | 188 +++++++-------- backend/schemas.py | 16 +- frontend/dashboard.html | 508 +++++++++++++++++++++------------------- requirements.txt | 3 +- 6 files changed, 464 insertions(+), 493 deletions(-) diff --git a/backend/main.py b/backend/main.py index 7e79623..d196054 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,30 +3,43 @@ from fastapi.responses import RedirectResponse, FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from typing import List -import os -import json +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, PasswordChange -from .models import User, Project, Message, SystemSettings, AIModel -from .orchestrator import run_orchestrator, sync_providers_models +from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog +from .orchestrator import run_orchestrator, sync_providers_models, sync_finances -# Création des tables Base.metadata.create_all(bind=engine) +app = FastAPI(title="AETHAS38") -app = FastAPI(title="AETHAS38 - Orchestrateur Multi-IA") - -# 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.on_event("startup") +async def startup_event(): asyncio.create_task(scheduler_task()) + +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") @@ -38,63 +51,41 @@ def get_current_user(request: Request, db: Session = Depends(get_db)): return user @app.get("/") -def read_root(db: Session = Depends(get_db)): - if is_setup_required(db): - return RedirectResponse(url="/setup") - return RedirectResponse(url="/login") - +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)): - 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) - +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)): - 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) +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=status.HTTP_403_FORBIDDEN, detail="Déjà installé.") - 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) + 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 - ) + 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)} + 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=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") + 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") - frontend_path = os.path.join(os.getcwd(), "frontend", "dashboard.html") - return FileResponse(frontend_path) + 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)): @@ -102,126 +93,88 @@ def change_password(passwords: PasswordChange, db: Session = Depends(get_db), cu 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 avec succès."} + return {"message": "Mot de passe mis à jour."} -# --- GESTION DES PROJETS --- +@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]) -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é"} -# --- NOUVEAU : EXPORT DE CONVERSATION --- @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)): - """Exporte l'intégralité d'une conversation au format TXT ou JSON.""" - 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") - + 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=aethas38_export_{project_id}.json"}) - else: - text = f"--- HISTORIQUE DU PROJET : {project.title} ---\n\n" - for m in messages: - role_name = "VOUS" if m.role == "user" else "AETHAS38" - date_str = m.created_at.strftime("%Y-%m-%d %H:%M:%S") - text += f"[{date_str}] {role_name}:\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=aethas38_export_{project_id}.txt"}) + 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"}) -# --- GESTION DES MESSAGES --- @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)): - 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() - - orchestrator_config = message.config.dict() if message.config else {"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() - + conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]} + ai_resp = await run_orchestrator(history, settings, conf) + 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() -# --- GESTION DES MODELES --- +# --- 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") async def trigger_model_sync(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 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) - -@app.get("/api/models") -def get_models(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - return db.query(AIModel).order_by(AIModel.name.asc()).all() + return await sync_providers_models(db, settings, "Manuelle") @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() - models_data = [{"provider": m.provider, "model_id": m.model_id, "name": m.name, "context_length": m.context_length, "pricing_prompt": m.pricing_prompt, "pricing_completion": m.pricing_completion} for m in models] - return Response(content=json.dumps(models_data), media_type="application/json", headers={"Content-Disposition": "attachment; filename=aethas38_models.json"}) - -@app.post("/api/models/import") -async def import_models(file: UploadFile = File(...), 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.") - content = await file.read() - try: - data = json.loads(content) - imported_count = 0 - for item in data: - existing = db.query(AIModel).filter(AIModel.model_id == item["model_id"]).first() - if not existing: - db.add(AIModel(**item)) - imported_count += 1 - db.commit() - return {"message": f"Import réussi. {imported_count} nouveaux modèles ajoutés."} - except Exception as e: - raise HTTPException(status_code=400, detail="Fichier JSON invalide ou mal formaté.") \ No newline at end of file + 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"}) \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 91dcc2a..b44e902 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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)) \ No newline at end of file diff --git a/backend/orchestrator.py b/backend/orchestrator.py index 01c1cee..76bfe81 100644 --- a/backend/orchestrator.py +++ b/backend/orchestrator.py @@ -3,125 +3,113 @@ 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 - - # httpx.AsyncClient permet des requêtes non-bloquantes (ultra rapide) +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" + +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: - # 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", {}) - # 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 + pp = 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 + 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 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 - 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}") - + 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: - return AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key) - raise ValueError(f"Aucun fournisseur configuré pour {model_id}") + 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) 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 + 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(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é - + 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) 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] + 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()) - # 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}) - - system_logger.info("Démarrage du Prompteur...") - optimized_prompt = await ask_agent(p_client, prompter_model, p_messages) + 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}]) - # É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) + 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) - # É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)}") \ No newline at end of file + 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." + return await ask_agent(get_client_for_model(c_mod, settings), c_mod, [{"role": "user", "content": synth}]) \ No newline at end of file diff --git a/backend/schemas.py b/backend/schemas.py index 3f812fc..8c17b12 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -6,12 +6,10 @@ 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,25 +25,20 @@ class LoginRequest(BaseModel): password: str totp_code: str -# --- GESTION DES 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 -# --- GESTION DES MESSAGES ET ORCHESTRATION --- class OrchestratorConfig(BaseModel): workers: List[str] prompter: Optional[str] = "gemini-3.5-flash-lite" @@ -62,11 +55,8 @@ class MessageResponse(MessageBase): id: int created_at: datetime project_id: int + class Config: from_attributes = True - class Config: - from_attributes = True - -# --- UTILISATEUR --- class PasswordChange(BaseModel): old_password: str new_password: str \ No newline at end of file diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 94ff84f..1a0b2fa 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -21,69 +21,12 @@
- -
-
-
-

Paramètres

- -
-
-

Sécurité du compte

-
{{ settingsMessage }}
-
-
-
- -
-
-
- - -
-
-
-

Base de données Modèles IA

- -
-
- - - Exporter (JSON) - - -
-
-

Aucun modèle en base. Cliquez sur Synchroniser.

- - - - - - - - - - - -
ID ModèleFournisseurContexte
{{ model.model_id }}{{ model.provider }}{{ model.context_length }}
-
-
-
-
-
+
+
- -
- Logo + +
+ AETHAS38
-
+
{{ activeProject.title }} - - -
- - + +
-
- AETHAS38 Logo -

Comment puis-je vous aider aujourd'hui ?

-

Sélectionnez ou créez un projet pour commencer.

-
- -
-
Démarrez la conversation ci-dessous.
- -
-
- {{ msg.role === 'user' ? 'Vous' : 'AETHAS38' }} -
+ +
+
+

Configuration Système

- -
- +
+
+

Sécurité

+
{{ settingsMessage }}
+
+ + + + +
+
- -
- - + +
+
+

Outils Administrateur

+

Fonctionnalités réservées aux administrateurs pour la gestion des modèles et le suivi de base.

+
+ + Exporter DB Modèles (JSON) +
+
-
-
-
-
-
-
+ +
+
+

Espace Réservé Super-Admin

+

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.

+
- -
-
-
- Travailleurs: - + + - {{ selectedWorkers.length }}/5
+
+ + + + + + + + + + + + +
ModèleFournisseurDomaineCoût (1M Tokens)ContexteDescription
{{ model.model_id }}{{ model.provider }}{{ model.domain }}GRATUITIn: ${{ model.pricing_prompt.toFixed(2) }}
Out:${{ model.pricing_completion.toFixed(2) }}
{{ (model.context_length / 1000).toFixed(0) }}k{{ model.description_fr }}
+
+
+
+ + +
@@ -263,24 +306,30 @@ 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 isSettingsModalOpen = ref(false); - const isModelsModalOpen = ref(false); - const isSavingSettings = ref(false); const isSyncing = ref(false); + const isSavingSettings = ref(false); + const settingsMessage = ref(""); const passForm = reactive({ old: '', new: '', confirm: '' }); - const modelsList = ref([]); + 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"); @@ -288,112 +337,97 @@ 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){ 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 openSettings = () => { isSettingsModalOpen.value = true; nextTick(() => lucide.createIcons()); }; - const closeSettings = () => { isSettingsModalOpen.value = false; }; - const openModelsModal = () => { fetchModels(); isModelsModalOpen.value = true; nextTick(() => lucide.createIcons()); }; + 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; }; - // --- NOUVEAU : FONCTIONS D'EXPORT ET TÉLÉCHARGEMENT --- - const exportProject = (format) => { - if(!activeProject.value) return; - window.open(`/api/projects/${activeProject.value.id}/export?format=${format}`, '_blank'); - }; - - const copyToClipboard = async (text) => { + const uploadAvatar = async (event) => { + const file = event.target.files[0]; + if (!file) return; + const formData = new FormData(); formData.append("file", file); try { - await navigator.clipboard.writeText(text); - alert("Texte copié dans le presse-papiers."); - } catch (err) { console.error('Erreur de copie', err); } + 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 downloadFile = (content, hint) => { - let filename = hint ? hint.trim() : 'fichier.txt'; - if (!filename.includes('.')) filename += '.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 = filename; - a.click(); - URL.revokeObjectURL(url); - }; - - const downloadMessage = (content, dateString) => { - const safeDate = dateString.replace(/[:T.Z]/g, '-'); - downloadFile(content, `aethas38_message_${safeDate}.txt`); - }; - - // Parseur Regex pour isoler les blocs de code / CSV - const parseMessage = (content) => { - const regex = /```([\w.-]*)\n([\s\S]*?)```/g; - let parts = []; - let lastIndex = 0; - let match; - while ((match = regex.exec(content)) !== null) { - if (match.index > lastIndex) { - parts.push({ type: 'text', content: content.slice(lastIndex, match.index) }); - } - parts.push({ type: 'code', language: match[1], content: match[2] }); - lastIndex = match.index + match[0].length; - } - if (lastIndex < content.length) { - parts.push({ type: 'text', content: content.slice(lastIndex) }); - } - return parts.length > 0 ? parts : [{ type: 'text', content: content }]; - }; - - const fetchModels = async () => { try { const res = await fetch('/api/models'); if(res.ok) modelsList.value = await res.json(); } catch(e) { console.error(e); } }; - const syncModels = async () => { isSyncing.value = true; try { const res = await fetch('/api/models/sync', { method: 'POST' }); if(res.ok) { alert("Synchronisation terminée !"); await fetchModels(); } } catch(e) { console.error(e); } isSyncing.value = false; }; - const importModels = 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/models/import', { method: 'POST', body: formData }); const data = await res.json(); alert(data.message || data.detail); await fetchModels(); } catch (e) { alert("Erreur d'import"); } event.target.value = null; }; - const limitWorkers = () => { if(selectedWorkers.value.length > 5) { selectedWorkers.value.pop(); alert("Maximum 5 travailleurs autorisés en parallèle."); } }; + 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) => { 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 () => { 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 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(); - + 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 } - }; - + 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) { console.error(error); } - finally { isAiThinking.value = false; scrollToBottom(); nextTick(()=>lucide.createIcons()); } + } catch (error) {} finally { isAiThinking.value = false; scrollToBottom(); nextTick(()=>lucide.createIcons()); } }; - const changePassword = async () => { /* Inchangé... */ }; - const renameProject = async (project) => { /* Inchangé... */ }; - const togglePin = async (projectId) => { /* Inchangé... */ }; - const deleteProject = async (projectId) => { /* Inchangé... */ }; + 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; } + }; - onMounted(() => { lucide.createIcons(); fetchProjects(); fetchModels(); }); + 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 { - 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, - isSettingsModalOpen, openSettings, closeSettings, passForm, settingsMessage, isSavingSettings, changePassword, - isModelsModalOpen, openModelsModal, modelsList, syncModels, importModels, + passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar, + modelInfo, filterSearch, filterDomain, filterPricing, filteredModels, formatDate, syncModels, isSyncing, selectedWorkers, selectedPrompter, selectedConcatenator, limitWorkers, exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage }; } }).mount('#app'); + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 354fe55..516fdfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,5 @@ bcrypt email-validator PyJWT openai -httpx \ No newline at end of file +httpx +python-multipart \ No newline at end of file