Compare commits

..
39 Commits
Author SHA1 Message Date
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
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
xavier 95125df3b2 fix: sécurisation extraction API, affichage permanent du solde dans le menu latéral et maj financière post-requête
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-07 19:16:52 +02:00
xavier 36f205a56e fix: ajout de la dépendance pytz manquante pour le planificateur CRON
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-07 18:45:37 +02:00
xavier 2eb3acceea feat: refonte bdd, ajout super-admin, pages de config par niveau, avatar natif et sélecteur de modèles par requête
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 18:39:11 +02:00
xavier 4d5046e8d0 feat: ajout des exports de conversation globaux et du parseur de code pour l'extraction ciblée
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 18:10:32 +02:00
xavier ba2f37cf59 fix: réorganisation des fonctions dans main.py pour corriger le NameError sur get_current_user
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-07 17:14:04 +02:00
xavier 4805228f28 fix: résolution des conflits d'imports et complétion des interfaces manquantes pour la v0.12.0
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 16:59:14 +02:00
xavier 6e4aee1984 feat: intégration du formulaire de modification du mot de passe utilisateur via le tableau de bord
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 16:42:38 +02:00
xavier b6765a9bc8 Maj readme
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-10-07 16:19:28 +02:00
xavier efdda0ed00 fix: ajout de la dépendance httpx manquante pour le module asynchrone
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-07 15:54:42 +02:00
xavier 8a32c16ff4 feat: implémentation de l'orchestrateur asynchrone multi-agents (prompteur/travailleurs/concaténeur) et API de synchronisation des modèles
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 15:39:24 +02:00
xavier 4ce11d0bfe fix: mise à jour de l'identifiant du modèle Gemini vers gemini-3.5-flash-lite
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-10-07 15:17:03 +02:00
xavier b3d92558fc fix: correction de l'indentation Python dans models.py provoquant le crash du backend
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 14:55:42 +02:00
xavier ec37ac06fe fix: retrait de la dépendance au logger manquant et priorisation de Gemini Direct
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 14:39:40 +02:00
xavier 6f7e3efd6e feat: ajout du système de rotation de logs (7j), correction du modèle générique OpenRouter, création du Wiki et préparation BDD modèles/finances
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-07 14:31:46 +02:00
xavier f794937fd8 fix: intégration du support natif Gemini et affichage des erreurs API détaillées
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 14:09:24 +02:00
xavier 4b80ff404f fix: nettoyage des routes API dupliquées bloquant l'IA et application forcée du frontend
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 11:35:48 +02:00
xavier 9288e849af feat: intégration du moteur d'IA (ai.py) et génération de la première réponse
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 11:00:49 +02:00
xavier 00a92de1c4 fix: alignement de l'alias de routage FastAPI sur le dossier physique assets pour corriger l'affichage du logo
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 10:50:50 +02:00
xavier 43be6b5be6 feat: intégration de tous les fournisseurs d'IA (OpenRouter, Gemini, Groq, DeepSeek, Mistral, Cloudflare, HF) dans la base de données et l'interface d'installation
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 10:26:04 +02:00
xavier 22c640837c feat: intégration du paramétrage obligatoire SMTP et optionnel des clés API lors du setup initial
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 10:17:23 +02:00
xavier 8dd8d65b42 fix: intégration de la route API de renommage des projets dans main.py
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 09:54:06 +02:00
xavier 3226886190 docs: mise à jour de la roadmap avec les nouvelles exigences métier (V1)
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 09:17:36 +02:00
xavier 71e80f93c6 feat: intégration complète de l'API de messagerie dans main.py
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 08:42:16 +02:00
xavier c8a38f3eb8 feat: ajout de l'épinglage et de la suppression des projets avec mise à jour UI
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-07 08:33:52 +02:00
xavier cac70a5aaa ci: intégration de docker/metadata-action pour générer dynamiquement les tags SemVer et latest
Build and Push Docker Image / build-and-push (push) Failing after 27s
2026-10-07 08:24:14 +02:00
14 changed files with 1590 additions and 365 deletions

No files matched your search

