Compare commits

...
7 Commits
Author SHA1 Message Date
xavier 60c0699c57 feat: intégration du workflow map-reduce parallèle pour le pré-traitement des fichiers joints, correction de session (7 jours) et redirection auto sur 401
Build and Push Docker Image / build-and-push (push) Successful in 55s
2026-10-08 11:31:42 +02:00
xavier 44bf0b868e feat: intégration du parsing backend (PDF, Excel, VBA) et gestion sécurisée des champs prompteur/concaténeur vides dans les templates
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-08 10:25:02 +02:00
xavier c779c51a03 fix: renforcement drastique de la regex du parseur .md et injection automatique de modèle de secours pour empêcher les cases vides
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-10-08 09:52:53 +02:00
xavier eaa0ff0564 feat: ajout de l'import de configuration IA via fichier .md et bouton de téléchargement du template
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-08 09:35:13 +02:00
xavier ecd9854527 feat: passage des prompt en traduction anglaise. Mise en place du téléchargement d'un *.md des modèles. Traduction de la description des modèles en Français.
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-08 09:24:04 +02:00
xavier f840561123 feat: support de 10 fichiers max avec destruction post-envoi, traduction automatique en anglais par le prompteur et traduction en français par le concaténeur en préservant le code
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-08 09:06:12 +02:00
xavier 48f929a8f3 feat: version v0.17.2 avec gestion intégrée et sécurisée des pièces jointes, destruction post-envoi et persistance des choix de modèles par filtres locaux
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-08 08:47:03 +02:00
5 changed files with 439 additions and 121 deletions

No files matched your search

