feat: Léa chat + IRBuilder enrichi (stratégies V4 complètes)

Aspect 2/4 Léa : interface conversationnelle
- chat_interface.py : ChatSession thread-safe, états idle/planning/awaiting/executing/done
- 5 endpoints REST : /api/v1/chat/* (session, message, history, confirm, sessions)
- web_dashboard/chat.html + chat.js : UI minimaliste, polling 2s, pas de framework
- Proxy Flask /api/chat/* → serveur streaming
- 34 tests (happy path, abandon, refus, erreurs, gemma4 down)

IRBuilder enrichi pour plans V4 complets
- _event_to_action() appelle enrich_click_from_screenshot() quand session_dir dispo
- Chaque clic porte _enrichment (by_text OCR, anchor_image_base64, vlm_description)
- ExecutionCompiler consomme l'enrichissement pour produire 3 stratégies par clic
  Avant : [ocr] uniquement, target="unknown_window"
  Après : [ocr, template, vlm] avec vrai texte OCR ("Rechercher", "Ouvrir")

Validé sur session réelle : 10/10 clics enrichis (by_text + anchor + vlm_description)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-10 09:01:13 +02:00
parent a6eb4c168f
commit f541bb8ce4
8 changed files with 2241 additions and 26 deletions

View File

@@ -0,0 +1,240 @@
// chat.js — Client Léa conversationnelle
// Logique minimaliste : pas de framework, fetch + polling.
const API_BASE = "/api/chat"; // Proxyfié par le dashboard Flask vers :5005
let sessionId = null;
let pollTimer = null;
let lastMessageCount = 0;
let currentState = "idle";
const STATE_LABELS = {
idle: "En attente",
planning: "Léa réfléchit…",
awaiting_confirmation: "En attente de confirmation",
executing: "Léa exécute le workflow…",
done: "Terminé",
error: "Erreur",
};
// -----------------------------------------------------------------------------
// Initialisation
// -----------------------------------------------------------------------------
async function initChat() {
try {
const resp = await fetch(`${API_BASE}/session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machine_id: "default" }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
sessionId = data.session_id;
currentState = data.state || "idle";
updateStatus(currentState);
renderMessages(data.history || []);
document.getElementById("sessionInfo").textContent = `Session ${sessionId}`;
startPolling();
} catch (err) {
console.error("Impossible de créer la session chat :", err);
showSystemMessage(`Impossible de créer la session chat : ${err.message}. Vérifiez que le serveur streaming (5005) est démarré.`);
}
}
// -----------------------------------------------------------------------------
// Envoi de messages
// -----------------------------------------------------------------------------
async function sendMessage() {
const input = document.getElementById("composerInput");
const text = (input.value || "").trim();
if (!text || !sessionId) return;
const sendBtn = document.getElementById("sendBtn");
sendBtn.disabled = true;
input.value = "";
autosizeTextarea();
// Affichage optimiste
appendMessage({
role: "user",
content: text,
timestamp: Date.now() / 1000,
});
try {
updateStatus("planning");
const resp = await fetch(`${API_BASE}/${sessionId}/message`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
currentState = data.state || "idle";
updateStatus(currentState);
renderMessages(data.history || []);
} catch (err) {
console.error("Erreur envoi message :", err);
showSystemMessage(`Erreur : ${err.message}`);
updateStatus("error");
} finally {
sendBtn.disabled = false;
input.focus();
}
}
async function confirmPlan(confirmed) {
if (!sessionId) return;
const confirmBar = document.getElementById("confirmBar");
confirmBar.classList.remove("visible");
try {
const resp = await fetch(`${API_BASE}/${sessionId}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmed }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
currentState = data.state || "idle";
updateStatus(currentState);
renderMessages(data.history || []);
} catch (err) {
console.error("Erreur confirmation :", err);
showSystemMessage(`Erreur confirmation : ${err.message}`);
}
}
// -----------------------------------------------------------------------------
// Polling
// -----------------------------------------------------------------------------
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollHistory, 2000);
}
async function pollHistory() {
if (!sessionId) return;
try {
const resp = await fetch(`${API_BASE}/${sessionId}/history`);
if (!resp.ok) return;
const data = await resp.json();
const snap = data.snapshot || {};
currentState = snap.state || "idle";
updateStatus(currentState, snap.progress || {});
const messages = snap.messages || [];
if (messages.length !== lastMessageCount) {
renderMessages(messages);
}
} catch (err) {
// Silencieux — on réessayera au prochain tick
}
}
// -----------------------------------------------------------------------------
// Rendu
// -----------------------------------------------------------------------------
function renderMessages(messages) {
const container = document.getElementById("messages");
container.innerHTML = "";
messages.forEach(msg => appendMessage(msg, false));
lastMessageCount = messages.length;
container.scrollTop = container.scrollHeight;
// Afficher/masquer la barre de confirmation
const confirmBar = document.getElementById("confirmBar");
if (currentState === "awaiting_confirmation") {
confirmBar.classList.add("visible");
} else {
confirmBar.classList.remove("visible");
}
}
function appendMessage(msg, autoscroll = true) {
const container = document.getElementById("messages");
const div = document.createElement("div");
div.className = `message ${msg.role}`;
const avatar = document.createElement("div");
avatar.className = "avatar";
if (msg.role === "user") avatar.textContent = "Vous";
else if (msg.role === "lea") avatar.textContent = "L";
else avatar.textContent = "i";
const bubbleWrap = document.createElement("div");
const bubble = document.createElement("div");
bubble.className = "bubble";
bubble.textContent = msg.content || "";
bubbleWrap.appendChild(bubble);
const ts = document.createElement("div");
ts.className = "timestamp";
try {
const d = new Date((msg.timestamp || 0) * 1000);
ts.textContent = d.toLocaleTimeString("fr-FR");
} catch (e) { ts.textContent = ""; }
bubbleWrap.appendChild(ts);
div.appendChild(avatar);
div.appendChild(bubbleWrap);
container.appendChild(div);
if (autoscroll) container.scrollTop = container.scrollHeight;
}
function showSystemMessage(text) {
appendMessage({
role: "system",
content: text,
timestamp: Date.now() / 1000,
});
}
function updateStatus(state, progress = {}) {
const dot = document.getElementById("statusDot");
const txt = document.getElementById("statusText");
dot.className = `status-dot ${state}`;
let label = STATE_LABELS[state] || state;
if (state === "executing" && progress && progress.total_actions) {
const done = progress.completed_actions || 0;
const total = progress.total_actions || 0;
label = `Léa exécute… ${done}/${total}`;
}
txt.textContent = label;
// Bloquer la saisie pendant planning/executing
const input = document.getElementById("composerInput");
const sendBtn = document.getElementById("sendBtn");
const blocked = (state === "planning" || state === "executing");
input.disabled = blocked;
sendBtn.disabled = blocked;
}
// -----------------------------------------------------------------------------
// UX composer
// -----------------------------------------------------------------------------
function handleKeydown(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
function autosizeTextarea() {
const input = document.getElementById("composerInput");
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 120) + "px";
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("composerInput");
input.addEventListener("input", autosizeTextarea);
initChat();
});