+14 -1
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
tags:
- 'v*' # Déclenche aussi le pipeline lors de la création d'un tag
jobs:
build-and-push:
@@ -16,6 +18,16 @@ jobs:
- name: Checkout du code
uses: actions/checkout@v3
# NOUVEAU : Génération dynamique des tags (SemVer + Latest)
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: gitea.aethas38.duckdns.org/xavier/multi-ia-codage
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
- name: Connexion au registre Gitea
uses: docker/login-action@v2
with:
@@ -28,4 +40,5 @@ jobs:
with:
context: .
push: true
tags: gitea.aethas38.duckdns.org/xavier/multi-ia-codage:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+15 -3
View File
@@ -2,7 +2,8 @@ name: Build and Publish Docker Image
on:
push:
branches: [ "main" ] # Modifiez si votre branche s'appelle "master"
branches: [ "main" ]
tags: [ 'v*' ] # Déclencheur sur les tags ajouté ici aussi
env:
REGISTRY: ghcr.io
@@ -13,12 +14,22 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Indispensable pour publier le paquet sur le dépôt
packages: write
steps:
- name: Checkout du code
uses: actions/checkout@v4
# NOUVEAU : Identique à Gitea, gère ghcr.io
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
- name: Connexion au registre GitHub (ghcr.io)
uses: docker/login-action@v3
with:
@@ -31,4 +42,5 @@ jobs:
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+64 -32
View File
@@ -5,46 +5,78 @@ Ce projet est un laboratoire d'intelligence artificielle personnel. Il agit comm
L'interface visuelle est inspirée de l'ergonomie de Gemini, avec le logo stylisé A38 (cerveau en réseau neuronal néon) comme charte graphique centrale.
## 🏗️ Stack Technique & Architecture
- **Backend :** FastAPI (Python) modulaire (une fonction = un module).
- **Frontend :** Vue.js + Tailwind CSS (Responsive / Mobile-first).
- **Base de Données :** PostgreSQL (gestion des utilisateurs, historiques, suivi financier).
- **Traitement Documentaire :** LibreOffice Headless (génération/conversion Excel, Word, PDF).
- **Déploiement :** Docker, géré via Dockge et Gitea, sécurisé sur grappe RAID.
* **Backend :** FastAPI (Python) modulaire asynchrone (httpx, asyncio)[cite: 2].
* **Frontend :** Vue.js + Tailwind CSS (Responsive / Mobile-first)[cite: 2].
* **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)[cite: 2].
* **Déploiement :** Docker, géré via Dockge et Gitea, sécurisé sur grappe RAID[cite: 2].
## 📌 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 :
- **Majeur (X) :** Changement profond d'architecture ou refonte globale.
- **Mineur (Y) :** Validation et ajout d'une nouvelle fonctionnalité de la roadmap.
- **Patch (Z) :** Corrections de bugs, correctifs de sécurité ou ajustements visuels mineurs.
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[cite: 2].
* **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[cite: 2].
## 📦 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[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.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[cite: 2].
## 🚀 ROADMAP & CAHIER DES CHARGES
### Sécurité & Infrastructure
- [x] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus).
- [x] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID.
- [x] Mots de passe cryptés (bcrypt), protection anti-brute force et anti-DDOS (à compléter pour le DDOS).
- [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.) sans accès SSH pour les utilisateurs.
- [x] Déconnexion automatique (timer invisible / session JWT) après 60 minutes d'inactivité.
- [ ] Procédure de réinitialisation/modification du mot de passe (Admin et Utilisateurs).
- [x] Configuration de GitHub Actions / Gitea Actions pour la création automatique des paquets Docker.
* [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] 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] 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].
### Administration & Gestion des Utilisateurs
- [ ] Page d'administration complète : changement des clés, ajout de fournisseurs, configuration et test du serveur mail.
- [x] Assistant de première installation (création du compte admin complet), puis auto-destruction / blocage du script.
- [ ] Système d'invitation par mail : création de compte avec génération de QR Code 2FA à la première connexion.
- [ ] Vérification du budget API toutes les 15 minutes et actualisation après chaque requête.
- [ ] Suivi financier détaillé par fournisseur d'IA.
* [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] Page de configuration par niveau (Super-Admin, Admin, Utilisateur)[cite: 2].
* [x] Upload et personnalisation dynamique d'avatar utilisateur[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)[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].
* [ ] Envoi de rapport d'activité automatisé chaque jeudi à 04h00[cite: 2].
### Expérience Utilisateur (UI/UX)
- [~] Thème moderne (Gemini-like) et 100% compatible smartphones (Setup et Login terminés).
- [ ] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés, avec le dernier utilisé en tête de liste.
- [ ] Plus de création automatique de nouveau projet à l'ouverture.
- [ ] Pré-filtre et classement automatique des modèles par catégorie (Texte/Code, Photo/Image, Vidéo, Son) avec l'aide de l'API Gemini lors de l'import.
- [x] Intégration permanente du logo AETHAS38 dans l'interface (Setup / Login).
### Moteur IA & Fonctionnalités d'Export
- [ ] Récupération dynamique de la liste des modèles directement auprès des fournisseurs par défaut (OpenRouter, Groq, Gemini, DeepSeek, Mistral, Cloudflare, HuggingFace, etc.).
- [ ] Pipeline multi-agents : 1 Rédacteur de prompt, jusqu'à 5 Travailleurs en parallèle, 1 Concaténeur final.
- [ ] Prise en charge de modèles IA pour le traitement de texte, la génération d'images, la synthèse vocale, la musique (calage sur vidéo) et la vidéo.
- [ ] Téléchargement des prompts, des réponses, et de l'historique complet en PDF, TXT, MD ou JSON.
- [ ] Bouton dédié pour télécharger les fichiers générés (Images, Vidéos, Excel mis en forme via LibreOffice).
* [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] 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].
* [ ] 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].
### Moteur IA, Modèles & Fonctionnalités
* [x] Cœur asynchrone pour la parallélisation des appels API (module `orchestrator.py`)[cite: 2].
* [x] Pipeline multi-agents complet avec sélection dynamique par requête :
* 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[cite: 2].
* 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].
* [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].
* [x] Extraction unitaire des retours IA et téléchargement global d'une conversation (formats TXT et JSON)[cite: 2].
* [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].
* [x] Classement, filtrage (Gratuit/Payant) et catégorisation des modèles (Texte, Code, Vision, Audio)[cite: 2].
* [ ] Traduction automatisée des descriptions des modèles via Ollama (local) ou Gemini[cite: 2].
* [ ] 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].
+20
View File
@@ -0,0 +1,20 @@
# 📚 WIKI AETHAS38 - Configuration des Fournisseurs IA
Ce document détaille la procédure pour obtenir les clés API avec les droits complets (Inférence + Accès financier) nécessaires au fonctionnement de l'Orchestrateur AETHAS38.
## 1. OpenRouter (Prioritaire)
OpenRouter agit comme un agrégateur. C'est le fournisseur recommandé car il permet d'accéder à presque tous les modèles (y compris Gemini, Claude, Llama) avec un seul portefeuille prépayé.
* **Obtenir la clé API :** Allez sur [OpenRouter Keys](https://openrouter.ai/keys). Créez une clé standard sans limite de crédit pour permettre à AETHAS38 de requêter les modèles.
* **Accès Financier (Management Key) :** Pour que AETHAS38 lise votre solde en temps réel, vous devez générer une clé spécifique d'administration (Management Key) sur la même page, ou accorder les droits `read_balance` à votre clé API principale selon les dernières mises à jour de leur interface.
## 2. Google Gemini Studio
* **Obtenir la clé API :** Rendez-vous sur [Google AI Studio](https://aistudio.google.com/app/apikey).
* **Levée des quotas :** Par défaut, la clé est gratuite mais soumise à des limites strictes (ex: 15 requêtes/minute) qui bloqueront le travail en parallèle d'AETHAS38. Pour un accès total, liez votre projet AI Studio à un compte de facturation Google Cloud Platform (GCP).
## 3. DeepSeek & Groq
* **DeepSeek :** [DeepSeek Platform](https://platform.deepseek.com/api_keys). Facturation à l'usage (Token). Il faut provisionner le compte par carte bancaire (Top-up).
* **Groq :** [Groq Console](https://console.groq.com/keys). Actuellement en phase gratuite avec quotas de requêtes très élevés, idéal pour les travailleurs de base de l'orchestrateur.
## 4. Cloudflare Workers AI & HuggingFace
* **Cloudflare :** [Cloudflare Dash](https://dash.cloudflare.com/profile/api-tokens). Créez un token personnalisé avec la permission `Workers AI : Read/Write`. L'ID de compte (Account ID) est visible dans la barre latérale droite de l'aperçu de la zone.
* **HuggingFace :** [HF Settings](https://huggingface.co/settings/tokens). Générez un token *Fine-Grained* et cochez les permissions liées à l'`Inference API` pour permettre l'exécution des modèles serverless.
+52
View File
@@ -0,0 +1,52 @@
from openai import OpenAI
from fastapi import HTTPException
from .models import SystemSettings
def get_ai_response(messages: list, settings: SystemSettings) -> str:
"""Route la conversation vers le premier fournisseur IA disponible, priorité à Gemini Direct."""
if not settings:
raise HTTPException(status_code=500, detail="Configuration système introuvable.")
formatted_messages = [{"role": msg.role, "content": msg.content} for msg in messages]
try:
# 1. Priorité absolue : Test Gemini direct
if settings.gemini_api_key:
client = OpenAI(
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
api_key=settings.gemini_api_key
)
# Correction : Utilisation exacte du modèle 3.5 Flash-Lite
model = "gemini-3.5-flash-lite"
response = client.chat.completions.create(model=model, messages=formatted_messages)
# 2. Test OpenRouter (Fallback)
elif settings.openrouter_api_key:
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key)
model = "google/gemini-3.5-flash-lite"
response = client.chat.completions.create(
model=model,
messages=formatted_messages,
extra_headers={"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38"}
)
# 3. Test DeepSeek
elif settings.deepseek_api_key:
client = OpenAI(base_url="https://api.deepseek.com/v1", api_key=settings.deepseek_api_key)
model = "deepseek-chat"
response = client.chat.completions.create(model=model, messages=formatted_messages)
# 4. Test Groq
elif settings.groq_api_key:
client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=settings.groq_api_key)
model = "llama3-8b-8192"
response = client.chat.completions.create(model=model, messages=formatted_messages)
else:
raise HTTPException(status_code=400, detail="Aucune clé API IA n'est configurée.")
return response.choices[0].message.content
except Exception as e:
print(f"Erreur API IA : {str(e)}")
raise HTTPException(status_code=502, detail=f"Détail du fournisseur : {str(e)}")
+24
View File
@@ -0,0 +1,24 @@
import logging
from logging.handlers import TimedRotatingFileHandler
import os
# Création du dossier logs à la racine du projet
log_dir = os.path.join(os.getcwd(), "logs")
os.makedirs(log_dir, exist_ok=True)
# Configuration du logger
system_logger = logging.getLogger("AETHAS38_Logger")
system_logger.setLevel(logging.INFO)
# Rotation tous les jours à minuit, conservation sur 7 jours
handler = TimedRotatingFileHandler(
filename=os.path.join(log_dir, "aethas38_system.log"),
when="midnight",
interval=1,
backupCount=7,
encoding="utf-8"
)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', datefmt='%d/%m/%Y %H:%M:%S')
handler.setFormatter(formatter)
system_logger.addHandler(handler)
+213 -139
View File
@@ -1,176 +1,250 @@
from fastapi import FastAPI, Depends, HTTPException, status, Request, Response
from fastapi import FastAPI, Depends, HTTPException, status, Request, Response, UploadFile, File
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
import os
# Importation de nos modules locaux
from .database import engine, Base, get_db
from .models import User
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
# MAJ BDD
from typing import List
from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse
from .models import User, Project
import os, json, asyncio, shutil, base64, io
from datetime import datetime
import pytz
from .database import engine, Base, get_db, SessionLocal
from .auth import get_password_hash, generate_totp_secret, get_totp_uri, verify_password, verify_totp, create_access_token, verify_token
from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse, PasswordChange
from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog
from .orchestrator import run_orchestrator, sync_providers_models, sync_finances
# Création des tables dans la base de données
Base.metadata.create_all(bind=engine)
app = FastAPI(title="AETHAS38")
app = FastAPI(title="AETHAS38 - Orchestrateur Multi-IA")
# Configuration des fichiers statiques (images, css locaux, etc.)
assets_path = os.path.join(os.getcwd(), "frontend", "assets")
os.makedirs(assets_path, exist_ok=True) # Crée le dossier s'il n'existe pas
app.mount("/static", StaticFiles(directory=assets_path), name="static")
avatars_path = os.path.join(assets_path, "avatars")
os.makedirs(assets_path, exist_ok=True)
os.makedirs(avatars_path, exist_ok=True)
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
def is_setup_required(db: Session) -> bool:
"""Vérifie si la base de données contient au moins un administrateur."""
admin = db.query(User).filter(User.is_admin == True).first()
return admin is None
async def scheduler_task():
tz = pytz.timezone('Europe/Paris')
while True:
now = datetime.now(tz)
if (now.hour == 0 or now.hour == 12) and now.minute == 0:
db = SessionLocal()
settings = db.query(SystemSettings).first()
if settings:
try: await sync_providers_models(db, settings, "Automatique")
except: pass
db.close()
await asyncio.sleep(60)
await asyncio.sleep(30)
@app.on_event("startup")
async def startup_event(): asyncio.create_task(scheduler_task())
def is_setup_required(db: Session) -> bool: return db.query(User).filter(User.is_superadmin == True).first() is None
def get_current_user(request: Request, db: Session = Depends(get_db)):
token = request.cookies.get("session_token")
if not token: raise HTTPException(status_code=401, detail="Non authentifié")
payload = verify_token(token)
if not payload: raise HTTPException(status_code=401, detail="Session expirée")
user = db.query(User).filter(User.username == payload.get("sub")).first()
if not user: raise HTTPException(status_code=401, detail="Utilisateur introuvable")
return user
@app.get("/")
def read_root(db: Session = Depends(get_db)):
if is_setup_required(db):
return RedirectResponse(url="/setup")
return RedirectResponse(url="/login")
def read_root(db: Session = Depends(get_db)): return RedirectResponse(url="/setup") if is_setup_required(db) else RedirectResponse(url="/login")
@app.get("/setup")
def setup_page(db: Session = Depends(get_db)):
"""Affiche l'interface d'installation Vue.js."""
if not is_setup_required(db):
return RedirectResponse(url="/login")
frontend_path = os.path.join(os.getcwd(), "frontend", "index.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface introuvable.")
return FileResponse(frontend_path)
def setup_page(db: Session = Depends(get_db)): return RedirectResponse(url="/login") if not is_setup_required(db) else FileResponse(os.path.join(os.getcwd(), "frontend", "index.html"))
@app.get("/login")
def login_page(db: Session = Depends(get_db)):
"""Affiche la page de connexion sécurisée."""
if is_setup_required(db):
return RedirectResponse(url="/setup")
frontend_path = os.path.join(os.getcwd(), "frontend", "login.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface de connexion introuvable.")
return FileResponse(frontend_path)
def login_page(db: Session = Depends(get_db)): return RedirectResponse(url="/setup") if is_setup_required(db) else FileResponse(os.path.join(os.getcwd(), "frontend", "login.html"))
@app.post("/api/setup")
def create_admin(admin_data: AdminCreate, db: Session = Depends(get_db)):
"""Reçoit les données du frontend, crée l'admin et retourne le QR Code 2FA."""
if not is_setup_required(db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="L'installation a déjà été effectuée."
)
# Sécurisation des accès
hashed_pw = get_password_hash(admin_data.password)
totp_secret = generate_totp_secret()
new_admin = User(
email=admin_data.email,
username=admin_data.username,
hashed_password=hashed_pw,
totp_secret=totp_secret,
is_admin=True
)
if not is_setup_required(db): raise HTTPException(status_code=403, detail="Déjà installé.")
new_admin = User(email=admin_data.email, username=admin_data.username, hashed_password=get_password_hash(admin_data.password), totp_secret=generate_totp_secret(), is_admin=True, is_superadmin=True)
db.add(new_admin)
new_settings = SystemSettings(smtp_host=admin_data.smtp_host, smtp_port=admin_data.smtp_port, smtp_user=admin_data.smtp_user, smtp_password=admin_data.smtp_password, openrouter_api_key=admin_data.openrouter_api_key, openrouter_management_key=admin_data.openrouter_management_key, groq_api_key=admin_data.groq_api_key, gemini_api_key=admin_data.gemini_api_key, deepseek_api_key=admin_data.deepseek_api_key, mistral_api_key=admin_data.mistral_api_key, cloudflare_account_id=admin_data.cloudflare_account_id, cloudflare_api_token=admin_data.cloudflare_api_token, huggingface_api_key=admin_data.huggingface_api_key)
db.add(new_settings)
db.commit()
db.refresh(new_admin)
# Génération de l'URI pour l'affichage du QR Code côté frontend
totp_uri = get_totp_uri(totp_secret, new_admin.username)
return {
"message": "Administrateur créé avec succès.",
"totp_secret": totp_secret,
"totp_uri": totp_uri
}
return {"message": "Succès", "totp_secret": new_admin.totp_secret, "totp_uri": get_totp_uri(new_admin.totp_secret, new_admin.username)}
@app.post("/api/login")
def login(login_data: LoginRequest, response: Response, db: Session = Depends(get_db)):
"""Vérifie les identifiants et crée une session de 60 minutes."""
user = db.query(User).filter(User.username == login_data.username).first()
# Vérification de l'utilisateur et du mot de passe
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nom d'utilisateur ou mot de passe incorrect."
)
# Vérification du code 2FA
if not verify_totp(user.totp_secret, login_data.totp_code):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Code 2FA invalide."
)
# Création du jeton de session
access_token = create_access_token(data={"sub": user.username})
# Injection du jeton dans un cookie sécurisé (invisible pour le JavaScript côté client)
response.set_cookie(
key="session_token",
value=access_token,
httponly=True,
max_age=3600, # 3600 secondes = 60 minutes
samesite="lax"
)
if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Identifiants incorrects.")
if not verify_totp(user.totp_secret, login_data.totp_code): raise HTTPException(status_code=401, detail="2FA invalide.")
# Correction : Extension de la durée de session à 7 jours (604800 secondes) pour éviter les erreurs 401 intempestives
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"}
@app.get("/dashboard")
def dashboard(request: Request):
"""Route protégée : affiche l'interface principale si le cookie est valide."""
token = request.cookies.get("session_token")
if not 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"))
payload = verify_token(token)
if not payload:
return RedirectResponse(url="/login")
@app.get("/api/users/me")
def get_me(current_user: User = Depends(get_current_user)):
return {"username": current_user.username, "is_admin": current_user.is_admin, "is_superadmin": current_user.is_superadmin, "avatar_path": current_user.avatar_path}
frontend_path = os.path.join(os.getcwd(), "frontend", "dashboard.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface du tableau de bord introuvable.")
@app.put("/api/users/me/password")
def change_password(passwords: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not verify_password(passwords.old_password, current_user.hashed_password): raise HTTPException(status_code=400, detail="L'ancien mot de passe est incorrect.")
if len(passwords.new_password) < 8: raise HTTPException(status_code=400, detail="8 caractères minimum.")
current_user.hashed_password = get_password_hash(passwords.new_password)
db.commit()
return {"message": "Mot de passe mis à jour."}
return FileResponse(frontend_path)
# --- GESTION DES PROJETS ---
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""Extrait l'utilisateur actuel à partir du cookie de session JWT."""
token = request.cookies.get("session_token")
if not token:
raise HTTPException(status_code=401, detail="Non authentifié")
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Session expirée")
user = db.query(User).filter(User.username == payload.get("sub")).first()
if not user:
raise HTTPException(status_code=401, detail="Utilisateur introuvable")
return user
@app.post("/api/users/me/avatar")
async def upload_avatar(file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
file_location = os.path.join(avatars_path, f"user_{current_user.id}.jpg")
with open(file_location, "wb") as buffer: shutil.copyfileobj(file.file, buffer)
current_user.avatar_path = f"/assets/avatars/user_{current_user.id}.jpg?v={int(datetime.now().timestamp())}"
db.commit()
return {"message": "Avatar mis à jour", "avatar_path": current_user.avatar_path}
@app.get("/api/projects", response_model=List[ProjectResponse])
def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Récupère tous les projets de l'utilisateur connecté."""
return db.query(Project).filter(Project.user_id == current_user.id).order_by(Project.created_at.desc()).all()
def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): return db.query(Project).filter(Project.user_id == current_user.id).order_by(Project.created_at.desc()).all()
@app.post("/api/projects", response_model=ProjectResponse)
def create_project(project: ProjectCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Crée un nouveau projet pour l'utilisateur connecté."""
new_project = Project(title=project.title, user_id=current_user.id)
db.add(new_project)
p = Project(title=project.title, user_id=current_user.id); db.add(p); db.commit(); db.refresh(p); return p
@app.put("/api/projects/{project_id}/rename", response_model=ProjectResponse)
def rename_project(project_id: int, project_data: ProjectRename, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not p: raise HTTPException(status_code=404)
p.title = project_data.title; db.commit(); db.refresh(p); return p
@app.put("/api/projects/{project_id}/pin", response_model=ProjectResponse)
def toggle_pin_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not p: raise HTTPException(status_code=404)
p.is_pinned = not p.is_pinned; db.commit(); db.refresh(p); return p
@app.delete("/api/projects/{project_id}")
def delete_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not p: raise HTTPException(status_code=404)
db.delete(p); db.commit(); return {"message": "Supprimé"}
@app.get("/api/projects/{project_id}/export")
def export_project(project_id: int, format: str = "txt", db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
messages = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
if format == "json":
data = [{"role": m.role, "content": m.content, "date": m.created_at.isoformat()} for m in messages]
return Response(content=json.dumps(data, indent=2), media_type="application/json", headers={"Content-Disposition": f"attachment; filename=export_{project_id}.json"})
text = f"--- HISTORIQUE : {p.title} ---\n\n"
for m in messages: text += f"[{m.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {'VOUS' if m.role == 'user' else 'AETHAS38'}:\n{m.content}\n\n{'-'*50}\n\n"
return Response(content=text, media_type="text/plain;charset=utf-8", headers={"Content-Disposition": f"attachment; filename=export_{project_id}.txt"})
@app.get("/api/projects/{project_id}/messages", response_model=List[MessageResponse])
def get_messages(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
@app.post("/api/projects/{project_id}/messages", response_model=List[MessageResponse])
async def create_message(project_id: int, message: MessageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
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})
# On ne stocke plus le contenu brut des fichiers en DB pour éviter d'exploser le contexte des requêtes suivantes
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()
db.refresh(new_project)
return new_project
history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
settings = db.query(SystemSettings).first()
conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]}
# Transmission des données de fichiers en mémoire vive à l'orchestrateur (Map-Reduce)
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()
return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
# --- ROUTES MODÈLES & FINANCES ---
@app.get("/api/models/info")
def get_models_info(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
settings = db.query(SystemSettings).first()
return {
"last_sync_date": settings.last_sync_date.isoformat() if settings and settings.last_sync_date else None,
"last_sync_type": settings.last_sync_type if settings else None,
"models": db.query(AIModel).order_by(AIModel.name.asc()).all(),
"finances": db.query(FinancialLog).all()
}
@app.get("/api/finances")
def get_finances(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(FinancialLog).all()
@app.post("/api/models/sync")
async def trigger_model_sync(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.")
settings = db.query(SystemSettings).first()
return await sync_providers_models(db, settings, "Manuelle")
@app.get("/api/models/export")
def export_models(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.")
models = db.query(AIModel).all()
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"})
+54 -22
View File
@@ -1,44 +1,76 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Float
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
totp_secret = Column(String, nullable=False)
email = Column(String, unique=True, index=True)
username = Column(String, unique=True, index=True)
hashed_password = Column(String)
totp_secret = Column(String)
is_admin = Column(Boolean, default=False)
# Relation : Un utilisateur peut avoir plusieurs projets
projects = relationship("Project", back_populates="owner", cascade="all, delete-orphan")
is_superadmin = Column(Boolean, default=False)
avatar_path = Column(String, nullable=True)
projects = relationship("Project", back_populates="owner")
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
title = Column(String, index=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
is_pinned = Column(Boolean, default=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Relations
user_id = Column(Integer, ForeignKey("users.id"))
owner = relationship("User", back_populates="projects")
messages = relationship("Message", back_populates="project", cascade="all, delete-orphan")
class Message(Base):
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
role = Column(String, nullable=False) # Ex: 'user', 'assistant', 'worker_1', 'system'
content = Column(Text, nullable=False)
role = Column(String)
content = Column(String)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
# Relation
project_id = Column(Integer, ForeignKey("projects.id"))
project = relationship("Project", back_populates="messages")
class SystemSettings(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
smtp_host = Column(String, nullable=False)
smtp_port = Column(Integer, nullable=False)
smtp_user = Column(String, nullable=False)
smtp_password = Column(String, nullable=False)
openrouter_api_key = Column(String, nullable=True)
openrouter_management_key = Column(String, nullable=True)
groq_api_key = Column(String, nullable=True)
gemini_api_key = Column(String, nullable=True)
deepseek_api_key = Column(String, nullable=True)
mistral_api_key = Column(String, nullable=True)
cloudflare_account_id = Column(String, nullable=True)
cloudflare_api_token = Column(String, nullable=True)
huggingface_api_key = Column(String, nullable=True)
last_sync_date = Column(DateTime, nullable=True)
last_sync_type = Column(String, nullable=True)
class AIModel(Base):
__tablename__ = "ai_models"
id = Column(Integer, primary_key=True, index=True)
provider = Column(String, index=True)
model_id = Column(String, unique=True, index=True)
name = Column(String)
description_fr = Column(String, nullable=True)
domain = Column(String, default="Texte")
is_free = Column(Boolean, default=False)
context_length = Column(Integer)
pricing_prompt = Column(Float)
pricing_completion = Column(Float)
last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class FinancialLog(Base):
__tablename__ = "financial_logs"
id = Column(Integer, primary_key=True, index=True)
provider = Column(String, unique=True, index=True)
balance = Column(Float, default=0.0)
total_usage = Column(Float, default=0.0)
checked_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
+267
View File
@@ -0,0 +1,267 @@
import asyncio
import httpx
import urllib.parse
from openai import AsyncOpenAI
from fastapi import HTTPException
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .models import SystemSettings, AIModel, FinancialLog
from datetime import datetime, timezone
def determine_domain(model_id: str) -> str:
mid = model_id.lower()
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 "audio" in mid or "whisper" in mid: return "Audio"
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 with httpx.AsyncClient(timeout=30.0) as client:
if settings.openrouter_management_key or settings.openrouter_api_key:
try:
key = settings.openrouter_management_key or settings.openrouter_api_key
cred_resp = await client.get("https://openrouter.ai/api/v1/credits", headers={"Authorization": f"Bearer {key}"})
balance = 0.0
usage = 0.0
if cred_resp.status_code == 200 and cred_resp.json().get("data"):
c_data = cred_resp.json().get("data", {})
balance = float(c_data.get("total_credits") or 0.0) - float(c_data.get("total_usage") or 0.0)
usage = float(c_data.get("total_usage") or 0.0)
else:
key_resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"})
if key_resp.status_code == 200:
data = key_resp.json().get("data", {})
limit = data.get("limit")
usage = float(data.get("usage") or 0.0)
balance = (float(limit) - usage) if limit is not None else -usage
update_finance_db(db, "OpenRouter", balance, usage)
except Exception as e: print(f"Erreur Finance OR: {e}")
if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0)
if settings.deepseek_api_key:
try:
resp = await client.get("https://api.deepseek.com/user/balance", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"})
if resp.status_code == 200:
infos = resp.json().get("balance_infos", [{}])[0]
update_finance_db(db, "DeepSeek", float(infos.get("total_balance", 0)), 0.0)
except Exception: pass
if settings.mistral_api_key: update_finance_db(db, "Mistral", 0.0, 0.0)
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):
try:
log = db.query(FinancialLog).filter(FinancialLog.provider == provider).first()
if log:
log.balance = balance; log.total_usage = usage; log.checked_at = datetime.now(timezone.utc)
else:
db.add(FinancialLog(provider=provider, balance=balance, total_usage=usage))
except Exception as e: print(f"Finance DB Error: {e}")
async def sync_providers_models(db: Session, settings: SystemSettings, sync_type: str = "Automatique"):
added = 0
models_to_process = {}
async with httpx.AsyncClient(timeout=90.0) as client:
if settings.openrouter_api_key:
try:
resp = await client.get("https://openrouter.ai/api/v1/models")
if resp.status_code == 200:
for item in resp.json().get("data", []):
try:
m_id = item.get("id")
if not m_id: continue
pricing = item.get("pricing") or {}
pp = float(pricing.get("prompt") or 0.0) * 1000000
pc = float(pricing.get("completion") or 0.0) * 1000000
is_free = (pp == 0.0 and pc == 0.0)
desc_en = item.get("description", "Generic AI Model.")
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 Exception as e: print(f"Erreur OR Models: {e}")
if settings.groq_api_key:
try:
resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"})
if resp.status_code == 200:
for item in resp.json().get("data", []):
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}
except Exception: pass
if settings.deepseek_api_key:
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", []):
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}
except Exception: pass
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", []):
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}
except Exception: pass
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", []):
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}
except Exception: pass
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", []):
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}
except Exception: pass
sem = asyncio.Semaphore(15)
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:
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.description_fr = data["desc"]; 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
if added % 50 == 0: db.commit()
except IntegrityError: db.rollback()
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()
await sync_finances(db, settings)
return {"status": "success", "models_processed": added}
def get_client_for_model(db: Session, model_id: str, settings: SystemSettings):
model_db = db.query(AIModel).filter(AIModel.model_id == model_id).first()
provider = model_db.provider if model_db else "openrouter"
if provider == "gemini" and settings.gemini_api_key:
return AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key), "gemini"
elif provider == "groq" and settings.groq_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, provider="openrouter"):
kwargs = {"model": model_id, "messages": messages}
if provider == "openrouter":
kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"}
resp = await client.chat.completions.create(**kwargs)
return resp.choices[0].message.content
async def run_orchestrator(db: Session, history: list, settings: SystemSettings, config: dict, extracted_files: list = None) -> str:
workers = config.get("workers", ["gemini-3.5-flash-lite"])
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]]
final_response = ""
try:
p_mod = config.get("prompter")
if not p_mod: p_mod = "gemini-3.5-flash-lite"
p_client, p_prov = get_client_for_model(db, p_mod, settings)
# --- WORKFLOW MAP-REDUCE : PRÉ-TRAITEMENT PARALLÈLE DES FICHIERS ---
files_context = ""
if extracted_files:
async def process_single_file(f):
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{f['content']}\n```"
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."
try:
analysis = await ask_agent(p_client, p_mod, [{"role": "system", "content": file_sys}, {"role": "user", "content": file_prompt}], p_prov)
return f"\n\n--- Extraction du fichier {f['name']} ---\n{analysis}"
except Exception as e:
return f"\n\n--- Erreur sur {f['name']} ---\n{str(e)}"
file_tasks = [process_single_file(f) for f in extracted_files]
file_analyses = await asyncio.gather(*file_tasks)
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 ---
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)
if len(workers) == 1:
w_mod = workers[0]
client, provider = get_client_for_model(db, w_mod, settings)
worker_response = await ask_agent(client, w_mod, formatted_history + [{"role": "user", "content": optimized}], provider)
responses = [worker_response]
else:
w_tasks = []
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")
if not c_mod: c_mod = "gemini-3.5-flash-lite"
c_client, c_prov = get_client_for_model(db, c_mod, settings)
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)
except Exception as e:
final_response = f"L'IA a rencontré une erreur critique: {str(e)}"
await sync_finances(db, settings)
return final_response
+45 -6
View File
@@ -1,28 +1,67 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional
from typing import Optional, List
class AdminCreate(BaseModel):
email: EmailStr
username: str
password: str
smtp_host: str
smtp_port: int
smtp_user: str
smtp_password: str
openrouter_api_key: Optional[str] = None
openrouter_management_key: Optional[str] = None
groq_api_key: Optional[str] = None
gemini_api_key: Optional[str] = None
deepseek_api_key: Optional[str] = None
mistral_api_key: Optional[str] = None
cloudflare_account_id: Optional[str] = None
cloudflare_api_token: Optional[str] = None
huggingface_api_key: Optional[str] = None
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str
# --- NOUVEAU : Schémas pour les Projets ---
class ProjectBase(BaseModel):
title: str
class ProjectCreate(ProjectBase):
pass
class ProjectCreate(ProjectBase): pass
class ProjectResponse(ProjectBase):
id: int
created_at: datetime
is_pinned: bool
class Config: from_attributes = True
class Config:
from_attributes = True
class ProjectRename(BaseModel):
title: str
class OrchestratorConfig(BaseModel):
workers: List[str]
prompter: 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):
role: str
content: str
class MessageCreate(MessageBase):
config: Optional[OrchestratorConfig] = None
files: Optional[List[AttachedFile]] = None
class MessageResponse(MessageBase):
id: int
created_at: datetime
project_id: int
class Config: from_attributes = True
class PasswordChange(BaseModel):
old_password: str
new_password: str
+646 -92
View File
@@ -6,12 +6,7 @@
<title>AETHAS38 - Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } }
}
}
tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
@@ -19,160 +14,719 @@
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
</style>
</head>
<body class="h-screen flex overflow-hidden">
<div id="app" class="flex w-full h-full relative bg-aethas-dark">
<div v-if="isSidebarOpen" @click="toggleSidebar" class="md:hidden fixed inset-0 bg-black/60 z-40 transition-opacity"></div>
<!-- MODAL PARAMÈTRES -->
<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>
<div :class="['transition-all duration-300 flex-shrink-0 relative z-50', isSidebarOpen ? 'w-72' : 'w-0']">
<!-- BARRE LATÉRALE -->
<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']">
<div class="p-4 border-b border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="/static/logo.png" alt="Logo" class="w-10 h-10 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<img src="/assets/logo.png" class="w-10 h-10 rounded-full border border-aethas-cyan object-cover bg-gray-800">
<span class="font-bold text-lg tracking-wider neon-text">AETHAS38</span>
</div>
<button @click="toggleSidebar" class="md:hidden text-gray-400 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
<button @click="toggleSidebar" class="md:hidden text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-4 space-y-2">
<button @click="createNewProject" :disabled="isLoading" class="w-full bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg py-2 px-4 flex items-center justify-center gap-2 shadow-md">
<i data-lucide="plus" class="w-4 h-4 text-aethas-cyan"></i> Nouveau Projet
</button>
<button v-if="user.is_admin" @click="activeView = 'models'" :class="['w-full border rounded-lg py-2 px-4 flex items-center justify-center gap-2 transition-colors', activeView === 'models' ? 'bg-aethas-cyan/10 border-aethas-cyan text-aethas-cyan' : 'bg-aethas-dark hover:bg-gray-800 text-gray-300 border-gray-700']">
<i data-lucide="database" class="w-4 h-4"></i> Modèles & Finances
</button>
</div>
<div class="p-4">
<button @click="createNewProject" :disabled="isLoading" class="w-full bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg py-2.5 px-4 flex items-center justify-center gap-2 transition-colors disabled:opacity-50">
<i data-lucide="plus" class="w-5 h-5 text-aethas-cyan"></i>
Nouveau Projet IA
</button>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Récents</h3>
<div class="flex-1 overflow-y-auto p-4 space-y-6 custom-scrollbar">
<div v-if="pinnedProjects.length > 0">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Épinglés</h3>
<ul class="space-y-1">
<li v-if="projects.length === 0" class="text-sm text-gray-500 italic p-2">Aucun projet pour le moment.</li>
<li v-for="project in projects" :key="project.id" class="p-2 hover:bg-gray-800 rounded cursor-pointer text-sm text-gray-300 truncate transition-colors flex items-center gap-2">
<i data-lucide="message-square" class="w-4 h-4 text-gray-500"></i>
{{ project.title }}
<li v-for="project in pinnedProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm', activeView === 'chat' && activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate"><i data-lucide="pin" class="w-3 h-3 text-aethas-cyan"></i><span class="truncate">{{ project.title }}</span></div>
<div class="hidden group-hover:flex items-center gap-1 bg-gray-800 pl-2 rounded">
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white"><i data-lucide="pin-off" class="w-3.5 h-3.5"></i></button>
<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>
</li>
</ul>
</div>
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Récents</h3>
<ul class="space-y-1">
<li v-for="project in recentProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm', activeView === 'chat' && activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate"><i data-lucide="message-square" class="w-3 h-3 text-gray-500"></i><span class="truncate">{{ project.title }}</span></div>
<div class="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="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>
</div>
</li>
</ul>
</div>
</div>
<div class="p-4 border-t border-gray-700 flex justify-between items-center">
<!-- ENCART FINANCIER -->
<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-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="fin.balance > 0 ? 'text-green-400 font-bold' : 'text-aethas-cyan'">${{ fin.balance > 0 ? fin.balance.toFixed(4) : Math.abs(fin.total_usage).toFixed(4) }}</span>
</div>
</div>
</div>
<div class="p-4 flex justify-between items-center cursor-pointer hover:bg-gray-800 transition-colors" @click="openSettings">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-sm font-bold shadow-md">
{{ username.charAt(0).toUpperCase() }}
<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 class="flex flex-col leading-tight">
<span class="text-sm font-medium">{{ user.username }}</span>
<span v-if="user.is_superadmin" class="text-[9px] text-purple-300">SUPER-ADMIN</span>
<span v-else-if="user.is_admin" class="text-[9px] text-red-300">ADMINISTRATEUR</span>
</div>
<span class="text-sm font-medium">{{ username }}</span>
</div>
<button class="text-gray-400 hover:text-white transition-colors"><i data-lucide="settings" class="w-5 h-5"></i></button>
<i data-lucide="settings" class="w-5 h-5 text-gray-400"></i>
</div>
</aside>
</div>
<main class="flex-1 flex flex-col min-w-0 z-10 transition-all duration-300">
<!-- ZONE PRINCIPALE -->
<main class="flex-1 flex flex-col min-w-0 z-10 transition-all duration-300 bg-aethas-dark relative">
<header class="flex items-center justify-between p-4 border-b border-gray-700 bg-aethas-panel h-[73px]">
<div class="flex items-center gap-3">
<button @click="toggleSidebar" class="text-gray-400 hover:text-white transition-colors p-1.5 rounded hover:bg-gray-700" title="Basculer le menu">
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
<div v-show="!isSidebarOpen" class="flex items-center gap-2 transition-opacity duration-300">
<img src="/static/logo.png" alt="Logo" class="w-8 h-8 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<button @click="toggleSidebar" class="text-gray-400 hover:text-white p-1.5 rounded hover:bg-gray-700"><i data-lucide="menu" class="w-6 h-6"></i></button>
<div v-show="!isSidebarOpen" class="flex items-center gap-2">
<img src="/assets/logo.png" class="w-8 h-8 rounded-full border border-aethas-cyan">
<span class="font-bold text-lg neon-text">AETHAS38</span>
</div>
</div>
<div v-if="activeView === 'chat' && activeProject" class="text-sm font-medium text-gray-300 flex items-center gap-4">
<span class="truncate max-w-[200px] md:max-w-md">{{ activeProject.title }}</span>
<div class="flex items-center gap-2 border-l border-gray-600 pl-4">
<button @click="exportProject('txt')" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 border border-gray-600 px-2 py-1 rounded flex items-center gap-1"><i data-lucide="download" class="w-3 h-3"></i> TXT</button>
<button @click="exportProject('json')" class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 border border-gray-600 px-2 py-1 rounded flex items-center gap-1"><i data-lucide="file-json" class="w-3 h-3"></i> JSON</button>
</div>
</div>
</header>
<div class="flex-1 overflow-y-auto p-4 md:p-8 flex flex-col items-center justify-center">
<img src="/static/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mb-6 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800 flex-shrink-0">
<h2 class="text-2xl font-bold text-gray-200 mb-2 text-center">Comment puis-je vous aider aujourd'hui ?</h2>
<p class="text-gray-500 text-sm text-center">L'orchestrateur Multi-IA est prêt.</p>
<!-- VUE : CONFIGURATION -->
<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">
<h2 class="text-2xl font-bold text-white flex items-center gap-2 border-b border-gray-700 pb-4"><i data-lucide="settings-2" class="w-6 h-6 text-aethas-cyan"></i> Configuration Système</h2>
<div class="flex border-b border-gray-700">
<button @click="settingsTab = 'user'" :class="['py-2 px-4 text-sm font-semibold border-b-2 transition-colors', settingsTab === 'user' ? 'border-aethas-cyan text-aethas-cyan' : 'border-transparent text-gray-400 hover:text-white']">Mon Profil (Utilisateur)</button>
<button v-if="user.is_admin" @click="settingsTab = 'admin'" :class="['py-2 px-4 text-sm font-semibold border-b-2 transition-colors', settingsTab === 'admin' ? 'border-red-500 text-red-400' : 'border-transparent text-gray-400 hover:text-white']">Administration</button>
<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 class="p-4 md:p-6 w-full max-w-4xl mx-auto">
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan focus-within:ring-1 focus-within:ring-aethas-cyan transition-all">
<button class="p-3 text-gray-400 hover:text-aethas-cyan transition-colors flex-shrink-0" title="Joindre un fichier">
<i data-lucide="paperclip" class="w-5 h-5"></i>
</button>
<textarea
v-model="prompt"
@input="autoResize"
ref="promptTextarea"
rows="1"
class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none leading-relaxed"
placeholder="Posez votre question à l'Orchestrateur..."></textarea>
<button class="p-3 text-aethas-cyan hover:text-white transition-colors flex-shrink-0" title="Envoyer">
<i data-lucide="send" class="w-5 h-5"></i>
</button>
<div v-if="settingsTab === 'user'" class="space-y-6">
<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>
<div class="flex items-center gap-6">
<img v-if="user.avatar_path" :src="user.avatar_path" class="w-20 h-20 rounded-full object-cover border-2 border-aethas-cyan bg-gray-900 shadow-lg">
<div v-else class="w-20 h-20 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-2xl font-bold shadow-lg">{{ user.username.charAt(0).toUpperCase() }}</div>
<div class="flex-1">
<label class="bg-aethas-panel hover:bg-gray-700 text-white border border-gray-600 px-4 py-2 rounded-lg cursor-pointer transition-colors text-sm inline-block">
<i data-lucide="upload" class="w-4 h-4 inline mr-1"></i> Uploader un nouvel Avatar
<input type="file" accept="image/*" @change="uploadAvatar" class="hidden">
</label>
</div>
<p class="text-center text-xs text-gray-500 mt-3">AETHAS38 peut faire des erreurs. Vérifiez toujours les informations importantes.</p>
</div>
</div>
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700">
<h3 class="text-lg font-semibold text-white mb-4">Sécurité</h3>
<div v-if="settingsMessage" class="p-3 rounded text-sm mb-4 bg-gray-900 border border-aethas-cyan text-aethas-cyan">{{ settingsMessage }}</div>
<div class="grid gap-4 max-w-md">
<input v-model="passForm.old" type="password" placeholder="Ancien mot de passe" class="bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none">
<input v-model="passForm.new" type="password" placeholder="Nouveau mot de passe" class="bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none">
<input v-model="passForm.confirm" type="password" placeholder="Confirmer" class="bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:outline-none">
<button @click="changePassword" :disabled="isSavingSettings" class="bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg disabled:opacity-50">Modifier le mot de passe</button>
</div>
</div>
</div>
<div v-if="settingsTab === 'admin' && user.is_admin" class="space-y-6">
<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>
<div class="flex gap-4">
<button @click="activeView = 'models'" class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg text-sm border border-gray-600">Ouvrir le panneau Modèles IA</button>
<a href="/api/models/export" target="_blank" class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg text-sm border border-gray-600">Exporter DB Modèles (JSON)</a>
</div>
</div>
</div>
<div v-if="settingsTab === 'super' && user.is_superadmin" class="space-y-6">
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700 border-l-4 border-l-aethas-purple">
<h3 class="text-lg font-semibold text-purple-400 mb-4">Espace Réservé Super-Admin</h3>
<p class="text-sm text-gray-400">Gestion globale du système.</p>
</div>
</div>
</div>
</div>
<!-- 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 custom-scrollbar">
<div class="flex justify-between items-end border-b border-gray-700 pb-4">
<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>
<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>
<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 class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="fin in financesList" :key="fin.provider" class="bg-gray-800 border border-gray-700 rounded-xl p-4 flex flex-col justify-between">
<div class="flex justify-between items-start mb-2"><span class="font-bold text-gray-200 capitalize">{{ fin.provider }}</span><i data-lucide="wallet" class="w-5 h-5 text-gray-500"></i></div>
<div>
<div class="text-2xl font-mono" :class="fin.balance > 0 ? 'text-green-400' : 'text-aethas-cyan'">${{ fin.balance > 0 ? fin.balance.toFixed(4) : Math.abs(fin.total_usage).toFixed(4) }}</div>
<div class="text-xs text-gray-400 mt-1">Conso. API : ${{ fin.total_usage.toFixed(4) }}</div>
</div>
</div>
</div>
<div class="bg-aethas-panel border border-gray-700 rounded-xl overflow-hidden flex flex-col">
<div class="p-4 bg-gray-800/50 border-b border-gray-700 flex flex-wrap gap-4 items-center">
<input v-model="filterSearch" type="text" placeholder="Rechercher..." class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 w-full md:w-64 focus:border-aethas-cyan focus:outline-none">
<select v-model="filterDomain" class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none">
<option value="">Tous les domaines</option>
<option value="Texte Polyvalent">Texte Polyvalent</option>
<option value="Code & Logique">Code & Logique</option>
<option value="Vision & Texte">Vision & Texte</option>
<option value="Audio">Audio</option>
</select>
<select v-model="filterPricing" class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none">
<option value="">Tous les prix</option>
<option value="free">Gratuits uniquement</option>
<option value="paid">Payants</option>
</select>
</div>
<div class="overflow-x-auto max-h-[500px] custom-scrollbar">
<table class="w-full text-left text-sm text-gray-300">
<thead class="text-xs uppercase bg-gray-800 text-gray-400 sticky top-0"><tr><th class="px-4 py-3">Modèle</th><th class="px-4 py-3">Fournisseur</th><th class="px-4 py-3">Domaine</th><th class="px-4 py-3">Coût (1M Tokens)</th><th class="px-4 py-3">Contexte</th><th class="px-4 py-3 w-1/3">Description</th></tr></thead>
<tbody>
<tr v-for="model in filteredModels" :key="model.model_id" class="border-b border-gray-700 hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-aethas-cyan break-all">{{ model.model_id }}</td>
<td class="px-4 py-3 capitalize">{{ model.provider }}</td>
<td class="px-4 py-3"><span class="bg-gray-700 text-gray-200 px-2 py-1 rounded text-xs">{{ model.domain }}</span></td>
<td class="px-4 py-3 font-mono text-xs"><span v-if="model.is_free" class="text-green-400 font-bold">GRATUIT</span><span v-else>In: ${{ model.pricing_prompt.toFixed(2) }}<br>Out: ${{ model.pricing_completion.toFixed(2) }}</span></td>
<td class="px-4 py-3">{{ (model.context_length / 1000).toFixed(0) }}k</td>
<td class="px-4 py-3 text-xs text-gray-400 truncate max-w-[250px]" :title="model.description_fr">{{ model.description_fr }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- VUE : 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">
<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>
<p class="text-gray-500 text-sm">Sélectionnez ou créez un projet dans la barre latérale.</p>
</div>
<template v-else>
<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">
<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>
</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>
<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>
<div class="w-full flex items-end">
<label class="p-3 text-gray-400 hover:text-aethas-cyan cursor-pointer transition-colors" title="Joindre des fichiers (max 10)">
<i data-lucide="paperclip" class="w-5 h-5"></i>
<input type="file" multiple @change="handleFileUpload" class="hidden">
</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>
</template>
</div>
</main>
</div>
<script>
const { createApp, ref, onMounted, nextTick } = Vue;
const { createApp, ref, reactive, onMounted, nextTick, computed } = Vue;
createApp({
setup() {
const username = ref("Admin");
const user = ref({ username: "Guest", is_admin: false, is_superadmin: false, avatar_path: null });
const activeView = ref('chat');
const settingsTab = ref('user');
const isSidebarOpen = ref(true);
const prompt = ref("");
const promptTextarea = ref(null);
const projects = ref([]);
const activeProject = ref(null);
const messages = ref([]);
const isLoading = ref(false);
const isAiThinking = ref(false);
const isSyncing = ref(false);
const isSavingSettings = ref(false);
const isSettingsModalOpen = ref(false);
const settingsMessage = ref("");
const passForm = reactive({ old: '', new: '', confirm: '' });
const modelInfo = reactive({ last_sync_date: null, last_sync_type: null, models: [] });
const financesList = ref([]);
const filterSearch = ref("");
const filterDomain = ref("");
const filterPricing = ref("");
const colPricing = ref("");
const colDomain = ref("");
const workers = ref(["", "", "", "", ""]);
const selectedPrompter = ref("");
const selectedConcatenator = ref("");
const attachedFiles = ref([]);
const pinnedProjects = computed(() => projects.value.filter(p => p.is_pinned));
const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned));
const filteredModels = computed(() => {
return modelInfo.models.filter(m => {
const matchSearch = (m.model_id.toLowerCase().includes(filterSearch.value.toLowerCase()) || m.provider.toLowerCase().includes(filterSearch.value.toLowerCase()));
const matchDomain = filterDomain.value === "" || m.domain === filterDomain.value;
let matchPrice = true;
if (filterPricing.value === 'free') matchPrice = m.is_free;
if (filterPricing.value === 'paid') matchPrice = !m.is_free;
return matchSearch && matchDomain && matchPrice;
});
});
const 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) => {
if (!isoString) return "Jamais";
const d = new Date(isoString);
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + " à " + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
};
const toggleSidebar = () => { isSidebarOpen.value = !isSidebarOpen.value; };
const autoResize = () => { const el = promptTextarea.value; if(el){ el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }};
const scrollToBottom = () => { nextTick(() => { const c = document.getElementById('chat-container'); if (c) c.scrollTop = c.scrollHeight; }); };
const autoResize = () => {
const el = promptTextarea.value;
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
};
const openSettings = () => { isSettingsModalOpen.value = true; nextTick(() => lucide.createIcons()); };
const closeSettings = () => { isSettingsModalOpen.value = false; };
const fetchProjects = async () => {
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 fetchFinances = async () => { try { const res = await fetch('/api/finances'); if(res.ok) financesList.value = await res.json(); } catch(e){} };
const syncModels = async () => {
isSyncing.value = true;
try {
const response = await fetch('/api/projects');
if (response.ok) {
projects.value = await response.json();
nextTick(() => lucide.createIcons());
}
} catch (error) {
console.error("Erreur lors de la récupération des projets", error);
}
};
const res = await fetch('/api/models/sync', { method: 'POST' });
if(res.ok) {
await fetchModelsInfo();
await fetchFinances();
const createNewProject = async () => {
isLoading.value = true;
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: "Nouvelle discussion" })
let mdContent = `# Extraction des Modèles IA - AETHAS38\n\n`;
const d = new Date();
const dateStr = d.getFullYear() + String(d.getMonth()+1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
mdContent += `**Date d'extraction :** ${d.toLocaleDateString('fr-FR')} à ${d.toLocaleTimeString('fr-FR')}\n\n`;
const providers = [...new Set(modelInfo.models.map(m => m.provider))];
providers.forEach(prov => {
mdContent += `## Fournisseur : ${prov.toUpperCase()}\n\n`;
const provModels = modelInfo.models.filter(m => m.provider === prov);
provModels.forEach(m => {
const priceInfo = m.is_free ? "**GRATUIT**" : `In: $${m.pricing_prompt.toFixed(2)} / Out: $${m.pricing_completion.toFixed(2)}`;
const ctxInfo = `${(m.context_length / 1000).toFixed(0)}k`;
mdContent += `- **${m.name || m.model_id}** (\`${m.model_id}\`)\n`;
mdContent += ` - *Domaine :* ${m.domain}\n`;
mdContent += ` - *Prix (1M tokens) :* ${priceInfo}\n`;
mdContent += ` - *Contexte :* ${ctxInfo}\n`;
mdContent += ` - *Description :* ${m.description_fr.replace(/\n/g, ' ')}\n\n`;
});
});
const filename = `${dateStr}-extraction-modèles.md`;
downloadFile(mdContent, filename);
alert("Synchronisation réussie ! Le fichier markdown a été généré et téléchargé.");
} else {
let errText = await res.text();
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: impossible de contacter le serveur."); }
isSyncing.value = false;
};
const downloadTemplate = () => {
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 handleConfigImport = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (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');
alert("Configuration IA importée et appliquée avec succès !");
};
reader.readAsText(file);
event.target.value = null;
};
const uploadAvatar = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData(); formData.append("file", file);
try {
const res = await fetch('/api/users/me/avatar', { method: 'POST', body: formData });
const data = await res.json();
if(res.ok) user.value.avatar_path = data.avatar_path;
else alert(data.detail);
} catch(e) { alert("Erreur d'upload"); }
event.target.value = null;
};
const 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) => {
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 });
};
// Les fichiers binaires sont lus en Base64.
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 copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text); alert("Copié !"); } catch (e) {} };
const downloadFile = (content, hint) => { let fname = hint ? hint.trim() : 'code.txt'; if (!fname.includes('.')) fname += '.txt'; const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fname; a.click(); URL.revokeObjectURL(url); };
const downloadMessage = (content, dateString) => { downloadFile(content, `message_${dateString.replace(/[:T.Z]/g, '-')}.txt`); };
const parseMessage = (content) => { const regex = /```([\w.-]*)\n([\s\S]*?)```/g; let parts = [], last = 0, m; while ((m = regex.exec(content)) !== null) { if (m.index > last) parts.push({ type: 'text', content: content.slice(last, m.index) }); parts.push({ type: 'code', language: m[1], content: m[2] }); last = m.index + m[0].length; } if (last < content.length) parts.push({ type: 'text', content: content.slice(last) }); return parts.length > 0 ? parts : [{ type: 'text', content: content }]; };
const fetchProjects = async () => { try { const res = await fetch('/api/projects'); if(res.ok) projects.value = await res.json(); nextTick(()=>lucide.createIcons()); } catch(e){} };
const selectProject = async (project) => { activeView.value = 'chat'; activeProject.value = project; messages.value = []; try { const res = await fetch(`/api/projects/${project.id}/messages`); if(res.ok) { messages.value = await res.json(); scrollToBottom(); nextTick(()=>lucide.createIcons()); } } catch(e){} };
const createNewProject = async () => { activeView.value = 'chat'; isLoading.value = true; try { const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({title: "Nouvelle discussion"})}); if(res.ok){ const np = await res.json(); projects.value.unshift(np); selectProject(np); } } catch(e){} finally{isLoading.value = false;} };
const sendMessage = async () => {
const activeWorkers = workers.value.filter(w => w && w.trim() !== "");
if ((!prompt.value.trim() && attachedFiles.value.length === 0) || !activeProject.value || activeWorkers.length === 0) {
alert("Veuillez sélectionner au moins un travailleur et rédiger ou joindre un contenu.");
return;
}
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 = [];
try {
const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (response.ok) {
const newProject = await response.json();
projects.value.unshift(newProject); // Ajoute le nouveau projet en haut de la liste
nextTick(() => lucide.createIcons());
const data = await response.json();
if (Array.isArray(data)) messages.value = data;
await fetchFinances();
} else if (response.status === 401) {
alert("Votre session a expiré. Veuillez vous reconnecter.");
window.location.href = '/login';
return;
} else {
const err = await response.json(); alert("Erreur API IA : " + err.detail);
}
} catch (error) {
console.error("Erreur lors de la création du projet", error);
alert("Erreur locale lors de l'envoi : " + error.message);
} finally {
isLoading.value = false;
isAiThinking.value = false;
scrollToBottom();
nextTick(()=>lucide.createIcons());
}
};
onMounted(() => {
lucide.createIcons();
fetchProjects();
});
const changePassword = async () => {
settingsMessage.value = "";
if (passForm.new !== passForm.confirm) { settingsMessage.value = "Mots de passe différents."; return; }
if (passForm.new.length < 8) { settingsMessage.value = "8 caractères minimum."; return; }
isSavingSettings.value = true;
try { const res = await fetch('/api/users/me/password', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old_password: passForm.old, new_password: passForm.new }) }); const data = await res.json(); if (res.ok) { settingsMessage.value = data.message; passForm.old=''; passForm.new=''; passForm.confirm=''; } else { settingsMessage.value = data.detail; } } catch (e) {} finally { isSavingSettings.value = false; }
};
return { username, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea, projects, createNewProject, isLoading };
const renameProject = async (project) => { const nt = window.prompt("Nom :", project.title); if (!nt || nt.trim() === "" || nt === project.title) return; try { const res = await fetch(`/api/projects/${project.id}/rename`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nt.trim() }) }); if (res.ok) { const up = await res.json(); const i = projects.value.findIndex(p => p.id === project.id); if (i !== -1) projects.value[i] = up; if (activeProject.value && activeProject.value.id === project.id) activeProject.value.title = up.title; } } catch (e) {} };
const togglePin = async (pid) => { try { const res = await fetch(`/api/projects/${pid}/pin`, { method: 'PUT' }); if (res.ok) { const up = await res.json(); const i = projects.value.findIndex(p => p.id === pid); if (i !== -1) projects.value[i] = up; } } catch (e) {} };
const deleteProject = async (pid) => { if (!confirm("Supprimer ce projet ?")) return; try { const res = await fetch(`/api/projects/${pid}`, { method: 'DELETE' }); if (res.ok) { projects.value = projects.value.filter(p => p.id !== pid); if (activeProject.value && activeProject.value.id === pid) activeProject.value = null; } } catch (e) {} };
onMounted(() => { lucide.createIcons(); fetchUser(); fetchProjects(); fetchModelsInfo(); fetchFinances(); });
return {
user, activeView, settingsTab, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea,
projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking,
createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject,
isSettingsModalOpen, openSettings, closeSettings, passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar,
modelInfo, financesList, filterSearch, filterDomain, filterPricing, filteredModels, getFilteredModelsForCol, formatDate, syncModels, isSyncing,
workers, selectedPrompter, selectedConcatenator, colPricing, colDomain, downloadTemplate, handleConfigImport,
attachedFiles, handleFileUpload, handlePaste, removeFile,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage
};
}
}).mount('#app');
</script>
+146 -37
View File
@@ -3,60 +3,166 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AETHAS38 - Installation</title>
<title>AETHAS38 - Installation Complète</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } }
}
}
tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.glass-panel { background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(10px); border: 1px solid rgba(6, 182, 212, 0.2); box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5); }
.neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4 sm:p-8">
<body class="flex items-center justify-center min-h-screen p-4 sm:p-8 overflow-y-auto">
<div id="app" class="w-full max-w-md transition-all duration-500">
<div id="app" class="w-full max-w-3xl transition-all duration-500 my-8">
<div class="text-center mb-8">
<!-- Chemin du logo corrigé -->
<img src="/static/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mx-auto mb-4 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800">
<img src="/assets/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mx-auto mb-4 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800">
<h1 class="text-3xl font-bold tracking-wider neon-text">AETHAS38</h1>
<p class="text-gray-400 text-sm mt-2">Configuration de l'Orchestrateur Multi-IA</p>
<p class="text-gray-400 text-sm mt-2">Configuration Initiale (Super Admin)</p>
</div>
<div class="glass-panel rounded-2xl p-6 sm:p-8">
<form v-if="!setupComplete" @submit.prevent="submitSetup" class="space-y-5">
<form v-if="!setupComplete" @submit.prevent="submitSetup" class="space-y-8">
<div v-if="errorMessage" class="p-3 bg-red-900/50 border border-red-500 rounded text-red-200 text-sm text-center">{{ errorMessage }}</div>
<!-- Section 1 : Compte Admin -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Email Administrateur</label>
<input v-model="form.email" type="email" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="admin@domaine.com">
<h2 class="text-lg font-semibold text-white border-b border-gray-700 pb-2 mb-4 flex items-center gap-2">
<i data-lucide="user-plus" class="w-5 h-5 text-aethas-cyan"></i> Compte Administrateur
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-300 mb-1">Nom d'utilisateur</label>
<input v-model="form.username" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan outline-none" placeholder="Admin">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Nom d'utilisateur</label>
<input v-model="form.username" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="Admin">
<label class="block text-sm text-gray-300 mb-1">Mot de passe</label>
<input v-model="form.password" type="password" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan outline-none" placeholder="••••••••">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Email de récupération</label>
<input v-model="form.email" type="email" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan outline-none" placeholder="admin@domaine.com">
</div>
</div>
</div>
<!-- Section 2 : Serveur Mail -->
<div>
<div class="flex items-center justify-between border-b border-gray-700 pb-2 mb-4">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<i data-lucide="mail" class="w-5 h-5 text-aethas-cyan"></i> Serveur Mail (Obligatoire)
</h2>
<button type="button" @click="showWiki = !showWiki" class="text-xs text-aethas-cyan hover:text-white underline">Aide FAI Français</button>
</div>
<div v-if="showWiki" class="mb-4 p-4 bg-gray-800 rounded-lg border border-gray-600 text-sm text-gray-300 space-y-2">
<p><strong>Freebox :</strong> Hôte: smtp.free.fr | Port: 465 (SSL) ou 587 (TLS)</p>
<p><strong>Orange :</strong> Hôte: smtp.orange.fr | Port: 465 (SSL)</p>
<p><strong>SFR :</strong> Hôte: smtp.sfr.fr | Port: 465 (SSL)</p>
<p><strong>Bouygues :</strong> Hôte: smtp.bbox.fr | Port: 587 (TLS)</p>
<p><strong>Gmail :</strong> Hôte: smtp.gmail.com | Port: 465 (Nécessite un "Mot de passe d'application" Google)</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-3">
<label class="block text-sm text-gray-300 mb-1">Hôte SMTP</label>
<input v-model="form.smtp_host" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none" placeholder="ex: smtp.free.fr">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Mot de passe</label>
<input v-model="form.password" type="password" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="••••••••">
<label class="block text-sm text-gray-300 mb-1">Port</label>
<input v-model="form.smtp_port" type="number" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none" placeholder="465">
</div>
<button type="submit" :disabled="isSubmitting" class="w-full bg-gradient-to-r from-aethas-cyan to-aethas-purple hover:opacity-90 text-white font-semibold py-3 px-4 rounded-lg transition-all shadow-[0_0_10px_rgba(168,85,247,0.4)] flex justify-center items-center mt-6">
<span v-if="isSubmitting">Création en cours...</span>
<span v-else>Initialiser le système</span>
<div class="md:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Utilisateur SMTP</label>
<input v-model="form.smtp_user" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Mot de passe SMTP</label>
<input v-model="form.smtp_password" type="password" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none">
</div>
</div>
</div>
<!-- Section 3 : Clés API IA -->
<div>
<h2 class="text-lg font-semibold text-white border-b border-gray-700 pb-2 mb-4 flex items-center gap-2">
<i data-lucide="cpu" class="w-5 h-5 text-aethas-cyan"></i> Moteurs IA (Optionnel - modifiable plus tard)
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- OpenRouter -->
<div class="space-y-2 bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<h3 class="text-sm font-bold text-white flex items-center justify-between">OpenRouter <a href="https://openrouter.ai/keys" target="_blank" class="text-aethas-cyan hover:underline text-xs"><i data-lucide="external-link" class="w-3 h-3 inline"></i> Récupérer</a></h3>
<p class="text-xs text-gray-400">Générez une clé standard pour les requêtes, et une "Management Key" pour la récupération des crédits.</p>
<input v-model="form.openrouter_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="API Key (sk-or-v1-...)">
<input v-model="form.openrouter_management_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="Management Key">
</div>
<!-- Gemini -->
<div class="space-y-2 bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<h3 class="text-sm font-bold text-white flex items-center justify-between">Google Gemini <a href="https://aistudio.google.com/app/apikey" target="_blank" class="text-aethas-cyan hover:underline text-xs"><i data-lucide="external-link" class="w-3 h-3 inline"></i> Récupérer</a></h3>
<p class="text-xs text-gray-400">Créez une API Key depuis Google AI Studio (Associez un projet Google Cloud pour lever les quotas gratuits limités).</p>
<input v-model="form.gemini_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="AIzaSy...">
</div>
<!-- Groq -->
<div class="space-y-2 bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<h3 class="text-sm font-bold text-white flex items-center justify-between">Groq <a href="https://console.groq.com/keys" target="_blank" class="text-aethas-cyan hover:underline text-xs"><i data-lucide="external-link" class="w-3 h-3 inline"></i> Récupérer</a></h3>
<p class="text-xs text-gray-400">Créez une clé d'API. L'accès aux modèles Llama est gratuit avec quotas.</p>
<input v-model="form.groq_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="gsk_...">
</div>
<!-- DeepSeek -->
<div class="space-y-2 bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<h3 class="text-sm font-bold text-white flex items-center justify-between">DeepSeek <a href="https://platform.deepseek.com/api_keys" target="_blank" class="text-aethas-cyan hover:underline text-xs"><i data-lucide="external-link" class="w-3 h-3 inline"></i> Récupérer</a></h3>
<p class="text-xs text-gray-400">Créez une clé d'API. Parfait pour le traitement du code de AETHAS38.</p>
<input v-model="form.deepseek_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="sk-...">
</div>
<!-- Mistral -->
<div class="space-y-2 bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<h3 class="text-sm font-bold text-white flex items-center justify-between">Mistral AI <a href="https://console.mistral.ai/api-keys/" target="_blank" class="text-aethas-cyan hover:underline text-xs"><i data-lucide="external-link" class="w-3 h-3 inline"></i> Récupérer</a></h3>
<p class="text-xs text-gray-400">Assurez-vous d'avoir enregistré une méthode de facturation pour lever la période d'essai.</p>
<input v-model="form.mistral_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="Clé API Mistral">
</div>
<!-- HuggingFace -->
<div class="space-y-2 bg-gray-800/50 p-3 rounded-lg border border-gray-700">
<h3 class="text-sm font-bold text-white flex items-center justify-between">HuggingFace <a href="https://huggingface.co/settings/tokens" target="_blank" class="text-aethas-cyan hover:underline text-xs"><i data-lucide="external-link" class="w-3 h-3 inline"></i> Récupérer</a></h3>
<p class="text-xs text-gray-400">Générez un Access Token (Fine-grained) avec les droits de lecture et génération (Inference).</p>
<input v-model="form.huggingface_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="hf_...">
</div>
<!-- Cloudflare -->
<div class="space-y-2 bg-gray-800/50 p-3 rounded-lg border border-gray-700 md:col-span-2">
<h3 class="text-sm font-bold text-white flex items-center justify-between">Cloudflare Workers AI <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" class="text-aethas-cyan hover:underline text-xs"><i data-lucide="external-link" class="w-3 h-3 inline"></i> Récupérer</a></h3>
<p class="text-xs text-gray-400">Créez un "Custom Token" avec l'autorisation "Workers AI : Read/Write". Votre ID de compte se trouve sur la page d'accueil Workers.</p>
<div class="flex gap-2">
<input v-model="form.cloudflare_account_id" type="text" class="w-1/3 bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="Account ID">
<input v-model="form.cloudflare_api_token" type="password" class="w-2/3 bg-aethas-dark border border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:border-aethas-cyan outline-none" placeholder="API Token">
</div>
</div>
</div>
</div>
<button type="submit" :disabled="isSubmitting" class="w-full bg-gradient-to-r from-aethas-cyan to-aethas-purple hover:opacity-90 text-white font-semibold py-3 px-4 rounded-lg transition-all shadow-[0_0_10px_rgba(168,85,247,0.4)] flex justify-center items-center mt-8">
<span v-if="isSubmitting">Initialisation et Chiffrement...</span>
<span v-else>Terminer l'installation</span>
</button>
</form>
<div v-else class="text-center space-y-6 animate-fade-in">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-900/50 text-green-400 mb-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<i data-lucide="check" class="w-6 h-6"></i>
</div>
<h2 class="text-xl font-bold text-white">Compte créé avec succès</h2>
<h2 class="text-xl font-bold text-white">Plateforme Opérationnelle</h2>
<div class="bg-white p-4 rounded-xl inline-block mx-auto min-w-[200px] min-h-[200px] flex items-center justify-center">
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="QR Code 2FA" class="w-full h-full object-contain">
@@ -65,7 +171,7 @@
<div class="text-sm text-gray-300 space-y-2">
<p>Scannez ce QR Code avec votre application 2FA (Keepassium, Google Authenticator...).</p>
<p class="text-xs text-gray-500 break-all bg-aethas-dark p-2 rounded">Secret manuel : {{ totpSecret }}</p>
<p class="text-xs text-gray-500 break-all bg-aethas-dark p-2 rounded border border-gray-600">Secret manuel : {{ totpSecret }}</p>
</div>
<a href="/login" class="block w-full border border-aethas-cyan text-aethas-cyan hover:bg-aethas-cyan hover:text-aethas-dark font-semibold py-3 px-4 rounded-lg transition-colors mt-6">
@@ -76,16 +182,25 @@
</div>
<script>
const { createApp, ref, reactive } = Vue;
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
const form = reactive({ email: '', username: '', password: '' });
const form = reactive({
email: '', username: '', password: '',
smtp_host: '', smtp_port: 465, smtp_user: '', smtp_password: '',
openrouter_api_key: '', openrouter_management_key: '',
groq_api_key: '', gemini_api_key: '', deepseek_api_key: '', mistral_api_key: '',
cloudflare_account_id: '', cloudflare_api_token: '', huggingface_api_key: ''
});
const isSubmitting = ref(false);
const setupComplete = ref(false);
const errorMessage = ref('');
const totpSecret = ref('');
const qrCodeUrl = ref('');
const showWiki = ref(false);
onMounted(() => { lucide.createIcons(); });
const submitSetup = async () => {
isSubmitting.value = true;
@@ -97,27 +212,21 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "Erreur lors de l'installation");
}
if (!response.ok) throw new Error(data.detail || "Erreur lors de l'installation");
totpSecret.value = data.totp_secret;
setupComplete.value = true;
// CORRECTION ICI : Génération par API externe ultra fiable (plus besoin de librairie)
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.totp_uri)}&color=0f172a&bgcolor=ffffff`;
setTimeout(() => lucide.createIcons(), 100);
} catch (error) {
errorMessage.value = error.message;
} finally {
isSubmitting.value = false;
}
};
return { form, isSubmitting, setupComplete, errorMessage, totpSecret, qrCodeUrl, submitSetup };
return { form, isSubmitting, setupComplete, errorMessage, totpSecret, qrCodeUrl, showWiki, submitSetup };
}
}).mount('#app');
</script>
+23 -33
View File
@@ -6,26 +6,12 @@
<title>AETHAS38 - Connexion</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
aethas: {
dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7'
}
}
}
}
}
tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.glass-panel {
background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(10px);
border: 1px solid rgba(6, 182, 212, 0.2); box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
}
.glass-panel { background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(10px); border: 1px solid rgba(6, 182, 212, 0.2); box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5); }
.neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
</style>
</head>
@@ -33,16 +19,14 @@
<div id="app" class="w-full max-w-md transition-all duration-500">
<div class="text-center mb-8">
<img src="https://via.placeholder.com/150x150/0f172a/06b6d4?text=Logo+A38" alt="AETHAS38 Logo" class="w-24 h-24 mx-auto mb-4 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)]">
<img src="/assets/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mx-auto mb-4 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800">
<h1 class="text-3xl font-bold tracking-wider neon-text">AETHAS38</h1>
<p class="text-gray-400 text-sm mt-2">Authentification Sécurisée</p>
</div>
<div class="glass-panel rounded-2xl p-6 sm:p-8">
<form @submit.prevent="submitLogin" class="space-y-5">
<div v-if="errorMessage" class="p-3 bg-red-900/50 border border-red-500 rounded text-red-200 text-sm text-center">
{{ errorMessage }}
</div>
<div v-if="errorMessage" class="p-3 bg-red-900/50 border border-red-500 rounded text-red-200 text-sm text-center">{{ errorMessage }}</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Nom d'utilisateur</label>
@@ -56,11 +40,11 @@
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Code 2FA (6 chiffres)</label>
<input v-model="form.totp_code" type="text" inputmode="numeric" pattern="[0-9]{6}" required maxlength="6" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors text-center tracking-widest text-lg" placeholder="123456">
<input v-model="form.totp_code" type="text" required pattern="[0-9]{6}" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors text-center tracking-widest text-lg" placeholder="123456">
</div>
<button type="submit" :disabled="isSubmitting" class="w-full bg-gradient-to-r from-aethas-cyan to-aethas-purple hover:opacity-90 text-white font-semibold py-3 px-4 rounded-lg transition-all shadow-[0_0_10px_rgba(168,85,247,0.4)] flex justify-center items-center mt-6">
<span v-if="isSubmitting">Vérification...</span>
<button type="submit" :disabled="isLoading" class="w-full bg-gradient-to-r from-aethas-cyan to-aethas-purple hover:opacity-90 text-white font-semibold py-3 px-4 rounded-lg transition-all shadow-[0_0_10px_rgba(168,85,247,0.4)] flex justify-center items-center mt-6">
<span v-if="isLoading">Connexion...</span>
<span v-else>Se connecter</span>
</button>
</form>
@@ -68,37 +52,43 @@
</div>
<script>
const { createApp, reactive, ref } = Vue;
const { createApp, ref, reactive } = Vue;
createApp({
setup() {
const form = reactive({ username: '', password: '', totp_code: '' });
const isSubmitting = ref(false);
const isLoading = ref(false);
const errorMessage = ref('');
const submitLogin = async () => {
isSubmitting.value = true;
isLoading.value = true;
errorMessage.value = '';
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || "Erreur de connexion");
// Redirection vers le tableau de bord une fois connecté
window.location.href = "/dashboard";
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "Erreur de connexion");
}
window.location.href = '/dashboard';
} catch (error) {
errorMessage.value = error.message;
} finally {
isSubmitting.value = false;
isLoading.value = false;
}
};
return { form, isSubmitting, errorMessage, submitLogin };
return { form, isLoading, errorMessage, submitLogin };
}
}).mount('#app');
</script>
</body>
</html>
+7
View File
@@ -11,3 +11,10 @@ python-multipart
bcrypt
email-validator
PyJWT
openai
httpx
python-multipart
pytz
PyPDF2
openpyxl
oletools