Compare commits

...
16 Commits
Author SHA1 Message Date
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
xavier 134312ebff feat maj fonctionnement
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-10-08 17:12:39 +02:00
xavier 83e70eadf6 feat: intégration du découpage en morceaux (chunking), pauses séquentielles anti 429 et plugin de compression de contexte OpenRouter
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-08 11:55:10 +02:00
xavier 60c0699c57 feat: intégration du workflow map-reduce parallèle pour le pré-traitement des fichiers joints, correction de session (7 jours) et redirection auto sur 401
Build and Push Docker Image / build-and-push (push) Successful in 55s
2026-10-08 11:31:42 +02:00
xavier 44bf0b868e feat: intégration du parsing backend (PDF, Excel, VBA) et gestion sécurisée des champs prompteur/concaténeur vides dans les templates
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-08 10:25:02 +02:00
xavier c779c51a03 fix: renforcement drastique de la regex du parseur .md et injection automatique de modèle de secours pour empêcher les cases vides
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-08 09:52:53 +02:00
xavier eaa0ff0564 feat: ajout de l'import de configuration IA via fichier .md et bouton de téléchargement du template
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-08 09:35:13 +02:00
xavier ecd9854527 feat: passage des prompt en traduction anglaise. Mise en place du téléchargement d'un *.md des modèles. Traduction de la description des modèles en Français.
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-08 09:24:04 +02:00
xavier f840561123 feat: support de 10 fichiers max avec destruction post-envoi, traduction automatique en anglais par le prompteur et traduction en français par le concaténeur en préservant le code
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-08 09:06:12 +02:00
xavier 48f929a8f3 feat: version v0.17.2 avec gestion intégrée et sécurisée des pièces jointes, destruction post-envoi et persistance des choix de modèles par filtres locaux
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-08 08:47:03 +02:00
xavier fd82d77709 fix: correction de la troncature du fichier dashboard.html et restauration complète de l'interpréteur Vue.js
Build and Push Docker Image / build-and-push (push) Failing after 30s
2026-10-07 21:37:19 +02:00
xavier 189a2e4bf6 feat: intégration de la barre de sélection tabulaire style grille (Payant/Gratuit, 5 Travailleurs, Prompteur, Concaténeur)
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-07 21:28:59 +02:00
xavier e322435bd8 fix: restauration du conteneur de chat, de la zone de texte et du sélecteur multi-agents par requête
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-07 21:15:59 +02:00
xavier efbecada77 docs: mise à jour complète du README pour refléter l'état de la v0.16.0 (super-admin, multi-fournisseurs, finances, avatars)
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-07 21:11:53 +02:00
xavier 4a9a0d8ec6 docs: mise à jour complète du README pour refléter l'état de la v0.16.0 (super-admin, multi-fournisseurs, finances, avatars)
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-07 21:10:10 +02:00
6 changed files with 895 additions and 310 deletions

No files matched your search