+73 -5
View File
@@ -3,7 +3,7 @@ from fastapi.responses import RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
import os, json, asyncio, shutil import os, json, asyncio, shutil, base64, io
from datetime import datetime from datetime import datetime
import pytz import pytz
@@ -73,7 +73,8 @@ def login(login_data: LoginRequest, response: Response, db: Session = Depends(ge
user = db.query(User).filter(User.username == login_data.username).first() user = db.query(User).filter(User.username == login_data.username).first()
if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Identifiants incorrects.") if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Identifiants incorrects.")
if not verify_totp(user.totp_secret, login_data.totp_code): raise HTTPException(status_code=401, detail="2FA invalide.") if not verify_totp(user.totp_secret, login_data.totp_code): raise HTTPException(status_code=401, detail="2FA invalide.")
response.set_cookie(key="session_token", value=create_access_token(data={"sub": user.username}), httponly=True, max_age=3600, samesite="lax") # Correction : Extension de la durée de session à 7 jours (604800 secondes) pour éviter les erreurs 401 intempestives
response.set_cookie(key="session_token", value=create_access_token(data={"sub": user.username}), httponly=True, max_age=604800, samesite="lax")
return {"message": "Connexion réussie"} return {"message": "Connexion réussie"}
@app.get("/dashboard") @app.get("/dashboard")
@@ -143,14 +144,81 @@ def get_messages(project_id: int, db: Session = Depends(get_db), current_user: U
@app.post("/api/projects/{project_id}/messages", response_model=List[MessageResponse]) @app.post("/api/projects/{project_id}/messages", response_model=List[MessageResponse])
async def create_message(project_id: int, message: MessageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): async def create_message(project_id: int, message: MessageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
db.add(Message(role=message.role, content=message.content, project_id=project_id)); db.commit() extracted_files_data = []
files_names = []
if message.files:
for f in message.files:
files_names.append(f.name)
content = f.content
if content.startswith("data:"):
try:
header, b64data = content.split(",", 1)
file_bytes = base64.b64decode(b64data)
ext = f.name.split('.')[-1].lower()
extracted_text = ""
if ext == 'pdf':
try:
import PyPDF2
reader = PyPDF2.PdfReader(io.BytesIO(file_bytes))
extracted_text = "\n".join([page.extract_text() for page in reader.pages if page.extract_text()])
except ImportError:
extracted_text = "[Erreur: L'administrateur doit exécuter 'pip install PyPDF2' sur le serveur pour lire les PDF.]"
elif ext in ['xls', 'xlsx', 'xlsm', 'xlsb']:
try:
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=False)
for sheet_name in wb.sheetnames:
sheet = wb[sheet_name]
extracted_text += f"\n--- Feuille : {sheet_name} ---\n"
for row in sheet.iter_rows(values_only=True):
row_vals = [str(cell) if cell is not None else "" for cell in row]
if any(row_vals):
extracted_text += "\t".join(row_vals) + "\n"
if ext in ['xlsm', 'xlsb', 'xls']:
try:
from oletools.olevba import VBA_Parser
vbaparser = VBA_Parser("filename", data=file_bytes)
if vbaparser.detect_vba_macros():
extracted_text += "\n\n--- MACROS VBA DETECTEES ---\n"
for (filename, stream_path, vba_filename, vba_code) in vbaparser.extract_macros():
extracted_text += f"\n// Module: {vba_filename}\n{vba_code}\n"
except ImportError:
extracted_text += "\n[Extraction VBA impossible: L'administrateur doit exécuter 'pip install oletools' sur le serveur.]\n"
except Exception as e:
extracted_text += f"\n[Erreur de lecture VBA interne: {str(e)}]\n"
except ImportError:
extracted_text = "[Erreur: L'administrateur doit exécuter 'pip install openpyxl' sur le serveur pour lire Excel.]"
else:
extracted_text = file_bytes.decode('utf-8', errors='replace')
extracted_files_data.append({"name": f.name, "content": extracted_text})
except Exception as e:
extracted_files_data.append({"name": f.name, "content": f"[ERREUR DE DECODAGE: {str(e)}]"})
else:
extracted_files_data.append({"name": f.name, "content": content})
# On ne stocke plus le contenu brut des fichiers en DB pour éviter d'exploser le contexte des requêtes suivantes
db_content = message.content
if files_names:
db_content += f"\n\n[Fichiers joints pour analyse : {', '.join(files_names)}]"
db.add(Message(role=message.role, content=db_content, project_id=project_id))
db.commit()
history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
settings = db.query(SystemSettings).first() settings = db.query(SystemSettings).first()
conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]} conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]}
ai_resp = await run_orchestrator(db, history, settings, conf) # Transmission de DB # Transmission des données de fichiers en mémoire vive à l'orchestrateur (Map-Reduce)
ai_resp = await run_orchestrator(db, history, settings, conf, extracted_files_data)
db.add(Message(role="assistant", content=ai_resp, project_id=project_id)); db.commit() db.add(Message(role="assistant", content=ai_resp, project_id=project_id))
db.commit()
return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
# --- ROUTES MODÈLES & FINANCES --- # --- ROUTES MODÈLES & FINANCES ---
+91 -68
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import httpx import httpx
import urllib.parse
from openai import AsyncOpenAI from openai import AsyncOpenAI
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -14,10 +15,21 @@ def determine_domain(model_id: str) -> str:
if "audio" in mid or "whisper" in mid: return "Audio" if "audio" in mid or "whisper" in mid: return "Audio"
return "Texte Polyvalent" return "Texte Polyvalent"
async def translate_en_to_fr(client: httpx.AsyncClient, text: str) -> str:
if not text: return "Aucune description fournie."
try:
short_text = text[:300].strip()
url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=fr&dt=t&q={urllib.parse.quote(short_text)}"
resp = await client.get(url, timeout=4.0)
if resp.status_code == 200:
translated = "".join([s[0] for s in resp.json()[0]])
return translated + ("..." if len(text) > 300 else "")
except Exception:
pass
return text[:200] + "..."
async def sync_finances(db: Session, settings: SystemSettings): async def sync_finances(db: Session, settings: SystemSettings):
"""Interroge les fournisseurs pour récupérer le solde financier exact."""
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
# OpenRouter
if settings.openrouter_management_key or settings.openrouter_api_key: if settings.openrouter_management_key or settings.openrouter_api_key:
try: try:
key = settings.openrouter_management_key or settings.openrouter_api_key key = settings.openrouter_management_key or settings.openrouter_api_key
@@ -38,10 +50,7 @@ async def sync_finances(db: Session, settings: SystemSettings):
update_finance_db(db, "OpenRouter", balance, usage) update_finance_db(db, "OpenRouter", balance, usage)
except Exception as e: print(f"Erreur Finance OR: {e}") except Exception as e: print(f"Erreur Finance OR: {e}")
# Groq
if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0) if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0)
# DeepSeek
if settings.deepseek_api_key: if settings.deepseek_api_key:
try: try:
resp = await client.get("https://api.deepseek.com/user/balance", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"}) resp = await client.get("https://api.deepseek.com/user/balance", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"})
@@ -49,11 +58,7 @@ async def sync_finances(db: Session, settings: SystemSettings):
infos = resp.json().get("balance_infos", [{}])[0] infos = resp.json().get("balance_infos", [{}])[0]
update_finance_db(db, "DeepSeek", float(infos.get("total_balance", 0)), 0.0) update_finance_db(db, "DeepSeek", float(infos.get("total_balance", 0)), 0.0)
except Exception: pass except Exception: pass
# Mistral AI
if settings.mistral_api_key: update_finance_db(db, "Mistral", 0.0, 0.0) if settings.mistral_api_key: update_finance_db(db, "Mistral", 0.0, 0.0)
# Gemini
if settings.gemini_api_key: update_finance_db(db, "Gemini", 0.0, 0.0) if settings.gemini_api_key: update_finance_db(db, "Gemini", 0.0, 0.0)
try: db.commit() try: db.commit()
@@ -70,11 +75,8 @@ def update_finance_db(db, provider, balance, usage):
async def sync_providers_models(db: Session, settings: SystemSettings, sync_type: str = "Automatique"): async def sync_providers_models(db: Session, settings: SystemSettings, sync_type: str = "Automatique"):
added = 0 added = 0
# On stocke les modèles en mémoire avant de les envoyer en base pour éviter les doublons
models_to_process = {} models_to_process = {}
async with httpx.AsyncClient(timeout=90.0) as client: async with httpx.AsyncClient(timeout=90.0) as client:
# 1. OpenRouter
if settings.openrouter_api_key: if settings.openrouter_api_key:
try: try:
resp = await client.get("https://openrouter.ai/api/v1/models") resp = await client.get("https://openrouter.ai/api/v1/models")
@@ -84,95 +86,82 @@ async def sync_providers_models(db: Session, settings: SystemSettings, sync_type
m_id = item.get("id") m_id = item.get("id")
if not m_id: continue if not m_id: continue
pricing = item.get("pricing") or {} pricing = item.get("pricing") or {}
try: pp = float(pricing.get("prompt") or 0.0) * 1000000 pp = float(pricing.get("prompt") or 0.0) * 1000000
except: pp = 0.0 pc = float(pricing.get("completion") or 0.0) * 1000000
try: pc = float(pricing.get("completion") or 0.0) * 1000000
except: pc = 0.0
is_free = (pp == 0.0 and pc == 0.0) is_free = (pp == 0.0 and pc == 0.0)
desc = item.get("description", "Modèle OpenRouter.")[:200] + "..." desc_en = item.get("description", "Generic AI Model.")
models_to_process[m_id] = {"provider": "openrouter", "name": item.get("name", "Inconnu"), "desc": desc, "domain": determine_domain(m_id), "is_free": is_free, "ctx": item.get("context_length", 0), "pp": pp, "pc": pc} models_to_process[m_id] = {"provider": "openrouter", "name": item.get("name", "Inconnu"), "desc_en": desc_en, "domain": determine_domain(m_id), "is_free": is_free, "ctx": item.get("context_length", 0), "pp": pp, "pc": pc}
except: pass except: pass
except Exception as e: print(f"Erreur OR Models: {e}") except Exception as e: print(f"Erreur OR Models: {e}")
# 2. Groq
if settings.groq_api_key: if settings.groq_api_key:
try: try:
resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"}) resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("data", []): for item in resp.json().get("data", []):
try: m_id = item["id"]
m_id = item["id"] models_to_process[m_id] = {"provider": "groq", "name": m_id.capitalize(), "desc": "Modèle très rapide hébergé sur LPU Groq.", "domain": determine_domain(m_id), "is_free": True, "ctx": 8192, "pp": 0.0, "pc": 0.0}
models_to_process[m_id] = {"provider": "groq", "name": m_id.capitalize(), "desc": "Modèle rapide LPU Groq.", "domain": determine_domain(m_id), "is_free": True, "ctx": 8192, "pp": 0.0, "pc": 0.0}
except: pass
except Exception: pass except Exception: pass
# 3. DeepSeek
if settings.deepseek_api_key: if settings.deepseek_api_key:
try: try:
resp = await client.get("https://api.deepseek.com/models", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"}) resp = await client.get("https://api.deepseek.com/models", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("data", []): for item in resp.json().get("data", []):
try: m_id = item["id"]
m_id = item["id"] models_to_process[m_id] = {"provider": "deepseek", "name": m_id.capitalize(), "desc": "Modèle officiel du fournisseur DeepSeek.", "domain": determine_domain(m_id), "is_free": False, "ctx": 64000, "pp": 0.14, "pc": 0.28}
models_to_process[m_id] = {"provider": "deepseek", "name": m_id.capitalize(), "desc": "Modèle officiel DeepSeek.", "domain": determine_domain(m_id), "is_free": False, "ctx": 64000, "pp": 0.14, "pc": 0.28}
except: pass
except Exception: pass except Exception: pass
# 4. Mistral
if settings.mistral_api_key: if settings.mistral_api_key:
try: try:
resp = await client.get("https://api.mistral.ai/v1/models", headers={"Authorization": f"Bearer {settings.mistral_api_key}"}) resp = await client.get("https://api.mistral.ai/v1/models", headers={"Authorization": f"Bearer {settings.mistral_api_key}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("data", []): for item in resp.json().get("data", []):
try: m_id = item["id"]
m_id = item["id"] models_to_process[m_id] = {"provider": "mistral", "name": m_id.capitalize(), "desc": "Modèle officiel développé par Mistral AI.", "domain": determine_domain(m_id), "is_free": False, "ctx": 32000, "pp": 0.2, "pc": 0.6}
models_to_process[m_id] = {"provider": "mistral", "name": m_id.capitalize(), "desc": "Modèle officiel Mistral AI.", "domain": determine_domain(m_id), "is_free": False, "ctx": 32000, "pp": 0.2, "pc": 0.6}
except: pass
except Exception: pass except Exception: pass
# 5. Gemini (Google)
if settings.gemini_api_key: if settings.gemini_api_key:
try: try:
resp = await client.get(f"https://generativelanguage.googleapis.com/v1beta/models?key={settings.gemini_api_key}") resp = await client.get(f"https://generativelanguage.googleapis.com/v1beta/models?key={settings.gemini_api_key}")
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("models", []): for item in resp.json().get("models", []):
try: m_id = item["name"].replace("models/", "")
m_id = item["name"].replace("models/", "") models_to_process[m_id] = {"provider": "gemini", "name": item.get("displayName", m_id), "desc": "Modèle natif de l'écosystème Google Gemini.", "domain": determine_domain(m_id), "is_free": True, "ctx": item.get("inputTokenLimit", 32000), "pp": 0.0, "pc": 0.0}
desc = item.get("description", "Modèle Google Gemini.")[:200] + "..."
models_to_process[m_id] = {"provider": "gemini", "name": item.get("displayName", m_id), "desc": desc, "domain": determine_domain(m_id), "is_free": True, "ctx": item.get("inputTokenLimit", 32000), "pp": 0.0, "pc": 0.0}
except: pass
except Exception: pass except Exception: pass
# 6. Cloudflare
if settings.cloudflare_account_id and settings.cloudflare_api_token: if settings.cloudflare_account_id and settings.cloudflare_api_token:
try: try:
url = f"https://api.cloudflare.com/client/v4/accounts/{settings.cloudflare_account_id}/ai/models/search" url = f"https://api.cloudflare.com/client/v4/accounts/{settings.cloudflare_account_id}/ai/models/search"
resp = await client.get(url, headers={"Authorization": f"Bearer {settings.cloudflare_api_token}"}) resp = await client.get(url, headers={"Authorization": f"Bearer {settings.cloudflare_api_token}"})
if resp.status_code == 200: if resp.status_code == 200:
for item in resp.json().get("result", []): for item in resp.json().get("result", []):
try: m_id = item.get("name")
m_id = item.get("name") models_to_process[m_id] = {"provider": "cloudflare", "name": m_id.split("/")[-1], "desc": "Modèle Serverless Cloudflare Workers AI.", "domain": determine_domain(m_id), "is_free": True, "ctx": 4096, "pp": 0.0, "pc": 0.0}
desc = item.get("description", "Modèle Cloudflare Workers AI.")[:200] + "..."
models_to_process[m_id] = {"provider": "cloudflare", "name": m_id.split("/")[-1], "desc": desc, "domain": determine_domain(m_id), "is_free": True, "ctx": 4096, "pp": 0.0, "pc": 0.0}
except: pass
except Exception: pass except Exception: pass
# --- Phase d'enregistrement sécurisée --- sem = asyncio.Semaphore(15)
for m_id, data in models_to_process.items(): async def process_and_translate(m_id, data, client_session):
async with sem:
if "desc_en" in data:
data["desc"] = await translate_en_to_fr(client_session, data["desc_en"])
return m_id, data
async with httpx.AsyncClient(timeout=30.0) as client_trans:
tasks = [process_and_translate(m_id, data, client_trans) for m_id, data in models_to_process.items()]
translated_results = await asyncio.gather(*tasks)
for m_id, data in translated_results:
try: try:
existing = db.query(AIModel).filter(AIModel.model_id == m_id).first() existing = db.query(AIModel).filter(AIModel.model_id == m_id).first()
if existing: if existing:
existing.pricing_prompt = data["pp"]; existing.pricing_completion = data["pc"]; existing.is_free = data["is_free"]; existing.last_updated = datetime.now(timezone.utc) existing.pricing_prompt = data["pp"]; existing.pricing_completion = data["pc"]; existing.is_free = data["is_free"]; existing.description_fr = data["desc"]; existing.last_updated = datetime.now(timezone.utc)
else: else:
db.add(AIModel(provider=data["provider"], model_id=m_id, name=data["name"], description_fr=data["desc"], domain=data["domain"], is_free=data["is_free"], context_length=data["ctx"], pricing_prompt=data["pp"], pricing_completion=data["pc"])) db.add(AIModel(provider=data["provider"], model_id=m_id, name=data["name"], description_fr=data["desc"], domain=data["domain"], is_free=data["is_free"], context_length=data["ctx"], pricing_prompt=data["pp"], pricing_completion=data["pc"]))
added += 1 added += 1
# Commit très fréquent pour éviter les gros blocs qui plantent if added % 50 == 0: db.commit()
if added % 50 == 0: except IntegrityError: db.rollback()
db.commit() except Exception: db.rollback()
except IntegrityError:
db.rollback() # Si conflit, on annule cette insertion et on continue
except Exception:
db.rollback()
try: try:
settings.last_sync_date = datetime.now(timezone.utc) settings.last_sync_date = datetime.now(timezone.utc)
@@ -180,13 +169,11 @@ async def sync_providers_models(db: Session, settings: SystemSettings, sync_type
db.commit() db.commit()
except Exception as e: except Exception as e:
db.rollback() db.rollback()
print(f"Erreur DB Commit Sync: {e}")
await sync_finances(db, settings) await sync_finances(db, settings)
return {"status": "success", "models_processed": added} return {"status": "success", "models_processed": added}
def get_client_for_model(db: Session, model_id: str, settings: SystemSettings): def get_client_for_model(db: Session, model_id: str, settings: SystemSettings):
"""Récupère dynamiquement le bon client OpenAI en fonction du fournisseur du modèle."""
model_db = db.query(AIModel).filter(AIModel.model_id == model_id).first() model_db = db.query(AIModel).filter(AIModel.model_id == model_id).first()
provider = model_db.provider if model_db else "openrouter" provider = model_db.provider if model_db else "openrouter"
@@ -210,33 +197,69 @@ async def ask_agent(client, model_id, messages, provider="openrouter"):
resp = await client.chat.completions.create(**kwargs) resp = await client.chat.completions.create(**kwargs)
return resp.choices[0].message.content return resp.choices[0].message.content
async def run_orchestrator(db: Session, history: list, settings: SystemSettings, config: dict) -> str: async def run_orchestrator(db: Session, history: list, settings: SystemSettings, config: dict, extracted_files: list = None) -> str:
workers = config.get("workers", ["gemini-3.5-flash-lite"]) workers = config.get("workers", ["gemini-3.5-flash-lite"])
user_prompt = history[-1].content user_prompt = history[-1].content
original_user_text = user_prompt.split("\n\n[Fichiers joints")[0] if "[Fichiers joints" in user_prompt else user_prompt
formatted_history = [{"role": msg.role, "content": msg.content} for msg in history[:-1]] formatted_history = [{"role": msg.role, "content": msg.content} for msg in history[:-1]]
final_response = "" final_response = ""
try: try:
p_mod = config.get("prompter")
if not p_mod: p_mod = "gemini-3.5-flash-lite"
p_client, p_prov = get_client_for_model(db, p_mod, settings)
# --- WORKFLOW MAP-REDUCE : PRÉ-TRAITEMENT PARALLÈLE DES FICHIERS ---
files_context = ""
if extracted_files:
async def process_single_file(f):
file_prompt = f"Demande de l'utilisateur : '{original_user_text}'.\n\nAnalysez le fichier ci-dessous. Extrayez, résumez et conservez méticuleusement tout le code, les macros VBA, les requêtes SQL, ou les données métier pertinentes pour répondre à la demande.\n\nFichier : {f['name']}\nContenu :\n```\n{f['content']}\n```"
file_sys = "You are an expert data analyst and senior developer. Extract the most important technical information from the file without losing critical code syntax."
try:
analysis = await ask_agent(p_client, p_mod, [{"role": "system", "content": file_sys}, {"role": "user", "content": file_prompt}], p_prov)
return f"\n\n--- Extraction du fichier {f['name']} ---\n{analysis}"
except Exception as e:
return f"\n\n--- Erreur sur {f['name']} ---\n{str(e)}"
file_tasks = [process_single_file(f) for f in extracted_files]
file_analyses = await asyncio.gather(*file_tasks)
files_context = "".join(file_analyses)
user_prompt = f"{original_user_text}\n\nVoici les données pré-traitées des fichiers joints :\n{files_context}"
# --- OPTIMISATION & TRADUCTION ---
prompt_system = "You are an expert prompt engineer. Translate and optimize the user request and any file context into clear, precise English tailored for AI execution. Keep all code blocks intact."
optimized = await ask_agent(p_client, p_mod, [{"role": "system", "content": prompt_system}, {"role": "user", "content": user_prompt}], p_prov)
if len(workers) == 1: if len(workers) == 1:
w_mod = workers[0] w_mod = workers[0]
client, provider = get_client_for_model(db, w_mod, settings) client, provider = get_client_for_model(db, w_mod, settings)
final_response = await ask_agent(client, w_mod, formatted_history + [{"role": "user", "content": user_prompt}], provider) worker_response = await ask_agent(client, w_mod, formatted_history + [{"role": "user", "content": optimized}], provider)
responses = [worker_response]
else: else:
p_mod = config.get("prompter", "gemini-3.5-flash-lite")
p_client, p_prov = get_client_for_model(db, p_mod, settings)
optimized = await ask_agent(p_client, p_mod, [{"role": "system", "content": "Optimise cette requête."}, {"role": "user", "content": user_prompt}], p_prov)
w_tasks = [] w_tasks = []
for w in workers: for w in workers:
w_client, w_prov = get_client_for_model(db, w, settings) w_client, w_prov = get_client_for_model(db, w, settings)
w_tasks.append(ask_agent(w_client, w, formatted_history + [{"role": "user", "content": optimized}], w_prov)) w_tasks.append(ask_agent(w_client, w, formatted_history + [{"role": "user", "content": optimized}], w_prov))
responses = await asyncio.gather(*w_tasks, return_exceptions=True) responses = await asyncio.gather(*w_tasks, return_exceptions=True)
c_mod = config.get("concatenator", "gemini-3.5-flash-lite") c_mod = config.get("concatenator")
c_client, c_prov = get_client_for_model(db, c_mod, settings) if not c_mod: c_mod = "gemini-3.5-flash-lite"
synth = f"Requête: {user_prompt}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{r}" for i, r in enumerate(responses)]) + "\n\nFais une synthèse finale."
final_response = await ask_agent(c_client, c_mod, [{"role": "user", "content": synth}], c_prov) c_client, c_prov = get_client_for_model(db, c_mod, settings)
concat_system = (
"You are a master lead developer and technical synthesizer. "
"Synthesize the provided expert responses into a single cohesive response. "
"Translate all explanatory text, descriptions, and user-facing prose into natural French. "
"CRITICAL: Do NOT translate code blocks, programming keywords, or source code contents. "
"You may translate code comments into French if appropriate, but leave code syntax strictly intact."
)
synth = f"User Request: {original_user_text}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{str(r)}" for i, r in enumerate(responses)])
final_response = await ask_agent(c_client, c_mod, [{"role": "system", "content": concat_system}, {"role": "user", "content": synth}], c_prov)
except Exception as e: except Exception as e:
final_response = f"L'IA a rencontré une erreur critique: {str(e)}" final_response = f"L'IA a rencontré une erreur critique: {str(e)}"
+5
View File
@@ -44,12 +44,17 @@ class OrchestratorConfig(BaseModel):
prompter: Optional[str] = "gemini-3.5-flash-lite" prompter: Optional[str] = "gemini-3.5-flash-lite"
concatenator: Optional[str] = "gemini-3.5-flash-lite" concatenator: Optional[str] = "gemini-3.5-flash-lite"
class AttachedFile(BaseModel):
name: str
content: str
class MessageBase(BaseModel): class MessageBase(BaseModel):
role: str role: str
content: str content: str
class MessageCreate(MessageBase): class MessageCreate(MessageBase):
config: Optional[OrchestratorConfig] = None config: Optional[OrchestratorConfig] = None
files: Optional[List[AttachedFile]] = None
class MessageResponse(MessageBase): class MessageResponse(MessageBase):
id: int id: int
+267 -48
View File
@@ -136,6 +136,62 @@
</div> </div>
</header> </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 (JSON)</a>
</div>
</div>
</div>
<div v-if="settingsTab === 'super' && user.is_superadmin" class="space-y-6">
<div class="bg-gray-800 p-6 rounded-xl border border-gray-700 border-l-4 border-l-aethas-purple">
<h3 class="text-lg font-semibold text-purple-400 mb-4">Espace Réservé Super-Admin</h3>
<p class="text-sm text-gray-400">Gestion globale du système.</p>
</div>
</div>
</div>
</div>
<!-- VUE : MODÈLES ET FINANCES --> <!-- 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 v-if="activeView === 'models' && user.is_admin" class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 custom-scrollbar">
<div class="flex justify-between items-end border-b border-gray-700 pb-4"> <div class="flex justify-between items-end border-b border-gray-700 pb-4">
@@ -236,24 +292,32 @@
</div> </div>
</div> </div>
<!-- BARRE DE SÉLECTION DE STYLE TABULAIRE (9 COLONNES STRICTES) --> <div class="p-4 md:px-6 md:pt-6 pb-2 w-full max-w-7xl mx-auto flex-shrink-0">
<div class="p-4 md:p-6 w-full max-w-7xl mx-auto space-y-2 flex-shrink-0"> <div class="flex justify-between items-center mb-2">
<div class="grid grid-cols-9 border border-black bg-gray-900 rounded-lg overflow-hidden text-center text-xs font-bold shadow-lg"> <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>
<!-- 1. PAYANT / GRATUIT --> <!-- 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"> <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> <label class="block text-[9px] text-gray-400 mb-1">PRIX</label>
<select v-model="filterPricing" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none"> <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="">TOUS</option>
<option value="free">GRATUIT</option> <option value="free">GRATUIT</option>
<option value="paid">PAYANT</option> <option value="paid">PAYANT</option>
</select> </select>
</div> </div>
<!-- 2. TYPE (DOMAINE) -->
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <label class="block text-[9px] text-gray-400 mb-1">TYPE</label>
<select v-model="filterDomain" class="w-full bg-aethas-dark border border-gray-700 rounded text-gray-200 text-[10px] p-1 outline-none"> <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="">TOUS</option>
<option value="Texte Polyvalent">Texte</option> <option value="Texte Polyvalent">Texte</option>
<option value="Code & Logique">Codage</option> <option value="Code & Logique">Codage</option>
@@ -262,75 +326,83 @@
</select> </select>
</div> </div>
<!-- 3. TRAVAILLEUR 1 -->
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <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"> <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 value="">(Aucun)</option>
<option v-for="m in filteredBarModels" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</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> </select>
</div> </div>
<!-- 4. TRAVAILLEUR 2 -->
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <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"> <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 value="">(Aucun)</option>
<option v-for="m in filteredBarModels" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</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> </select>
</div> </div>
<!-- 5. TRAVAILLEUR 3 -->
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <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"> <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 value="">(Aucun)</option>
<option v-for="m in filteredBarModels" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</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> </select>
</div> </div>
<!-- 6. TRAVAILLEUR 4 -->
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <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"> <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 value="">(Aucun)</option>
<option v-for="m in filteredBarModels" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</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> </select>
</div> </div>
<!-- 7. TRAVAILLEUR 5 -->
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <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"> <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 value="">(Aucun)</option>
<option v-for="m in filteredBarModels" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</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> </select>
</div> </div>
<!-- 8. PROMPTEUR -->
<div class="border-r border-black p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <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"> <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="gemini-3.5-flash-lite">Gemini Flash</option> <option value="">(Par défaut)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</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> </select>
</div> </div>
<!-- 9. CONCATENEUR -->
<div class="p-1.5 bg-gray-800 flex flex-col justify-center"> <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> <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"> <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="gemini-3.5-flash-lite">Gemini Flash</option> <option value="">(Par défaut)</option>
<option v-for="m in modelInfo.models" :key="m.model_id" :value="m.model_id">{{ m.name || m.model_id }}</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> </select>
</div> </div>
</div> </div>
<!-- Champ de texte du message --> <!-- 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"> <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">
<textarea v-model="prompt" @input="autoResize" @keydown.enter.exact.prevent="sendMessage" ref="promptTextarea" :disabled="isAiThinking" rows="1" class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none disabled:opacity-50 custom-scrollbar" placeholder="Posez votre question à l'orchestrateur..."></textarea> <div v-if="attachedFiles.length > 0" class="w-full flex flex-wrap gap-2 px-2 pt-2">
<button @click="sendMessage" :disabled="!prompt.trim() || isAiThinking" class="p-3 text-aethas-cyan hover:text-white disabled:opacity-50"><i data-lucide="send" class="w-5 h-5"></i></button> <div 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>
</div> </div>
</template> </template>
@@ -369,9 +441,14 @@
const filterDomain = ref(""); const filterDomain = ref("");
const filterPricing = ref(""); const filterPricing = ref("");
const workers = ref(["gemini-3.5-flash-lite", "", "", "", ""]); const colPricing = ref("");
const selectedPrompter = ref("gemini-3.5-flash-lite"); const colDomain = ref("");
const selectedConcatenator = ref("gemini-3.5-flash-lite");
const workers = ref(["", "", "", "", ""]);
const selectedPrompter = ref("");
const selectedConcatenator = ref("");
const attachedFiles = ref([]);
const 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));
@@ -387,15 +464,28 @@
}); });
}); });
const filteredBarModels = computed(() => { const getFilteredModelsForCol = (pricingFilter, domainFilter, currentSelectedId) => {
return modelInfo.models.filter(m => { let filtered = modelInfo.models.filter(m => {
const matchDomain = filterDomain.value === "" || m.domain === filterDomain.value; const matchDomain = domainFilter === "" || m.domain === domainFilter;
let matchPrice = true; let matchPrice = true;
if (filterPricing.value === 'free') matchPrice = m.is_free; if (pricingFilter === 'free') matchPrice = m.is_free;
if (filterPricing.value === 'paid') matchPrice = !m.is_free; if (pricingFilter === 'paid') matchPrice = !m.is_free;
return matchDomain && matchPrice; return matchDomain && matchPrice;
}); });
});
if (currentSelectedId && currentSelectedId.trim() !== "") {
const exists = filtered.find(m => m.model_id === currentSelectedId);
if (!exists) {
const original = modelInfo.models.find(m => m.model_id === currentSelectedId);
if (original) {
filtered.unshift(original);
} else {
filtered.unshift({ model_id: currentSelectedId, name: currentSelectedId, domain: "Importé", pricing_prompt: 0, pricing_completion: 0 });
}
}
}
return filtered;
};
const formatDate = (isoString) => { const formatDate = (isoString) => {
if (!isoString) return "Jamais"; if (!isoString) return "Jamais";
@@ -421,7 +511,30 @@
if(res.ok) { if(res.ok) {
await fetchModelsInfo(); await fetchModelsInfo();
await fetchFinances(); await fetchFinances();
alert("Synchronisation réussie !");
let mdContent = `# Extraction des Modèles IA - AETHAS38\n\n`;
const d = new Date();
const dateStr = d.getFullYear() + String(d.getMonth()+1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
mdContent += `**Date d'extraction :** ${d.toLocaleDateString('fr-FR')} à ${d.toLocaleTimeString('fr-FR')}\n\n`;
const providers = [...new Set(modelInfo.models.map(m => m.provider))];
providers.forEach(prov => {
mdContent += `## Fournisseur : ${prov.toUpperCase()}\n\n`;
const provModels = modelInfo.models.filter(m => m.provider === prov);
provModels.forEach(m => {
const priceInfo = m.is_free ? "**GRATUIT**" : `In: $${m.pricing_prompt.toFixed(2)} / Out: $${m.pricing_completion.toFixed(2)}`;
const ctxInfo = `${(m.context_length / 1000).toFixed(0)}k`;
mdContent += `- **${m.name || m.model_id}** (\`${m.model_id}\`)\n`;
mdContent += ` - *Domaine :* ${m.domain}\n`;
mdContent += ` - *Prix (1M tokens) :* ${priceInfo}\n`;
mdContent += ` - *Contexte :* ${ctxInfo}\n`;
mdContent += ` - *Description :* ${m.description_fr.replace(/\n/g, ' ')}\n\n`;
});
});
const filename = `${dateStr}-extraction-modèles.md`;
downloadFile(mdContent, filename);
alert("Synchronisation réussie ! Le fichier markdown a été généré et téléchargé.");
} else { } else {
let errText = await res.text(); let errText = await res.text();
try { const errObj = JSON.parse(errText); errText = errObj.detail || errText; } catch(e){} try { const errObj = JSON.parse(errText); errText = errObj.detail || errText; } catch(e){}
@@ -431,6 +544,41 @@
isSyncing.value = false; isSyncing.value = false;
}; };
const downloadTemplate = () => {
const templateContent = `# AETHAS38 - Configuration Multi-Agents\n\n**Rappel important :**\n- **Prompteur :** Le système de l'orchestrateur utilisera ce modèle pour traduire et optimiser votre requête initiale en ANGLAIS (langue native des IA).\n- **Concaténeur :** Le système utilisera ce modèle pour regrouper les réponses des travailleurs et traduire la synthèse finale en FRANÇAIS (en préservant le code brut).\n\n## Sélection des Modèles\nRenseignez l'ID exact du modèle (ex: gemini-3.5-flash-lite, mistralai/mistral-large-4-0). Laissez vide si inutilisé.\n\n- Prompteur : \n- Travailleur 1 : \n- Travailleur 2 : \n- Travailleur 3 : \n- Travailleur 4 : \n- Travailleur 5 : \n- Concaténeur : \n`;
downloadFile(templateContent, 'aethas38-config-agents.md');
};
const handleConfigImport = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
const extractModel = (key) => {
const safeKey = key.replace(/[éèe]/gi, '[éèe]');
const regex = new RegExp(`-\\s*${safeKey}\\s*:\\s*([^\\r\\n]*)`, 'i');
const match = content.match(regex);
return match && match[1] ? match[1].trim() : "";
};
const w1 = extractModel('Travailleur 1');
const w2 = extractModel('Travailleur 2');
const w3 = extractModel('Travailleur 3');
const w4 = extractModel('Travailleur 4');
const w5 = extractModel('Travailleur 5');
workers.value = [w1, w2, w3, w4, w5];
selectedPrompter.value = extractModel('Prompteur');
selectedConcatenator.value = extractModel('Concateneur');
alert("Configuration IA importée et appliquée avec succès !");
};
reader.readAsText(file);
event.target.value = null;
};
const uploadAvatar = async (event) => { const uploadAvatar = async (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -444,6 +592,55 @@
event.target.value = null; event.target.value = null;
}; };
const handleFileUpload = (event) => {
const files = event.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
if (attachedFiles.value.length >= 10) {
alert("Maximum 10 fichiers autorisés par message.");
break;
}
processFile(files[i]);
}
event.target.value = null;
};
const handlePaste = (event) => {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (let index in items) {
let item = items[index];
if (item.kind === 'file') {
let file = item.getAsFile();
if (file) {
if (attachedFiles.value.length >= 10) {
alert("Maximum 10 fichiers autorisés par message.");
break;
}
processFile(file);
}
}
}
};
const processFile = (file) => {
const reader = new FileReader();
const textExts = ['.txt', '.md', '.csv', '.json', '.py', '.js', '.html', '.css', '.xml', '.yml', '.yaml', '.sh', '.bat', '.ps1', '.ini', '.cfg', '.conf', '.log'];
const isText = textExts.some(ext => file.name.toLowerCase().endsWith(ext)) || file.type.startsWith('text/');
reader.onload = (e) => {
attachedFiles.value.push({ name: file.name, content: e.target.result });
};
// Les fichiers binaires sont lus en Base64.
if (isText) {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
};
const removeFile = (index) => { attachedFiles.value.splice(index, 1); };
const exportProject = (format) => { if(activeProject.value) window.open(`/api/projects/${activeProject.value.id}/export?format=${format}`, '_blank'); }; const exportProject = (format) => { if(activeProject.value) window.open(`/api/projects/${activeProject.value.id}/export?format=${format}`, '_blank'); };
const copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text); alert("Copié !"); } catch (e) {} }; const copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text); alert("Copié !"); } catch (e) {} };
const downloadFile = (content, hint) => { let fname = hint ? hint.trim() : 'code.txt'; if (!fname.includes('.')) fname += '.txt'; const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fname; a.click(); URL.revokeObjectURL(url); }; const downloadFile = (content, hint) => { let fname = hint ? hint.trim() : 'code.txt'; if (!fname.includes('.')) fname += '.txt'; const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fname; a.click(); URL.revokeObjectURL(url); };
@@ -456,33 +653,54 @@
const sendMessage = async () => { const sendMessage = async () => {
const activeWorkers = workers.value.filter(w => w && w.trim() !== ""); const activeWorkers = workers.value.filter(w => w && w.trim() !== "");
if (!prompt.value.trim() || !activeProject.value || activeWorkers.length === 0) { if ((!prompt.value.trim() && attachedFiles.value.length === 0) || !activeProject.value || activeWorkers.length === 0) {
alert("Veuillez sélectionner au moins un travailleur."); alert("Veuillez sélectionner au moins un travailleur et rédiger ou joindre un contenu.");
return; return;
} }
const userText = prompt.value.trim(); prompt.value = ""; autoResize(); const userText = prompt.value.trim();
messages.value.push({ id: Date.now(), role: 'user', content: userText }); prompt.value = "";
isAiThinking.value = true; scrollToBottom(); 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 = { const payload = {
role: 'user', role: 'user',
content: userText, content: userText || "Analyse ces fichiers joints.",
config: { config: {
workers: activeWorkers, workers: activeWorkers,
prompter: selectedPrompter.value, prompter: selectedPrompter.value,
concatenator: selectedConcatenator.value concatenator: selectedConcatenator.value
} },
files: payloadFiles
}; };
attachedFiles.value = [];
try { try {
const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const response = await fetch(`/api/projects/${activeProject.value.id}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (Array.isArray(data)) messages.value = data; if (Array.isArray(data)) messages.value = data;
await fetchFinances(); await fetchFinances();
} else if (response.status === 401) {
alert("Votre session a expiré. Veuillez vous reconnecter.");
window.location.href = '/login';
return;
} else { } else {
const err = await response.json(); alert("Erreur API IA : " + err.detail); const err = await response.json(); alert("Erreur API IA : " + err.detail);
} }
} catch (error) {} finally { isAiThinking.value = false; scrollToBottom(); nextTick(()=>lucide.createIcons()); } } catch (error) {
alert("Erreur locale lors de l'envoi : " + error.message);
} finally {
isAiThinking.value = false;
scrollToBottom();
nextTick(()=>lucide.createIcons());
}
}; };
const changePassword = async () => { const changePassword = async () => {
@@ -504,8 +722,9 @@
projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking, projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking,
createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject, createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject,
isSettingsModalOpen, openSettings, closeSettings, passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar, isSettingsModalOpen, openSettings, closeSettings, passForm, settingsMessage, isSavingSettings, changePassword, uploadAvatar,
modelInfo, financesList, filterSearch, filterDomain, filterPricing, filteredModels, filteredBarModels, formatDate, syncModels, isSyncing, modelInfo, financesList, filterSearch, filterDomain, filterPricing, filteredModels, getFilteredModelsForCol, formatDate, syncModels, isSyncing,
workers, selectedPrompter, selectedConcatenator, workers, selectedPrompter, selectedConcatenator, colPricing, colDomain, downloadTemplate, handleConfigImport,
attachedFiles, handleFileUpload, handlePaste, removeFile,
exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage exportProject, copyToClipboard, downloadFile, downloadMessage, parseMessage
}; };
} }
+3
View File
@@ -15,3 +15,6 @@ openai
httpx httpx
python-multipart python-multipart
pytz pytz
PyPDF2
openpyxl
oletools