import { api } from "../lib/api.js"; import { emit, on } from "../lib/bus.js"; class ConversationInspector extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); this.chatId = null; this.messages = []; this.runs = []; this.rowOrder = []; this.rowMap = new Map(); this.heights = new Map(); this._playing = false; this._playIdx = 0; this._timer = null; this.shadowRoot.innerHTML = `
Inspector
Seleccioná una conversación.
`; } connectedCallback() { this.shadowRoot.getElementById("play").onclick = () => this.play(); this.shadowRoot.getElementById("pause").onclick = () => this.pause(); this.shadowRoot.getElementById("step").onclick = () => this.step(); this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => { // Si es el mismo chat, no recargar (para no borrar items optimistas) if (this.chatId === chat_id) return; this.chatId = chat_id; await this.loadData(); }); this._unsubRun = on("run:created", (run) => { if (this.chatId && run.chat_id === this.chatId) { this.loadData(); } }); this._unsubLayout = on("ui:bubblesLayout", ({ chat_id, items }) => { if (!this.chatId || chat_id !== this.chatId) return; this.heights = new Map((items || []).map((it) => [it.message_id, it.height || 0])); this.applyHeights(); }); this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop }) => { if (!this.chatId || chat_id !== this.chatId) return; const list = this.shadowRoot.getElementById("list"); list.scrollTop = scrollTop || 0; }); this._unsubSelectMessage = on("ui:selectedMessage", ({ message }) => { const messageId = message?.message_id || null; if (messageId) this.highlight(messageId); }); // Listen for optimistic messages to add placeholder item this._unsubOptimistic = on("message:optimistic", (msg) => { if (!this.chatId) { this.chatId = msg.chat_id; this.shadowRoot.getElementById("chat").textContent = msg.chat_id; this.shadowRoot.getElementById("meta").textContent = "Nueva conversación"; } if (msg.chat_id !== this.chatId) return; this.addOptimisticItem(msg); }); } disconnectedCallback() { this._unsubSel?.(); this._unsubRun?.(); this._unsubLayout?.(); this._unsubScroll?.(); this._unsubSelectMessage?.(); this._unsubOptimistic?.(); this.pause(); } async loadData() { const chatEl = this.shadowRoot.getElementById("chat"); const metaEl = this.shadowRoot.getElementById("meta"); const countEl = this.shadowRoot.getElementById("count"); const list = this.shadowRoot.getElementById("list"); chatEl.textContent = this.chatId || "—"; metaEl.textContent = "Cargando…"; countEl.textContent = ""; list.innerHTML = ""; if (!this.chatId) { metaEl.textContent = "Seleccioná una conversación."; return; } try { const [msgs, runs] = await Promise.all([ api.messages({ chat_id: this.chatId, limit: 200 }), api.runs({ chat_id: this.chatId, limit: 200 }), ]); this.messages = msgs.items || []; this.runs = runs.items || []; this.render(); this.applyHeights(); } catch (e) { metaEl.textContent = `Error cargando: ${String(e?.message || e)}`; this.messages = []; this.runs = []; } } runMap() { const map = new Map(); for (const r of this.runs || []) { map.set(r.run_id, r); } return map; } buildRows() { const runsById = this.runMap(); const rows = []; let nextRun = null; for (let i = this.messages.length - 1; i >= 0; i--) { const msg = this.messages[i]; const run = msg?.run_id ? runsById.get(msg.run_id) : null; if (run) nextRun = run; rows[i] = { message: msg, run, nextRun }; } return rows; } formatCart(items) { const list = Array.isArray(items) ? items : []; if (!list.length) return "—"; return list .map((it) => { const label = it.label || it.name || `#${it.product_id}`; const qty = it.quantity != null ? `${it.quantity}` : "?"; const unit = it.unit || ""; return `${label} (${qty}${unit ? " " + unit : ""})`; }) .join(" · "); } toolSummary(tools = []) { return tools.map((t) => ({ type: t.type || t.name || "tool", ok: t.ok !== false, error: t.error || null, })); } render() { const metaEl = this.shadowRoot.getElementById("meta"); const countEl = this.shadowRoot.getElementById("count"); const list = this.shadowRoot.getElementById("list"); metaEl.textContent = `Inspector de ${this.messages.length} mensajes.`; countEl.textContent = this.messages.length ? `${this.messages.length} filas` : ""; // Preserve optimistic items before clearing const optimisticItems = [...list.querySelectorAll('.item[data-message-id^="optimistic-"]')]; // Obtener timestamps de mensajes IN del servidor para comparar const serverInTimestamps = this.messages .filter(m => m.direction === "in") .map(m => new Date(m.ts).getTime()); list.innerHTML = ""; this.rowMap.clear(); this.rowOrder = []; const rows = this.buildRows(); for (const row of rows) { const msg = row.message; const run = row.run; const dir = msg?.direction === "in" ? "in" : "out"; const el = document.createElement("div"); el.className = `item ${dir}`; el.dataset.messageId = msg.message_id; const intent = run?.llm_output?.intent || "—"; const nextState = run?.llm_output?.next_state || "—"; const prevState = row.nextRun?.prev_state || "—"; const basket = run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || []; const tools = this.toolSummary(run?.tools || []); const llmMeta = run?.llm_output?._llm || null; const llmStatus = llmMeta?.audit?.validation?.ok === false ? "warn" : "ok"; const llmNote = llmMeta?.audit?.validation?.ok === false ? "NLU inválido (fallback)" : llmMeta?.audit?.validation?.retried ? "NLU ok (retry)" : "NLU ok"; el.innerHTML = `
${dir === "in" ? "IN" : "OUT"}
${new Date(msg.ts).toLocaleString()}
STATE
${dir === "out" ? nextState : prevState}
INTENT
${dir === "out" ? intent : "—"}
NLU
${dir === "out" && llmMeta ? llmNote : "—"}
Carrito: ${dir === "out" ? this.formatCart(basket) : "—"}
${tools .map( (t) => `${t.type}` ) .join("")}
`; el.onclick = () => { this.highlight(msg.message_id); }; list.appendChild(el); this.rowMap.set(msg.message_id, el); this.rowOrder.push(msg.message_id); } // Re-add preserved optimistic items ONLY if no server message covers it for (const optItem of optimisticItems) { // Obtener timestamp del optimista (está en el ID: optimistic-{timestamp}) const msgId = optItem.dataset.messageId; const optTs = parseInt(msgId.replace("optimistic-", ""), 10) || 0; // Si hay un mensaje del servidor con timestamp cercano (10 seg), no re-agregar const hasServerMatch = serverInTimestamps.some(ts => Math.abs(ts - optTs) < 10000); if (hasServerMatch) { continue; } list.appendChild(optItem); this.rowMap.set(msgId, optItem); this.rowOrder.push(msgId); } // Auto-scroll al final list.scrollTop = list.scrollHeight; } applyHeights() { const BUBBLE_MARGIN = 12; // same as .bubble margin-bottom in run-timeline const MIN_ITEM_HEIGHT = 120; // minimum height for inspector items for (const [messageId, el] of this.rowMap.entries()) { const bubbleHeight = this.heights.get(messageId) || 0; // bubbleHeight includes offsetHeight + marginBottom const bubbleContentHeight = Math.max(0, bubbleHeight - BUBBLE_MARGIN); // Use the max between bubble height and our minimum const targetHeight = Math.max(bubbleContentHeight, MIN_ITEM_HEIGHT); el.style.minHeight = `${targetHeight}px`; el.style.marginBottom = `${BUBBLE_MARGIN}px`; } // After applying, emit final heights back so bubbles can sync requestAnimationFrame(() => this.emitInspectorHeights()); } emitInspectorHeights() { const items = []; for (const [messageId, el] of this.rowMap.entries()) { const styles = window.getComputedStyle(el); const marginBottom = parseInt(styles.marginBottom || "0", 10) || 0; items.push({ message_id: messageId, height: (el.offsetHeight || 0) + marginBottom, }); } emit("ui:inspectorLayout", { chat_id: this.chatId, items }); } highlight(messageId) { for (const [id, el] of this.rowMap.entries()) { el.classList.toggle("active", id === messageId); } emit("ui:highlightMessage", { message_id: messageId }); const idx = this.rowOrder.indexOf(messageId); if (idx >= 0) this._playIdx = idx; } play() { if (this._playing) return; this._playing = true; this._timer = setInterval(() => this.step(), 800); } pause() { this._playing = false; if (this._timer) clearInterval(this._timer); this._timer = null; } step() { if (!this.rowOrder.length) return; if (this._playIdx >= this.rowOrder.length) { this.pause(); return; } const messageId = this.rowOrder[this._playIdx]; this.highlight(messageId); this._playIdx += 1; } addOptimisticItem(msg) { const list = this.shadowRoot.getElementById("list"); if (!list) return; // Remove any existing optimistic item const existing = list.querySelector(`.item[data-message-id^="optimistic-"]`); if (existing) existing.remove(); const el = document.createElement("div"); el.className = "item in"; el.dataset.messageId = msg.message_id; el.innerHTML = `
IN
${new Date(msg.ts).toLocaleString()}
STATE
INTENT
NLU
procesando...
Carrito:
`; list.appendChild(el); list.scrollTop = list.scrollHeight; this.rowMap.set(msg.message_id, el); this.rowOrder.push(msg.message_id); // Apply min height el.style.minHeight = "120px"; el.style.marginBottom = "12px"; } } customElements.define("conversation-inspector", ConversationInspector);