+52 -98
View File
@@ -6,123 +6,77 @@ L'interface visuelle est inspirée de l'ergonomie de Gemini, avec le logo stylis
## 🏗️ Stack Technique & Architecture ## 🏗️ Stack Technique & Architecture
* **Backend :** FastAPI (Python) modulaire (une fonction = un module) asynchrone (httpx, asyncio). * **Backend :** FastAPI (Python) modulaire asynchrone (httpx, asyncio)[cite: 2].
* **Frontend :** Vue.js + Tailwind CSS (Responsive / Mobile-first). * **Frontend :** Vue.js + Tailwind CSS (Responsive / Mobile-first)[cite: 2].
* **Base de Données :** PostgreSQL (gestion des utilisateurs, historiques, suivi financier). * **Base de Données :** PostgreSQL (gestion des utilisateurs, rôles hiérarchiques Super-Admin/Admin/User, historiques, suivi financier)[cite: 2].
* **Traitement Documentaire :** LibreOffice Headless (génération/conversion Excel, Word, PDF). * **Traitement Documentaire :** LibreOffice Headless (génération/conversion Excel, Word, PDF)[cite: 2].
* **Déploiement :** Docker, géré via Dockge et Gitea, sécurisé sur grappe RAID. * **Déploiement :** Docker, géré via Dockge et Gitea, sécurisé sur grappe RAID[cite: 2].
## 📌 Règles de Versioning (SemVer) ## 📌 Règles de Versioning (SemVer)
Le développement suit un versioning strict (format `vX.Y.Z`) synchronisé avec les tags Gitea et GitHub, affiché dynamiquement sur l'interface : Le développement suit un versioning strict (format `vX.Y.Z`) synchronisé avec les tags Gitea et GitHub, affiché dynamiquement sur l'interface[cite: 2]:
* **Majeur (X) :** Changement profond d'architecture ou refonte globale. * **Majeur (X) :** Changement profond d'architecture ou refonte globale[cite: 2].
* **Mineur (Y) :** Validation et ajout d'une nouvelle fonctionnalité de la roadmap. * **Mineur (Y) :** Validation et ajout d'une nouvelle fonctionnalité de la roadmap[cite: 2].
* **Patch (Z) :** Corrections de bugs, correctifs de sécurité ou ajustements visuels mineurs. * **Patch (Z) :** Corrections de bugs, correctifs de sécurité ou ajustements visuels mineurs[cite: 2].
## 📦 Récupération des Paquets Docker (Tags) ## 📦 Récupération des Paquets Docker (Tags)
Les images Docker sont construites automatiquement par les pipelines CI/CD lors de la création d'un tag. Les images Docker sont construites automatiquement par les pipelines CI/CD lors de la création d'un tag[cite: 2].
* **Sur Gitea :** Depuis la page principale de votre dépôt, naviguez dans l'onglet **Packages** (ou Paquets). Vous y trouverez le registre de conteneurs avec toutes les versions taguées (`v0.10.1`, `latest`, etc.). * **Sur Gitea :** Depuis la page principale de votre dépôt, naviguez dans l'onglet **Packages** (ou Paquets). Vous y trouverez le registre de conteneurs avec toutes les versions taguées (`v0.16.1`, `latest`, etc.)[cite: 2].
* **Sur GitHub :** Depuis la page principale du dépôt, regardez dans la colonne de droite la section **Packages**. Cliquez dessus pour accéder au GitHub Container Registry (`ghcr.io/votre-utilisateur/aethas38-multi-ia`) et visualiser la liste des tags disponibles à tirer via Docker/Dockge. * **Sur GitHub :** Depuis la page principale du dépôt, regardez dans la colonne de droite la section **Packages**. Cliquez dessus pour accéder au GitHub Container Registry (`ghcr.io/votre-utilisateur/aethas38-multi-ia`) et visualiser la liste des tags disponibles à tirer via Docker/Dockge[cite: 2].
## 🚀 ROADMAP & CAHIER DES CHARGES ## 🚀 ROADMAP & CAHIER DES CHARGES
### Sécurité & Infrastructure ### Sécurité & Infrastructure
* [x] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus). * [x] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus)[cite: 2].
* [x] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID[cite: 2].
* [x] Mots de passe cryptés (bcrypt), protection anti-brute force[cite: 2].
* [x] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID. * [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.)[cite: 2].
* [x] Déconnexion automatique (timer invisible / session JWT) après 60 minutes d'inactivité[cite: 2].
* [x] Configuration de GitHub Actions / Gitea Actions pour la création automatique des paquets Docker (Tags SemVer + latest)[cite: 2].
* [x] Mots de passe cryptés (bcrypt), protection anti-brute force. * [x] Horodatage UTC en base de données pour immunité au changement d'heure français (Prévention crash 2FA)[cite: 2].
* [x] Rotation automatique des logs système : génération d'un fichier log par jour avec purge stricte des fichiers de plus de 7 jours[cite: 2].
* [x] Procédure de modification sécurisée du mot de passe (Utilisateurs et Admins)[cite: 2].
* [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.).
* [x] Déconnexion automatique (timer invisible / session JWT) après 60 minutes d'inactivité.
* [x] Configuration de GitHub Actions / Gitea Actions pour la création automatique des paquets Docker (Tags SemVer + latest).
* [x] Horodatage UTC en base de données pour immunité au changement d'heure français (Prévention crash 2FA).
* [x] Rotation automatique des logs système : génération d'un fichier log par jour avec purge stricte des fichiers de plus de 7 jours.
* [ ] Procédure de réinitialisation/modification du mot de passe (Admin et Utilisateurs).
### Administration & Gestion des Utilisateurs ### Administration & Gestion des Utilisateurs
* [x] Assistant de première installation (création du compte admin complet). * [x] Assistant de première installation : l'initiateur du projet se voit attribuer automatiquement le rôle exclusif de **Super-Admin** (un seul par projet)[cite: 2].
* [x] Refonte du setup initial (Super Admin) : configuration du serveur mail (opérateurs FR/Gmail) et paramétrage complet des clés fournisseurs IA[cite: 2].
* [x] Intégration de la roue crantée (Paramètres) en bas de la barre latérale[cite: 2].
* [x] Refonte du setup initial (Super Admin) : Obligation de configurer le serveur mail (aide opérateurs FR/Gmail) et paramétrage complet des clés fournisseurs IA (OpenRouter, Groq, Gemini, DeepSeek, Mistral, Cloudflare, HF). * [x] Page de configuration par niveau (Super-Admin, Admin, Utilisateur)[cite: 2].
* [x] Intégration de la roue crantée (Paramètres) en bas de la barre latérale. * [x] Upload et personnalisation dynamique d'avatar utilisateur[cite: 2].
* [ ] Page de configuration Utilisateur : changement d'email (avec validation via lien envoyé) et de mot de passe. * [ ] Hiérarchie des rôles : Un Admin peut proposer l'élévation d'un User en Admin (formulaire soumis à la validation exclusive du Super Admin)[cite: 2].
* [ ] Export de l'activité Admin : Fichier Excel avec KPI, graphiques "camembert", et suivi financier par utilisateur[cite: 2].
* [x] Suivi financier en temps réel et vérification du budget API (mis à jour après chaque requête et affiché de manière non invasive sous le nom)[cite: 2].
* [ ] Page de configuration Admin : ajout/modification de fournisseurs, clés API, et serveur mail en permanence. * [ ] Envoi de rapport d'activité automatisé chaque jeudi à 04h00[cite: 2].
* [ ] Hiérarchie des rôles : Un Admin peut proposer l'élévation d'un User en Admin (formulaire soumis à la validation exclusive du Super Admin).
* [ ] Export de l'activité Admin : Fichier Excel avec KPI, graphiques "camembert", et suivi financier par utilisateur.
* [ ] Suivi financier en temps réel et vérification du budget API.
* [ ] Envoi de rapport d'activité automatisé chaque jeudi à 04h00.
### Expérience Utilisateur (UI/UX) ### Expérience Utilisateur (UI/UX)
* [x] Thème moderne (Gemini-like) et 100% compatible smartphones. * [x] Thème moderne (Gemini-like) et 100% compatible smartphones[cite: 2].
* [x] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés (Récents)[cite: 2].
* [x] Arrêt de la création automatique de nouveau projet à l'ouverture[cite: 2].
* [x] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés (Récents). * [x] Intégration permanente du logo AETHAS38 en local (Setup / Login / Dashboard)[cite: 2].
* [x] Indicateur visuel (Loader/Animation) signalant que l'IA AETHAS38 "réfléchit" ou travaille[cite: 2].
* [x] Fonctionnalité de renommage manuel des discussions/projets[cite: 2].
* [x] Arrêt de la création automatique de nouveau projet à l'ouverture. * [ ] Affichage natif et fluide des images directement dans le flux de la discussion[cite: 2].
* [ ] Système de partage des réponses sur les réseaux et messageries externes (Facebook, WhatsApp, etc.)[cite: 2].
* [x] Intégration permanente du logo AETHAS38 en local (Setup / Login / Dashboard).
* [x] Indicateur visuel (Loader/Animation) signalant que l'IA AETHAS38 "réfléchit" ou travaille.
* [x] Fonctionnalité de renommage manuel des discussions/projets.
* [ ] Affichage natif et fluide des images directement dans le flux de la discussion.
* [ ] Système de partage des réponses sur les réseaux et messageries externes (Facebook, WhatsApp, etc.).
### Moteur IA, Modèles & Fonctionnalités ### Moteur IA, Modèles & Fonctionnalités
* [x] Cœur asynchrone pour la parallélisation des appels API (module `orchestrator.py`). * [x] Cœur asynchrone pour la parallélisation des appels API (module `orchestrator.py`)[cite: 2].
* [ ] Pipeline multi-agents complet avec sélection dynamique : * [x] Pipeline multi-agents complet avec sélection dynamique par requête :
* Possibilité de sélectionner de 1 à 5 travailleurs IA en parallèle. * Sélection de 1 à 5 travailleurs IA en parallèle[cite: 2].
* Si un seul travailleur est sélectionné, bypass automatique du prompteur et du concaténeur. * Si un seul travailleur est sélectionné, bypass automatique du prompteur et du concaténeur[cite: 2].
* Si > 1 travailleur, activation de la chaîne complète (1 Prompteur ➔ N Travailleurs ➔ 1 Concaténeur). * Si > 1 travailleur, activation de la chaîne complète (1 Prompteur ➔ N Travailleurs ➔ 1 Concaténeur)[cite: 2].
* [x] Extraction universelle multi-fournisseurs (OpenRouter, Groq, DeepSeek, Mistral, Gemini, Cloudflare) avec timeout porté à 90 secondes[cite: 2].
* [x] Tâche planifiée (CRON) pour la mise à jour des modèles toutes les 12h (00h00 et 12h00)[cite: 2].
* [ ] Extraction, mise à jour (via tâche planifiée CRON toutes les 12h ou manuelle) et gestion des modèles depuis les API fournisseurs vers la BDD. * [x] Page spécifique dédiée aux modèles permettant l'export, l'import manuel d'un fichier d'extraction JSON, ainsi que le filtrage par prix, domaine et mots-clés[cite: 2].
* [ ] Page spécifique d'interface pour les modèles permettant l'export, l'import manuel d'un fichier d'extraction, et la sélection. * [x] Extraction unitaire des retours IA et téléchargement global d'une conversation (formats TXT et JSON)[cite: 2].
* [ ] Extraction unitaire des retours IA. * [x] Détection et téléchargement ciblé de pièces jointes ou blocs spécifiques (Code brut, scripts, fichiers de données) générés par l'IA en respectant le nommage exact demandé[cite: 2].
* [ ] Extraction/Téléchargement de la totalité d'une conversation dans des formats utiles aux différents fournisseurs (JSONL, TXT, etc.). * [x] Classement, filtrage (Gratuit/Payant) et catégorisation des modèles (Texte, Code, Vision, Audio)[cite: 2].
* [ ] Détection et téléchargement ciblé de pièces jointes ou blocs spécifiques (Code brut, fichiers Excel `.xlsx`) générés par l'IA en respectant le nommage exact demandé. * [ ] Traduction automatisée des descriptions des modèles via Ollama (local) ou Gemini[cite: 2].
* [ ] Classement, filtrage (Gratuit/Payant) et catégorisation des modèles (Texte, Code, Image, Audio, Vidéo). * [ ] Interface d'aide à la décision pour le choix des modèles selon la tâche souhaitée[cite: 2].
* [ ] Mécanisme de fallback / termes génériques pour les appels API (protection contre le dépassement des quotas)[cite: 2].
* [ ] Traduction automatisée des descriptions des modèles via Ollama (local) ou Gemini.
* [ ] Interface d'aide à la décision pour le choix des modèles selon la tâche souhaitée.
* [ ] Mécanisme de fallback / termes génériques pour les appels API (protection contre le dépassement des quotas Gemini).
+152 -10
View File
@@ -3,15 +3,15 @@ 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, json, asyncio, shutil import os, json, asyncio, shutil, base64, io, csv, zipfile
from datetime import datetime from datetime import datetime
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 from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse, PasswordChange, ModelReplacementRequest, LogRequest
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 from .orchestrator import run_orchestrator, sync_providers_models, sync_finances, activity_logs, log_activity
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
app = FastAPI(title="AETHAS38") app = FastAPI(title="AETHAS38")
@@ -73,7 +73,7 @@ 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=3600, samesite="lax") response.set_cookie(key="session_token", value=create_access_token(data={"sub": user.username}), httponly=True, max_age=604800, samesite="lax")
return {"message": "Connexion réussie"} return {"message": "Connexion réussie"}
@app.get("/dashboard") @app.get("/dashboard")
@@ -82,6 +82,52 @@ def dashboard(request: Request):
if not token or not verify_token(token): return RedirectResponse(url="/login") if not token or not verify_token(token): return RedirectResponse(url="/login")
return FileResponse(os.path.join(os.getcwd(), "frontend", "dashboard.html")) return FileResponse(os.path.join(os.getcwd(), "frontend", "dashboard.html"))
@app.get("/api/logs")
def get_logs():
return {"logs": activity_logs}
@app.post("/api/logs")
def add_frontend_log(req: LogRequest):
log_activity(f"[Système UI] {req.message}")
return {"status": "ok"}
@app.post("/api/models/suggest_replacement")
async def suggest_replacement(req: ModelReplacementRequest, db: Session = Depends(get_db)):
log_activity(f"⚠️ Modèle indisponible: {req.missing_model}. Demande de suggestion à Gemini...")
settings = db.query(SystemSettings).first()
if not settings or not settings.gemini_api_key:
log_activity("Clé Gemini non trouvée. Fallback forcé sur gemini-3.5-flash-lite.")
return {"suggestion": "gemini-3.5-flash-lite", "reason": "Clé API Gemini non configurée dans le système."}
models = db.query(AIModel).all()
available = [m.model_id for m in models]
prompt = f"Le modèle IA '{req.missing_model}' n'est plus disponible. Voici les modèles disponibles : {', '.join(available)}. Trouve le modèle le plus proche techniquement. Réponds UNIQUEMENT avec ce format strict : ID_DU_MODELE | Brève explication en français de 10 mots max. Si aucun ne correspond, renvoie gemini-3.5-flash-lite | Par défaut."
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key)
resp = await client.chat.completions.create(model="gemini-3.5-flash-lite", messages=[{"role": "user", "content": prompt}], max_tokens=50)
res = resp.choices[0].message.content.strip()
if "|" in res:
parts = res.split("|")
sugg = parts[0].strip()
reason = parts[1].strip()
else:
sugg = res.strip()
reason = "Sélectionné par Gemini."
if sugg not in available and sugg != "gemini-3.5-flash-lite":
sugg = "gemini-3.5-flash-lite"
reason = "Gemini a suggéré un modèle invalide. Fallback par défaut."
log_activity(f"✅ Remplacement trouvé : {req.missing_model} -> {sugg}")
return {"suggestion": sugg, "reason": reason}
except Exception as e:
log_activity(f"Erreur d'interrogation Gemini: {str(e)}. Fallback par défaut.")
return {"suggestion": "gemini-3.5-flash-lite", "reason": f"Erreur API."}
@app.get("/api/users/me") @app.get("/api/users/me")
def get_me(current_user: User = Depends(get_current_user)): 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} return {"username": current_user.username, "is_admin": current_user.is_admin, "is_superadmin": current_user.is_superadmin, "avatar_path": current_user.avatar_path}
@@ -143,14 +189,79 @@ 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)):
db.add(Message(role=message.role, content=message.content, project_id=project_id)); db.commit() extracted_files_data = []
files_names = []
if message.files:
for f in message.files:
files_names.append(f.name)
content = f.content
if content.startswith("data:"):
try:
header, b64data = content.split(",", 1)
file_bytes = base64.b64decode(b64data)
ext = f.name.split('.')[-1].lower()
extracted_text = ""
if ext == 'pdf':
try:
import PyPDF2
reader = PyPDF2.PdfReader(io.BytesIO(file_bytes))
extracted_text = "\n".join([page.extract_text() for page in reader.pages if page.extract_text()])
except ImportError:
extracted_text = "[Erreur: L'administrateur doit exécuter 'pip install PyPDF2' sur le serveur pour lire les PDF.]"
elif ext in ['xls', 'xlsx', 'xlsm', 'xlsb']:
try:
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=False)
for sheet_name in wb.sheetnames:
sheet = wb[sheet_name]
extracted_text += f"\n--- Feuille : {sheet_name} ---\n"
for row in sheet.iter_rows(values_only=True):
row_vals = [str(cell) if cell is not None else "" for cell in row]
if any(row_vals):
extracted_text += "\t".join(row_vals) + "\n"
if ext in ['xlsm', 'xlsb', 'xls']:
try:
from oletools.olevba import VBA_Parser
vbaparser = VBA_Parser("filename", data=file_bytes)
if vbaparser.detect_vba_macros():
extracted_text += "\n\n--- MACROS VBA DETECTEES ---\n"
for (filename, stream_path, vba_filename, vba_code) in vbaparser.extract_macros():
extracted_text += f"\n// Module: {vba_filename}\n{vba_code}\n"
except ImportError:
extracted_text += "\n[Extraction VBA impossible: L'administrateur doit exécuter 'pip install oletools' sur le serveur.]\n"
except Exception as e:
extracted_text += f"\n[Erreur de lecture VBA interne: {str(e)}]\n"
except ImportError:
extracted_text = "[Erreur: L'administrateur doit exécuter 'pip install openpyxl' sur le serveur pour lire Excel.]"
else:
extracted_text = file_bytes.decode('utf-8', errors='replace')
extracted_files_data.append({"name": f.name, "content": extracted_text})
except Exception as e:
extracted_files_data.append({"name": f.name, "content": f"[ERREUR DE DECODAGE: {str(e)}]"})
else:
extracted_files_data.append({"name": f.name, "content": content})
db_content = message.content
if files_names:
db_content += f"\n\n[Fichiers joints pour analyse : {', '.join(files_names)}]"
db.add(Message(role=message.role, content=db_content, project_id=project_id))
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"]} conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]}
ai_resp = await run_orchestrator(db, history, settings, conf) # Transmission de DB ai_resp = await run_orchestrator(db, history, settings, conf, extracted_files_data)
db.add(Message(role="assistant", content=ai_resp, project_id=project_id)); db.commit() 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() return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
# --- ROUTES MODÈLES & FINANCES --- # --- ROUTES MODÈLES & FINANCES ---
@@ -177,6 +288,37 @@ async def trigger_model_sync(db: Session = Depends(get_db), current_user: User =
@app.get("/api/models/export") @app.get("/api/models/export")
def export_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)):
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 admin requis.")
models = db.query(AIModel).all() models = db.query(AIModel).order_by(AIModel.provider.asc(), AIModel.name.asc()).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"}) csv_io = io.StringIO()
writer = csv.writer(csv_io, delimiter=',')
writer.writerow(["Provider", "Model ID", "Name", "Domain", "Is Free", "Context Length", "Pricing Prompt", "Pricing Completion", "Description"])
for m in models:
writer.writerow([m.provider, m.model_id, m.name, m.domain, m.is_free, m.context_length, m.pricing_prompt, m.pricing_completion, m.description_fr])
md_content = f"# Extraction des Modèles IA - AETHAS38\n\n**Date d'extraction :** {datetime.now().strftime('%d/%m/%Y à %H:%M:%S')}\n\n"
providers = sorted(list(set(m.provider for m in models)))
for prov in providers:
md_content += f"## Fournisseur : {prov.upper()}\n\n"
prov_models = [m for m in models if m.provider == prov]
for m in prov_models:
price_info = "**GRATUIT**" if m.is_free else f"In: ${m.pricing_prompt:.2f} / Out: ${m.pricing_completion:.2f}"
ctx_info = f"{int(m.context_length/1000)}k"
desc = m.description_fr.replace('\n', ' ') if m.description_fr else ""
md_content += f"- **{m.name or m.model_id}** (`{m.model_id}`)\n"
md_content += f" - *Domaine :* {m.domain}\n"
md_content += f" - *Prix (1M tokens) :* {price_info}\n"
md_content += f" - *Contexte :* {ctx_info}\n"
md_content += f" - *Description :* {desc}\n\n"
zip_io = io.BytesIO()
with zipfile.ZipFile(zip_io, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("models_export.csv", csv_io.getvalue().encode('utf-8'))
zf.writestr(f"{datetime.now().strftime('%Y%m%d')}-extraction-modeles.md", md_content.encode('utf-8'))
zip_io.seek(0)
return Response(
content=zip_io.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=aethas38_models_{datetime.now().strftime('%Y%m%d')}.zip"}
)
+141 -68
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import httpx import httpx
import urllib.parse
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
@@ -7,6 +8,16 @@ from sqlalchemy.exc import IntegrityError
from .models import SystemSettings, AIModel, FinancialLog from .models import SystemSettings, AIModel, FinancialLog
from datetime import datetime, timezone from datetime import datetime, timezone
# --- LOGGER GLOBAL POUR LE TERMINAL ---
activity_logs = []
def log_activity(msg: str):
ts = datetime.now(timezone.utc).strftime('%H:%M:%S')
activity_logs.append(f"[{ts}] {msg}")
if len(activity_logs) > 100:
activity_logs.pop(0)
# --------------------------------------
def determine_domain(model_id: str) -> str: def determine_domain(model_id: str) -> str:
mid = model_id.lower() mid = model_id.lower()
if "vision" in mid or "vl" in mid or "omni" in mid: return "Vision & Texte" if "vision" in mid or "vl" in mid or "omni" in mid: return "Vision & Texte"
@@ -14,10 +25,21 @@ def determine_domain(model_id: str) -> str:
if "audio" in mid or "whisper" in mid: return "Audio" if "audio" in mid or "whisper" in mid: return "Audio"
return "Texte Polyvalent" return "Texte Polyvalent"
async def translate_en_to_fr(client: httpx.AsyncClient, text: str) -> str:
if not text: return "Aucune description fournie."
try:
short_text = text[:300].strip()
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=fr&dt=t&q={urllib.parse.quote(short_text)}"
resp = await client.get(url, timeout=4.0)
if resp.status_code == 200:
translated = "".join([s[0] for s in resp.json()[0]])
return translated + ("..." if len(text) > 300 else "")
except Exception:
pass
return text[:200] + "..."
async def sync_finances(db: Session, settings: SystemSettings): async def sync_finances(db: Session, settings: SystemSettings):
"""Interroge les fournisseurs pour récupérer le solde financier exact."""
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
# OpenRouter
if settings.openrouter_management_key or settings.openrouter_api_key: if settings.openrouter_management_key or settings.openrouter_api_key:
try: try:
key = settings.openrouter_management_key or settings.openrouter_api_key key = settings.openrouter_management_key or settings.openrouter_api_key
@@ -38,10 +60,7 @@ async def sync_finances(db: Session, settings: SystemSettings):
update_finance_db(db, "OpenRouter", balance, usage) update_finance_db(db, "OpenRouter", balance, usage)
except Exception as e: print(f"Erreur Finance OR: {e}") except Exception as e: print(f"Erreur Finance OR: {e}")
# Groq
if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0) if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0)
# DeepSeek
if settings.deepseek_api_key: if settings.deepseek_api_key:
try: try:
resp = await client.get("https://api.deepseek.com/user/balance", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"}) resp = await client.get("https://api.deepseek.com/user/balance", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"})
@@ -49,11 +68,7 @@ async def sync_finances(db: Session, settings: SystemSettings):
infos = resp.json().get("balance_infos", [{}])[0] infos = resp.json().get("balance_infos", [{}])[0]
update_finance_db(db, "DeepSeek", float(infos.get("total_balance", 0)), 0.0) update_finance_db(db, "DeepSeek", float(infos.get("total_balance", 0)), 0.0)
except Exception: pass except Exception: pass
# Mistral AI
if settings.mistral_api_key: update_finance_db(db, "Mistral", 0.0, 0.0) if settings.mistral_api_key: update_finance_db(db, "Mistral", 0.0, 0.0)
# Gemini
if settings.gemini_api_key: update_finance_db(db, "Gemini", 0.0, 0.0) if settings.gemini_api_key: update_finance_db(db, "Gemini", 0.0, 0.0)
try: db.commit() try: db.commit()
@@ -69,12 +84,10 @@ def update_finance_db(db, provider, balance, usage):
except Exception as e: print(f"Finance DB Error: {e}") except Exception as e: print(f"Finance DB Error: {e}")
async def sync_providers_models(db: Session, settings: SystemSettings, sync_type: str = "Automatique"): async def sync_providers_models(db: Session, settings: SystemSettings, sync_type: str = "Automatique"):
log_activity(f"Lancement de la synchronisation des modèles ({sync_type})...")
added = 0 added = 0
# On stocke les modèles en mémoire avant de les envoyer en base pour éviter les doublons
models_to_process = {} models_to_process = {}
async with httpx.AsyncClient(timeout=90.0) as client: async with httpx.AsyncClient(timeout=90.0) as client:
# 1. OpenRouter
if settings.openrouter_api_key: if settings.openrouter_api_key:
try: try:
resp = await client.get("https://openrouter.ai/api/v1/models") resp = await client.get("https://openrouter.ai/api/v1/models")
@@ -84,95 +97,82 @@ async def sync_providers_models(db: Session, settings: SystemSettings, sync_type
m_id = item.get("id") m_id = item.get("id")
if not m_id: continue if not m_id: continue
pricing = item.get("pricing") or {} pricing = item.get("pricing") or {}
try: pp = float(pricing.get("prompt") or 0.0) * 1000000 pp = float(pricing.get("prompt") or 0.0) * 1000000
except: pp = 0.0 pc = float(pricing.get("completion") or 0.0) * 1000000
try: pc = float(pricing.get("completion") or 0.0) * 1000000
except: pc = 0.0
is_free = (pp == 0.0 and pc == 0.0) is_free = (pp == 0.0 and pc == 0.0)
desc = item.get("description", "Modèle OpenRouter.")[:200] + "..." desc_en = item.get("description", "Generic AI Model.")
models_to_process[m_id] = {"provider": "openrouter", "name": item.get("name", "Inconnu"), "desc": desc, "domain": determine_domain(m_id), "is_free": is_free, "ctx": item.get("context_length", 0), "pp": pp, "pc": pc} models_to_process[m_id] = {"provider": "openrouter", "name": item.get("name", "Inconnu"), "desc_en": desc_en, "domain": determine_domain(m_id), "is_free": is_free, "ctx": item.get("context_length", 0), "pp": pp, "pc": pc}
except: pass except: pass
except Exception as e: print(f"Erreur OR Models: {e}") except Exception as e: print(f"Erreur OR Models: {e}")
# 2. Groq
if settings.groq_api_key: if settings.groq_api_key:
try: try:
resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"}) resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("data", []): for item in resp.json().get("data", []):
try: m_id = item["id"]
m_id = item["id"] models_to_process[m_id] = {"provider": "groq", "name": m_id.capitalize(), "desc": "Modèle très rapide hébergé sur LPU Groq.", "domain": determine_domain(m_id), "is_free": True, "ctx": 8192, "pp": 0.0, "pc": 0.0}
models_to_process[m_id] = {"provider": "groq", "name": m_id.capitalize(), "desc": "Modèle rapide LPU Groq.", "domain": determine_domain(m_id), "is_free": True, "ctx": 8192, "pp": 0.0, "pc": 0.0}
except: pass
except Exception: pass except Exception: pass
# 3. DeepSeek
if settings.deepseek_api_key: if settings.deepseek_api_key:
try: try:
resp = await client.get("https://api.deepseek.com/models", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"}) resp = await client.get("https://api.deepseek.com/models", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("data", []): for item in resp.json().get("data", []):
try: m_id = item["id"]
m_id = item["id"] models_to_process[m_id] = {"provider": "deepseek", "name": m_id.capitalize(), "desc": "Modèle officiel du fournisseur DeepSeek.", "domain": determine_domain(m_id), "is_free": False, "ctx": 64000, "pp": 0.14, "pc": 0.28}
models_to_process[m_id] = {"provider": "deepseek", "name": m_id.capitalize(), "desc": "Modèle officiel DeepSeek.", "domain": determine_domain(m_id), "is_free": False, "ctx": 64000, "pp": 0.14, "pc": 0.28}
except: pass
except Exception: pass except Exception: pass
# 4. Mistral
if settings.mistral_api_key: if settings.mistral_api_key:
try: try:
resp = await client.get("https://api.mistral.ai/v1/models", headers={"Authorization": f"Bearer {settings.mistral_api_key}"}) resp = await client.get("https://api.mistral.ai/v1/models", headers={"Authorization": f"Bearer {settings.mistral_api_key}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("data", []): for item in resp.json().get("data", []):
try: m_id = item["id"]
m_id = item["id"] models_to_process[m_id] = {"provider": "mistral", "name": m_id.capitalize(), "desc": "Modèle officiel développé par Mistral AI.", "domain": determine_domain(m_id), "is_free": False, "ctx": 32000, "pp": 0.2, "pc": 0.6}
models_to_process[m_id] = {"provider": "mistral", "name": m_id.capitalize(), "desc": "Modèle officiel Mistral AI.", "domain": determine_domain(m_id), "is_free": False, "ctx": 32000, "pp": 0.2, "pc": 0.6}
except: pass
except Exception: pass except Exception: pass
# 5. Gemini (Google)
if settings.gemini_api_key: if settings.gemini_api_key:
try: try:
resp = await client.get(f"https://generativelanguage.googleapis.com/v1beta/models?key={settings.gemini_api_key}") resp = await client.get(f"https://generativelanguage.googleapis.com/v1beta/models?key={settings.gemini_api_key}")
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("models", []): for item in resp.json().get("models", []):
try: m_id = item["name"].replace("models/", "")
m_id = item["name"].replace("models/", "") models_to_process[m_id] = {"provider": "gemini", "name": item.get("displayName", m_id), "desc": "Modèle natif de l'écosystème Google Gemini.", "domain": determine_domain(m_id), "is_free": True, "ctx": item.get("inputTokenLimit", 32000), "pp": 0.0, "pc": 0.0}
desc = item.get("description", "Modèle Google Gemini.")[:200] + "..."
models_to_process[m_id] = {"provider": "gemini", "name": item.get("displayName", m_id), "desc": desc, "domain": determine_domain(m_id), "is_free": True, "ctx": item.get("inputTokenLimit", 32000), "pp": 0.0, "pc": 0.0}
except: pass
except Exception: pass except Exception: pass
# 6. Cloudflare
if settings.cloudflare_account_id and settings.cloudflare_api_token: if settings.cloudflare_account_id and settings.cloudflare_api_token:
try: try:
url = f"https://api.cloudflare.com/client/v4/accounts/{settings.cloudflare_account_id}/ai/models/search" url = f"https://api.cloudflare.com/client/v4/accounts/{settings.cloudflare_account_id}/ai/models/search"
resp = await client.get(url, headers={"Authorization": f"Bearer {settings.cloudflare_api_token}"}) resp = await client.get(url, headers={"Authorization": f"Bearer {settings.cloudflare_api_token}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("result", []): for item in resp.json().get("result", []):
try: m_id = item.get("name")
m_id = item.get("name") models_to_process[m_id] = {"provider": "cloudflare", "name": m_id.split("/")[-1], "desc": "Modèle Serverless Cloudflare Workers AI.", "domain": determine_domain(m_id), "is_free": True, "ctx": 4096, "pp": 0.0, "pc": 0.0}
desc = item.get("description", "Modèle Cloudflare Workers AI.")[:200] + "..."
models_to_process[m_id] = {"provider": "cloudflare", "name": m_id.split("/")[-1], "desc": desc, "domain": determine_domain(m_id), "is_free": True, "ctx": 4096, "pp": 0.0, "pc": 0.0}
except: pass
except Exception: pass except Exception: pass
# --- Phase d'enregistrement sécurisée --- sem = asyncio.Semaphore(15)
for m_id, data in models_to_process.items(): async def process_and_translate(m_id, data, client_session):
async with sem:
if "desc_en" in data:
data["desc"] = await translate_en_to_fr(client_session, data["desc_en"])
return m_id, data
async with httpx.AsyncClient(timeout=30.0) as client_trans:
tasks = [process_and_translate(m_id, data, client_trans) for m_id, data in models_to_process.items()]
translated_results = await asyncio.gather(*tasks)
for m_id, data in translated_results:
try: try:
existing = db.query(AIModel).filter(AIModel.model_id == m_id).first() existing = db.query(AIModel).filter(AIModel.model_id == m_id).first()
if existing: if existing:
existing.pricing_prompt = data["pp"]; existing.pricing_completion = data["pc"]; existing.is_free = data["is_free"]; existing.last_updated = datetime.now(timezone.utc) existing.pricing_prompt = data["pp"]; existing.pricing_completion = data["pc"]; existing.is_free = data["is_free"]; existing.description_fr = data["desc"]; existing.last_updated = datetime.now(timezone.utc)
else: else:
db.add(AIModel(provider=data["provider"], model_id=m_id, name=data["name"], description_fr=data["desc"], domain=data["domain"], is_free=data["is_free"], context_length=data["ctx"], pricing_prompt=data["pp"], pricing_completion=data["pc"])) db.add(AIModel(provider=data["provider"], model_id=m_id, name=data["name"], description_fr=data["desc"], domain=data["domain"], is_free=data["is_free"], context_length=data["ctx"], pricing_prompt=data["pp"], pricing_completion=data["pc"]))
added += 1 added += 1
# Commit très fréquent pour éviter les gros blocs qui plantent if added % 50 == 0: db.commit()
if added % 50 == 0: except IntegrityError: db.rollback()
db.commit() except Exception: db.rollback()
except IntegrityError:
db.rollback() # Si conflit, on annule cette insertion et on continue
except Exception:
db.rollback()
try: try:
settings.last_sync_date = datetime.now(timezone.utc) settings.last_sync_date = datetime.now(timezone.utc)
@@ -180,13 +180,12 @@ async def sync_providers_models(db: Session, settings: SystemSettings, sync_type
db.commit() db.commit()
except Exception as e: except Exception as e:
db.rollback() db.rollback()
print(f"Erreur DB Commit Sync: {e}")
await sync_finances(db, settings) await sync_finances(db, settings)
log_activity(f"Synchronisation terminée : {added} modèles analysés.")
return {"status": "success", "models_processed": added} return {"status": "success", "models_processed": added}
def get_client_for_model(db: Session, model_id: str, settings: SystemSettings): def get_client_for_model(db: Session, model_id: str, settings: SystemSettings):
"""Récupère dynamiquement le bon client OpenAI en fonction du fournisseur du modèle."""
model_db = db.query(AIModel).filter(AIModel.model_id == model_id).first() model_db = db.query(AIModel).filter(AIModel.model_id == model_id).first()
provider = model_db.provider if model_db else "openrouter" provider = model_db.provider if model_db else "openrouter"
@@ -207,38 +206,112 @@ 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"}]}
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
async def run_orchestrator(db: Session, history: list, settings: SystemSettings, config: dict) -> str: async def run_orchestrator(db: Session, history: list, settings: SystemSettings, config: dict, extracted_files: list = None) -> str:
workers = config.get("workers", ["gemini-3.5-flash-lite"]) workers = config.get("workers", ["gemini-3.5-flash-lite"])
user_prompt = history[-1].content user_prompt = history[-1].content
original_user_text = user_prompt.split("\n\n[Fichiers joints")[0] if "[Fichiers joints" in user_prompt else user_prompt
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]]
final_response = "" final_response = ""
try: try:
p_mod = config.get("prompter")
if not p_mod: p_mod = "gemini-3.5-flash-lite"
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)
# --- WORKFLOW MAP-REDUCE : PRÉ-TRAITEMENT SÉQUENTIEL & CHUNKING ---
files_context = ""
if extracted_files:
log_activity(f"Traitement Map-Reduce de {len(extracted_files)} fichier(s) joint(s).")
async def process_single_file(f):
file_sys = "You are an expert data analyst and senior developer. Extract the most important technical information from the file without losing critical code syntax."
content = f['content']
chunk_size = 150000
if len(content) > chunk_size:
chunks = [content[i:i+chunk_size] for i in range(0, len(content), chunk_size)]
chunk_analyses = []
log_activity(f"Fichier lourd ({f['name']}): Chunking en {len(chunks)} morceaux.")
for idx, chunk in enumerate(chunks):
file_prompt = f"Demande de l'utilisateur : '{original_user_text}'.\n\nPartie {idx+1}/{len(chunks)} du fichier '{f['name']}'. Analysez, extrayez et résumez le code, VBA, SQL ou les données pertinentes.\n\nContenu :\n```\n{chunk}\n```"
try:
analysis = await ask_agent(p_client, p_mod, [{"role": "system", "content": file_sys}, {"role": "user", "content": file_prompt}], p_prov)
chunk_analyses.append(analysis)
log_activity(f"Analyse chunk {idx+1}/{len(chunks)} pour {f['name']} réussie.")
except Exception as e:
chunk_analyses.append(f"[Erreur sur la partie {idx+1}: {str(e)}]")
log_activity(f"Erreur chunk {idx+1}/{len(chunks)} pour {f['name']}: {str(e)}")
await asyncio.sleep(1.5)
return f"\n\n--- Extraction du fichier {f['name']} (en {len(chunks)} parties) ---\n" + "\n".join(chunk_analyses)
else:
file_prompt = f"Demande de l'utilisateur : '{original_user_text}'.\n\nAnalysez le fichier ci-dessous. Extrayez, résumez et conservez méticuleusement tout le code, les macros VBA, les requêtes SQL, ou les données métier pertinentes pour répondre à la demande.\n\nFichier : {f['name']}\nContenu :\n```\n{content}\n```"
try:
analysis = await ask_agent(p_client, p_mod, [{"role": "system", "content": file_sys}, {"role": "user", "content": file_prompt}], p_prov)
log_activity(f"Analyse intégrale de {f['name']} réussie.")
return f"\n\n--- Extraction du fichier {f['name']} ---\n{analysis}"
except Exception as e:
log_activity(f"Erreur d'analyse sur {f['name']}: {str(e)}")
return f"\n\n--- Erreur sur {f['name']} ---\n{str(e)}"
file_analyses = []
for f in extracted_files:
analysis = await process_single_file(f)
file_analyses.append(analysis)
await asyncio.sleep(1.5)
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}"
# --- OPTIMISATION & TRADUCTION ---
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."
optimized = await ask_agent(p_client, p_mod, [{"role": "system", "content": prompt_system}, {"role": "user", "content": user_prompt}], p_prov)
log_activity(f"Lancement de {len(workers)} travailleur(s) en parallèle...")
if len(workers) == 1: if len(workers) == 1:
w_mod = workers[0] w_mod = workers[0]
client, provider = get_client_for_model(db, w_mod, settings) client, provider = get_client_for_model(db, w_mod, settings)
final_response = await ask_agent(client, w_mod, formatted_history + [{"role": "user", "content": user_prompt}], provider) worker_response = await ask_agent(client, w_mod, formatted_history + [{"role": "user", "content": optimized}], provider)
responses = [worker_response]
log_activity(f"Travailleur 1 ({w_mod}) a terminé.")
else: else:
p_mod = config.get("prompter", "gemini-3.5-flash-lite")
p_client, p_prov = get_client_for_model(db, p_mod, settings)
optimized = await ask_agent(p_client, p_mod, [{"role": "system", "content": "Optimise cette requête."}, {"role": "user", "content": user_prompt}], p_prov)
w_tasks = [] w_tasks = []
for w in workers: for w in workers:
w_client, w_prov = get_client_for_model(db, w, settings) w_client, w_prov = get_client_for_model(db, w, settings)
w_tasks.append(ask_agent(w_client, w, formatted_history + [{"role": "user", "content": optimized}], w_prov)) w_tasks.append(ask_agent(w_client, w, formatted_history + [{"role": "user", "content": optimized}], w_prov))
responses = await asyncio.gather(*w_tasks, return_exceptions=True) responses = await asyncio.gather(*w_tasks, return_exceptions=True)
log_activity("Tous les travailleurs ont terminé leur analyse.")
c_mod = config.get("concatenator")
if not c_mod: c_mod = "gemini-3.5-flash-lite"
c_client, c_prov = get_client_for_model(db, c_mod, settings)
log_activity(f"Synthèse et traduction finale via Concaténeur ({c_mod})...")
concat_system = (
"You are a master lead developer and technical synthesizer. "
"Synthesize the provided expert responses into a single cohesive response. "
"Translate all explanatory text, descriptions, and user-facing prose into natural French. "
"CRITICAL: Do NOT translate code blocks, programming keywords, or source code contents. "
"You may translate code comments into French if appropriate, but leave code syntax strictly intact."
)
synth = f"User Request: {original_user_text}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{str(r)}" for i, r in enumerate(responses)])
final_response = await ask_agent(c_client, c_mod, [{"role": "system", "content": concat_system}, {"role": "user", "content": synth}], c_prov)
log_activity("Orchestration terminée avec succès.")
c_mod = config.get("concatenator", "gemini-3.5-flash-lite")
c_client, c_prov = get_client_for_model(db, c_mod, settings)
synth = f"Requête: {user_prompt}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{r}" for i, r in enumerate(responses)]) + "\n\nFais une synthèse finale."
final_response = await ask_agent(c_client, c_mod, [{"role": "user", "content": synth}], c_prov)
except Exception as e: except Exception as e:
final_response = f"L'IA a rencontré une erreur critique: {str(e)}" final_response = f"L'IA a rencontré une erreur critique: {str(e)}"
log_activity(f"ERREUR CRITIQUE: {str(e)}")
await sync_finances(db, settings) await sync_finances(db, settings)
return final_response return final_response
+12 -1
View File
@@ -44,12 +44,17 @@ class OrchestratorConfig(BaseModel):
prompter: Optional[str] = "gemini-3.5-flash-lite" prompter: Optional[str] = "gemini-3.5-flash-lite"
concatenator: Optional[str] = "gemini-3.5-flash-lite" concatenator: Optional[str] = "gemini-3.5-flash-lite"
class AttachedFile(BaseModel):
name: str
content: str
class MessageBase(BaseModel): class MessageBase(BaseModel):
role: str role: str
content: str content: str
class MessageCreate(MessageBase): class MessageCreate(MessageBase):
config: Optional[OrchestratorConfig] = None config: Optional[OrchestratorConfig] = None
files: Optional[List[AttachedFile]] = None
class MessageResponse(MessageBase): class MessageResponse(MessageBase):
id: int id: int
@@ -59,4 +64,10 @@ class MessageResponse(MessageBase):
class PasswordChange(BaseModel): class PasswordChange(BaseModel):
old_password: str old_password: str
new_password: str new_password: str
class ModelReplacementRequest(BaseModel):
missing_model: str
class LogRequest(BaseModel):
message: str
+534 -132
View File
@@ -22,26 +22,6 @@
<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">
<!-- MODAL PARAMÈTRES (Sécurité) -->
<div v-if="isSettingsModalOpen" class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4">
<div class="bg-aethas-panel border border-gray-600 rounded-2xl w-full max-w-md overflow-hidden shadow-[0_0_30px_rgba(0,0,0,0.7)]">
<div class="p-4 border-b border-gray-700 flex justify-between items-center bg-gray-800/50">
<h2 class="text-lg font-bold text-white flex items-center gap-2"><i data-lucide="settings" class="w-5 h-5 text-aethas-cyan"></i> Paramètres</h2>
<button @click="closeSettings" class="text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-6 space-y-4">
<h3 class="text-sm font-semibold text-aethas-cyan uppercase tracking-wider mb-2">Sécurité du compte</h3>
<div v-if="settingsMessage" :class="['p-3 rounded text-sm text-center', settingsMessage.includes('succès') ? 'bg-green-900/50 text-green-200 border border-green-500' : 'bg-red-900/50 text-red-200 border border-red-500']">{{ settingsMessage }}</div>
<div><label class="block text-sm text-gray-300 mb-1">Ancien mot de passe</label><input v-model="passForm.old" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none"></div>
<div><label class="block text-sm text-gray-300 mb-1">Nouveau mot de passe</label><input v-model="passForm.new" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none"></div>
<div><label class="block text-sm text-gray-300 mb-1">Confirmer</label><input v-model="passForm.confirm" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none"></div>
<button @click="changePassword" :disabled="isSavingSettings" class="w-full mt-4 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2.5 px-4 rounded-lg transition-colors border border-gray-500 disabled:opacity-50">
<span v-if="isSavingSettings">Mise à jour...</span><span v-else>Modifier</span>
</button>
</div>
</div>
</div>
<!-- BARRE LATÉRALE --> <!-- BARRE LATÉRALE -->
<div :class="['transition-all duration-300 flex-shrink-0 relative z-50 flex flex-col', isSidebarOpen ? 'w-72' : 'w-0']"> <div :class="['transition-all duration-300 flex-shrink-0 relative z-50 flex flex-col', isSidebarOpen ? 'w-72' : 'w-0']">
@@ -63,7 +43,7 @@
</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 custom-scrollbar">
<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-2">É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">
@@ -91,8 +71,20 @@
</div> </div>
</div> </div>
<!-- NOUVEAU : ENCART FINANCIER SOUS LE MENU RÉCENTS --> <!-- TERMINAL SYSTEME -->
<div class="px-4 pb-2 border-b border-gray-700"> <div class="h-40 border-t border-gray-700 bg-black flex flex-col font-mono text-[9px] flex-shrink-0 shadow-inner">
<div class="p-1.5 bg-gray-900 border-b border-gray-700 text-aethas-cyan font-bold flex items-center justify-between">
<div class="flex items-center gap-1"><i data-lucide="terminal" class="w-3 h-3"></i> SYSTEM LOGS</div>
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
</div>
<div class="flex-1 overflow-y-auto p-2 custom-scrollbar text-green-400 space-y-1" id="terminal-logs">
<div v-for="(log, i) in systemLogs" :key="i" class="break-words">{{ log }}</div>
<div v-if="systemLogs.length === 0" class="text-gray-600">En attente d'activité serveur...</div>
</div>
</div>
<!-- ENCART FINANCIER -->
<div class="px-4 pb-2 pt-2 border-t border-gray-700 bg-aethas-panel flex-shrink-0">
<div v-if="financesList.length > 0" class="flex flex-col gap-1.5 mb-2"> <div v-if="financesList.length > 0" class="flex flex-col gap-1.5 mb-2">
<div v-for="fin in financesList" :key="fin.provider" class="text-[10px] bg-gray-800/80 border border-gray-600/50 px-2 py-1.5 rounded flex justify-between items-center shadow-inner"> <div v-for="fin in financesList" :key="fin.provider" class="text-[10px] bg-gray-800/80 border border-gray-600/50 px-2 py-1.5 rounded flex justify-between items-center shadow-inner">
<span class="text-gray-400 capitalize">{{ fin.provider }}</span> <span class="text-gray-400 capitalize">{{ fin.provider }}</span>
@@ -101,7 +93,7 @@
</div> </div>
</div> </div>
<div class="p-4 flex justify-between items-center cursor-pointer hover:bg-gray-800 transition-colors" @click="activeView = 'settings'"> <div class="p-4 flex justify-between items-center cursor-pointer hover:bg-gray-800 transition-colors border-t border-gray-700 flex-shrink-0" @click="openSettings">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img v-if="user.avatar_path" :src="user.avatar_path" class="w-8 h-8 rounded-full object-cover border border-gray-600 bg-gray-900"> <img v-if="user.avatar_path" :src="user.avatar_path" class="w-8 h-8 rounded-full object-cover border border-gray-600 bg-gray-900">
<div v-else class="w-8 h-8 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-sm font-bold shadow-md">{{ user.username.charAt(0).toUpperCase() }}</div> <div 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>
@@ -136,8 +128,8 @@
</div> </div>
</header> </header>
<!-- VUE : CONFIGURATION (Par Niveaux) --> <!-- VUE : CONFIGURATION -->
<div v-if="activeView === 'settings'" class="flex-1 overflow-y-auto p-4 md:p-8"> <div v-if="activeView === 'settings'" class="flex-1 overflow-y-auto p-4 md:p-8 custom-scrollbar">
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<h2 class="text-2xl font-bold text-white flex items-center gap-2 border-b border-gray-700 pb-4"><i data-lucide="settings-2" class="w-6 h-6 text-aethas-cyan"></i> Configuration Système</h2> <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>
@@ -147,7 +139,6 @@
<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> <button v-if="user.is_superadmin" @click="settingsTab = 'super'" :class="['py-2 px-4 text-sm font-semibold border-b-2 transition-colors', settingsTab === 'super' ? 'border-aethas-purple text-purple-400' : 'border-transparent text-gray-400 hover:text-white']">Super-Admin</button>
</div> </div>
<!-- Tab: Utilisateur -->
<div v-if="settingsTab === 'user'" class="space-y-6"> <div v-if="settingsTab === 'user'" class="space-y-6">
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700"> <div class="bg-gray-800 p-6 rounded-xl border border-gray-700">
<h3 class="text-lg font-semibold text-white mb-4">Personnalisation du Profil</h3> <h3 class="text-lg font-semibold text-white mb-4">Personnalisation du Profil</h3>
@@ -159,7 +150,6 @@
<i data-lucide="upload" class="w-4 h-4 inline mr-1"></i> Uploader un nouvel Avatar <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"> <input type="file" accept="image/*" @change="uploadAvatar" class="hidden">
</label> </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>
</div> </div>
@@ -175,38 +165,33 @@
</div> </div>
</div> </div>
<!-- Tab: Admin -->
<div v-if="settingsTab === 'admin' && user.is_admin" class="space-y-6"> <div v-if="settingsTab === 'admin' && user.is_admin" class="space-y-6">
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700"> <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> <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"> <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> <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> <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 (ZIP CSV/MD)</a>
</div> </div>
</div> </div>
</div> </div>
<!-- Tab: Super-Admin -->
<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</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> <p class="text-sm text-gray-400">Gestion globale du système.</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- VUE : MODÈLES ET FINANCES (Admin Only) --> <!-- VUE : MODÈLES ET FINANCES -->
<div v-if="activeView === 'models' && user.is_admin" class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6"> <div v-if="activeView === 'models' && user.is_admin" class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 custom-scrollbar">
<div class="flex justify-between items-end border-b border-gray-700 pb-4"> <div class="flex justify-between items-end border-b border-gray-700 pb-4">
<div> <div>
<h2 class="text-2xl font-bold text-white flex items-center gap-2"><i data-lucide="cpu" class="w-6 h-6 text-aethas-cyan"></i> Modèles & Finances API</h2> <h2 class="text-2xl font-bold text-white flex items-center gap-2"><i data-lucide="cpu" class="w-6 h-6 text-aethas-cyan"></i> Modèles & Finances API</h2>
<p class="text-sm text-gray-400 mt-1">Dernière extraction : <span class="text-gray-200 font-mono">{{ formatDate(modelInfo.last_sync_date) }}</span> <span class="text-xs bg-gray-800 px-2 py-0.5 rounded border border-gray-600 ml-2 uppercase">{{ modelInfo.last_sync_type || 'Aucune' }}</span></p> <p class="text-sm text-gray-400 mt-1">Dernière extraction : <span class="text-gray-200 font-mono">{{ formatDate(modelInfo.last_sync_date) }}</span> <span class="text-xs bg-gray-800 px-2 py-0.5 rounded border border-gray-600 ml-2 uppercase">{{ modelInfo.last_sync_type || 'Aucune' }}</span></p>
</div> </div>
<div class="flex gap-2"> <button @click="syncModels" :disabled="isSyncing" class="bg-aethas-cyan hover:bg-cyan-600 text-aethas-dark font-semibold py-2 px-4 rounded flex items-center gap-2 text-sm disabled:opacity-50"><i data-lucide="refresh-cw" :class="{'animate-spin': isSyncing}" class="w-4 h-4"></i> Forcer Extraction</button>
<button @click="syncModels" :disabled="isSyncing" class="bg-aethas-cyan hover:bg-cyan-600 text-aethas-dark font-semibold py-2 px-4 rounded flex items-center gap-2 text-sm disabled:opacity-50"><i data-lucide="refresh-cw" :class="{'animate-spin': isSyncing}" class="w-4 h-4"></i> Forcer Extraction</button>
</div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -222,6 +207,10 @@
<div class="bg-aethas-panel border border-gray-700 rounded-xl overflow-hidden flex flex-col"> <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"> <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"> <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="filterProvider" 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 fournisseurs</option>
<option v-for="p in uniqueProviders" :key="p" :value="p">{{ p.toUpperCase() }}</option>
</select>
<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"> <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="">Tous les domaines</option>
<option value="Texte Polyvalent">Texte Polyvalent</option> <option value="Texte Polyvalent">Texte Polyvalent</option>
@@ -234,6 +223,11 @@
<option value="free">Gratuits uniquement</option> <option value="free">Gratuits uniquement</option>
<option value="paid">Payants</option> <option value="paid">Payants</option>
</select> </select>
<!-- BOUTON D'EXPORT CSV -->
<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">
@@ -253,91 +247,173 @@
</div> </div>
</div> </div>
<!-- VUE : CHAT PAR DÉFAUT --> <!-- VUE : CHAT -->
<template v-if="activeView === 'chat'"> <div v-show="activeView === 'chat'" class="flex-1 flex flex-col min-h-0">
<div v-if="!activeProject" class="flex-1 overflow-y-auto p-4 flex flex-col items-center justify-center"> <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)]"> <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> <h2 class="text-2xl font-bold text-gray-200 mb-2">Comment puis-je vous aider aujourd'hui ?</h2>
<p class="text-gray-500 text-sm">Sélectionnez ou créez un projet dans la barre latérale.</p>
</div> </div>
<div v-else class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 flex flex-col custom-scrollbar" id="chat-container"> <template v-else>
<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 class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 flex flex-col custom-scrollbar" id="chat-container">
<div v-if="messages.length === 0" class="flex-1 flex items-center justify-center text-gray-500 italic">Démarrez la conversation ci-dessous.</div>
<div v-for="msg in messages" :key="msg.id" :class="['max-w-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"> <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']">
<template v-for="(part, index) in parseMessage(msg.content)" :key="index"> <div class="flex items-center gap-2 mb-2">
<div v-if="part.type === 'text'" class="whitespace-pre-wrap font-sans">{{ part.content }}</div> <img v-if="msg.role === 'user' && user.avatar_path" :src="user.avatar_path" class="w-5 h-5 rounded-full object-cover">
<div v-if="part.type === 'code'" class="my-4 bg-gray-900 rounded-lg border border-gray-700 overflow-hidden shadow-inner"> <span class="text-xs font-semibold uppercase tracking-wider text-gray-400">{{ msg.role === 'user' ? 'Vous' : 'AETHAS38' }}</span>
<div class="flex justify-between items-center px-4 py-2 bg-gray-800 border-b border-gray-700"> </div>
<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="text-gray-200 text-sm leading-relaxed w-full">
<template v-for="(part, index) in parseMessage(msg.content)" :key="index">
<div v-if="part.type === 'text'" class="whitespace-pre-wrap font-sans">{{ part.content }}</div>
<div v-if="part.type === 'code'" class="my-4 bg-gray-900 rounded-lg border border-gray-700 overflow-hidden shadow-inner">
<div class="flex justify-between items-center px-4 py-2 bg-gray-800 border-b border-gray-700">
<span class="text-xs text-gray-400 font-mono flex items-center gap-2"><i data-lucide="file-code-2" class="w-4 h-4"></i> {{ part.language || 'code' }}</span>
<button @click="downloadFile(part.content, part.language)" class="text-aethas-cyan hover:text-white flex items-center gap-1 text-xs transition-colors bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded"><i data-lucide="download" class="w-3 h-3"></i> Fichier</button>
</div>
<pre class="p-4 text-xs font-mono text-gray-300 overflow-x-auto custom-scrollbar"><code>{{ part.content }}</code></pre>
</div> </div>
<pre class="p-4 text-xs font-mono text-gray-300 overflow-x-auto custom-scrollbar"><code>{{ part.content }}</code></pre> </template>
</div>
<!-- Boutons de copie et téléchargement individuel -->
<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="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>
</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>
<div class="p-4 md:px-6 md:pt-6 pb-2 w-full max-w-7xl mx-auto flex-shrink-0">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Configuration des IA</span>
<div class="flex gap-2">
<button @click="downloadTemplate" class="text-xs bg-gray-800 hover:bg-gray-700 text-aethas-cyan border border-gray-600 px-2 py-1 rounded flex items-center gap-1 transition-colors" title="Télécharger le modèle .md"><i data-lucide="download" class="w-3 h-3"></i> Template MD</button>
<label class="text-xs bg-gray-800 hover:bg-gray-700 text-aethas-cyan border border-gray-600 px-2 py-1 rounded flex items-center gap-1 transition-colors cursor-pointer" title="Importer une configuration .md">
<i data-lucide="upload" class="w-3 h-3"></i> Importer
<input type="file" accept=".md" @change="handleConfigImport" class="hidden">
</label>
</div>
</div>
<!-- BARRE DE SÉLECTION -->
<div class="grid grid-cols-9 border border-black bg-gray-900 rounded-lg overflow-hidden text-center text-xs font-bold shadow-lg mb-4">
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-gray-400 mb-1">PRIX</label>
<select v-model="colPricing" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none">
<option value="">TOUS</option>
<option value="free">GRATUIT</option>
<option value="paid">PAYANT</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-gray-400 mb-1">TYPE</label>
<select v-model="colDomain" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none">
<option value="">TOUS</option>
<option value="Texte Polyvalent">Texte</option>
<option value="Code & Logique">Codage</option>
<option value="Vision & Texte">Vision</option>
<option value="Audio">Audio</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 1</label>
<select v-model="workers[0]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[0])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 2</label>
<select v-model="workers[1]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[1])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 3</label>
<select v-model="workers[2]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[2])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 4</label>
<select v-model="workers[3]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[3])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 5</label>
<select v-model="workers[4]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[4])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-gray-400 mb-1">PROMPTEUR</label>
<select v-model="selectedPrompter" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Par défaut)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, selectedPrompter)" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-purple mb-1">CONCATENEUR</label>
<select v-model="selectedConcatenator" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Par défaut)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, selectedConcatenator)" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
</div>
<!-- CHAT INPUT -->
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan flex-col gap-2">
<div v-if="attachedFiles.length > 0" class="w-full flex flex-wrap gap-2 px-2 pt-2">
<div v-for="(file, index) in attachedFiles" :key="index" class="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 flex items-center gap-2">
<i data-lucide="file" class="w-3.5 h-3.5 text-aethas-cyan"></i>
<span class="truncate max-w-[150px]">{{ file.name }}</span>
<button @click="removeFile(index)" class="text-red-400 hover:text-red-300"><i data-lucide="x" class="w-3 h-3"></i></button>
</div> </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>
<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> <div class="w-full flex items-end">
<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"> <label class="p-3 text-gray-400 hover:text-aethas-cyan cursor-pointer transition-colors" title="Joindre des fichiers (max 10)">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option> <i data-lucide="paperclip" class="w-5 h-5"></i>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option> <input type="file" multiple @change="handleFileUpload" class="hidden">
</select> </label>
<textarea v-model="prompt" @input="autoResize" @paste="handlePaste" @keydown.enter.exact.prevent="sendMessage" ref="promptTextarea" :disabled="isAiThinking" rows="1" class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none disabled:opacity-50 custom-scrollbar" :placeholder="attachedFiles.length > 0 ? attachedFiles.length + ' fichier(s) attaché(s)' : 'Posez votre question ou glissez jusqu\'à 10 fichiers ici...'"></textarea>
<button @click="sendMessage" :disabled="(!prompt.trim() && attachedFiles.length === 0) || 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>
</div> </div>
</div> </div>
</template>
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan"> </div>
<textarea v-model="prompt" @input="autoResize" @keydown.enter.exact.prevent="sendMessage" ref="promptTextarea" :disabled="isAiThinking" rows="1" class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none disabled:opacity-50 custom-scrollbar" placeholder="Sélectionnez vos modèles ci-dessus, puis posez votre question..."></textarea>
<button @click="sendMessage" :disabled="!prompt.trim() || isAiThinking" class="p-3 text-aethas-cyan hover:text-white disabled:opacity-50"><i data-lucide="send" class="w-5 h-5"></i></button>
</div>
</div>
</template>
</main> </main>
</div> </div>
<script> <script>
const { createApp, ref, reactive, onMounted, nextTick, computed } = Vue; const { createApp, ref, reactive, onMounted, nextTick, computed, watch } = Vue;
createApp({ createApp({
setup() { setup() {
@@ -357,33 +433,70 @@
const isSyncing = ref(false); const isSyncing = ref(false);
const isSavingSettings = ref(false); const isSavingSettings = ref(false);
const settingsMessage = ref("");
const passForm = reactive({ old: '', new: '', confirm: '' }); const passForm = reactive({ old: '', new: '', confirm: '' });
const settingsMessage = ref("");
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([]);
const filterSearch = ref(""); const filterSearch = ref("");
const filterDomain = ref(""); const filterDomain = ref("");
const filterPricing = ref(""); const filterPricing = ref("");
const filterProvider = ref("");
const selectedWorkers = ref(["gemini-3.5-flash-lite"]); const colPricing = ref("");
const selectedPrompter = ref("gemini-3.5-flash-lite"); const colDomain = ref("");
const selectedConcatenator = ref("gemini-3.5-flash-lite");
const workers = ref(["", "", "", "", ""]);
const selectedPrompter = ref("");
const selectedConcatenator = ref("");
const attachedFiles = ref([]);
const systemLogs = ref([]);
let logInterval = null;
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 uniqueProviders = computed(() => {
return [...new Set(modelInfo.models.map(m => m.provider))].sort();
});
const filteredModels = computed(() => { const filteredModels = computed(() => {
return modelInfo.models.filter(m => { return modelInfo.models.filter(m => {
const matchSearch = (m.model_id.toLowerCase().includes(filterSearch.value.toLowerCase()) || m.provider.toLowerCase().includes(filterSearch.value.toLowerCase())); 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; const matchDomain = filterDomain.value === "" || m.domain === filterDomain.value;
const matchProvider = filterProvider.value === "" || m.provider === filterProvider.value;
let matchPrice = true; let matchPrice = true;
if (filterPricing.value === 'free') matchPrice = m.is_free; if (filterPricing.value === 'free') matchPrice = m.is_free;
if (filterPricing.value === 'paid') matchPrice = !m.is_free; if (filterPricing.value === 'paid') matchPrice = !m.is_free;
return matchSearch && matchDomain && matchPrice; return matchSearch && matchDomain && matchProvider && matchPrice;
}); });
}); });
const getFilteredModelsForCol = (pricingFilter, domainFilter, currentSelectedId) => {
let filtered = modelInfo.models.filter(m => {
const matchDomain = domainFilter === "" || m.domain === domainFilter;
let matchPrice = true;
if (pricingFilter === 'free') matchPrice = m.is_free;
if (pricingFilter === 'paid') matchPrice = !m.is_free;
return matchDomain && matchPrice;
});
if (currentSelectedId && currentSelectedId.trim() !== "") {
const exists = filtered.find(m => m.model_id === currentSelectedId);
if (!exists) {
const original = modelInfo.models.find(m => m.model_id === currentSelectedId);
if (original) {
filtered.unshift(original);
} else {
filtered.unshift({ model_id: currentSelectedId, name: currentSelectedId, domain: "Importé", pricing_prompt: 0, pricing_completion: 0 });
}
}
}
return filtered;
};
const formatDate = (isoString) => { const formatDate = (isoString) => {
if (!isoString) return "Jamais"; if (!isoString) return "Jamais";
const d = new Date(isoString); const d = new Date(isoString);
@@ -394,26 +507,172 @@
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 = () => {
activeView.value = 'settings';
if(window.innerWidth < 768) isSidebarOpen.value = false;
nextTick(() => lucide.createIcons());
};
const logToTerminal = async (msg) => {
try { await fetch('/api/logs', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ message: msg }) }); } catch(e){}
};
const fetchLogs = async () => {
try {
const res = await fetch('/api/logs');
if (res.ok) {
const data = await res.json();
if (systemLogs.value.length !== data.logs.length || data.logs[data.logs.length-1] !== systemLogs.value[systemLogs.value.length-1]) {
systemLogs.value = data.logs;
nextTick(() => {
const el = document.getElementById('terminal-logs');
if (el) el.scrollTop = el.scrollHeight;
});
}
}
} catch(e){}
};
const validateConfigAndReplace = async () => {
if (!modelInfo.models || modelInfo.models.length === 0) return;
const availableModels = modelInfo.models.map(m => m.model_id);
const replacements = [];
const checkAndReplace = async (val, setterCallback, roleName) => {
if (val && val.trim() !== "" && !availableModels.includes(val)) {
logToTerminal(`Le modèle ${val} n'est plus reconnu. Recherche Gemini...`);
try {
const res = await fetch('/api/models/suggest_replacement', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ missing_model: val })
});
if (res.ok) {
const data = await res.json();
setterCallback(data.suggestion);
replacements.push(`- ${roleName} : ${val} a été remplacé par ${data.suggestion}\n Raison: ${data.reason}`);
} else {
setterCallback("gemini-3.5-flash-lite");
replacements.push(`- ${roleName} : ${val} remplacé par gemini-3.5-flash-lite (Erreur API)`);
}
} catch(e) {
setterCallback("gemini-3.5-flash-lite");
}
}
};
await checkAndReplace(selectedPrompter.value, (n) => selectedPrompter.value = n, "Prompteur");
await checkAndReplace(selectedConcatenator.value, (n) => selectedConcatenator.value = n, "Concaténeur");
for(let i=0; i<5; i++) {
await checkAndReplace(workers.value[i], (n) => workers.value[i] = n, `Travailleur ${i+1}`);
}
if (replacements.length > 0) {
alert("⚠️ Mise à jour de la conversation\n\nCertains modèles ne sont plus disponibles. L'IA a trouvé des alternatives :\n\n" + replacements.join("\n\n"));
}
};
watch([workers, selectedPrompter, selectedConcatenator], () => {
if (activeProject.value) {
const config = {
workers: workers.value,
prompter: selectedPrompter.value,
concatenator: selectedConcatenator.value
};
localStorage.setItem('aethas_config_' + activeProject.value.id, JSON.stringify(config));
}
}, { deep: true });
const fetchUser = async () => { try { const res = await fetch('/api/users/me'); if(res.ok) user.value = await res.json(); } catch(e){} }; const fetchUser = async () => { try { const res = await fetch('/api/users/me'); if(res.ok) user.value = await res.json(); } catch(e){} };
const fetchModelsInfo = async () => { try { const res = await fetch('/api/models/info'); if(res.ok) { const data = await res.json(); modelInfo.last_sync_date = data.last_sync_date; modelInfo.last_sync_type = data.last_sync_type; modelInfo.models = data.models; } } catch(e){} }; const fetchModelsInfo = async () => { try { const res = await fetch('/api/models/info'); if(res.ok) { const data = await res.json(); modelInfo.last_sync_date = data.last_sync_date; modelInfo.last_sync_type = data.last_sync_type; modelInfo.models = data.models; } } catch(e){} };
const fetchFinances = async () => { try { const res = await fetch('/api/finances'); if(res.ok) financesList.value = await res.json(); } catch(e){} }; const fetchFinances = async () => { try { const res = await fetch('/api/finances'); if(res.ok) financesList.value = await res.json(); } catch(e){} };
const syncModels = async () => { const syncModels = async () => {
isSyncing.value = true; isSyncing.value = true;
logToTerminal("Déclenchement manuel de l'extraction des modèles...");
try { try {
const res = await fetch('/api/models/sync', { method: 'POST' }); const res = await fetch('/api/models/sync', { method: 'POST' });
if(res.ok) { if(res.ok) {
await fetchModelsInfo(); await fetchModelsInfo();
await fetchFinances(); await fetchFinances();
alert("Synchronisation terminée !"); alert("Synchronisation réussie !");
} else if (res.status === 401) {
alert("Votre session a expiré."); window.location.href = '/login';
} else { } else {
const err = await res.json(); let errText = await res.text();
alert("Erreur: " + err.detail); try { const errObj = JSON.parse(errText); errText = errObj.detail || errText; } catch(e){}
alert("Erreur retournée par le serveur : " + errText);
} }
} catch(e) { alert("Erreur réseau de synchronisation."); } } catch(e) { alert("Erreur réseau: impossible de contacter le serveur."); }
isSyncing.value = false; isSyncing.value = false;
}; };
const downloadTemplate = () => {
logToTerminal("Téléchargement du template MD généré.");
const templateContent = `# AETHAS38 - Configuration Multi-Agents\n\n**Rappel important :**\n- **Prompteur :** Le système de l'orchestrateur utilisera ce modèle pour traduire et optimiser votre requête initiale en ANGLAIS (langue native des IA).\n- **Concaténeur :** Le système utilisera ce modèle pour regrouper les réponses des travailleurs et traduire la synthèse finale en FRANÇAIS (en préservant le code brut).\n\n## Sélection des Modèles\nRenseignez l'ID exact du modèle (ex: gemini-3.5-flash-lite, mistralai/mistral-large-4-0). Laissez vide si inutilisé.\n\n- Prompteur : \n- Travailleur 1 : \n- Travailleur 2 : \n- Travailleur 3 : \n- Travailleur 4 : \n- Travailleur 5 : \n- Concaténeur : \n`;
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 file = event.target.files[0];
if (!file) return;
logToTerminal(`Importation de configuration depuis ${file.name}`);
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target.result;
const extractModel = (key) => {
const safeKey = key.replace(/[éèe]/gi, '[éèe]');
const regex = new RegExp(`-\\s*${safeKey}\\s*:\\s*([^\\r\\n]*)`, 'i');
const match = content.match(regex);
return match && match[1] ? match[1].trim() : "";
};
const w1 = extractModel('Travailleur 1');
const w2 = extractModel('Travailleur 2');
const w3 = extractModel('Travailleur 3');
const w4 = extractModel('Travailleur 4');
const w5 = extractModel('Travailleur 5');
workers.value = [w1, w2, w3, w4, w5];
selectedPrompter.value = extractModel('Prompteur');
selectedConcatenator.value = extractModel('Concateneur');
await validateConfigAndReplace();
alert("Configuration IA importée et appliquée avec succès !");
};
reader.readAsText(file);
event.target.value = null;
};
const uploadAvatar = async (event) => { const uploadAvatar = async (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -427,32 +686,166 @@
event.target.value = null; event.target.value = null;
}; };
const handleFileUpload = (event) => {
const files = event.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
if (attachedFiles.value.length >= 10) {
alert("Maximum 10 fichiers autorisés par message.");
break;
}
processFile(files[i]);
}
event.target.value = null;
};
const handlePaste = (event) => {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (let index in items) {
let item = items[index];
if (item.kind === 'file') {
let file = item.getAsFile();
if (file) {
if (attachedFiles.value.length >= 10) {
alert("Maximum 10 fichiers autorisés par message.");
break;
}
processFile(file);
}
}
}
};
const processFile = (file) => {
logToTerminal(`Fichier ${file.name} ajouté en pièce jointe.`);
const reader = new FileReader();
const textExts = ['.txt', '.md', '.csv', '.json', '.py', '.js', '.html', '.css', '.xml', '.yml', '.yaml', '.sh', '.bat', '.ps1', '.ini', '.cfg', '.conf', '.log'];
const isText = textExts.some(ext => file.name.toLowerCase().endsWith(ext)) || file.type.startsWith('text/');
reader.onload = (e) => {
attachedFiles.value.push({ name: file.name, content: e.target.result });
};
if (isText) {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
};
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 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 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 }]; };
const fetchProjects = async () => { try { const res = await fetch('/api/projects'); if(res.ok) projects.value = await res.json(); nextTick(()=>lucide.createIcons()); } catch(e){} }; 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 selectProject = async (project) => {
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;} }; activeView.value = 'chat';
activeProject.value = project;
messages.value = [];
logToTerminal(`Chargement du projet: ${project.title}`);
const saved = localStorage.getItem('aethas_config_' + project.id);
if (saved) {
try {
const config = JSON.parse(saved);
workers.value = config.workers || ["", "", "", "", ""];
selectedPrompter.value = config.prompter || "";
selectedConcatenator.value = config.concatenator || "";
} catch(e) {
workers.value = ["", "", "", "", ""];
selectedPrompter.value = "";
selectedConcatenator.value = "";
}
} else {
workers.value = ["", "", "", "", ""];
selectedPrompter.value = "";
selectedConcatenator.value = "";
}
try {
const res = await fetch(`/api/projects/${project.id}/messages`);
if(res.ok) {
messages.value = await res.json();
scrollToBottom();
nextTick(()=>lucide.createIcons());
} else if (res.status === 401) {
alert("Votre session a expiré."); window.location.href = '/login';
}
} catch(e){}
await validateConfigAndReplace();
};
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); } else if (res.status === 401) { alert("Session expirée."); window.location.href='/login'; }
} catch(e){} finally{isLoading.value = false;}
};
const sendMessage = async () => { const sendMessage = async () => {
if (!prompt.value.trim() || !activeProject.value || selectedWorkers.value.length === 0) return; const activeWorkers = workers.value.filter(w => w && w.trim() !== "");
const userText = prompt.value.trim(); prompt.value = ""; autoResize(); if ((!prompt.value.trim() && attachedFiles.value.length === 0) || !activeProject.value || activeWorkers.length === 0) {
messages.value.push({ id: Date.now(), role: 'user', content: userText }); alert("Veuillez sélectionner au moins un travailleur et rédiger ou joindre un contenu.");
isAiThinking.value = true; scrollToBottom(); return;
const payload = { role: 'user', content: userText, config: { workers: selectedWorkers.value, prompter: selectedPrompter.value, concatenator: selectedConcatenator.value } }; }
const userText = prompt.value.trim();
prompt.value = "";
autoResize();
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}]` : "") });
isAiThinking.value = true;
scrollToBottom();
const payloadFiles = [...attachedFiles.value];
const payload = {
role: 'user',
content: userText || "Analyse ces fichiers joints.",
config: { workers: activeWorkers, prompter: selectedPrompter.value, concatenator: selectedConcatenator.value },
files: payloadFiles
};
attachedFiles.value = [];
logToTerminal("Envoi de la requête à l'orchestrateur...");
try { try {
const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (Array.isArray(data)) messages.value = data; if (Array.isArray(data)) messages.value = data;
await fetchFinances(); // MAJ du budget sous le nom await fetchFinances();
} else if (response.status === 401) {
alert("Votre session a expiré. Veuillez vous reconnecter.");
window.location.href = '/login';
return;
} else { } else {
const err = await response.json(); alert("Erreur API IA : " + err.detail); const err = await response.json(); alert("Erreur API IA : " + err.detail);
} }
} catch (error) {} finally { isAiThinking.value = false; scrollToBottom(); nextTick(()=>lucide.createIcons()); } } catch (error) {
alert("Erreur locale lors de l'envoi : " + error.message);
} finally {
isAiThinking.value = false;
scrollToBottom();
nextTick(()=>lucide.createIcons());
}
}; };
const changePassword = async () => { const changePassword = async () => {
@@ -466,18 +859,27 @@
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 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 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 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(); fetchFinances(); }); onMounted(() => {
lucide.createIcons();
fetchUser();
fetchProjects();
fetchModelsInfo().then(() => {
if(activeProject.value) validateConfigAndReplace();
});
fetchFinances();
logInterval = setInterval(fetchLogs, 2000);
});
return { return {
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,
passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar, openSettings, passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar,
modelInfo, financesList, filterSearch, filterDomain, filterPricing, filteredModels, formatDate, syncModels, isSyncing, modelInfo, financesList, filterSearch, filterDomain, filterPricing, filterProvider, uniqueProviders, filteredModels, getFilteredModelsForCol, formatDate, syncModels, isSyncing,
selectedWorkers, selectedPrompter, selectedConcatenator, limitWorkers, workers, selectedPrompter, selectedConcatenator, colPricing, colDomain, downloadTemplate, exportCSV, handleConfigImport,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage attachedFiles, handleFileUpload, handlePaste, removeFile,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage, systemLogs
}; };
} }
}).mount('#app'); }).mount('#app');
+4 -1
View File
@@ -14,4 +14,7 @@ PyJWT
openai openai
httpx httpx
python-multipart python-multipart
pytz pytz
PyPDF2
openpyxl
oletools