Compare commits

..
3 Commits
Author SHA1 Message Date
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
4 changed files with 225 additions and 73 deletions

No files matched your search

+29 -19
View File
@@ -22,29 +22,39 @@ Le développement suit un versioning strict (format `vX.Y.Z`) synchronisé avec
### Sécurité & Infrastructure ### Sécurité & Infrastructure
- [x] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus). - [x] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus).
- [x] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID. - [x] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID.
- [x] Mots de passe cryptés (bcrypt), protection anti-brute force et anti-DDOS (à compléter pour le DDOS). - [x] Mots de passe cryptés (bcrypt), protection anti-brute force.
- [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.) sans accès SSH pour les utilisateurs. - [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.).
- [x] Déconnexion automatique (timer invisible / session JWT) après 60 minutes d'inactivité. - [x] Déconnexion automatique (timer invisible / session JWT) après 60 minutes d'inactivité.
- [x] Configuration de GitHub Actions / Gitea Actions pour la création automatique des paquets Docker (Tags SemVer + latest).
- [x] Horodatage UTC en base de données pour immunité au changement d'heure français (Prévention crash 2FA).
- [ ] Procédure de réinitialisation/modification du mot de passe (Admin et Utilisateurs). - [ ] Procédure de réinitialisation/modification du mot de passe (Admin et Utilisateurs).
- [x] Configuration de GitHub Actions / Gitea Actions pour la création automatique des paquets Docker.
### 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).
- [x] Assistant de première installation (création du compte admin complet), puis auto-destruction / blocage du script. - [ ] Refonte du setup initial (Super Admin) : Obligation de configurer le serveur mail (aide opérateurs FR/Gmail) et proposition de saisie des clés API.
- [ ] Système d'invitation par mail : création de compte avec génération de QR Code 2FA à la première connexion. - [ ] Intégration de la roue crantée (Paramètres) en bas de la barre latérale.
- [ ] Vérification du budget API toutes les 15 minutes et actualisation après chaque requête. - [ ] Page de configuration Utilisateur : changement d'email (avec validation via lien envoyé) et de mot de passe.
- [ ] Suivi financier détaillé par fournisseur d'IA. - [ ] Page de configuration Admin : ajout/modification de fournisseurs, clés API, et serveur mail en permanence.
- [ ] Hiérarchie des rôles : Un Admin peut proposer l'élévation d'un User en Admin (formulaire soumis à la validation exclusive du Super Admin).
- [ ] Export de l'activité Admin : Fichier Excel avec KPI, graphiques "camembert", et suivi financier par utilisateur.
- [ ] Suivi financier en temps réel et vérification du budget API.
- [ ] Envoi de rapport d'activité automatisé chaque jeudi à 04h00.
### Expérience Utilisateur (UI/UX) ### Expérience Utilisateur (UI/UX)
- [~] Thème moderne (Gemini-like) et 100% compatible smartphones (Setup et Login terminés). - [x] Thème moderne (Gemini-like) et 100% compatible smartphones.
- [ ] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés, avec le dernier utilisé en tête de liste. - [x] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés (Récents).
- [ ] Plus de création automatique de nouveau projet à l'ouverture. - [x] Arrêt de la 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 en local (Setup / Login / Dashboard).
- [x] Intégration permanente du logo AETHAS38 dans l'interface (Setup / Login). - [ ] Indicateur visuel (Loader/Animation) signalant que l'IA AETHAS38 "réfléchit" ou travaille.
- [ ] Fonctionnalité de renommage manuel des discussions/projets.
### Moteur IA & Fonctionnalités d'Export ### Moteur IA, Modèles & Fonctionnalités
- [ ] Récupération dynamique de la liste des modèles directement auprès des fournisseurs par défaut (OpenRouter, Groq, Gemini, DeepSeek, Mistral, Cloudflare, HuggingFace, etc.). - [ ] Pipeline multi-agents : 1 Rédacteur de prompt, jusqu'à 5 Travailleurs, 1 Concaténeur final.
- [ ] Pipeline multi-agents : 1 Rédacteur de prompt, jusqu'à 5 Travailleurs en parallèle, 1 Concaténeur final. - [ ] Extraction automatisée des modèles disponibles chez les fournisseurs (sauvegarde txt horodatée ex: "07/10/2026 à 10h00").
- [ ] Prise en charge de modèles IA pour le traitement de texte, la génération d'images, la synthèse vocale, la musique (calage sur vidéo) et la vidéo. - [ ] Tâche planifiée (CRON) pour la mise à jour des modèles toutes les 12h (le timer se reset si mise à jour manuelle).
- [ ] Téléchargement des prompts, des réponses, et de l'historique complet en PDF, TXT, MD ou JSON. - [ ] Classement, filtrage (Gratuit/Payant) et catégorisation des modèles (Texte, Code, Image, Audio, Vidéo).
- [ ] Bouton dédié pour télécharger les fichiers générés (Images, Vidéos, Excel mis en forme via LibreOffice). - [ ] Upload manuel du fichier d'extraction des modèles avec intégration dans l'interface filtrable.
- [ ] Traduction automatisée des descriptions des modèles via Ollama (local) ou Gemini.
- [ ] Interface d'aide à la décision pour le choix des modèles selon la tâche souhaitée.
- [ ] Mécanisme de fallback / termes génériques pour les appels API (protection contre le dépassement des quotas Gemini).
- [ ] Téléchargement des requêtes, réponses et fichiers générés (Excel, PDF, TXT, Images).
+38 -7
View File
@@ -2,18 +2,14 @@ from fastapi import FastAPI, Depends, HTTPException, status, Request, Response
from fastapi.responses import RedirectResponse, FileResponse from fastapi.responses import RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List
import os import os
# Importation de nos modules locaux # Importation de nos modules locaux
from .database import engine, Base, get_db from .database import engine, Base, get_db
from .models import User from .models import User, Project, Message
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
# MAJ BDD
from typing import List
from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse
from .models import User, Project
# Création des tables dans la base de données # Création des tables dans la base de données
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -175,6 +171,18 @@ def create_project(project: ProjectCreate, db: Session = Depends(get_db), curren
db.refresh(new_project) db.refresh(new_project)
return new_project return new_project
@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)):
"""Renomme un projet existant."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
project.title = project_data.title
db.commit()
db.refresh(project)
return project
@app.put("/api/projects/{project_id}/pin", response_model=ProjectResponse) @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)): def toggle_pin_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Bascule le statut épinglé (is_pinned) d'un projet.""" """Bascule le statut épinglé (is_pinned) d'un projet."""
@@ -197,3 +205,26 @@ def delete_project(project_id: int, db: Session = Depends(get_db), current_user:
db.delete(project) db.delete(project)
db.commit() db.commit()
return {"message": "Projet supprimé"} return {"message": "Projet supprimé"}
# --- GESTION DES MESSAGES ---
@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)):
"""Récupère tous les messages d'un projet spécifique."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
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=MessageResponse)
def create_message(project_id: int, message: MessageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Ajoute un message à un projet."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
new_message = Message(role=message.role, content=message.content, project_id=project_id)
db.add(new_message)
db.commit()
db.refresh(new_message)
return new_message
+19
View File
@@ -26,3 +26,22 @@ class ProjectResponse(ProjectBase):
class Config: class Config:
from_attributes = True from_attributes = True
# --- NOUVEAU : Schémas pour les Messages ---
class MessageBase(BaseModel):
role: str
content: str
class MessageCreate(MessageBase):
pass
class MessageResponse(MessageBase):
id: int
created_at: datetime
project_id: int
class Config:
from_attributes = True
class ProjectRename(BaseModel):
title: str
+139 -47
View File
@@ -8,9 +8,7 @@
<script> <script>
tailwind.config = { tailwind.config = {
darkMode: 'class', darkMode: 'class',
theme: { theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } }
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>
@@ -26,9 +24,9 @@
<body class="h-screen flex overflow-hidden"> <body class="h-screen flex overflow-hidden">
<div id="app" class="flex w-full h-full relative bg-aethas-dark"> <div id="app" class="flex w-full h-full relative bg-aethas-dark">
<div v-if="isSidebarOpen" @click="toggleSidebar" class="md:hidden fixed inset-0 bg-black/60 z-40 transition-opacity"></div> <div v-if="isSidebarOpen" @click="toggleSidebar" class="md:hidden fixed inset-0 bg-black/60 z-40 transition-opacity"></div>
<!-- BARRE LATÉRALE -->
<div :class="['transition-all duration-300 flex-shrink-0 relative z-50', isSidebarOpen ? 'w-72' : 'w-0']"> <div :class="['transition-all duration-300 flex-shrink-0 relative z-50', isSidebarOpen ? 'w-72' : 'w-0']">
<aside :class="['absolute top-0 left-0 h-full w-72 bg-aethas-panel border-r border-gray-700 flex flex-col transition-transform duration-300', isSidebarOpen ? 'translate-x-0' : '-translate-x-full']"> <aside :class="['absolute top-0 left-0 h-full w-72 bg-aethas-panel border-r border-gray-700 flex flex-col transition-transform duration-300', isSidebarOpen ? 'translate-x-0' : '-translate-x-full']">
<div class="p-4 border-b border-gray-700 flex items-center justify-between"> <div class="p-4 border-b border-gray-700 flex items-center justify-between">
@@ -36,15 +34,12 @@
<img src="/static/logo.png" alt="Logo" class="w-10 h-10 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0"> <img src="/static/logo.png" alt="Logo" class="w-10 h-10 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<span class="font-bold text-lg tracking-wider neon-text">AETHAS38</span> <span class="font-bold text-lg tracking-wider neon-text">AETHAS38</span>
</div> </div>
<button @click="toggleSidebar" class="md:hidden text-gray-400 hover:text-white"> <button @click="toggleSidebar" class="md:hidden text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div> </div>
<div class="p-4"> <div class="p-4">
<button @click="createNewProject" :disabled="isLoading" class="w-full bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg py-2.5 px-4 flex items-center justify-center gap-2 transition-colors disabled:opacity-50 shadow-md"> <button @click="createNewProject" :disabled="isLoading" class="w-full bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg py-2.5 px-4 flex items-center justify-center gap-2 transition-colors disabled:opacity-50 shadow-md">
<i data-lucide="plus" class="w-5 h-5 text-aethas-cyan"></i> <i data-lucide="plus" class="w-5 h-5 text-aethas-cyan"></i> Nouveau Projet IA
Nouveau Projet IA
</button> </button>
</div> </div>
@@ -53,12 +48,13 @@
<div v-if="pinnedProjects.length > 0"> <div v-if="pinnedProjects.length > 0">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Épinglés</h3> <h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Épinglés</h3>
<ul class="space-y-1"> <ul class="space-y-1">
<li v-for="project in pinnedProjects" :key="project.id" class="group flex items-center justify-between p-2 hover:bg-gray-800 rounded cursor-pointer text-sm text-gray-300 transition-colors"> <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 transition-colors', 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"> <div class="flex items-center gap-2 truncate">
<i data-lucide="pin" class="w-4 h-4 text-aethas-cyan fill-aethas-cyan flex-shrink-0"></i> <i data-lucide="pin" class="w-4 h-4 text-aethas-cyan fill-aethas-cyan flex-shrink-0"></i>
<span class="truncate">{{ project.title }}</span> <span class="truncate">{{ project.title }}</span>
</div> </div>
<div class="hidden group-hover:flex items-center gap-1 flex-shrink-0"> <div class="hidden group-hover:flex items-center gap-1 flex-shrink-0 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan p-1 rounded hover:bg-gray-700" title="Renommer"><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 p-1 rounded hover:bg-gray-700" title="Désépingler"><i data-lucide="pin-off" class="w-3.5 h-3.5"></i></button> <button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700" title="Désépingler"><i data-lucide="pin-off" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button> <button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
</div> </div>
@@ -70,13 +66,14 @@
<div> <div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Récents</h3> <h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Récents</h3>
<ul class="space-y-1"> <ul class="space-y-1">
<li v-if="recentProjects.length === 0 && pinnedProjects.length === 0" class="text-sm text-gray-500 italic p-2">Aucun projet pour le moment.</li> <li v-if="recentProjects.length === 0 && pinnedProjects.length === 0" class="text-sm text-gray-500 italic p-2">Aucun projet.</li>
<li v-for="project in recentProjects" :key="project.id" class="group flex items-center justify-between p-2 hover:bg-gray-800 rounded cursor-pointer text-sm text-gray-300 transition-colors"> <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 transition-colors', 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"> <div class="flex items-center gap-2 truncate">
<i data-lucide="message-square" class="w-4 h-4 text-gray-500 flex-shrink-0"></i> <i data-lucide="message-square" class="w-4 h-4 text-gray-500 flex-shrink-0"></i>
<span class="truncate">{{ project.title }}</span> <span class="truncate">{{ project.title }}</span>
</div> </div>
<div class="hidden group-hover:flex items-center gap-1 flex-shrink-0"> <div class="hidden group-hover:flex items-center gap-1 flex-shrink-0 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan p-1 rounded hover:bg-gray-700" title="Renommer"><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 p-1 rounded hover:bg-gray-700" title="Épingler"><i data-lucide="pin" class="w-3.5 h-3.5"></i></button> <button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700" title="Épingler"><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 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button> <button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
</div> </div>
@@ -85,52 +82,77 @@
</div> </div>
</div> </div>
<div class="p-4 border-t border-gray-700 flex justify-between items-center"> <div class="p-4 border-t border-gray-700 flex items-center gap-2">
<div class="flex items-center gap-2"> <div class="w-8 h-8 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-sm font-bold shadow-md">{{ username.charAt(0).toUpperCase() }}</div>
<div class="w-8 h-8 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-sm font-bold shadow-md">
{{ username.charAt(0).toUpperCase() }}
</div>
<span class="text-sm font-medium">{{ username }}</span> <span class="text-sm font-medium">{{ username }}</span>
</div> </div>
<button class="text-gray-400 hover:text-white transition-colors"><i data-lucide="settings" class="w-5 h-5"></i></button>
</div>
</aside> </aside>
</div> </div>
<!-- ZONE PRINCIPALE -->
<main class="flex-1 flex flex-col min-w-0 z-10 transition-all duration-300"> <main class="flex-1 flex flex-col min-w-0 z-10 transition-all duration-300">
<header class="flex items-center justify-between p-4 border-b border-gray-700 bg-aethas-panel h-[73px]"> <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"> <div class="flex items-center gap-3">
<button @click="toggleSidebar" class="text-gray-400 hover:text-white transition-colors p-1.5 rounded hover:bg-gray-700" title="Basculer le menu"> <button @click="toggleSidebar" class="text-gray-400 hover:text-white transition-colors p-1.5 rounded hover:bg-gray-700"><i data-lucide="menu" class="w-6 h-6"></i></button>
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
<div v-show="!isSidebarOpen" class="flex items-center gap-2 transition-opacity duration-300"> <div v-show="!isSidebarOpen" class="flex items-center gap-2 transition-opacity duration-300">
<img src="/static/logo.png" alt="Logo" class="w-8 h-8 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0"> <img src="/static/logo.png" alt="Logo" class="w-8 h-8 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<span class="font-bold text-lg neon-text">AETHAS38</span> <span class="font-bold text-lg neon-text">AETHAS38</span>
</div> </div>
</div> </div>
<div v-if="activeProject" class="text-sm font-medium text-gray-300 flex items-center gap-2">
{{ activeProject.title }}
<button @click="renameProject(activeProject)" class="text-gray-500 hover:text-aethas-cyan"><i data-lucide="edit-2" class="w-4 h-4"></i></button>
</div>
</header> </header>
<div class="flex-1 overflow-y-auto p-4 md:p-8 flex flex-col items-center justify-center"> <!-- Affichage par défaut -->
<div v-if="!activeProject" class="flex-1 overflow-y-auto p-4 md:p-8 flex flex-col items-center justify-center">
<img src="/static/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mb-6 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800 flex-shrink-0"> <img src="/static/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mb-6 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800 flex-shrink-0">
<h2 class="text-2xl font-bold text-gray-200 mb-2 text-center">Comment puis-je vous aider aujourd'hui ?</h2> <h2 class="text-2xl font-bold text-gray-200 mb-2 text-center">Comment puis-je vous aider aujourd'hui ?</h2>
<p class="text-gray-500 text-sm text-center">L'orchestrateur Multi-IA est prêt.</p> <p class="text-gray-500 text-sm text-center">Sélectionnez ou créez un projet pour commencer.</p>
</div> </div>
<!-- Zone de Chat -->
<div v-else class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 flex flex-col" 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-3xl 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']">
<div class="flex items-center gap-2 mb-2">
<i v-if="msg.role === 'user'" data-lucide="user" class="w-4 h-4 text-gray-400"></i>
<i v-else data-lucide="bot" class="w-4 h-4 text-aethas-cyan"></i>
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">{{ msg.role === 'user' ? 'Vous' : 'AETHAS38' }}</span>
</div>
<div class="text-gray-200 whitespace-pre-wrap text-sm leading-relaxed">{{ msg.content }}</div>
</div>
<!-- Indicateur de chargement IA -->
<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 items-center gap-2 mb-2">
<i data-lucide="bot" class="w-4 h-4 text-aethas-cyan"></i>
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">AETHAS38</span>
</div>
<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>
<!-- Saisie du Prompt -->
<div class="p-4 md:p-6 w-full max-w-4xl mx-auto"> <div class="p-4 md:p-6 w-full max-w-4xl mx-auto">
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan focus-within:ring-1 focus-within:ring-aethas-cyan transition-all"> <div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan focus-within:ring-1 focus-within:ring-aethas-cyan transition-all">
<button class="p-3 text-gray-400 hover:text-aethas-cyan transition-colors flex-shrink-0" title="Joindre un fichier"> <button class="p-3 text-gray-400 hover:text-aethas-cyan transition-colors flex-shrink-0" :disabled="!activeProject || isAiThinking"><i data-lucide="paperclip" class="w-5 h-5"></i></button>
<i data-lucide="paperclip" class="w-5 h-5"></i>
</button>
<textarea <textarea
v-model="prompt" v-model="prompt"
@input="autoResize" @input="autoResize"
@keydown.enter.exact.prevent="sendMessage"
ref="promptTextarea" ref="promptTextarea"
:disabled="!activeProject || isAiThinking"
rows="1" rows="1"
class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none leading-relaxed" class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none leading-relaxed disabled:opacity-50"
placeholder="Posez votre question à l'Orchestrateur..."></textarea> placeholder="Posez votre question à l'Orchestrateur..."></textarea>
<button class="p-3 text-aethas-cyan hover:text-white transition-colors flex-shrink-0" title="Envoyer"> <button @click="sendMessage" :disabled="!activeProject || !prompt.trim() || isAiThinking" class="p-3 text-aethas-cyan hover:text-white transition-colors flex-shrink-0 disabled:opacity-50 disabled:text-gray-500"><i data-lucide="send" class="w-5 h-5"></i></button>
<i data-lucide="send" class="w-5 h-5"></i>
</button>
</div> </div>
<p class="text-center text-xs text-gray-500 mt-3">AETHAS38 peut faire des erreurs. Vérifiez toujours les informations importantes.</p> <p class="text-center text-xs text-gray-500 mt-3">AETHAS38 peut faire des erreurs. Vérifiez toujours les informations importantes.</p>
</div> </div>
@@ -138,7 +160,6 @@
</div> </div>
<script> <script>
// Import de "computed" ajouté pour filtrer les listes
const { createApp, ref, onMounted, nextTick, computed } = Vue; const { createApp, ref, onMounted, nextTick, computed } = Vue;
createApp({ createApp({
@@ -148,20 +169,27 @@
const prompt = ref(""); const prompt = ref("");
const promptTextarea = ref(null); const promptTextarea = ref(null);
const projects = ref([]); const projects = ref([]);
const activeProject = ref(null);
const messages = ref([]);
const isLoading = ref(false); const isLoading = ref(false);
const isAiThinking = ref(false);
// Propriétés calculées pour séparer les projets épinglés et récents
const pinnedProjects = computed(() => projects.value.filter(p => p.is_pinned)); const pinnedProjects = computed(() => projects.value.filter(p => p.is_pinned));
const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned)); const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned));
const toggleSidebar = () => { isSidebarOpen.value = !isSidebarOpen.value; }; const toggleSidebar = () => { isSidebarOpen.value = !isSidebarOpen.value; };
const autoResize = () => { const autoResize = () => {
const el = promptTextarea.value; const el = promptTextarea.value;
if (!el) return; if (!el) return;
el.style.height = 'auto'; el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px'; el.style.height = el.scrollHeight + 'px';
}; };
const scrollToBottom = () => {
nextTick(() => {
const container = document.getElementById('chat-container');
if (container) container.scrollTop = container.scrollHeight;
});
};
const fetchProjects = async () => { const fetchProjects = async () => {
try { try {
@@ -170,7 +198,20 @@
projects.value = await response.json(); projects.value = await response.json();
nextTick(() => lucide.createIcons()); nextTick(() => lucide.createIcons());
} }
} catch (error) { console.error("Erreur", error); } } catch (error) { console.error(error); }
};
const selectProject = async (project) => {
activeProject.value = project;
messages.value = [];
try {
const response = await fetch(`/api/projects/${project.id}/messages`);
if (response.ok) {
messages.value = await response.json();
scrollToBottom();
nextTick(() => lucide.createIcons());
}
} catch (error) { console.error(error); }
}; };
const createNewProject = async () => { const createNewProject = async () => {
@@ -184,34 +225,84 @@
if (response.ok) { if (response.ok) {
const newProject = await response.json(); const newProject = await response.json();
projects.value.unshift(newProject); projects.value.unshift(newProject);
nextTick(() => lucide.createIcons()); selectProject(newProject);
} }
} catch (error) { console.error("Erreur", error); } } catch (error) { console.error(error); }
finally { isLoading.value = false; } finally { isLoading.value = false; }
}; };
// Fonction pour basculer l'état épinglé const renameProject = async (project) => {
const togglePin = async (projectId) => { const newTitle = window.prompt("Nouveau nom du projet :", project.title);
if (!newTitle || newTitle.trim() === "" || newTitle === project.title) return;
try {
const response = await fetch(`/api/projects/${project.id}/rename`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle.trim() })
});
if (response.ok) {
const updatedProject = await response.json();
const index = projects.value.findIndex(p => p.id === project.id);
if (index !== -1) projects.value[index] = updatedProject;
if (activeProject.value && activeProject.value.id === project.id) {
activeProject.value.title = updatedProject.title;
}
}
} catch (error) { console.error("Erreur de renommage", error); }
};
const sendMessage = async () => {
if (!prompt.value.trim() || !activeProject.value) return;
const userText = prompt.value.trim();
prompt.value = "";
autoResize();
messages.value.push({ id: Date.now(), role: 'user', content: userText });
isAiThinking.value = true;
scrollToBottom();
nextTick(() => lucide.createIcons());
try {
await fetch(`/api/projects/${activeProject.value.id}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'user', content: userText })
});
// SIMULATION TEMPORAIRE : Attente de l'IA (2 secondes)
setTimeout(() => {
isAiThinking.value = false;
scrollToBottom();
// La prochaine étape remplacera ce setTimeout par le véritable appel au moteur IA !
}, 2000);
} catch (error) {
console.error("Erreur envoi message", error);
isAiThinking.value = false;
}
};
const togglePin = async (projectId) => { /* ... code existant identique ... */
try { try {
const response = await fetch(`/api/projects/${projectId}/pin`, { method: 'PUT' }); const response = await fetch(`/api/projects/${projectId}/pin`, { method: 'PUT' });
if (response.ok) { if (response.ok) {
const updatedProject = await response.json(); const updatedProject = await response.json();
const index = projects.value.findIndex(p => p.id === projectId); const index = projects.value.findIndex(p => p.id === projectId);
if (index !== -1) projects.value[index] = updatedProject; if (index !== -1) projects.value[index] = updatedProject;
nextTick(() => lucide.createIcons());
} }
} catch (error) { console.error("Erreur d'épinglage", error); } } catch (error) { console.error(error); }
}; };
// Fonction pour supprimer un projet const deleteProject = async (projectId) => { /* ... code existant identique ... */
const deleteProject = async (projectId) => {
if (!confirm("Voulez-vous vraiment supprimer ce projet ?")) return; if (!confirm("Voulez-vous vraiment supprimer ce projet ?")) return;
try { try {
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' }); const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (response.ok) { if (response.ok) {
projects.value = projects.value.filter(p => p.id !== projectId); projects.value = projects.value.filter(p => p.id !== projectId);
if (activeProject.value && activeProject.value.id === projectId) activeProject.value = null;
} }
} catch (error) { console.error("Erreur de suppression", error); } } catch (error) { console.error(error); }
}; };
onMounted(() => { onMounted(() => {
@@ -221,7 +312,8 @@
return { return {
username, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea, username, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea,
projects, pinnedProjects, recentProjects, createNewProject, isLoading, togglePin, deleteProject projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking,
createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject
}; };
} }
}).mount('#app'); }).mount('#app');