2026-06-12 18:16:58 +02:00
import chainlit as cl
2026-07-08 16:11:11 +02:00
import asyncio
import zipfile
2026-06-12 18:16:58 +02:00
import httpx
import json
2026-07-08 16:11:11 +02:00
import os
import io
2026-06-12 18:16:58 +02:00
2026-07-15 14:00:59 +02:00
# --- CONFIGURATION GITEA ---
GITEA_TOKEN = os . getenv ( " GITEA_TOKEN " , " ton_token_gitea_ici " )
async def push_readme_update_to_gitea ( repo_url : str , readme_content : str ) :
"""
Met à jour ou crée le fichier README . md directement sur Gitea via son API REST .
"""
if not repo_url :
return
import base64
clean_url = repo_url . replace ( " .git " , " " )
parts = clean_url . rstrip ( " / " ) . split ( " / " )
if len ( parts ) > = 2 :
repo_name = parts [ - 1 ]
owner = parts [ - 2 ]
# API Gitea pour récupérer/modifier le contenu d'un fichier
gitea_file_url = f " http://git-arc:3000/api/v1/repos/ { owner } / { repo_name } /contents/README.md "
headers = {
" Content-Type " : " application/json "
}
if GITEA_TOKEN :
headers [ " Authorization " ] = f " token { GITEA_TOKEN } "
async with httpx . AsyncClient ( ) as client :
sha = None
# 1. On tente de récupérer le SHA du README actuel s'il existe (requis par Gitea pour les updates)
try :
get_resp = await client . get ( gitea_file_url , headers = headers , timeout = 5.0 )
if get_resp . status_code == 200 :
sha = get_resp . json ( ) . get ( " sha " )
except Exception as e :
print ( f " ⚠️ Impossible de récupérer le README actuel (il n ' existe peut-être pas) : { e } " )
# 2. Encodage du contenu en base64 (requis par l'API Gitea)
encoded_content = base64 . b64encode ( readme_content . encode ( " utf-8 " ) ) . decode ( " utf-8 " )
payload = {
" content " : encoded_content ,
" message " : " docs: ajouter le statut de conformité QA au README " ,
" branch " : " main " # ou "master" selon ta configuration par défaut
}
if sha :
payload [ " sha " ] = sha # Requis si le fichier existe déjà
# 3. Écriture (PUT crée ou met à jour le fichier)
try :
put_resp = await client . put ( gitea_file_url , headers = headers , json = payload , timeout = 10.0 )
if put_resp . status_code in [ 200 , 201 ] :
print ( " 📝 README.md mis à jour avec succès sur Gitea ! " )
else :
print ( f " ⚠️ Échec de la mise à jour du README sur Gitea : { put_resp . status_code } - { put_resp . text } " )
except Exception as e :
print ( f " ❌ Erreur lors de l ' écriture du README sur Gitea : { e } " )
async def delete_gitea_repository ( repo_url : str ) :
"""
Supprime le dépôt sur Gitea via son API REST en cas d ' échec ou d ' abandon .
"""
if not repo_url :
return
# Nettoyage de l'URL pour extraire le owner et le repo_name
clean_url = repo_url . replace ( " .git " , " " )
parts = clean_url . rstrip ( " / " ) . split ( " / " )
if len ( parts ) > = 2 :
repo_name = parts [ - 1 ]
owner = parts [ - 2 ]
# URL interne au réseau Docker pour appeler l'API Gitea
gitea_api_url = f " http://git-arc:3000/api/v1/repos/ { owner } / { repo_name } "
headers = { }
if GITEA_TOKEN :
headers [ " Authorization " ] = f " token { GITEA_TOKEN } "
try :
async with httpx . AsyncClient ( ) as client :
response = await client . delete ( gitea_api_url , headers = headers , timeout = 10.0 )
if response . status_code == 204 :
print ( f " 🗑️ Dépôt Gitea { owner } / { repo_name } supprimé avec succès. " )
else :
print ( f " ⚠️ Échec de la suppression Gitea : { response . status_code } - { response . text } " )
except Exception as e :
print ( f " ❌ Erreur lors de la suppression du dépôt Gitea : { e } " )
def inject_conforme_badge ( state : dict ) - > dict :
"""
Injecte la mention ' Conforme ' dans le README . md du projet
dans l ' état de session (gère le format liste de dict ou dictionnaire plat).
"""
dev_data = state . get ( " generated_code " , { } ) or { }
raw_files = dev_data . get ( " files " , { } )
badge_text = " \n \n --- \n 🛡️ **Statut QA :** ✅ Conforme \n "
if isinstance ( raw_files , list ) :
readme_found = False
for f in raw_files :
if isinstance ( f , dict ) :
path = f . get ( " path " ) or f . get ( " filename " ) or f . get ( " name " ) or " "
if path . lower ( ) in [ " readme.md " , " readme " ] :
content = f . get ( " content " ) or f . get ( " code " ) or " "
if " ✅ Conforme " not in content :
new_content = content + badge_text
if " content " in f :
f [ " content " ] = new_content
elif " code " in f :
f [ " code " ] = new_content
readme_found = True
break
if not readme_found :
raw_files . append ( {
" path " : " README.md " ,
" content " : " # Projet \n " + badge_text
} )
elif isinstance ( raw_files , dict ) :
readme_key = None
for k in raw_files . keys ( ) :
if k . lower ( ) in [ " readme.md " , " readme " ] :
readme_key = k
break
if readme_key :
content = raw_files [ readme_key ] or " "
if " ✅ Conforme " not in content :
raw_files [ readme_key ] = content + badge_text
else :
raw_files [ " README.md " ] = " # Projet " + badge_text
return state
async def reset_to_start ( message_text : str = " Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel. " ) :
"""
Réinitialise complètement l ' état de la session utilisateur.
"""
initial_state = {
" user_input " : " " ,
" user_feedback " : None ,
" chat_history " : [ ] ,
" spec " : { } ,
" status " : " start " ,
" loop_count " : 0 ,
" existing_project " : None ,
" generated_code " : None ,
" qa_result " : None ,
" is_completed " : False
}
cl . user_session . set ( " graph_state " , initial_state )
cl . user_session . set ( " current_files " , { } )
cl . user_session . set ( " side_code_viewer " , None )
cl . user_session . set ( " files_message " , None )
await cl . Message ( content = message_text ) . send ( )
2026-07-08 16:11:11 +02:00
async def render_workflow_state ( new_state : dict ) :
"""
Fonction centrale pour aiguiller l ' affichage Chainlit
en fonction du statut renvoyé par LangGraph .
"""
current_status = new_state . get ( " status " )
2026-06-12 18:16:58 +02:00
2026-07-08 16:11:11 +02:00
if current_status == " spec_incomplete " :
2026-06-16 11:27:41 +02:00
spec = new_state . get ( " spec " , { } )
question = spec . get ( " clarifying_question " )
await cl . Message ( content = f " **Spécifications incomplètes** \n \n { question } " ) . send ( )
2026-07-08 16:11:11 +02:00
elif current_status == " spec_ready " :
2026-06-16 11:27:41 +02:00
spec = new_state . get ( " spec " , { } )
summary = " ### Éléments importants à retenir de ton projet : \n \n "
2026-07-08 16:11:11 +02:00
summary + = f " - **Nom du projet** : { spec . get ( ' title ' ) } \n "
summary + = f " - **Description** : { spec . get ( ' description ' ) } \n "
2026-06-17 10:18:55 +02:00
summary + = " - **Actions** : \n "
2026-07-08 16:11:11 +02:00
summary + = " \n " . join ( f " - { req } " for req in spec . get ( " requirements " , [ ] ) ) + " \n "
2026-06-17 10:18:55 +02:00
summary + = " - **Contraintes** : \n "
2026-07-08 16:11:11 +02:00
summary + = " \n " . join ( f " - { constraint } " for constraint in spec . get ( " constraints " , [ ] ) ) + " \n "
summary + = f " - **Langage** : { spec . get ( ' language ' ) } \n \n "
summary + = " **Est-ce que cela vous convient ?** "
2026-06-16 11:27:41 +02:00
res = await cl . AskActionMessage (
content = summary ,
actions = [
cl . Action ( name = " oui " , payload = { " value " : " oui " } , label = " Oui, c ' est parfait 👍 " ) ,
cl . Action ( name = " non " , payload = { " value " : " non " } , label = " Non, modifier ❌ " )
] ,
timeout = 3600
) . send ( )
if res is None :
await cl . Message (
content = " ⏰ **Session expirée.** Si tu es toujours là, envoie un message pour relancer l ' analyse. "
) . send ( )
return
if res and res . get ( " name " ) == " oui " :
await cl . Message ( content = " 🚀 **Spécifications validées !** Lancement de la génération du code... " ) . send ( )
new_state [ " status " ] = " spec_approved "
cl . user_session . set ( " graph_state " , new_state )
async with httpx . AsyncClient ( ) as client :
response = await client . post (
" http://127.0.0.1:8000/api/workflow/run " ,
json = new_state ,
timeout = 600.0
)
final_state = response . json ( )
cl . user_session . set ( " graph_state " , final_state )
2026-07-08 16:11:11 +02:00
await render_workflow_state ( final_state )
2026-06-16 11:27:41 +02:00
else :
new_state [ " status " ] = " spec_incomplete "
cl . user_session . set ( " graph_state " , new_state )
await cl . Message (
content = " 🔄 **Compris.** Qu ' est-ce qui ne convient pas ? S ' il te plaît, précise les éléments manquants ou à corriger : "
) . send ( )
2026-07-08 16:11:11 +02:00
elif current_status in [ " wait_human_review " , " approved_by_human " ] :
2026-07-15 14:00:59 +02:00
dev_data = new_state . get ( " generated_code " , { } ) or { }
qa_res = new_state . get ( " qa_result " , { } ) or { }
2026-07-08 16:11:11 +02:00
gitea_url = dev_data . get ( " repo_url " , " " )
if gitea_url and " git-arc:3000 " in gitea_url :
gitea_url = gitea_url . replace ( " git-arc:3000 " , " localhost:3000 " )
2026-07-15 14:00:59 +02:00
is_safe = qa_res . get ( " is_complete_and_safe " , False )
# ==================== PARCOURS 1 : LE PROJET EST CONFORME ====================
if is_safe :
raw_files = dev_data . get ( " files " , { } )
files = { }
if isinstance ( raw_files , list ) :
for f in raw_files :
if isinstance ( f , dict ) :
path = f . get ( " path " ) or f . get ( " filename " ) or f . get ( " name " )
content = f . get ( " content " ) or f . get ( " code " ) or " "
if path :
files [ path ] = content
elif isinstance ( raw_files , dict ) :
files = raw_files
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
access_md = " **Résultat global QA :** ✅ CONFORME \n \n "
access_md + = " ### 🔗 Accès au projet \n \n "
if gitea_url :
access_md + = f " 🔗 **Lien vers le dépôt Gitea :** [Accéder au dépôt]( { gitea_url } ) \n \n "
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
project_title = dev_data . get ( " spec_title " , " projet " )
message_elements = [ ]
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
if files :
zip_buffer = io . BytesIO ( )
with zipfile . ZipFile ( zip_buffer , " w " , zipfile . ZIP_DEFLATED ) as zip_file :
for filepath , file_content in files . items ( ) :
zip_file . writestr ( filepath , file_content )
zip_bytes = zip_buffer . getvalue ( )
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
message_elements . append (
cl . File ( name = f " { project_title } .zip " , content = zip_bytes , display = " inline " )
2026-07-08 16:11:11 +02:00
)
2026-07-15 14:00:59 +02:00
else :
access_md + = " ⚠️ Aucun fichier source trouvé pour générer le ZIP. \n "
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
await cl . Message ( content = access_md , elements = message_elements ) . send ( )
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
cl . user_session . set ( " current_files " , files )
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
file_actions = [ ]
for filename in files . keys ( ) :
file_actions . append (
cl . Action (
name = " open_file " ,
payload = { " filename " : filename } ,
label = f " 📄 { filename } "
)
)
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
files_message = cl . Message (
content = " ### 📂 Code Source Produit \n \n Clique sur un fichier pour l ' afficher : " ,
actions = file_actions
)
await files_message . send ( )
cl . user_session . set ( " files_message " , files_message )
res = await cl . AskActionMessage (
content = " **Souhaitez-vous valider et packager ce livrable ?** " ,
actions = [
cl . Action ( name = " valider_projet " , payload = { " value " : " approve " } , label = " ✅ Valider & Livrer " , description = " Ajoute le statut conforme au README et livre l ' archive " ) ,
cl . Action ( name = " refuser_projet " , payload = { " value " : " refuse " } , label = " ❌ Refuser et corriger " , description = " Renvoie le projet au PM avec tes commentaires " )
] ,
timeout = 3600
2026-07-08 16:11:11 +02:00
) . send ( )
2026-07-15 14:00:59 +02:00
if res and res . get ( " name " ) == " valider_projet " :
await cl . Message ( content = " 🎉 **Projet validé et marqué comme conforme !** " ) . send ( )
new_state = inject_conforme_badge ( new_state )
readme_content = " # Projet \n \n --- \n 🛡️ **Statut QA :** ✅ Conforme \n "
dev_data = new_state . get ( " generated_code " , { } ) or { }
raw_files = dev_data . get ( " files " , { } )
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
if isinstance ( raw_files , list ) :
for f in raw_files :
if isinstance ( f , dict ) :
path = f . get ( " path " ) or f . get ( " filename " ) or f . get ( " name " ) or " "
if path . lower ( ) in [ " readme.md " , " readme " ] :
readme_content = f . get ( " content " ) or f . get ( " code " ) or " "
break
elif isinstance ( raw_files , dict ) :
for k , v in raw_files . items ( ) :
if k . lower ( ) in [ " readme.md " , " readme " ] :
readme_content = v
break
await push_readme_update_to_gitea ( gitea_url , readme_content )
new_state [ " status " ] = " human_approved "
cl . user_session . set ( " graph_state " , new_state )
2026-07-08 16:11:11 +02:00
async with httpx . AsyncClient ( ) as client :
resp = await client . post ( " http://127.0.0.1:8000/api/workflow/run " , json = new_state , timeout = 600.0 )
2026-07-15 14:00:59 +02:00
final_state = resp . json ( )
cl . user_session . set ( " graph_state " , final_state )
elif res and res . get ( " name " ) == " refuser_projet " :
feedback_user = await cl . AskUserMessage (
content = " Veuillez décrire les corrections ou les modifications à apporter au projet. " ,
timeout = 600
) . send ( )
2026-07-08 16:11:11 +02:00
2026-07-15 14:00:59 +02:00
if feedback_user :
new_state [ " status " ] = " human_refused "
new_state [ " user_feedback " ] = feedback_user [ " output " ]
cl . user_session . set ( " graph_state " , new_state )
await cl . Message ( content = " 🔄 **Feedback transmis.** Prise en compte des modifications... " ) . send ( )
async with httpx . AsyncClient ( ) as client :
resp = await client . post ( " http://127.0.0.1:8000/api/workflow/run " , json = new_state , timeout = 600.0 )
loop_state = resp . json ( )
cl . user_session . set ( " graph_state " , loop_state )
await render_workflow_state ( loop_state )
# ==================== PARCOURS 2 : LE PROJET EST NON CONFORME ====================
else :
qa_feedback = qa_res . get ( " feedback " , " L ' agent QA a détecté des anomalies majeures sans description. " )
non_compliant_md = (
" 🚨 **Projet Non Conforme** \n \n "
" Malheureusement, le code produit par ARC n ' a pas passé les critères de sécurité ou de qualité requis. \n \n "
f " ### 📋 Rapport d ' audit QA : \n "
f " > { qa_feedback } \n \n "
" Pour des raisons de sécurité et de qualité, le projet ne peut pas être livré en l ' état. \n \n "
)
await cl . Message ( content = non_compliant_md ) . send ( )
res = await cl . AskActionMessage (
content = " **Que souhaitez-vous faire ?** " ,
actions = [
cl . Action ( name = " recommencer_projet " , payload = { " value " : " restart " } , label = " 🔄 Recommencer de zéro " , description = " Supprime le dépôt et réinitialise l ' analyse " ) ,
cl . Action ( name = " abandonner_projet " , payload = { " value " : " abort " } , label = " 🗑️ Abandonner & Quitter " , description = " Supprime le dépôt et réinitialise l ' application " )
] ,
timeout = 3600
) . send ( )
if res and res . get ( " name " ) in [ " recommencer_projet " , " abandonner_projet " ] :
await cl . Message ( content = " 🗑️ **Nettoyage en cours... Suppression du dépôt Gitea...** " ) . send ( )
# Suppression du dépôt distant
await delete_gitea_repository ( gitea_url )
if res . get ( " name " ) == " recommencer_projet " :
reset_msg = (
" 🔄 **Ancien projet supprimé avec succès.** \n \n "
" Faisons table rase ! Décris-moi ton besoin logiciel pour repartir sur de nouvelles bases : 👇 "
)
await reset_to_start ( reset_msg )
else :
reset_msg = (
" ❌ **Session fermée et dépôt nettoyé.** \n \n "
" Si tu as un nouveau besoin à soumettre plus tard, envoie simplement un message pour démarrer. "
)
await reset_to_start ( reset_msg )
2026-07-08 16:11:11 +02:00
elif current_status == " delivered " :
await cl . Message ( content = " ✅ Ce projet a déjà été traité et livré. " ) . send ( )
2026-06-16 11:27:41 +02:00
else :
await cl . Message (
2026-07-08 16:11:11 +02:00
content = f " Résultat du traitement : \n ```json \n { json . dumps ( new_state , indent = 2 , ensure_ascii = False ) } \n ``` "
) . send ( )
# --- ÉVÉNEMENTS CHAINLIT STANDARDS ---
@cl.action_callback ( " open_file " )
async def on_open_file ( action : cl . Action ) :
filename = action . payload . get ( " filename " )
files = cl . user_session . get ( " current_files " , { } )
content = files . get ( filename , " " )
2026-07-09 10:23:04 +02:00
if content is None :
content = " "
if not str ( content ) . strip ( ) :
content = " (Fichier vide) "
2026-07-08 16:11:11 +02:00
ext = filename . split ( " . " ) [ - 1 ] if " . " in filename else " text "
is_md = ext in ( " md " , " markdown " )
files_message = cl . user_session . get ( " files_message " )
message_id = files_message . id if files_message else None
old_viewer = cl . user_session . get ( " side_code_viewer " )
if old_viewer :
await old_viewer . remove ( )
new_viewer = cl . Text (
name = filename ,
2026-07-09 10:23:04 +02:00
content = str ( content ) ,
2026-07-08 16:11:11 +02:00
language = None if is_md else ext ,
display = " side " ,
)
await new_viewer . send ( for_id = message_id )
cl . user_session . set ( " side_code_viewer " , new_viewer )
2026-07-15 14:00:59 +02:00
2026-07-08 16:11:11 +02:00
@cl.on_chat_start
async def on_chat_start ( ) :
2026-07-15 14:00:59 +02:00
await reset_to_start ( )
2026-07-08 16:11:11 +02:00
@cl.on_message
async def on_message ( message : cl . Message ) :
state = cl . user_session . get ( " graph_state " )
if " chat_history " not in state :
state [ " chat_history " ] = [ ]
if " status " not in state :
state [ " status " ] = " start "
if state . get ( " status " ) == " spec_incomplete " :
state [ " user_feedback " ] = message . content
else :
state [ " user_input " ] = message . content
state [ " user_feedback " ] = None
async with httpx . AsyncClient ( ) as client :
response = await client . post (
" http://127.0.0.1:8000/api/workflow/run " ,
json = state ,
timeout = 600.0
)
new_state = response . json ( )
cl . user_session . set ( " graph_state " , new_state )
await render_workflow_state ( new_state )