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-08 16:11:11 +02:00
|
|
|
# --- FONCTION DE RENDU DE L'UI SELON LE STATUT ---
|
|
|
|
|
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
|
|
|
# Rappel de la fonction pour traiter le nouvel état de review
|
|
|
|
|
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"]:
|
|
|
|
|
dev_data = new_state.get("generated_code", {})
|
|
|
|
|
qa_res = new_state.get("qa_result", {})
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
is_safe = qa_res.get("is_complete_and_safe", False)
|
|
|
|
|
badge_qa = "✅ CONFORME" if is_safe else "⚠️ SÉCURITÉ/QUALITÉ À VÉRIFIER"
|
|
|
|
|
|
|
|
|
|
gitea_url = dev_data.get("repo_url", "")
|
|
|
|
|
# Correction de l'URL pour y accéder depuis ton navigateur (hors réseau Docker)
|
|
|
|
|
if gitea_url and "git-arc:3000" in gitea_url:
|
|
|
|
|
gitea_url = gitea_url.replace("git-arc:3000", "localhost:3000")
|
|
|
|
|
|
|
|
|
|
access_md = f"**Résultat global QA :** {badge_qa}\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"
|
|
|
|
|
|
|
|
|
|
# 2. Gestion du bouton de téléchargement ZIP (Correction finale de la variable)
|
|
|
|
|
project_title = new_state.get("generated_code", {}).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()
|
|
|
|
|
|
|
|
|
|
# 3. Code Source Produit (Regroupé dans un seul message avec ses actions)
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
# 4. Demande de validation (Boutons Valider / Refuser)
|
|
|
|
|
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="Génère l'archive et indexe le projet"),
|
|
|
|
|
cl.Action(name="refuser_projet", payload={"value": "refuse"}, label="❌ Refuser et corriger", description="Renvoie le projet au PM avec vos commentaires")
|
|
|
|
|
],
|
|
|
|
|
timeout=3600
|
|
|
|
|
).send()
|
2026-06-16 11:27:41 +02:00
|
|
|
|
2026-07-08 16:11:11 +02:00
|
|
|
if res and res.get("name") == "valider_projet":
|
|
|
|
|
await cl.Message(content="🎉 **Projet validé.**").send()
|
|
|
|
|
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":
|
|
|
|
|
await cl.Message(content="❌ **Projet refusé.**").send()
|
|
|
|
|
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"]
|
|
|
|
|
new_state["status"] = "spec_incomplete"
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
if loop_state.get("status") == "spec_incomplete":
|
|
|
|
|
question = loop_state.get("spec", {}).get("clarifying_question")
|
|
|
|
|
await cl.Message(content=f"**ARC a analysé vos retours mais a besoin d'une précision :**\n\n{question}").send()
|
|
|
|
|
|
|
|
|
|
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, "")
|
|
|
|
|
|
|
|
|
|
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=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():
|
|
|
|
|
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)
|
|
|
|
|
await cl.Message(content="Bonjour 👋 Je suis ARC. Décris-moi ton besoin logiciel.").send()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
# Appel du rendu
|
|
|
|
|
await render_workflow_state(new_state)
|