Compare commits

..
5 Commits
Author SHA1 Message Date
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
xavier b554c983a6 fix: sécurisation des insertions de modèles via dictionnaire temporaire pour éviter les Duplicate Key
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-07 19:40:09 +02:00
xavier 007e723e4e feat: intégration extraction universelle multi-fournisseurs avec timeout 90s et routage dynamique
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-07 19:29:17 +02:00
3 changed files with 299 additions and 250 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).
+165 -54
View File
@@ -3,45 +3,45 @@ import httpx
from openai import AsyncOpenAI from openai import AsyncOpenAI
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from 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
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: return "Vision & Texte" if "vision" in mid or "vl" in mid or "omni" in mid: return "Vision & Texte"
if "coder" in mid or "code" in mid or "math" in mid: return "Code & Logique" if "coder" in mid or "code" in mid or "math" in mid: return "Code & Logique"
if "audio" in mid or "whisper" in mid: return "Audio" if "audio" in mid or "whisper" in mid: return "Audio"
return "Texte Polyvalent" return "Texte Polyvalent"
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.""" """Interroge les fournisseurs pour récupérer le solde financier exact."""
async with httpx.AsyncClient() 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
# Test de l'endpoint des crédits prépayés en priorité
cred_resp = await client.get("https://openrouter.ai/api/v1/credits", headers={"Authorization": f"Bearer {key}"}) cred_resp = await client.get("https://openrouter.ai/api/v1/credits", headers={"Authorization": f"Bearer {key}"})
balance = 0.0 balance = 0.0
usage = 0.0 usage = 0.0
if cred_resp.status_code == 200 and cred_resp.json().get("data"): if cred_resp.status_code == 200 and cred_resp.json().get("data"):
data = cred_resp.json().get("data", {}) c_data = cred_resp.json().get("data", {})
balance = float(data.get("total_credits") or 0.0) - float(data.get("total_usage") or 0.0) balance = float(c_data.get("total_credits") or 0.0) - float(c_data.get("total_usage") or 0.0)
usage = float(data.get("total_usage") or 0.0) usage = float(c_data.get("total_usage") or 0.0)
else: else:
# Fallback sur l'usage de la clé si pas de crédits prépayés
key_resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"}) key_resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"})
if key_resp.status_code == 200: if key_resp.status_code == 200:
data = key_resp.json().get("data", {}) data = key_resp.json().get("data", {})
limit = data.get("limit") limit = data.get("limit")
usage = float(data.get("usage") or 0.0) usage = float(data.get("usage") or 0.0)
balance = (float(limit) - usage) if limit is not None else -usage balance = (float(limit) - usage) if limit is not None else -usage
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}")
if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0) # Gratuit en Beta # Groq
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,62 +49,164 @@ 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
db.commit()
# Mistral AI
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)
try: db.commit()
except: db.rollback()
def update_finance_db(db, provider, balance, usage): def update_finance_db(db, provider, balance, usage):
log = db.query(FinancialLog).filter(FinancialLog.provider == provider).first() try:
if log: log = db.query(FinancialLog).filter(FinancialLog.provider == provider).first()
log.balance = balance; log.total_usage = usage; log.checked_at = datetime.now(timezone.utc) if log:
else: log.balance = balance; log.total_usage = usage; log.checked_at = datetime.now(timezone.utc)
db.add(FinancialLog(provider=provider, balance=balance, total_usage=usage)) else:
db.add(FinancialLog(provider=provider, balance=balance, total_usage=usage))
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"):
added = 0 added = 0
async with httpx.AsyncClient() as client: # On stocke les modèles en mémoire avant de les envoyer en base pour éviter les doublons
models_to_process = {}
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")
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("data", []): for item in resp.json().get("data", []):
pricing = item.get("pricing") or {} try:
pp = float(pricing.get("prompt") or 0.0) * 1000000 m_id = item.get("id")
pc = float(pricing.get("completion") or 0.0) * 1000000 if not m_id: continue
is_free = (pp == 0.0 and pc == 0.0) pricing = item.get("pricing") or {}
desc = item.get("description", "Modèle IA générique.")[:200] + "..." try: pp = float(pricing.get("prompt") or 0.0) * 1000000
process_model(db, "openrouter", item["id"], item["name"], desc, determine_domain(item["id"]), is_free, item.get("context_length", 0), pp, pc) except: pp = 0.0
added += 1 try: pc = float(pricing.get("completion") or 0.0) * 1000000
except Exception as e: print(f"Erreur Modèles OR: {e}") except: pc = 0.0
is_free = (pp == 0.0 and pc == 0.0)
desc = item.get("description", "Modèle OpenRouter.")[:200] + "..."
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}
except: pass
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", []):
process_model(db, "groq", item["id"], item["id"].capitalize(), "Modèle ultra-rapide exécuté sur LPU Groq.", determine_domain(item["id"]), True, 8192, 0.0, 0.0) try:
added += 1 m_id = item["id"]
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
settings.last_sync_date = datetime.now(timezone.utc) # 3. DeepSeek
settings.last_sync_type = sync_type if settings.deepseek_api_key:
db.commit() try:
resp = await client.get("https://api.deepseek.com/models", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"})
if resp.status_code == 200:
for item in resp.json().get("data", []):
try:
m_id = item["id"]
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
# 4. Mistral
if settings.mistral_api_key:
try:
resp = await client.get("https://api.mistral.ai/v1/models", headers={"Authorization": f"Bearer {settings.mistral_api_key}"})
if resp.status_code == 200:
for item in resp.json().get("data", []):
try:
m_id = item["id"]
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
# 5. Gemini (Google)
if settings.gemini_api_key:
try:
resp = await client.get(f"https://generativelanguage.googleapis.com/v1beta/models?key={settings.gemini_api_key}")
if resp.status_code == 200:
for item in resp.json().get("models", []):
try:
m_id = item["name"].replace("models/", "")
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
# 6. Cloudflare
if settings.cloudflare_account_id and settings.cloudflare_api_token:
try:
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}"})
if resp.status_code == 200:
for item in resp.json().get("result", []):
try:
m_id = item.get("name")
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
# --- Phase d'enregistrement sécurisée ---
for m_id, data in models_to_process.items():
try:
existing = db.query(AIModel).filter(AIModel.model_id == m_id).first()
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)
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"]))
added += 1
# Commit très fréquent pour éviter les gros blocs qui plantent
if added % 50 == 0:
db.commit()
except IntegrityError:
db.rollback() # Si conflit, on annule cette insertion et on continue
except Exception:
db.rollback()
try:
settings.last_sync_date = datetime.now(timezone.utc)
settings.last_sync_type = sync_type
db.commit()
except Exception as e:
db.rollback()
print(f"Erreur DB Commit Sync: {e}")
await sync_finances(db, settings) await sync_finances(db, settings)
return {"status": "success", "models_processed": added} return {"status": "success", "models_processed": added}
def process_model(db, provider, mod_id, name, desc, domain, is_free, ctx, pp, pc): def get_client_for_model(db: Session, model_id: str, settings: SystemSettings):
existing = db.query(AIModel).filter(AIModel.model_id == mod_id).first() """Récupère dynamiquement le bon client OpenAI en fonction du fournisseur du modèle."""
if existing: model_db = db.query(AIModel).filter(AIModel.model_id == model_id).first()
existing.pricing_prompt = pp; existing.pricing_completion = pc; existing.is_free = is_free; existing.last_updated = datetime.now(timezone.utc) provider = model_db.provider if model_db else "openrouter"
else:
db.add(AIModel(provider=provider, model_id=mod_id, name=name, description_fr=desc, domain=domain, is_free=is_free, context_length=ctx, pricing_prompt=pp, pricing_completion=pc))
def get_client_for_model(model_id: str, settings: SystemSettings): if provider == "gemini" and settings.gemini_api_key:
if "gemini" in model_id.lower() and settings.gemini_api_key: return AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key) return AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key), "gemini"
elif "groq" in model_id.lower() or "llama" in model_id.lower(): return AsyncOpenAI(base_url="https://api.groq.com/openai/v1", api_key=settings.groq_api_key) elif provider == "groq" and settings.groq_api_key:
return AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key) return AsyncOpenAI(base_url="https://api.groq.com/openai/v1", api_key=settings.groq_api_key), "groq"
elif provider == "deepseek" and settings.deepseek_api_key:
return AsyncOpenAI(base_url="https://api.deepseek.com/v1", api_key=settings.deepseek_api_key), "deepseek"
elif provider == "mistral" and settings.mistral_api_key:
return AsyncOpenAI(base_url="https://api.mistral.ai/v1", api_key=settings.mistral_api_key), "mistral"
elif provider == "cloudflare" and settings.cloudflare_account_id and settings.cloudflare_api_token:
return AsyncOpenAI(base_url=f"https://api.cloudflare.com/client/v4/accounts/{settings.cloudflare_account_id}/ai/v1", api_key=settings.cloudflare_api_token), "cloudflare"
return AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key), "openrouter"
async def ask_agent(client, model_id, messages, is_openrouter=False): async def ask_agent(client, model_id, messages, provider="openrouter"):
kwargs = {"model": model_id, "messages": messages} kwargs = {"model": model_id, "messages": messages}
if is_openrouter: kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"} if provider == "openrouter":
kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"}
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
@@ -114,20 +216,29 @@ async def run_orchestrator(db: Session, history: list, settings: SystemSettings,
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 = ""
if len(workers) == 1: try:
w_mod = workers[0] if len(workers) == 1:
final_response = await ask_agent(get_client_for_model(w_mod, settings), w_mod, formatted_history + [{"role": "user", "content": user_prompt}], "openrouter" in w_mod.lower()) w_mod = workers[0]
else: client, provider = get_client_for_model(db, w_mod, settings)
p_mod = config.get("prompter", "gemini-3.5-flash-lite") final_response = await ask_agent(client, w_mod, formatted_history + [{"role": "user", "content": user_prompt}], provider)
optimized = await ask_agent(get_client_for_model(p_mod, settings), p_mod, [{"role": "system", "content": "Optimise cette requête."}, {"role": "user", "content": user_prompt}]) 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 = [ask_agent(get_client_for_model(w, settings), w, formatted_history + [{"role": "user", "content": optimized}], "openrouter" in w.lower()) for w in workers] w_tasks = []
responses = await asyncio.gather(*w_tasks, return_exceptions=True) for w in workers:
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))
responses = await asyncio.gather(*w_tasks, return_exceptions=True)
c_mod = config.get("concatenator", "gemini-3.5-flash-lite") c_mod = config.get("concatenator", "gemini-3.5-flash-lite")
synth = f"Requête: {user_prompt}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{r}" for i, r in enumerate(responses)]) + "\n\nFais une synthèse finale." c_client, c_prov = get_client_for_model(db, c_mod, settings)
final_response = await ask_agent(get_client_for_model(c_mod, settings), c_mod, [{"role": "user", "content": synth}]) 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:
final_response = f"L'IA a rencontré une erreur critique: {str(e)}"
# MAJ Financière après requête
await sync_finances(db, settings) await sync_finances(db, settings)
return final_response return final_response
+82 -98
View File
@@ -23,26 +23,6 @@
<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']">
<aside :class="['absolute top-0 left-0 h-full w-72 bg-aethas-panel border-r border-gray-700 flex flex-col transition-transform duration-300', isSidebarOpen ? 'translate-x-0' : '-translate-x-full']"> <aside :class="['absolute top-0 left-0 h-full w-72 bg-aethas-panel border-r border-gray-700 flex flex-col transition-transform duration-300', isSidebarOpen ? 'translate-x-0' : '-translate-x-full']">
@@ -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">
@@ -84,14 +64,14 @@
<div class="hidden group-hover:flex items-center gap-1 bg-gray-800 pl-2 rounded"> <div class="hidden group-hover:flex items-center gap-1 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan"><i data-lucide="edit-2" class="w-3.5 h-3.5"></i></button> <button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan"><i data-lucide="edit-2" class="w-3.5 h-3.5"></i></button>
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white"><i data-lucide="pin" class="w-3.5 h-3.5"></i></button> <button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white"><i data-lucide="pin" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button> <button @click.stop="deleteProject(project.id)" class="text-red-500 hover:text-red-400"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<!-- NOUVEAU : ENCART FINANCIER SOUS LE MENU RÉCENTS --> <!-- ENCART FINANCIER -->
<div class="px-4 pb-2 border-b border-gray-700"> <div class="px-4 pb-2 border-b border-gray-700">
<div v-if="financesList.length > 0" class="flex flex-col gap-1.5 mb-2"> <div v-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">
@@ -137,7 +117,7 @@
</header> </header>
<!-- VUE : CONFIGURATION (Par Niveaux) --> <!-- VUE : CONFIGURATION (Par Niveaux) -->
<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>
@@ -198,7 +178,7 @@
</div> </div>
<!-- VUE : MODÈLES ET FINANCES (Admin Only) --> <!-- VUE : MODÈLES ET FINANCES (Admin Only) -->
<div v-if="activeView === 'models' && user.is_admin" class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6"> <div 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>
@@ -253,86 +233,89 @@
</div> </div>
</div> </div>
<!-- VUE : CHAT PAR DÉFAUT --> <!-- VUE : CHAT (Toujours accessible et affichée par défaut) -->
<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>
<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 & PROMPT -->
<div class="p-4 md:p-6 w-full max-w-5xl mx-auto space-y-2 flex-shrink-0">
<div class="bg-gray-800/80 border border-gray-700 rounded-xl p-3 flex flex-wrap gap-4 items-center text-xs shadow-md">
<div class="flex items-center gap-2 flex-1 min-w-[200px]">
<span class="text-aethas-cyan font-semibold w-20"><i data-lucide="users" class="w-3 h-3 inline"></i> Travailleurs:</span>
<select multiple v-model="selectedWorkers" @change="limitWorkers" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 w-full h-16 outline-none focus:border-aethas-cyan custom-scrollbar">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }} ({{ m.domain }})</option>
</select>
<span class="text-gray-500 ml-1">{{ selectedWorkers.length }}/5</span>
</div>
<div class="flex flex-col gap-2 flex-1 min-w-[200px]" :class="{'opacity-30 pointer-events-none': selectedWorkers.length <= 1}">
<div class="flex items-center gap-2">
<span class="text-gray-400 font-semibold w-24"><i data-lucide="edit-3" class="w-3 h-3 inline"></i> Prompteur:</span>
<select v-model="selectedPrompter" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 flex-1 outline-none truncate">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-aethas-purple font-semibold w-24"><i data-lucide="git-merge" class="w-3 h-3 inline"></i> Concaténeur:</span>
<select v-model="selectedConcatenator" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 flex-1 outline-none truncate">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div> </div>
</template>
</div>
<div v-if="msg.role !== 'user'" class="mt-3 flex items-center gap-3 border-t border-gray-700/50 pt-2 opacity-50 hover:opacity-100 transition-opacity">
<button @click="copyToClipboard(msg.content)" class="text-xs text-gray-400 hover:text-aethas-cyan flex items-center gap-1"><i data-lucide="copy" class="w-3 h-3"></i> Copier</button>
<button @click="downloadMessage(msg.content, msg.created_at)" class="text-xs text-gray-400 hover:text-aethas-cyan flex items-center gap-1"><i data-lucide="file-down" class="w-3 h-3"></i> Extraire</button>
</div>
</div>
<div v-if="isAiThinking" class="max-w-3xl rounded-2xl p-4 bg-aethas-panel mr-auto border border-aethas-cyan/30 w-24">
<div class="flex space-x-1.5 h-4 items-center">
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.15s"></div>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.3s"></div>
</div>
</div>
</div>
<!-- ZONE SÉLECTION MODÈLES PAR REQUÊTE -->
<div v-if="activeProject" class="p-4 md:p-6 w-full max-w-5xl mx-auto space-y-2">
<div class="bg-gray-800/80 border border-gray-700 rounded-xl p-3 flex flex-wrap gap-4 items-center text-xs shadow-md">
<div class="flex items-center gap-2 flex-1 min-w-[200px]">
<span class="text-aethas-cyan font-semibold w-20"><i data-lucide="users" class="w-3 h-3 inline"></i> Travailleurs:</span>
<select multiple v-model="selectedWorkers" @change="limitWorkers" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 w-full h-16 outline-none focus:border-aethas-cyan custom-scrollbar">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }} ({{ m.domain }})</option>
</select>
<span class="text-gray-500 ml-1">{{ selectedWorkers.length }}/5</span>
</div>
<div class="flex flex-col gap-2 flex-1 min-w-[200px]" :class="{'opacity-30 pointer-events-none': selectedWorkers.length <= 1}">
<div class="flex items-center gap-2">
<span class="text-gray-400 font-semibold w-24"><i data-lucide="edit-3" class="w-3 h-3 inline"></i> Prompteur:</span>
<select v-model="selectedPrompter" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 flex-1 outline-none truncate">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-aethas-purple font-semibold w-24"><i data-lucide="git-merge" class="w-3 h-3 inline"></i> Concaténeur:</span>
<select v-model="selectedConcatenator" class="bg-aethas-dark border border-gray-600 rounded px-2 py-1 text-gray-200 flex-1 outline-none truncate">
<option value="gemini-3.5-flash-lite">Gemini 3.5 Flash-Lite (Direct)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div> </div>
</div> </div>
</div>
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan"> <div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan">
<textarea v-model="prompt" @input="autoResize" @keydown.enter.exact.prevent="sendMessage" ref="promptTextarea" :disabled="isAiThinking" rows="1" class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none disabled:opacity-50 custom-scrollbar" placeholder="Sélectionnez vos modèles ci-dessus, puis posez votre question..."></textarea> <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> <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> </div>
</div> </template>
</template> </div>
</main> </main>
</div> </div>
@@ -405,12 +388,13 @@
if(res.ok) { if(res.ok) {
await fetchModelsInfo(); await fetchModelsInfo();
await fetchFinances(); await fetchFinances();
alert("Synchronisation terminée !"); alert("Synchronisation réussie !");
} 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 (" + e.message + ")."); }
isSyncing.value = false; isSyncing.value = false;
}; };
@@ -448,7 +432,7 @@
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 { } else {
const err = await response.json(); alert("Erreur API IA : " + err.detail); const err = await response.json(); alert("Erreur API IA : " + err.detail);
} }