Compare commits

...
50 Commits
Author SHA1 Message Date
xavier 108ba8f167 fix(ui): restauration de l'accès à la vue complète de configuration (Profil, Admin, Super-Admin)
Build and Push Docker Image / build-and-push (push) Successful in 49s
2026-10-08 18:33:46 +02:00
xavier 5dea7d3353 Feat extract modèles disponible
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-08 18:16:49 +02:00
xavier 134312ebff feat maj fonctionnement
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-10-08 17:12:39 +02:00
xavier 83e70eadf6 feat: intégration du découpage en morceaux (chunking), pauses séquentielles anti 429 et plugin de compression de contexte OpenRouter
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-08 11:55:10 +02:00
xavier 60c0699c57 feat: intégration du workflow map-reduce parallèle pour le pré-traitement des fichiers joints, correction de session (7 jours) et redirection auto sur 401
Build and Push Docker Image / build-and-push (push) Successful in 55s
2026-10-08 11:31:42 +02:00
xavier 44bf0b868e feat: intégration du parsing backend (PDF, Excel, VBA) et gestion sécurisée des champs prompteur/concaténeur vides dans les templates
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-08 10:25:02 +02:00
xavier c779c51a03 fix: renforcement drastique de la regex du parseur .md et injection automatique de modèle de secours pour empêcher les cases vides
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-08 09:52:53 +02:00
xavier eaa0ff0564 feat: ajout de l'import de configuration IA via fichier .md et bouton de téléchargement du template
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-08 09:35:13 +02:00
xavier ecd9854527 feat: passage des prompt en traduction anglaise. Mise en place du téléchargement d'un *.md des modèles. Traduction de la description des modèles en Français.
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-08 09:24:04 +02:00
xavier f840561123 feat: support de 10 fichiers max avec destruction post-envoi, traduction automatique en anglais par le prompteur et traduction en français par le concaténeur en préservant le code
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-08 09:06:12 +02:00
xavier 48f929a8f3 feat: version v0.17.2 avec gestion intégrée et sécurisée des pièces jointes, destruction post-envoi et persistance des choix de modèles par filtres locaux
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-08 08:47:03 +02:00
xavier fd82d77709 fix: correction de la troncature du fichier dashboard.html et restauration complète de l'interpréteur Vue.js
Build and Push Docker Image / build-and-push (push) Failing after 30s
2026-10-07 21:37:19 +02:00
xavier 189a2e4bf6 feat: intégration de la barre de sélection tabulaire style grille (Payant/Gratuit, 5 Travailleurs, Prompteur, Concaténeur)
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-07 21:28:59 +02:00
xavier e322435bd8 fix: restauration du conteneur de chat, de la zone de texte et du sélecteur multi-agents par requête
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-07 21:15:59 +02:00
xavier efbecada77 docs: mise à jour complète du README pour refléter l'état de la v0.16.0 (super-admin, multi-fournisseurs, finances, avatars)
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-10-07 21:11:53 +02:00
xavier 4a9a0d8ec6 docs: mise à jour complète du README pour refléter l'état de la v0.16.0 (super-admin, multi-fournisseurs, finances, avatars)
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-07 21:10:10 +02:00
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
xavier d41edde9bb feat: création de l'API de gestion des projets et liaison dynamique avec le Dashboard
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-10-07 08:17:41 +02:00
xavier fd53c75dee modif QRCode
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 08:10:14 +02:00
xavier e1d7ae87e3 correction authentification
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-07 08:02:17 +02:00
xavier 74b87a3b27 feat: création des modèles de base de données Project et Message
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-10-07 07:42:24 +02:00
xavier 02ba8bfff3 fix: configuration de StaticFiles sur FastAPI et mise à jour des liens du logo en local
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-10-07 02:04:48 +02:00
xavier 554f00f2b1 feat: ajout de la barre latérale rétractable, du champ de texte auto-extensible et intégration des logos
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-10-07 01:49:23 +02:00
xavier defa47ae45 feat: création de la structure UI du Dashboard (style Gemini) et liaison avec la route protégée
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-10-07 01:26:21 +02:00
17 changed files with 2000 additions and 226 deletions

No files matched your search

