Compare commits

...
5 Commits
Author SHA1 Message Date
xavier e8288731d8 feat: intégration du panneau super-admin (clés API/SMTP), recherche web native OpenRouter, cookie étendu (8h), toast notification et reset textarea
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-10-09 06:45:10 +02:00
xavier e0f504c8ee fix(ui): restauration de l'accès à la vue complète de configuration (Profil, Admin, Super-Admin) suite au crash de Vue.js
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-08 19:11:17 +02:00
xavier 487da2ed4d feat: auto-renommage des projets, assignation de modèles par clic, nettoyage automatique à 7 jours
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-08 18:55:46 +02:00
xavier 108ba8f167 fix(ui): restauration de l'accès à la vue complète de configuration (Profil, Admin, Super-Admin)
Build and Push Docker Image / build-and-push (push) Successful in 49s
2026-10-08 18:33:46 +02:00
xavier 5dea7d3353 Feat extract modèles disponible
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-08 18:16:49 +02:00
4 changed files with 354 additions and 33 deletions

No files matched your search

+51 -8
View File
@@ -4,12 +4,12 @@ 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, json, asyncio, shutil, base64, io, csv, zipfile import os, json, asyncio, shutil, base64, io, csv, zipfile
from datetime import datetime from datetime import datetime, timedelta, timezone
import pytz import pytz
from .database import engine, Base, get_db, SessionLocal 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 .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, ModelReplacementRequest, LogRequest from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse, PasswordChange, ModelReplacementRequest, LogRequest, SystemSettingsUpdate, SystemSettingsResponse
from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog
from .orchestrator import run_orchestrator, sync_providers_models, sync_finances, activity_logs, log_activity from .orchestrator import run_orchestrator, sync_providers_models, sync_finances, activity_logs, log_activity
@@ -28,11 +28,25 @@ async def scheduler_task():
now = datetime.now(tz) now = datetime.now(tz)
if (now.hour == 0 or now.hour == 12) and now.minute == 0: if (now.hour == 0 or now.hour == 12) and now.minute == 0:
db = SessionLocal() db = SessionLocal()
settings = db.query(SystemSettings).first() try:
if settings: settings = db.query(SystemSettings).first()
try: await sync_providers_models(db, settings, "Automatique") if settings:
except: pass try: await sync_providers_models(db, settings, "Automatique")
db.close() except: pass
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
old_projects = db.query(Project).filter(Project.is_pinned == False, Project.created_at < cutoff).all()
if old_projects:
log_activity(f"[Nettoyage] Suppression de {len(old_projects)} discussion(s) de plus de 7 jours.")
for op in old_projects:
db.delete(op)
db.commit()
except Exception as e:
db.rollback()
log_activity(f"[Erreur Nettoyage] {str(e)}")
finally:
db.close()
await asyncio.sleep(60) await asyncio.sleep(60)
await asyncio.sleep(30) await asyncio.sleep(30)
@@ -73,7 +87,9 @@ def login(login_data: LoginRequest, response: Response, db: Session = Depends(ge
user = db.query(User).filter(User.username == login_data.username).first() 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 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.") 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=604800, samesite="lax")
# Session valide 8 heures (28800 secondes)
response.set_cookie(key="session_token", value=create_access_token(data={"sub": user.username}), httponly=True, max_age=28800, samesite="lax")
return {"message": "Connexion réussie"} return {"message": "Connexion réussie"}
@app.get("/dashboard") @app.get("/dashboard")
@@ -148,6 +164,23 @@ async def upload_avatar(file: UploadFile = File(...), db: Session = Depends(get_
db.commit() db.commit()
return {"message": "Avatar mis à jour", "avatar_path": current_user.avatar_path} return {"message": "Avatar mis à jour", "avatar_path": current_user.avatar_path}
# --- ROUTES SUPER-ADMIN ---
@app.get("/api/settings", response_model=SystemSettingsResponse)
def get_settings(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not current_user.is_superadmin: raise HTTPException(status_code=403, detail="Super-Admin requis.")
return db.query(SystemSettings).first()
@app.put("/api/settings")
def update_settings(settings_data: SystemSettingsUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not current_user.is_superadmin: raise HTTPException(status_code=403, detail="Super-Admin requis.")
s = db.query(SystemSettings).first()
if not s: raise HTTPException(status_code=404)
for k, v in settings_data.dict(exclude_unset=True).items():
setattr(s, k, v)
db.commit()
log_activity("Configuration système mise à jour par le Super-Admin.")
return {"message": "Paramètres mis à jour avec succès."}
@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)): 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()
@@ -189,6 +222,16 @@ def get_messages(project_id: int, db: Session = Depends(get_db), current_user: U
@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)):
p = db.query(Project).filter(Project.id == project_id).first()
if p and p.title == "Nouvelle discussion":
user_msgs = db.query(Message).filter(Message.project_id == project_id, Message.role == "user").order_by(Message.created_at.asc()).all()
if len(user_msgs) == 1:
first_content = user_msgs[0].content.split("\n\n[Fichiers joints")[0].strip()
new_title = first_content.split('\n')[0][:35].strip()
if not new_title: new_title = "Discussion"
p.title = new_title + ("..." if len(first_content) > 35 else "")
db.commit()
extracted_files_data = [] extracted_files_data = []
files_names = [] files_names = []
+2 -3
View File
@@ -206,7 +206,8 @@ async def ask_agent(client, model_id, messages, provider="openrouter"):
kwargs = {"model": model_id, "messages": messages} kwargs = {"model": model_id, "messages": messages}
if provider == "openrouter": if provider == "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"}
kwargs["extra_body"] = {"plugins": [{"id": "context-compression"}]} # Activation native du plugin web-search et de compression
kwargs["extra_body"] = {"plugins": [{"id": "context-compression"}, {"id": "web-search"}]}
resp = await client.chat.completions.create(**kwargs) resp = await client.chat.completions.create(**kwargs)
return resp.choices[0].message.content return resp.choices[0].message.content
@@ -225,7 +226,6 @@ async def run_orchestrator(db: Session, history: list, settings: SystemSettings,
log_activity(f"Démarrage de l'orchestration. Modèle Prompteur: {p_mod}") log_activity(f"Démarrage de l'orchestration. Modèle Prompteur: {p_mod}")
p_client, p_prov = get_client_for_model(db, p_mod, settings) p_client, p_prov = get_client_for_model(db, p_mod, settings)
# --- WORKFLOW MAP-REDUCE : PRÉ-TRAITEMENT SÉQUENTIEL & CHUNKING ---
files_context = "" files_context = ""
if extracted_files: if extracted_files:
log_activity(f"Traitement Map-Reduce de {len(extracted_files)} fichier(s) joint(s).") log_activity(f"Traitement Map-Reduce de {len(extracted_files)} fichier(s) joint(s).")
@@ -270,7 +270,6 @@ async def run_orchestrator(db: Session, history: list, settings: SystemSettings,
files_context = "".join(file_analyses) files_context = "".join(file_analyses)
user_prompt = f"{original_user_text}\n\nVoici les données pré-traitées des fichiers joints :\n{files_context}" user_prompt = f"{original_user_text}\n\nVoici les données pré-traitées des fichiers joints :\n{files_context}"
# --- OPTIMISATION & TRADUCTION ---
log_activity(f"Optimisation/Traduction de la requête via Prompteur...") log_activity(f"Optimisation/Traduction de la requête via Prompteur...")
prompt_system = "You are an expert prompt engineer. Translate and optimize the user request and any file context into clear, precise English tailored for AI execution. Keep all code blocks intact." prompt_system = "You are an expert prompt engineer. Translate and optimize the user request and any file context into clear, precise English tailored for AI execution. Keep all code blocks intact."
optimized = await ask_agent(p_client, p_mod, [{"role": "system", "content": prompt_system}, {"role": "user", "content": user_prompt}], p_prov) optimized = await ask_agent(p_client, p_mod, [{"role": "system", "content": prompt_system}, {"role": "user", "content": user_prompt}], p_prov)
+20
View File
@@ -20,6 +20,26 @@ class AdminCreate(BaseModel):
cloudflare_api_token: Optional[str] = None cloudflare_api_token: Optional[str] = None
huggingface_api_key: Optional[str] = None huggingface_api_key: Optional[str] = None
class SystemSettingsBase(BaseModel):
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
openrouter_api_key: Optional[str] = None
openrouter_management_key: Optional[str] = None
groq_api_key: Optional[str] = None
gemini_api_key: Optional[str] = None
deepseek_api_key: Optional[str] = None
mistral_api_key: Optional[str] = None
cloudflare_account_id: Optional[str] = None
cloudflare_api_token: Optional[str] = None
huggingface_api_key: Optional[str] = None
class SystemSettingsUpdate(SystemSettingsBase): pass
class SystemSettingsResponse(SystemSettingsBase):
class Config: from_attributes = True
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
+281 -22
View File
@@ -23,7 +23,55 @@
<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">
<!-- MODAL PARAMÈTRES --> <!-- TOAST NOTIFICATION TEMPORAIRE -->
<div v-if="toastVisible" class="fixed bottom-6 right-6 bg-gray-800 border-l-4 border-aethas-cyan text-gray-200 p-4 rounded shadow-[0_0_15px_rgba(6,182,212,0.3)] z-[200] flex items-center gap-3 transition-opacity duration-300">
<i data-lucide="check-circle" class="w-5 h-5 text-aethas-cyan"></i>
<span class="text-sm font-medium">{{ toastMessage }}</span>
</div>
<!-- MODAL ASSIGNATION MODÈLE -->
<div v-if="isAssignModalOpen" class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
<div class="bg-aethas-panel border border-gray-600 rounded-2xl w-full max-w-md overflow-hidden shadow-[0_0_30px_rgba(0,0,0,0.7)]">
<div class="p-4 border-b border-gray-700 flex justify-between items-center bg-gray-800/50">
<h2 class="text-lg font-bold text-white flex items-center gap-2"><i data-lucide="link" class="w-5 h-5 text-aethas-cyan"></i> Assigner un modèle</h2>
<button @click="closeAssignModal" class="text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-6 space-y-4">
<div v-if="assignMessage" class="p-3 rounded text-sm text-center bg-green-900/50 text-green-200 border border-green-500">{{ assignMessage }}</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Modèle sélectionné</label>
<input type="text" :value="assignForm.modelId" disabled class="w-full bg-gray-800 border border-gray-600 rounded-lg px-4 py-2 text-aethas-cyan font-mono text-xs">
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Associer à la discussion :</label>
<select v-model="assignForm.projectId" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none text-gray-200">
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Définir le rôle :</label>
<select v-model="assignForm.role" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none text-gray-200">
<option value="prompter">Prompteur</option>
<option value="worker1">Travailleur 1</option>
<option value="worker2">Travailleur 2</option>
<option value="worker3">Travailleur 3</option>
<option value="worker4">Travailleur 4</option>
<option value="worker5">Travailleur 5</option>
<option value="concatenator">Concaténeur</option>
</select>
</div>
<button @click="confirmAssignModel" class="w-full mt-4 bg-aethas-cyan hover:bg-cyan-600 text-black font-bold py-2.5 px-4 rounded-lg transition-colors">
Valider l'assignation
</button>
</div>
</div>
</div>
<!-- MODAL PARAMÈTRES (PROFIL) -->
<div v-if="isSettingsModalOpen" class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4"> <div v-if="isSettingsModalOpen" class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
<div class="bg-aethas-panel border border-gray-600 rounded-2xl w-full max-w-md overflow-hidden shadow-[0_0_30px_rgba(0,0,0,0.7)]"> <div class="bg-aethas-panel border border-gray-600 rounded-2xl w-full max-w-md overflow-hidden shadow-[0_0_30px_rgba(0,0,0,0.7)]">
<div class="p-4 border-b border-gray-700 flex justify-between items-center bg-gray-800/50"> <div class="p-4 border-b border-gray-700 flex justify-between items-center bg-gray-800/50">
@@ -77,7 +125,10 @@
</ul> </ul>
</div> </div>
<div> <div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Récents</h3> <div class="flex items-center justify-between mb-2">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Récents</h3>
<span class="text-[9px] text-red-400/80" title="Les discussions non épinglées de plus de 7 jours sont supprimées automatiquement.">Auto-suppression > 7j</span>
</div>
<ul class="space-y-1"> <ul class="space-y-1">
<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', activeView === 'chat' && activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate"><i data-lucide="message-square" class="w-3 h-3 text-gray-500"></i><span class="truncate">{{ project.title }}</span></div> <div class="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>
@@ -197,8 +248,33 @@
<div v-if="settingsTab === 'super' && user.is_superadmin" class="space-y-6"> <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"> <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> <h3 class="text-lg font-semibold text-purple-400 mb-4">Espace Réservé Super-Admin - Clés API & SMTP</h3>
<p class="text-sm text-gray-400">Gestion globale du système.</p>
<div v-if="adminSettingsMessage" class="p-3 rounded text-sm mb-4 bg-gray-900 border border-aethas-purple text-purple-300">{{ adminSettingsMessage }}</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Clés API -->
<div><label class="block text-xs text-gray-400 mb-1">OpenRouter API Key</label><input v-model="adminSettings.openrouter_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">OpenRouter Management Key</label><input v-model="adminSettings.openrouter_management_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">Gemini API Key</label><input v-model="adminSettings.gemini_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">Groq API Key</label><input v-model="adminSettings.groq_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">DeepSeek API Key</label><input v-model="adminSettings.deepseek_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">Mistral API Key</label><input v-model="adminSettings.mistral_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">HuggingFace API Key</label><input v-model="adminSettings.huggingface_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">Cloudflare Account ID</label><input v-model="adminSettings.cloudflare_account_id" type="text" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">Cloudflare API Token</label><input v-model="adminSettings.cloudflare_api_token" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<!-- SMTP -->
<div class="col-span-1 md:col-span-2 mt-2 border-t border-gray-700 pt-4"><h4 class="text-sm font-semibold text-gray-300 mb-2">Configuration SMTP</h4></div>
<div><label class="block text-xs text-gray-400 mb-1">SMTP Host</label><input v-model="adminSettings.smtp_host" type="text" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">SMTP Port</label><input v-model.number="adminSettings.smtp_port" type="number" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">SMTP User</label><input v-model="adminSettings.smtp_user" type="text" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
<div><label class="block text-xs text-gray-400 mb-1">SMTP Password</label><input v-model="adminSettings.smtp_password" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded p-2 text-sm text-gray-200 focus:border-aethas-purple outline-none"></div>
</div>
<button @click="saveAdminSettings" :disabled="isSavingAdminSettings" class="mt-6 w-full bg-aethas-purple hover:bg-purple-600 text-white font-bold py-2 rounded transition-colors disabled:opacity-50">
Sauvegarder la configuration
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -243,12 +319,29 @@
<option value="free">Gratuits uniquement</option> <option value="free">Gratuits uniquement</option>
<option value="paid">Payants</option> <option value="paid">Payants</option>
</select> </select>
<button @click="exportCSV" class="bg-aethas-panel border border-gray-600 hover:border-aethas-cyan text-gray-300 hover:text-white px-3 py-1.5 rounded flex items-center gap-2 text-sm transition-colors md:ml-auto">
<i data-lucide="download" class="w-4 h-4 text-green-500"></i> Export CSV
</button>
</div> </div>
<div class="overflow-x-auto max-h-[500px] custom-scrollbar"> <div class="overflow-x-auto max-h-[500px] custom-scrollbar">
<table class="w-full text-left text-sm text-gray-300"> <table class="w-full text-left text-sm text-gray-300">
<thead class="text-xs uppercase bg-gray-800 text-gray-400 sticky top-0"><tr><th class="px-4 py-3">Modèle</th><th class="px-4 py-3">Fournisseur</th><th class="px-4 py-3">Domaine</th><th class="px-4 py-3">Coût (1M Tokens)</th><th class="px-4 py-3">Contexte</th><th class="px-4 py-3 w-1/3">Description</th></tr></thead> <thead class="text-xs uppercase bg-gray-800 text-gray-400 sticky top-0">
<tr>
<th class="px-4 py-3 text-center">Assigner</th>
<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> <tbody>
<tr v-for="model in filteredModels" :key="model.model_id" class="border-b border-gray-700 hover:bg-gray-800/50 transition-colors"> <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 text-center">
<input type="radio" name="modelSelect" @click.prevent="openAssignModal(model.model_id)" class="w-4 h-4 text-aethas-cyan bg-gray-900 border-gray-600 focus:ring-aethas-cyan cursor-pointer">
</td>
<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 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 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"><span class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs">{{ model.domain }}</span></td>
@@ -286,17 +379,21 @@
<div v-if="part.type === 'code'" class="my-4 bg-gray-900 rounded-lg border border-gray-700 overflow-hidden shadow-inner"> <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"> <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> <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 class="flex items-center">
<!-- BOUTON COPIER CODE (NOUVEAU) -->
<button @click="copyToClipboard(part.content)" 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 mr-2"><i data-lucide="copy" class="w-3 h-3"></i> Copier Code</button>
<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>
</div> </div>
<pre class="p-4 text-xs font-mono text-gray-300 overflow-x-auto custom-scrollbar"><code>{{ part.content }}</code></pre> <pre class="p-4 text-xs font-mono text-gray-300 overflow-x-auto custom-scrollbar"><code>{{ part.content }}</code></pre>
</div> </div>
</template> </template>
</div> </div>
<!-- Boutons présents sur toutes les bulles (utilisateur et IA) --> <!-- Boutons de copie et téléchargement individuel du message -->
<div class="mt-3 flex items-center gap-3 border-t border-gray-700/50 pt-2 opacity-50 hover:opacity-100 transition-opacity"> <div 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="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 Message</button>
<button @click="downloadMessage(msg.content, msg.created_at || new Date().toISOString())" 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> <button @click="downloadMessage(msg.content, msg.created_at || new Date().toISOString())" 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 Message</button>
</div> </div>
</div> </div>
@@ -446,11 +543,30 @@
const isLoading = ref(false); const isLoading = ref(false);
const isAiThinking = ref(false); const isAiThinking = ref(false);
const isSyncing = ref(false); const isSyncing = ref(false);
const isSavingSettings = ref(false);
// TOAST (Non bloquant)
const toastVisible = ref(false);
const toastMessage = ref("");
const showToast = (msg) => {
toastMessage.value = msg;
toastVisible.value = true;
setTimeout(() => { toastVisible.value = false; }, 5000); // Disparaît après 5 secondes
};
const isAssignModalOpen = ref(false);
const assignForm = reactive({ modelId: '', projectId: '', role: 'worker1' });
const assignMessage = ref("");
const isSettingsModalOpen = ref(false); const isSettingsModalOpen = ref(false);
const settingsMessage = ref("");
// FORMULAIRES SETTINGS
const passForm = reactive({ old: '', new: '', confirm: '' }); const passForm = reactive({ old: '', new: '', confirm: '' });
const settingsMessage = ref("");
const isSavingSettings = ref(false);
const adminSettings = reactive({});
const adminSettingsMessage = ref("");
const isSavingAdminSettings = ref(false);
const modelInfo = reactive({ last_sync_date: null, last_sync_type: null, models: [] }); const modelInfo = reactive({ last_sync_date: null, last_sync_type: null, models: [] });
const financesList = ref([]); const financesList = ref([]);
@@ -523,8 +639,89 @@
const autoResize = () => { const el = promptTextarea.value; if(el){ el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }}; 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 scrollToBottom = () => { nextTick(() => { const c = document.getElementById('chat-container'); if (c) c.scrollTop = c.scrollHeight; }); };
const openSettings = () => { isSettingsModalOpen.value = true; nextTick(() => lucide.createIcons()); }; const fetchAdminSettings = async () => {
const closeSettings = () => { isSettingsModalOpen.value = false; }; try {
const res = await fetch('/api/settings');
if (res.ok) {
const data = await res.json();
Object.assign(adminSettings, data);
}
} catch(e) {}
};
const saveAdminSettings = async () => {
isSavingAdminSettings.value = true;
adminSettingsMessage.value = "";
try {
const res = await fetch('/api/settings', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(adminSettings)
});
if (res.ok) {
adminSettingsMessage.value = "Paramètres sauvegardés avec succès.";
setTimeout(() => adminSettingsMessage.value="", 3000);
} else {
adminSettingsMessage.value = "Erreur lors de la sauvegarde.";
}
} catch (e) {
adminSettingsMessage.value = "Erreur réseau.";
}
isSavingAdminSettings.value = false;
};
const openSettings = () => {
activeView.value = 'settings';
if(window.innerWidth < 768) isSidebarOpen.value = false;
if(user.value.is_superadmin) fetchAdminSettings();
nextTick(() => lucide.createIcons());
};
const closeSettings = () => {
isSettingsModalOpen.value = false;
};
const openAssignModal = (modelId) => {
assignForm.modelId = modelId;
assignForm.projectId = activeProject.value ? activeProject.value.id : (projects.value.length > 0 ? projects.value[0].id : '');
assignForm.role = 'worker1';
assignMessage.value = "";
isAssignModalOpen.value = true;
nextTick(() => lucide.createIcons());
};
const closeAssignModal = () => {
isAssignModalOpen.value = false;
};
const confirmAssignModel = () => {
if(!assignForm.projectId || !assignForm.role) {
assignMessage.value = "Veuillez sélectionner une discussion et un rôle.";
return;
}
const storageKey = 'aethas_config_' + assignForm.projectId;
let config = { workers: ["", "", "", "", ""], prompter: "", concatenator: "" };
const saved = localStorage.getItem(storageKey);
if (saved) {
try { config = JSON.parse(saved); } catch(e){}
}
if(assignForm.role === 'prompter') config.prompter = assignForm.modelId;
else if(assignForm.role === 'concatenator') config.concatenator = assignForm.modelId;
else {
const idx = parseInt(assignForm.role.replace('worker', '')) - 1;
config.workers[idx] = assignForm.modelId;
}
localStorage.setItem(storageKey, JSON.stringify(config));
if (activeProject.value && activeProject.value.id == assignForm.projectId) {
workers.value = config.workers;
selectedPrompter.value = config.prompter;
selectedConcatenator.value = config.concatenator;
}
assignMessage.value = `Modèle assigné avec succès !`;
setTimeout(() => { closeAssignModal(); }, 1200);
};
const logToTerminal = async (msg) => { const logToTerminal = async (msg) => {
try { await fetch('/api/logs', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ message: msg }) }); } catch(e){} try { await fetch('/api/logs', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ message: msg }) }); } catch(e){}
@@ -607,7 +804,7 @@
if(res.ok) { if(res.ok) {
await fetchModelsInfo(); await fetchModelsInfo();
await fetchFinances(); await fetchFinances();
alert("Synchronisation réussie !"); showToast("Synchronisation réussie !");
} else if (res.status === 401) { } else if (res.status === 401) {
alert("Votre session a expiré."); window.location.href = '/login'; alert("Votre session a expiré."); window.location.href = '/login';
} else { } else {
@@ -625,6 +822,35 @@
downloadFile(templateContent, 'aethas38-config-agents.md'); downloadFile(templateContent, 'aethas38-config-agents.md');
}; };
const exportCSV = () => {
logToTerminal("Génération de l'extraction CSV (vue filtrée)...");
let csvContent = "\uFEFF";
csvContent += "Fournisseur,Modèle,Nom,Domaine,Gratuit,Contexte,Prix_In,Prix_Out,Description\n";
filteredModels.value.forEach(m => {
const escapeCSV = (str) => {
if (str === null || str === undefined) return '""';
return `"${String(str).replace(/"/g, '""')}"`;
};
const row = [
escapeCSV(m.provider),
escapeCSV(m.model_id),
escapeCSV(m.name),
escapeCSV(m.domain),
m.is_free ? "Oui" : "Non",
m.context_length,
m.pricing_prompt,
m.pricing_completion,
escapeCSV(m.description_fr)
];
csvContent += row.join(",") + "\n";
});
const d = new Date();
const dateStr = d.getFullYear() + String(d.getMonth()+1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
downloadFile(csvContent, `${dateStr}-extraction-modeles.csv`, 'text/csv;charset=utf-8');
};
const handleConfigImport = (event) => { const handleConfigImport = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -651,7 +877,7 @@
selectedConcatenator.value = extractModel('Concateneur'); selectedConcatenator.value = extractModel('Concateneur');
await validateConfigAndReplace(); await validateConfigAndReplace();
alert("Configuration IA importée et appliquée avec succès !"); showToast("Configuration IA importée et appliquée avec succès !");
}; };
reader.readAsText(file); reader.readAsText(file);
event.target.value = null; event.target.value = null;
@@ -720,8 +946,27 @@
const removeFile = (index) => { attachedFiles.value.splice(index, 1); }; const removeFile = (index) => { attachedFiles.value.splice(index, 1); };
const exportProject = (format) => { if(activeProject.value) window.open(`/api/projects/${activeProject.value.id}/export?format=${format}`, '_blank'); }; 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); }; // COPIE REMPLACEE PAR UN TOAST (NON BLOQUANT)
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
showToast("Texte copié dans le presse-papier !");
} catch (e) {}
};
const downloadFile = (content, hint, mimeType = 'text/plain;charset=utf-8') => {
let fname = hint ? hint.trim() : 'code.txt';
if (!fname.includes('.')) fname += '.txt';
const blob = new Blob([content], { type: mimeType });
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 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 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 }]; };
@@ -779,8 +1024,12 @@
return; return;
} }
const userText = prompt.value.trim(); const userText = prompt.value.trim();
// REINITIALISATION DE LA HAUTEUR DU TEXTAREA
prompt.value = ""; prompt.value = "";
autoResize(); nextTick(() => {
if (promptTextarea.value) promptTextarea.value.style.height = 'auto';
});
const fileNames = attachedFiles.value.map(f => f.name).join(", "); const fileNames = attachedFiles.value.map(f => f.name).join(", ");
messages.value.push({ id: Date.now(), role: 'user', content: userText + (attachedFiles.value.length > 0 ? `\n[Fichiers joints: ${fileNames}]` : "") }); messages.value.push({ id: Date.now(), role: 'user', content: userText + (attachedFiles.value.length > 0 ? `\n[Fichiers joints: ${fileNames}]` : "") });
@@ -817,6 +1066,13 @@
isAiThinking.value = false; isAiThinking.value = false;
scrollToBottom(); scrollToBottom();
nextTick(()=>lucide.createIcons()); nextTick(()=>lucide.createIcons());
fetchProjects().then(() => {
if(activeProject.value) {
const updated = projects.value.find(p => p.id === activeProject.value.id);
if(updated) activeProject.value.title = updated.title;
}
});
} }
}; };
@@ -837,7 +1093,6 @@
fetchUser(); fetchUser();
fetchProjects(); fetchProjects();
fetchModelsInfo().then(() => { fetchModelsInfo().then(() => {
// Force la validation de la première vue si nécessaire
if(activeProject.value) validateConfigAndReplace(); if(activeProject.value) validateConfigAndReplace();
}); });
fetchFinances(); fetchFinances();
@@ -848,11 +1103,15 @@
user, activeView, settingsTab, 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,
isSettingsModalOpen, openSettings, closeSettings, passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar, openSettings, closeSettings, passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar,
adminSettings, adminSettingsMessage, isSavingAdminSettings, saveAdminSettings,
modelInfo, financesList, filterSearch, filterDomain, filterPricing, filterProvider, uniqueProviders, filteredModels, getFilteredModelsForCol, formatDate, syncModels, isSyncing, modelInfo, financesList, filterSearch, filterDomain, filterPricing, filterProvider, uniqueProviders, filteredModels, getFilteredModelsForCol, formatDate, syncModels, isSyncing,
workers, selectedPrompter, selectedConcatenator, colPricing, colDomain, downloadTemplate, handleConfigImport, workers, selectedPrompter, selectedConcatenator, colPricing, colDomain, downloadTemplate, exportCSV, handleConfigImport,
isAssignModalOpen, openAssignModal, closeAssignModal, assignForm, assignMessage, confirmAssignModel,
isSettingsModalOpen,
attachedFiles, handleFileUpload, handlePaste, removeFile, attachedFiles, handleFileUpload, handlePaste, removeFile,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage, systemLogs exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage, systemLogs,
toastVisible, toastMessage, showToast
}; };
} }
}).mount('#app'); }).mount('#app');