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>
241 lines
7.9 KiB
JavaScript
241 lines
7.9 KiB
JavaScript
// 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();
|
|
});
|