import chainlit as cl import asyncio import zipfile import httpx import json import os import io # --- 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() 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") if current_status == "spec_incomplete": spec = new_state.get("spec", {}) question = spec.get("clarifying_question") await cl.Message(content=f"**Spécifications incomplètes**\n\n{question}").send() elif current_status == "spec_ready": spec = new_state.get("spec", {}) summary = "### Éléments importants à retenir de ton projet :\n\n" summary += f"- **Nom du projet** : {spec.get('title')}\n" summary += f"- **Description** : {spec.get('description')}\n" summary += "- **Actions** :\n" summary += "\n".join(f" - {req}" for req in spec.get("requirements", [])) + "\n" summary += "- **Contraintes** :\n" 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 ?**" 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) await render_workflow_state(final_state) 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() elif current_status in ["wait_human_review", "approved_by_human"]: dev_data = new_state.get("generated_code", {}) or {} qa_res = new_state.get("qa_result", {}) or {} 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") 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 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" project_title = dev_data.get("spec_title", "projet") message_elements = [] 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() message_elements.append( cl.File(name=f"{project_title}.zip", content=zip_bytes, display="inline") ) else: access_md += "⚠️ Aucun fichier source trouvé pour générer le ZIP.\n" await cl.Message(content=access_md, elements=message_elements).send() cl.user_session.set("current_files", files) file_actions = [] for filename in files.keys(): file_actions.append( cl.Action( name="open_file", payload={"filename": filename}, label=f"📄 {filename}" ) ) files_message = cl.Message( content="### 📂 Code Source Produit\n\nClique 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 ).send() 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", {}) 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) 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) 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() 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) elif current_status == "delivered": await cl.Message(content="✅ Ce projet a déjà été traité et livré.").send() else: await cl.Message( 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, "") if content is None: content = "" if not str(content).strip(): content = "(Fichier vide)" 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, content=str(content), 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) @cl.on_chat_start async def on_chat_start(): await reset_to_start() @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)