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:
240
web_dashboard/static/js/chat.js
Normal file
240
web_dashboard/static/js/chat.js
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user