+14 -1
View File
@@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- main - main
tags:
- 'v*' # Déclenche aussi le pipeline lors de la création d'un tag
jobs: jobs:
build-and-push: build-and-push:
@@ -16,6 +18,16 @@ jobs:
- name: Checkout du code - name: Checkout du code
uses: actions/checkout@v3 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 - name: Connexion au registre Gitea
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -28,4 +40,5 @@ jobs:
with: with:
context: . context: .
push: true 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: on:
push: push:
branches: [ "main" ] # Modifiez si votre branche s'appelle "master" branches: [ "main" ]
tags: [ 'v*' ] # Déclencheur sur les tags ajouté ici aussi
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
@@ -13,12 +14,22 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write # Indispensable pour publier le paquet sur le dépôt packages: write
steps: steps:
- name: Checkout du code - name: Checkout du code
uses: actions/checkout@v4 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) - name: Connexion au registre GitHub (ghcr.io)
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -31,4 +42,5 @@ jobs:
with: with:
context: . context: .
push: true push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -30,3 +30,6 @@ env/
.vscode/ .vscode/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
#perso
consignes.txt
+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. 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 ## 🏗️ Stack Technique & Architecture
- **Backend :** FastAPI (Python) modulaire (une fonction = un module).
- **Frontend :** Vue.js + Tailwind CSS (Responsive / Mobile-first). * **Backend :** FastAPI (Python) modulaire asynchrone (httpx, asyncio)[cite: 2].
- **Base de Données :** PostgreSQL (gestion des utilisateurs, historiques, suivi financier). * **Frontend :** Vue.js + Tailwind CSS (Responsive / Mobile-first)[cite: 2].
- **Traitement Documentaire :** LibreOffice Headless (génération/conversion Excel, Word, PDF). * **Base de Données :** PostgreSQL (gestion des utilisateurs, rôles hiérarchiques Super-Admin/Admin/User, historiques, suivi financier)[cite: 2].
- **Déploiement :** Docker, géré via Dockge et Gitea, sécurisé sur grappe RAID. * **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) ## 📌 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. 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]:
- **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. * **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 ## 🚀 ROADMAP & CAHIER DES CHARGES
### Sécurité & Infrastructure ### 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] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus)[cite: 2].
- [x] Mots de passe cryptés (bcrypt), protection anti-brute force et anti-DDOS (à compléter pour le DDOS). * [x] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID[cite: 2].
- [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.) sans accès SSH pour les utilisateurs. * [x] Mots de passe cryptés (bcrypt), protection anti-brute force[cite: 2].
- [x] Déconnexion automatique (timer invisible / session JWT) après 60 minutes d'inactivité. * [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.)[cite: 2].
- [ ] Procédure de réinitialisation/modification du mot de passe (Admin et Utilisateurs). * [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. * [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 ### 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. * [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].
- [ ] Système d'invitation par mail : création de compte avec génération de QR Code 2FA à la première connexion. * [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].
- [ ] Vérification du budget API toutes les 15 minutes et actualisation après chaque requête. * [x] Intégration de la roue crantée (Paramètres) en bas de la barre latérale[cite: 2].
- [ ] Suivi financier détaillé par fournisseur d'IA. * [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) ### 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 * [x] Thème moderne (Gemini-like) et 100% compatible smartphones[cite: 2].
- [ ] 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.). * [x] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés (Récents)[cite: 2].
- [ ] Pipeline multi-agents : 1 Rédacteur de prompt, jusqu'à 5 Travailleurs en parallèle, 1 Concaténeur final. * [x] Arrêt de la création automatique de nouveau projet à l'ouverture[cite: 2].
- [ ] 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. * [x] Intégration permanente du logo AETHAS38 en local (Setup / Login / Dashboard)[cite: 2].
- [ ] Téléchargement des prompts, des réponses, et de l'historique complet en PDF, TXT, MD ou JSON. * [x] Indicateur visuel (Loader/Animation) signalant que l'IA AETHAS38 "réfléchit" ou travaille[cite: 2].
- [ ] Bouton dédié pour télécharger les fichiers générés (Images, Vidéos, Excel mis en forme via LibreOffice). * [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)}")
+1 -1
View File
@@ -28,7 +28,7 @@ from datetime import datetime, timedelta, timezone
import os import os
# Dans l'idéal, cette clé secrète sera à placer dans votre fichier .env plus tard # Dans l'idéal, cette clé secrète sera à placer dans votre fichier .env plus tard
SECRET_KEY = os.getenv("SECRET_KEY", "`*\$Tu*CCc5hglT$mX'HykxERDljBz") SECRET_KEY = os.getenv("SECRET_KEY", r"`*\$Tu*CCc5hglT$mX'HykxERDljBz")
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 ACCESS_TOKEN_EXPIRE_MINUTES = 60
+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)
+295 -104
View File
@@ -1,133 +1,324 @@
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.responses import RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import os from typing import List
import os, json, asyncio, shutil, base64, io, csv, zipfile
from datetime import datetime
import pytz
# Importation de nos modules locaux from .database import engine, Base, get_db, SessionLocal
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 .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 from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse, PasswordChange, ModelReplacementRequest, LogRequest
from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog
from .orchestrator import run_orchestrator, sync_providers_models, sync_finances, activity_logs, log_activity
# Création des tables dans la base de données
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
app = FastAPI(title="AETHAS38")
app = FastAPI(title="AETHAS38 - Orchestrateur Multi-IA") assets_path = os.path.join(os.getcwd(), "frontend", "assets")
avatars_path = os.path.join(assets_path, "avatars")
os.makedirs(assets_path, exist_ok=True)
os.makedirs(avatars_path, exist_ok=True)
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
def is_setup_required(db: Session) -> bool: async def scheduler_task():
"""Vérifie si la base de données contient au moins un administrateur.""" tz = pytz.timezone('Europe/Paris')
admin = db.query(User).filter(User.is_admin == True).first() while True:
return admin is None 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("/") @app.get("/")
def read_root(db: Session = Depends(get_db)): def read_root(db: Session = Depends(get_db)): return RedirectResponse(url="/setup") if is_setup_required(db) else RedirectResponse(url="/login")
if is_setup_required(db):
return RedirectResponse(url="/setup")
return RedirectResponse(url="/login")
@app.get("/setup") @app.get("/setup")
def setup_page(db: Session = Depends(get_db)): 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"))
"""Affiche l'interface d'installation Vue.js."""
if not is_setup_required(db):
return RedirectResponse(url="/login")
# Chemin vers le fichier HTML depuis la racine de l'application Docker (/app)
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)
@app.get("/login") @app.get("/login")
def login_page(db: Session = Depends(get_db)): 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"))
"""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)
@app.post("/api/setup") @app.post("/api/setup")
def create_admin(admin_data: AdminCreate, db: Session = Depends(get_db)): 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=403, detail="Déjà installé.")
if not is_setup_required(db): 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)
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
)
db.add(new_admin) 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.commit()
db.refresh(new_admin) db.refresh(new_admin)
return {"message": "Succès", "totp_secret": new_admin.totp_secret, "totp_uri": get_totp_uri(new_admin.totp_secret, new_admin.username)}
# 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
}
@app.post("/api/login") @app.post("/api/login")
def login(login_data: LoginRequest, response: Response, db: Session = Depends(get_db)): 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() user = db.query(User).filter(User.username == login_data.username).first()
if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Identifiants incorrects.")
# Vérification de l'utilisateur et du mot de passe if not verify_totp(user.totp_secret, login_data.totp_code): raise HTTPException(status_code=401, detail="2FA invalide.")
if not user or not verify_password(login_data.password, user.hashed_password): response.set_cookie(key="session_token", value=create_access_token(data={"sub": user.username}), httponly=True, max_age=604800, samesite="lax")
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"
)
return {"message": "Connexion réussie"} return {"message": "Connexion réussie"}
@app.get("/dashboard") @app.get("/dashboard")
def dashboard(request: Request): def dashboard(request: Request):
"""Route protégée : nécessite un cookie de session valide."""
token = request.cookies.get("session_token") token = request.cookies.get("session_token")
if not token: if not token or not verify_token(token): return RedirectResponse(url="/login")
return RedirectResponse(url="/login") return FileResponse(os.path.join(os.getcwd(), "frontend", "dashboard.html"))
payload = verify_token(token) @app.get("/api/logs")
if not payload: def get_logs():
# Si le token a expiré (60 min) ou est invalide, retour au login return {"logs": activity_logs}
return RedirectResponse(url="/login")
username = payload.get("sub") @app.post("/api/logs")
return {"message": f"Bienvenue sur le tableau de bord sécurisé, {username} !"} def add_frontend_log(req: LogRequest):
log_activity(f"[Système UI] {req.message}")
return {"status": "ok"}
@app.post("/api/models/suggest_replacement")
async def suggest_replacement(req: ModelReplacementRequest, db: Session = Depends(get_db)):
log_activity(f"⚠️ Modèle indisponible: {req.missing_model}. Demande de suggestion à Gemini...")
settings = db.query(SystemSettings).first()
if not settings or not settings.gemini_api_key:
log_activity("Clé Gemini non trouvée. Fallback forcé sur gemini-3.5-flash-lite.")
return {"suggestion": "gemini-3.5-flash-lite", "reason": "Clé API Gemini non configurée dans le système."}
models = db.query(AIModel).all()
available = [m.model_id for m in models]
prompt = f"Le modèle IA '{req.missing_model}' n'est plus disponible. Voici les modèles disponibles : {', '.join(available)}. Trouve le modèle le plus proche techniquement. Réponds UNIQUEMENT avec ce format strict : ID_DU_MODELE | Brève explication en français de 10 mots max. Si aucun ne correspond, renvoie gemini-3.5-flash-lite | Par défaut."
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key)
resp = await client.chat.completions.create(model="gemini-3.5-flash-lite", messages=[{"role": "user", "content": prompt}], max_tokens=50)
res = resp.choices[0].message.content.strip()
if "|" in res:
parts = res.split("|")
sugg = parts[0].strip()
reason = parts[1].strip()
else:
sugg = res.strip()
reason = "Sélectionné par Gemini."
if sugg not in available and sugg != "gemini-3.5-flash-lite":
sugg = "gemini-3.5-flash-lite"
reason = "Gemini a suggéré un modèle invalide. Fallback par défaut."
log_activity(f"✅ Remplacement trouvé : {req.missing_model} -> {sugg}")
return {"suggestion": sugg, "reason": reason}
except Exception as e:
log_activity(f"Erreur d'interrogation Gemini: {str(e)}. Fallback par défaut.")
return {"suggestion": "gemini-3.5-flash-lite", "reason": f"Erreur API."}
@app.get("/api/users/me")
def get_me(current_user: User = Depends(get_current_user)):
return {"username": current_user.username, "is_admin": current_user.is_admin, "is_superadmin": current_user.is_superadmin, "avatar_path": current_user.avatar_path}
@app.put("/api/users/me/password")
def change_password(passwords: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
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."}
@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)): 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)):
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})
db_content = message.content
if files_names:
db_content += f"\n\n[Fichiers joints pour analyse : {', '.join(files_names)}]"
db.add(Message(role=message.role, content=db_content, project_id=project_id))
db.commit()
history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
settings = db.query(SystemSettings).first()
conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]}
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).order_by(AIModel.provider.asc(), AIModel.name.asc()).all()
csv_io = io.StringIO()
writer = csv.writer(csv_io, delimiter=',')
writer.writerow(["Provider", "Model ID", "Name", "Domain", "Is Free", "Context Length", "Pricing Prompt", "Pricing Completion", "Description"])
for m in models:
writer.writerow([m.provider, m.model_id, m.name, m.domain, m.is_free, m.context_length, m.pricing_prompt, m.pricing_completion, m.description_fr])
md_content = f"# Extraction des Modèles IA - AETHAS38\n\n**Date d'extraction :** {datetime.now().strftime('%d/%m/%Y à %H:%M:%S')}\n\n"
providers = sorted(list(set(m.provider for m in models)))
for prov in providers:
md_content += f"## Fournisseur : {prov.upper()}\n\n"
prov_models = [m for m in models if m.provider == prov]
for m in prov_models:
price_info = "**GRATUIT**" if m.is_free else f"In: ${m.pricing_prompt:.2f} / Out: ${m.pricing_completion:.2f}"
ctx_info = f"{int(m.context_length/1000)}k"
desc = m.description_fr.replace('\n', ' ') if m.description_fr else ""
md_content += f"- **{m.name or m.model_id}** (`{m.model_id}`)\n"
md_content += f" - *Domaine :* {m.domain}\n"
md_content += f" - *Prix (1M tokens) :* {price_info}\n"
md_content += f" - *Contexte :* {ctx_info}\n"
md_content += f" - *Description :* {desc}\n\n"
zip_io = io.BytesIO()
with zipfile.ZipFile(zip_io, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("models_export.csv", csv_io.getvalue().encode('utf-8'))
zf.writestr(f"{datetime.now().strftime('%Y%m%d')}-extraction-modeles.md", md_content.encode('utf-8'))
zip_io.seek(0)
return Response(
content=zip_io.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=aethas38_models_{datetime.now().strftime('%Y%m%d')}.zip"}
)
+68 -8
View File
@@ -1,16 +1,76 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Float
from sqlalchemy.orm import relationship
from datetime import datetime, timezone from datetime import datetime, timezone
from .database import Base from .database import Base
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False) email = Column(String, unique=True, index=True)
username = Column(String, unique=True, index=True, nullable=False) username = Column(String, unique=True, index=True)
hashed_password = Column(String, nullable=False) hashed_password = Column(String)
totp_secret = Column(String, nullable=True) # Secret pour Keepassium/Authenticator totp_secret = Column(String)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False)
is_active = Column(Boolean, default=True) 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, index=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
last_activity = Column(DateTime, nullable=True) # Servira pour la déconnexion après 60 min is_pinned = Column(Boolean, default=False)
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)
content = Column(String)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
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))
+317
View File
@@ -0,0 +1,317 @@
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
# --- LOGGER GLOBAL POUR LE TERMINAL ---
activity_logs = []
def log_activity(msg: str):
ts = datetime.now(timezone.utc).strftime('%H:%M:%S')
activity_logs.append(f"[{ts}] {msg}")
if len(activity_logs) > 100:
activity_logs.pop(0)
# --------------------------------------
def determine_domain(model_id: str) -> str:
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"):
log_activity(f"Lancement de la synchronisation des modèles ({sync_type})...")
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)
log_activity(f"Synchronisation terminée : {added} modèles analysés.")
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"}
kwargs["extra_body"] = {"plugins": [{"id": "context-compression"}]}
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"
log_activity(f"Démarrage de l'orchestration. Modèle Prompteur: {p_mod}")
p_client, p_prov = get_client_for_model(db, p_mod, settings)
# --- WORKFLOW MAP-REDUCE : PRÉ-TRAITEMENT SÉQUENTIEL & CHUNKING ---
files_context = ""
if extracted_files:
log_activity(f"Traitement Map-Reduce de {len(extracted_files)} fichier(s) joint(s).")
async def process_single_file(f):
file_sys = "You are an expert data analyst and senior developer. Extract the most important technical information from the file without losing critical code syntax."
content = f['content']
chunk_size = 150000
if len(content) > chunk_size:
chunks = [content[i:i+chunk_size] for i in range(0, len(content), chunk_size)]
chunk_analyses = []
log_activity(f"Fichier lourd ({f['name']}): Chunking en {len(chunks)} morceaux.")
for idx, chunk in enumerate(chunks):
file_prompt = f"Demande de l'utilisateur : '{original_user_text}'.\n\nPartie {idx+1}/{len(chunks)} du fichier '{f['name']}'. Analysez, extrayez et résumez le code, VBA, SQL ou les données pertinentes.\n\nContenu :\n```\n{chunk}\n```"
try:
analysis = await ask_agent(p_client, p_mod, [{"role": "system", "content": file_sys}, {"role": "user", "content": file_prompt}], p_prov)
chunk_analyses.append(analysis)
log_activity(f"Analyse chunk {idx+1}/{len(chunks)} pour {f['name']} réussie.")
except Exception as e:
chunk_analyses.append(f"[Erreur sur la partie {idx+1}: {str(e)}]")
log_activity(f"Erreur chunk {idx+1}/{len(chunks)} pour {f['name']}: {str(e)}")
await asyncio.sleep(1.5)
return f"\n\n--- Extraction du fichier {f['name']} (en {len(chunks)} parties) ---\n" + "\n".join(chunk_analyses)
else:
file_prompt = f"Demande de l'utilisateur : '{original_user_text}'.\n\nAnalysez le fichier ci-dessous. Extrayez, résumez et conservez méticuleusement tout le code, les macros VBA, les requêtes SQL, ou les données métier pertinentes pour répondre à la demande.\n\nFichier : {f['name']}\nContenu :\n```\n{content}\n```"
try:
analysis = await ask_agent(p_client, p_mod, [{"role": "system", "content": file_sys}, {"role": "user", "content": file_prompt}], p_prov)
log_activity(f"Analyse intégrale de {f['name']} réussie.")
return f"\n\n--- Extraction du fichier {f['name']} ---\n{analysis}"
except Exception as e:
log_activity(f"Erreur d'analyse sur {f['name']}: {str(e)}")
return f"\n\n--- Erreur sur {f['name']} ---\n{str(e)}"
file_analyses = []
for f in extracted_files:
analysis = await process_single_file(f)
file_analyses.append(analysis)
await asyncio.sleep(1.5)
files_context = "".join(file_analyses)
user_prompt = f"{original_user_text}\n\nVoici les données pré-traitées des fichiers joints :\n{files_context}"
# --- OPTIMISATION & TRADUCTION ---
log_activity(f"Optimisation/Traduction de la requête via Prompteur...")
prompt_system = "You are an expert prompt engineer. Translate and optimize the user request and any file context into clear, precise English tailored for AI execution. Keep all code blocks intact."
optimized = await ask_agent(p_client, p_mod, [{"role": "system", "content": prompt_system}, {"role": "user", "content": user_prompt}], p_prov)
log_activity(f"Lancement de {len(workers)} travailleur(s) en parallèle...")
if len(workers) == 1:
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]
log_activity(f"Travailleur 1 ({w_mod}) a terminé.")
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)
log_activity("Tous les travailleurs ont terminé leur analyse.")
c_mod = config.get("concatenator")
if not c_mod: c_mod = "gemini-3.5-flash-lite"
c_client, c_prov = get_client_for_model(db, c_mod, settings)
log_activity(f"Synthèse et traduction finale via Concaténeur ({c_mod})...")
concat_system = (
"You are a master lead developer and technical synthesizer. "
"Synthesize the provided expert responses into a single cohesive response. "
"Translate all explanatory text, descriptions, and user-facing prose into natural French. "
"CRITICAL: Do NOT translate code blocks, programming keywords, or source code contents. "
"You may translate code comments into French if appropriate, but leave code syntax strictly intact."
)
synth = f"User Request: {original_user_text}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{str(r)}" for i, r in enumerate(responses)])
final_response = await ask_agent(c_client, c_mod, [{"role": "system", "content": concat_system}, {"role": "user", "content": synth}], c_prov)
log_activity("Orchestration terminée avec succès.")
except Exception as e:
final_response = f"L'IA a rencontré une erreur critique: {str(e)}"
log_activity(f"ERREUR CRITIQUE: {str(e)}")
await sync_finances(db, settings)
return final_response
+62 -1
View File
@@ -1,12 +1,73 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional, List
class AdminCreate(BaseModel): class AdminCreate(BaseModel):
email: EmailStr email: EmailStr
username: str username: str
password: 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
# NOUVEAU : Schéma pour la connexion
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
totp_code: str totp_code: str
class ProjectBase(BaseModel):
title: str
class ProjectCreate(ProjectBase): pass
class ProjectResponse(ProjectBase):
id: int
created_at: datetime
is_pinned: bool
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
class ModelReplacementRequest(BaseModel):
missing_model: str
class LogRequest(BaseModel):
message: str
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+888
View File
@@ -0,0 +1,888 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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' } } } } }
</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; }
.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; }
.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">
<!-- 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="/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>
</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="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-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>
<!-- TERMINAL SYSTEME -->
<div class="h-40 border-t border-gray-700 bg-black flex flex-col font-mono text-[9px] flex-shrink-0 shadow-inner">
<div class="p-1.5 bg-gray-900 border-b border-gray-700 text-aethas-cyan font-bold flex items-center justify-between">
<div class="flex items-center gap-1"><i data-lucide="terminal" class="w-3 h-3"></i> SYSTEM LOGS</div>
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
</div>
<div class="flex-1 overflow-y-auto p-2 custom-scrollbar text-green-400 space-y-1" id="terminal-logs">
<div v-for="(log, i) in systemLogs" :key="i" class="break-words">{{ log }}</div>
<div v-if="systemLogs.length === 0" class="text-gray-600">En attente d'activité serveur...</div>
</div>
</div>
<!-- ENCART FINANCIER -->
<div class="px-4 pb-2 pt-2 border-t border-gray-700 bg-aethas-panel flex-shrink-0">
<div v-if="financesList.length > 0" class="flex flex-col gap-1.5 mb-2">
<div v-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 border-t border-gray-700 flex-shrink-0" @click="openSettings">
<div class="flex items-center gap-2">
<img v-if="user.avatar_path" :src="user.avatar_path" class="w-8 h-8 rounded-full object-cover border border-gray-600 bg-gray-900">
<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>
</div>
<i data-lucide="settings" class="w-5 h-5 text-gray-400"></i>
</div>
</aside>
</div>
<!-- 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 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>
<!-- 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 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>
</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 (ZIP CSV/MD)</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="filterProvider" class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none">
<option value="">Tous les fournisseurs</option>
<option v-for="p in uniqueProviders" :key="p" :value="p">{{ p.toUpperCase() }}</option>
</select>
<select v-model="filterDomain" class="bg-gray-900 border border-gray-600 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none">
<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>
<!-- BOUTON D'EXPORT CSV -->
<button @click="exportCSV" class="bg-aethas-panel border border-gray-600 hover:border-aethas-cyan text-gray-300 hover:text-white px-3 py-1.5 rounded flex items-center gap-2 text-sm transition-colors md:ml-auto">
<i data-lucide="download" class="w-4 h-4 text-green-500"></i> Export CSV
</button>
</div>
<div 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>
<!-- Boutons de copie et téléchargement individuel -->
<div class="mt-3 flex items-center gap-3 border-t border-gray-700/50 pt-2 opacity-50 hover:opacity-100 transition-opacity">
<button @click="copyToClipboard(msg.content)" class="text-xs text-gray-400 hover:text-aethas-cyan flex items-center gap-1"><i data-lucide="copy" class="w-3 h-3"></i> Copier</button>
<button @click="downloadMessage(msg.content, msg.created_at || new Date().toISOString())" class="text-xs text-gray-400 hover:text-aethas-cyan flex items-center gap-1"><i data-lucide="file-down" class="w-3 h-3"></i> Extraire</button>
</div>
</div>
<div v-if="isAiThinking" class="max-w-3xl rounded-2xl p-4 bg-aethas-panel mr-auto border border-aethas-cyan/30 w-24">
<div class="flex space-x-1.5 h-4 items-center">
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.15s"></div>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.3s"></div>
</div>
</div>
</div>
<div class="p-4 md:px-6 md:pt-6 pb-2 w-full max-w-7xl mx-auto flex-shrink-0">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Configuration des IA</span>
<div class="flex gap-2">
<button @click="downloadTemplate" class="text-xs bg-gray-800 hover:bg-gray-700 text-aethas-cyan border border-gray-600 px-2 py-1 rounded flex items-center gap-1 transition-colors" title="Télécharger le modèle .md"><i data-lucide="download" class="w-3 h-3"></i> Template MD</button>
<label class="text-xs bg-gray-800 hover:bg-gray-700 text-aethas-cyan border border-gray-600 px-2 py-1 rounded flex items-center gap-1 transition-colors cursor-pointer" title="Importer une configuration .md">
<i data-lucide="upload" class="w-3 h-3"></i> Importer
<input type="file" accept=".md" @change="handleConfigImport" class="hidden">
</label>
</div>
</div>
<!-- BARRE DE SÉLECTION -->
<div class="grid grid-cols-9 border border-black bg-gray-900 rounded-lg overflow-hidden text-center text-xs font-bold shadow-lg mb-4">
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-gray-400 mb-1">PRIX</label>
<select v-model="colPricing" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none">
<option value="">TOUS</option>
<option value="free">GRATUIT</option>
<option value="paid">PAYANT</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-gray-400 mb-1">TYPE</label>
<select v-model="colDomain" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none">
<option value="">TOUS</option>
<option value="Texte Polyvalent">Texte</option>
<option value="Code & Logique">Codage</option>
<option value="Vision & Texte">Vision</option>
<option value="Audio">Audio</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 1</label>
<select v-model="workers[0]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[0])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 2</label>
<select v-model="workers[1]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[1])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 3</label>
<select v-model="workers[2]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[2])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 4</label>
<select v-model="workers[3]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[3])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-cyan mb-1">TRAVAILLEUR 5</label>
<select v-model="workers[4]" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Aucun)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, workers[4])" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-gray-400 mb-1">PROMPTEUR</label>
<select v-model="selectedPrompter" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Par défaut)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, selectedPrompter)" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
<div class="p-1.5 bg-gray-800 flex flex-col justify-center">
<label class="block text-[9px] text-aethas-purple mb-1">CONCATENEUR</label>
<select v-model="selectedConcatenator" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none truncate">
<option value="">(Par défaut)</option>
<option v-for="m in getFilteredModelsForCol(colPricing, colDomain, selectedConcatenator)" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</option>
</select>
</div>
</div>
<!-- CHAT INPUT -->
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan flex-col gap-2">
<div v-if="attachedFiles.length > 0" class="w-full flex flex-wrap gap-2 px-2 pt-2">
<div v-for="(file, index) in attachedFiles" :key="index" class="bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 flex items-center gap-2">
<i data-lucide="file" class="w-3.5 h-3.5 text-aethas-cyan"></i>
<span class="truncate max-w-[150px]">{{ file.name }}</span>
<button @click="removeFile(index)" class="text-red-400 hover:text-red-300"><i data-lucide="x" class="w-3 h-3"></i></button>
</div>
</div>
<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, reactive, onMounted, nextTick, computed, watch } = Vue;
createApp({
setup() {
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 passForm = reactive({ old: '', new: '', confirm: '' });
const settingsMessage = ref("");
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 filterProvider = ref("");
const colPricing = ref("");
const colDomain = ref("");
const workers = ref(["", "", "", "", ""]);
const selectedPrompter = ref("");
const selectedConcatenator = ref("");
const attachedFiles = ref([]);
const systemLogs = ref([]);
let logInterval = null;
const pinnedProjects = computed(() => projects.value.filter(p => p.is_pinned));
const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned));
const uniqueProviders = computed(() => {
return [...new Set(modelInfo.models.map(m => m.provider))].sort();
});
const filteredModels = computed(() => {
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;
const matchProvider = filterProvider.value === "" || m.provider === filterProvider.value;
let matchPrice = true;
if (filterPricing.value === 'free') matchPrice = m.is_free;
if (filterPricing.value === 'paid') matchPrice = !m.is_free;
return matchSearch && matchDomain && matchProvider && matchPrice;
});
});
const getFilteredModelsForCol = (pricingFilter, domainFilter, currentSelectedId) => {
let filtered = modelInfo.models.filter(m => {
const matchDomain = domainFilter === "" || m.domain === domainFilter;
let matchPrice = true;
if (pricingFilter === 'free') matchPrice = m.is_free;
if (pricingFilter === 'paid') matchPrice = !m.is_free;
return matchDomain && matchPrice;
});
if (currentSelectedId && currentSelectedId.trim() !== "") {
const exists = filtered.find(m => m.model_id === currentSelectedId);
if (!exists) {
const original = modelInfo.models.find(m => m.model_id === currentSelectedId);
if (original) {
filtered.unshift(original);
} else {
filtered.unshift({ model_id: currentSelectedId, name: currentSelectedId, domain: "Importé", pricing_prompt: 0, pricing_completion: 0 });
}
}
}
return filtered;
};
const formatDate = (isoString) => {
if (!isoString) return "Jamais";
const d = new Date(isoString);
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + " à " + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
};
const toggleSidebar = () => { isSidebarOpen.value = !isSidebarOpen.value; };
const autoResize = () => { const el = promptTextarea.value; if(el){ el.style.height = 'auto'; el.style.height = el.scrollHeight + 'px'; }};
const scrollToBottom = () => { nextTick(() => { const c = document.getElementById('chat-container'); if (c) c.scrollTop = c.scrollHeight; }); };
const openSettings = () => {
activeView.value = 'settings';
if(window.innerWidth < 768) isSidebarOpen.value = false;
nextTick(() => lucide.createIcons());
};
const logToTerminal = async (msg) => {
try { await fetch('/api/logs', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ message: msg }) }); } catch(e){}
};
const fetchLogs = async () => {
try {
const res = await fetch('/api/logs');
if (res.ok) {
const data = await res.json();
if (systemLogs.value.length !== data.logs.length || data.logs[data.logs.length-1] !== systemLogs.value[systemLogs.value.length-1]) {
systemLogs.value = data.logs;
nextTick(() => {
const el = document.getElementById('terminal-logs');
if (el) el.scrollTop = el.scrollHeight;
});
}
}
} catch(e){}
};
const validateConfigAndReplace = async () => {
if (!modelInfo.models || modelInfo.models.length === 0) return;
const availableModels = modelInfo.models.map(m => m.model_id);
const replacements = [];
const checkAndReplace = async (val, setterCallback, roleName) => {
if (val && val.trim() !== "" && !availableModels.includes(val)) {
logToTerminal(`Le modèle ${val} n'est plus reconnu. Recherche Gemini...`);
try {
const res = await fetch('/api/models/suggest_replacement', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ missing_model: val })
});
if (res.ok) {
const data = await res.json();
setterCallback(data.suggestion);
replacements.push(`- ${roleName} : ${val} a été remplacé par ${data.suggestion}\n Raison: ${data.reason}`);
} else {
setterCallback("gemini-3.5-flash-lite");
replacements.push(`- ${roleName} : ${val} remplacé par gemini-3.5-flash-lite (Erreur API)`);
}
} catch(e) {
setterCallback("gemini-3.5-flash-lite");
}
}
};
await checkAndReplace(selectedPrompter.value, (n) => selectedPrompter.value = n, "Prompteur");
await checkAndReplace(selectedConcatenator.value, (n) => selectedConcatenator.value = n, "Concaténeur");
for(let i=0; i<5; i++) {
await checkAndReplace(workers.value[i], (n) => workers.value[i] = n, `Travailleur ${i+1}`);
}
if (replacements.length > 0) {
alert("⚠️ Mise à jour de la conversation\n\nCertains modèles ne sont plus disponibles. L'IA a trouvé des alternatives :\n\n" + replacements.join("\n\n"));
}
};
watch([workers, selectedPrompter, selectedConcatenator], () => {
if (activeProject.value) {
const config = {
workers: workers.value,
prompter: selectedPrompter.value,
concatenator: selectedConcatenator.value
};
localStorage.setItem('aethas_config_' + activeProject.value.id, JSON.stringify(config));
}
}, { deep: true });
const fetchUser = async () => { try { const res = await fetch('/api/users/me'); if(res.ok) user.value = await res.json(); } catch(e){} };
const 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;
logToTerminal("Déclenchement manuel de l'extraction des modèles...");
try {
const res = await fetch('/api/models/sync', { method: 'POST' });
if(res.ok) {
await fetchModelsInfo();
await fetchFinances();
alert("Synchronisation réussie !");
} else if (res.status === 401) {
alert("Votre session a expiré."); window.location.href = '/login';
} 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 = () => {
logToTerminal("Téléchargement du template MD généré.");
const templateContent = `# AETHAS38 - Configuration Multi-Agents\n\n**Rappel important :**\n- **Prompteur :** Le système de l'orchestrateur utilisera ce modèle pour traduire et optimiser votre requête initiale en ANGLAIS (langue native des IA).\n- **Concaténeur :** Le système utilisera ce modèle pour regrouper les réponses des travailleurs et traduire la synthèse finale en FRANÇAIS (en préservant le code brut).\n\n## Sélection des Modèles\nRenseignez l'ID exact du modèle (ex: gemini-3.5-flash-lite, mistralai/mistral-large-4-0). Laissez vide si inutilisé.\n\n- Prompteur : \n- Travailleur 1 : \n- Travailleur 2 : \n- Travailleur 3 : \n- Travailleur 4 : \n- Travailleur 5 : \n- Concaténeur : \n`;
downloadFile(templateContent, 'aethas38-config-agents.md');
};
const exportCSV = () => {
logToTerminal("Génération de l'extraction CSV (vue filtrée)...");
let csvContent = "\uFEFF";
csvContent += "Fournisseur,Modèle,Nom,Domaine,Gratuit,Contexte,Prix_In,Prix_Out,Description\n";
filteredModels.value.forEach(m => {
const escapeCSV = (str) => {
if (str === null || str === undefined) return '""';
return `"${String(str).replace(/"/g, '""')}"`;
};
const row = [
escapeCSV(m.provider),
escapeCSV(m.model_id),
escapeCSV(m.name),
escapeCSV(m.domain),
m.is_free ? "Oui" : "Non",
m.context_length,
m.pricing_prompt,
m.pricing_completion,
escapeCSV(m.description_fr)
];
csvContent += row.join(",") + "\n";
});
const d = new Date();
const dateStr = d.getFullYear() + String(d.getMonth()+1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
downloadFile(csvContent, `${dateStr}-extraction-modeles.csv`, 'text/csv;charset=utf-8');
};
const handleConfigImport = (event) => {
const file = event.target.files[0];
if (!file) return;
logToTerminal(`Importation de configuration depuis ${file.name}`);
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target.result;
const extractModel = (key) => {
const safeKey = key.replace(/[éèe]/gi, '[éèe]');
const regex = new RegExp(`-\\s*${safeKey}\\s*:\\s*([^\\r\\n]*)`, 'i');
const match = content.match(regex);
return match && match[1] ? match[1].trim() : "";
};
const w1 = extractModel('Travailleur 1');
const w2 = extractModel('Travailleur 2');
const w3 = extractModel('Travailleur 3');
const w4 = extractModel('Travailleur 4');
const w5 = extractModel('Travailleur 5');
workers.value = [w1, w2, w3, w4, w5];
selectedPrompter.value = extractModel('Prompteur');
selectedConcatenator.value = extractModel('Concateneur');
await validateConfigAndReplace();
alert("Configuration IA importée et appliquée avec succès !");
};
reader.readAsText(file);
event.target.value = null;
};
const uploadAvatar = async (event) => {
const 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) => {
logToTerminal(`Fichier ${file.name} ajouté en pièce jointe.`);
const reader = new FileReader();
const textExts = ['.txt', '.md', '.csv', '.json', '.py', '.js', '.html', '.css', '.xml', '.yml', '.yaml', '.sh', '.bat', '.ps1', '.ini', '.cfg', '.conf', '.log'];
const isText = textExts.some(ext => file.name.toLowerCase().endsWith(ext)) || file.type.startsWith('text/');
reader.onload = (e) => {
attachedFiles.value.push({ name: file.name, content: e.target.result });
};
if (isText) {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
};
const removeFile = (index) => { attachedFiles.value.splice(index, 1); };
const exportProject = (format) => { if(activeProject.value) window.open(`/api/projects/${activeProject.value.id}/export?format=${format}`, '_blank'); };
const copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text); alert("Copié !"); } catch (e) {} };
const downloadFile = (content, hint, mimeType = 'text/plain;charset=utf-8') => {
let fname = hint ? hint.trim() : 'code.txt';
if (!fname.includes('.')) fname += '.txt';
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fname;
a.click();
URL.revokeObjectURL(url);
};
const downloadMessage = (content, dateString) => { downloadFile(content, `message_${dateString.replace(/[:T.Z]/g, '-')}.txt`); };
const 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 = [];
logToTerminal(`Chargement du projet: ${project.title}`);
const saved = localStorage.getItem('aethas_config_' + project.id);
if (saved) {
try {
const config = JSON.parse(saved);
workers.value = config.workers || ["", "", "", "", ""];
selectedPrompter.value = config.prompter || "";
selectedConcatenator.value = config.concatenator || "";
} catch(e) {
workers.value = ["", "", "", "", ""];
selectedPrompter.value = "";
selectedConcatenator.value = "";
}
} else {
workers.value = ["", "", "", "", ""];
selectedPrompter.value = "";
selectedConcatenator.value = "";
}
try {
const res = await fetch(`/api/projects/${project.id}/messages`);
if(res.ok) {
messages.value = await res.json();
scrollToBottom();
nextTick(()=>lucide.createIcons());
} else if (res.status === 401) {
alert("Votre session a expiré."); window.location.href = '/login';
}
} catch(e){}
await validateConfigAndReplace();
};
const createNewProject = async () => {
activeView.value = 'chat'; isLoading.value = true;
try {
const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({title: "Nouvelle discussion"})});
if(res.ok){ const np = await res.json(); projects.value.unshift(np); selectProject(np); } else if (res.status === 401) { alert("Session expirée."); window.location.href='/login'; }
} catch(e){} finally{isLoading.value = false;}
};
const sendMessage = async () => {
const 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 = [];
logToTerminal("Envoi de la requête à l'orchestrateur...");
try {
const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) messages.value = data;
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) {
alert("Erreur locale lors de l'envoi : " + error.message);
} finally {
isAiThinking.value = false;
scrollToBottom();
nextTick(()=>lucide.createIcons());
}
};
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; }
};
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().then(() => {
if(activeProject.value) validateConfigAndReplace();
});
fetchFinances();
logInterval = setInterval(fetchLogs, 2000);
});
return {
user, activeView, settingsTab, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea,
projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking,
createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject,
openSettings, passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar,
modelInfo, financesList, filterSearch, filterDomain, filterPricing, filterProvider, uniqueProviders, filteredModels, getFilteredModelsForCol, formatDate, syncModels, isSyncing,
workers, selectedPrompter, selectedConcatenator, colPricing, colDomain, downloadTemplate, exportCSV, handleConfigImport,
attachedFiles, handleFileUpload, handlePaste, removeFile,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage, systemLogs
};
}
}).mount('#app');
</script>
</body>
</html>
+147 -43
View File
@@ -3,62 +3,167 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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 src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
darkMode: 'class',
theme: {
extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } }
}
}
</script> </script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script> <script src="https://unpkg.com/lucide@latest"></script>
<style> <style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; } 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; } .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> </style>
</head> </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"> <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> <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>
<div class="glass-panel rounded-2xl p-6 sm:p-8"> <div class="glass-panel rounded-2xl p-6 sm:p-8">
<form v-if="!setupComplete" @submit.prevent="submitSetup" class="space-y-8">
<form v-if="!setupComplete" @submit.prevent="submitSetup" 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>
<!-- Section 1 : Compte Admin -->
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-1">Email Administrateur</label> <h2 class="text-lg font-semibold text-white border-b border-gray-700 pb-2 mb-4 flex items-center gap-2">
<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"> <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 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> </div>
<!-- Section 2 : Serveur Mail -->
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-1">Nom d'utilisateur</label> <div class="flex items-center justify-between border-b border-gray-700 pb-2 mb-4">
<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"> <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 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>
<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> </div>
<!-- Section 3 : Clés API IA -->
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-1">Mot de passe</label> <h2 class="text-lg font-semibold text-white border-b border-gray-700 pb-2 mb-4 flex items-center gap-2">
<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="••••••••"> <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> </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> <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-else>Initialiser le système</span> <span v-if="isSubmitting">Initialisation et Chiffrement...</span>
<span v-else>Terminer l'installation</span>
</button> </button>
</form> </form>
<div v-else class="text-center space-y-6 animate-fade-in"> <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"> <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> </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>
<!-- CORRECTION ICI : Utilisation d'une balise <img> avec rendu direct -->
<div class="bg-white p-4 rounded-xl inline-block mx-auto min-w-[200px] min-h-[200px] flex items-center justify-center"> <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"> <img v-if="qrCodeUrl" :src="qrCodeUrl" alt="QR Code 2FA" class="w-full h-full object-contain">
<span v-else class="text-gray-500 text-sm">Génération...</span> <span v-else class="text-gray-500 text-sm">Génération...</span>
@@ -66,7 +171,7 @@
<div class="text-sm text-gray-300 space-y-2"> <div class="text-sm text-gray-300 space-y-2">
<p>Scannez ce QR Code avec votre application 2FA (Keepassium, Google Authenticator...).</p> <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> </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"> <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">
@@ -77,16 +182,25 @@
</div> </div>
<script> <script>
const { createApp, ref, reactive } = Vue; const { createApp, ref, reactive, onMounted } = Vue;
createApp({ createApp({
setup() { 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 isSubmitting = ref(false);
const setupComplete = ref(false); const setupComplete = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const totpSecret = ref(''); const totpSecret = ref('');
const qrCodeUrl = ref(''); const qrCodeUrl = ref('');
const showWiki = ref(false);
onMounted(() => { lucide.createIcons(); });
const submitSetup = async () => { const submitSetup = async () => {
isSubmitting.value = true; isSubmitting.value = true;
@@ -98,31 +212,21 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form) body: JSON.stringify(form)
}); });
const data = await response.json(); 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; totpSecret.value = data.totp_secret;
setupComplete.value = true; setupComplete.value = true;
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.totp_uri)}&color=0f172a&bgcolor=ffffff`;
// CORRECTION ICI : Génération d'une URL d'image (Data URL) au lieu du Canvas setTimeout(() => lucide.createIcons(), 100);
qrCodeUrl.value = await QRCode.toDataURL(data.totp_uri, {
width: 200,
margin: 1,
color: { dark: "#0f172a", light: "#ffffff" }
});
} catch (error) { } catch (error) {
errorMessage.value = error.message; errorMessage.value = error.message;
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
}; };
return { form, isSubmitting, setupComplete, errorMessage, totpSecret, qrCodeUrl, showWiki, submitSetup };
return { form, isSubmitting, setupComplete, errorMessage, totpSecret, qrCodeUrl, submitSetup };
} }
}).mount('#app'); }).mount('#app');
</script> </script>
+23 -33
View File
@@ -6,26 +6,12 @@
<title>AETHAS38 - Connexion</title> <title>AETHAS38 - Connexion</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
darkMode: 'class',
theme: {
extend: {
colors: {
aethas: {
dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7'
}
}
}
}
}
</script> </script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style> <style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; } body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.glass-panel { .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); }
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; } .neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
</style> </style>
</head> </head>
@@ -33,16 +19,14 @@
<div id="app" class="w-full max-w-md transition-all duration-500"> <div id="app" class="w-full max-w-md transition-all duration-500">
<div class="text-center mb-8"> <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> <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> <p class="text-gray-400 text-sm mt-2">Authentification Sécurisée</p>
</div> </div>
<div class="glass-panel rounded-2xl p-6 sm:p-8"> <div class="glass-panel rounded-2xl p-6 sm:p-8">
<form @submit.prevent="submitLogin" class="space-y-5"> <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"> <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>
{{ errorMessage }}
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-1">Nom d'utilisateur</label> <label class="block text-sm font-medium text-gray-300 mb-1">Nom d'utilisateur</label>
@@ -56,11 +40,11 @@
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-1">Code 2FA (6 chiffres)</label> <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> </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"> <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="isSubmitting">Vérification...</span> <span v-if="isLoading">Connexion...</span>
<span v-else>Se connecter</span> <span v-else>Se connecter</span>
</button> </button>
</form> </form>
@@ -68,37 +52,43 @@
</div> </div>
<script> <script>
const { createApp, reactive, ref } = Vue; const { createApp, ref, reactive } = Vue;
createApp({ createApp({
setup() { setup() {
const form = reactive({ username: '', password: '', totp_code: '' }); const form = reactive({ username: '', password: '', totp_code: '' });
const isSubmitting = ref(false); const isLoading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const submitLogin = async () => { const submitLogin = async () => {
isSubmitting.value = true; isLoading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
const response = await fetch('/api/login', { const response = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form) 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é const data = await response.json();
window.location.href = "/dashboard";
if (!response.ok) {
throw new Error(data.detail || "Erreur de connexion");
}
window.location.href = '/dashboard';
} catch (error) { } catch (error) {
errorMessage.value = error.message; errorMessage.value = error.message;
} finally { } finally {
isSubmitting.value = false; isLoading.value = false;
} }
}; };
return { form, isSubmitting, errorMessage, submitLogin };
return { form, isLoading, errorMessage, submitLogin };
} }
}).mount('#app'); }).mount('#app');
</script> </script>
</body> </body>
</html> </html>
+7
View File
@@ -11,3 +11,10 @@ python-multipart
bcrypt bcrypt
email-validator email-validator
PyJWT PyJWT
openai
httpx
python-multipart
pytz
PyPDF2
openpyxl
oletools