mejoras varias en frontend, separacion de intent y state, pick de articulos

This commit is contained in:
Lucas Tettamanti
2026-01-06 15:50:02 -03:00
parent dab52492b4
commit 8bb21b4edb
17 changed files with 1826 additions and 209 deletions

View File

@@ -5,6 +5,8 @@ class ChatSimulator extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._lastPayload = null;
this._sending = false;
this.shadowRoot.innerHTML = `
<style>
:host { display:block; padding:12px; }
@@ -15,12 +17,8 @@ class ChatSimulator extends HTMLElement {
textarea { width:100%; min-height:70px; resize:vertical; }
button { cursor:pointer; }
button.primary { background:#1f6feb; border-color:#1f6feb; }
.chatlog { display:flex; flex-direction:column; gap:8px; max-height:280px; overflow:auto; padding-right:6px; margin-top:8px; }
/* WhatsApp-ish dark theme bubbles */
.msg { max-width:90%; padding:8px 10px; border-radius:14px; border:1px solid #253245; font-size:13px; line-height:1.35; white-space:pre-wrap; word-break:break-word; box-shadow: 0 1px 0 rgba(0,0,0,.35); }
.msg.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
.msg.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
button:disabled { opacity:.6; cursor:not-allowed; }
.status { margin-top:8px; font-size:12px; color:#8aa0b5; }
</style>
<div class="box">
@@ -35,19 +33,15 @@ class ChatSimulator extends HTMLElement {
<input id="evoTo" style="flex:1" value="5491137887040@s.whatsapp.net" placeholder="to (destino receptor)" />
</div>
<div class="row" style="margin-top:8px">
<input id="pushName" style="flex:1" value="SimUser" placeholder="pushName (opcional)" />
<input id="pushName" style="flex:1" value="test_lucas" placeholder="pushName (opcional)" />
</div>
<div class="muted" style="margin-top:8px">Chat</div>
<div class="chatlog" id="log"></div>
<textarea id="evoText" placeholder="Texto a enviar por Evolution…"></textarea>
<div class="muted" style="margin-top:7px">Enviar mensaje</div>
<textarea id="evoText" style="margin-top:8px" placeholder="Texto a enviar por Evolution…"></textarea>
<div class="row" style="margin-top:8px">
<button class="primary" id="sendEvo" style="flex:1">Send</button>
<button id="retry" style="width:140px">Retry last</button>
</div>
</div>
<div class="box">
<div class="muted">Última respuesta (raw)</div>
<pre id="raw">—</pre>
<div class="status" id="status">—</div>
</div>
`;
}
@@ -59,8 +53,54 @@ class ChatSimulator extends HTMLElement {
const evoPushEl = this.shadowRoot.getElementById("pushName");
const evoTextEl = this.shadowRoot.getElementById("evoText");
const sendEvoEl = this.shadowRoot.getElementById("sendEvo");
const retryEl = this.shadowRoot.getElementById("retry");
const statusEl = this.shadowRoot.getElementById("status");
const sendAction = async () => {
const genId = () =>
(self.crypto?.randomUUID?.() || `${Date.now()}${Math.random()}`)
.replace(/-/g, "")
.slice(0, 22)
.toUpperCase();
const buildPayload = ({ text, from, to, instance, pushName }) => {
const nowSec = Math.floor(Date.now() / 1000);
return {
body: {
event: "messages.upsert",
instance,
data: {
key: {
remoteJid: from,
fromMe: false,
id: genId(),
participant: "",
addressingMode: "pn",
},
pushName: pushName || "test_lucas",
status: "DELIVERY_ACK",
message: { conversation: text },
messageType: "conversation",
messageTimestamp: nowSec,
instanceId: genId(),
source: "sim",
to,
},
date_time: new Date().toISOString(),
sender: from,
server_url: "http://localhost",
apikey: "SIM",
},
};
};
const setSending = (v) => {
this._sending = Boolean(v);
sendEvoEl.disabled = this._sending;
retryEl.disabled = this._sending;
statusEl.textContent = this._sending ? "Enviando…" : "—";
};
const sendAction = async ({ payloadOverride = null } = {}) => {
const instance = evoInstanceEl.value.trim() || "Piaf";
const from = evoFromEl.value.trim() || "5491133230322@s.whatsapp.net"; // cliente
const to = evoToEl.value.trim() || "5491137887040@s.whatsapp.net"; // canal/destino
@@ -72,57 +112,53 @@ class ChatSimulator extends HTMLElement {
return;
}
const nowSec = Math.floor(Date.now() / 1000);
const genId = () =>
(self.crypto?.randomUUID?.() || `${Date.now()}${Math.random()}`)
.replace(/-/g, "")
.slice(0, 22)
.toUpperCase();
const payload = {
body: {
event: "messages.upsert",
instance,
data: {
key: {
// remoteJid debe ser el cliente (buyer)
remoteJid: from,
fromMe: false,
id: genId(),
participant: "",
addressingMode: "pn",
},
pushName: pushName || "SimUser",
status: "DELIVERY_ACK",
message: { conversation: text },
messageType: "conversation",
messageTimestamp: nowSec,
instanceId: genId(),
source: "sim",
},
date_time: new Date().toISOString(),
sender: from,
server_url: "http://localhost",
apikey: "SIM",
},
};
const data = await api.simEvolution(payload);
this.shadowRoot.getElementById("raw").textContent = JSON.stringify(data, null, 2);
console.log("[evolution sim] webhook response:", data);
if (!data.ok) {
this.append("bot", "Error en Evolution Sim.");
return;
}
// Optimistic: que aparezca en la columna izquierda al instante
emit("conversation:upsert", {
chat_id: from,
from: pushName || "test_lucas",
state: "IDLE",
intent: "other",
status: "ok",
last_activity: new Date().toISOString(),
last_run_id: null,
});
emit("ui:selectedChat", { chat_id: from });
this.append("user", text);
this.append("bot", `[Evolution] enviado (sim): ${text}`);
evoTextEl.value = "";
const payload = payloadOverride || buildPayload({ text, from, to, instance, pushName });
this._lastPayload = { ...payload, body: { ...payload.body, data: { ...payload.body.data, key: { ...payload.body.data.key, id: genId() } } } };
setSending(true);
try {
const data = await api.simEvolution(payload);
console.log("[evolution sim] webhook response:", data);
if (!data.ok) {
statusEl.textContent = "Error enviando (ver consola)";
return;
}
evoTextEl.value = "";
evoTextEl.focus();
} catch (e) {
statusEl.textContent = `Error: ${String(e?.message || e)}`;
} finally {
setSending(false);
}
};
sendEvoEl.onclick = sendAction;
retryEl.onclick = () => {
const chat_id = evoFromEl.value.trim() || "5491133230322@s.whatsapp.net";
setSending(true);
api
.retryLast(chat_id)
.then((r) => {
if (!r?.ok) statusEl.textContent = `Retry error: ${r?.error || "unknown"}`;
else statusEl.textContent = "Retry enviado.";
})
.catch((e) => (statusEl.textContent = `Retry error: ${String(e?.message || e)}`))
.finally(() => setSending(false));
};
retryEl.disabled = false;
evoTextEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -144,12 +180,8 @@ class ChatSimulator extends HTMLElement {
}
append(who, text) {
const log = this.shadowRoot.getElementById("log");
const el = document.createElement("div");
el.className = "msg " + (who === "user" ? "user" : "bot");
el.textContent = text;
log.appendChild(el);
log.scrollTop = log.scrollHeight;
void who;
void text;
}
}

View File

@@ -7,6 +7,9 @@ class ConversationList extends HTMLElement {
this.attachShadow({ mode: "open" });
this.conversations = [];
this.selected = null;
this.tab = "chats"; // chats | users
this.users = [];
this.selectedUser = null;
this.shadowRoot.innerHTML = `
<style>
@@ -15,6 +18,11 @@ class ConversationList extends HTMLElement {
.row { display:flex; gap:8px; align-items:center; }
input,select,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
button { cursor:pointer; }
button.ghost { background:transparent; }
button:disabled { opacity:.6; cursor:not-allowed; }
.tabs { display:flex; gap:8px; margin-bottom:10px; }
.tab { flex:1; text-align:center; padding:8px; border-radius:8px; border:1px solid #253245; cursor:pointer; color:#8aa0b5; background:#121823; }
.tab.active { border-color:#1f6feb; color:#e7eef7; background:#0f1520; }
.list { display:flex; flex-direction:column; gap:8px; }
.item { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; cursor:pointer; }
.item:hover { border-color:#2b3b52; }
@@ -25,12 +33,24 @@ class ConversationList extends HTMLElement {
.chip { display:inline-flex; align-items:center; gap:6px; padding:3px 8px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:12px; color:#8aa0b5; }
.dot { width:8px; height:8px; border-radius:50%; }
.ok{ background:#2ecc71 } .warn{ background:#f1c40f } .err{ background:#e74c3c }
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:8px; flex-wrap:wrap; }
</style>
<div class="box">
<div class="tabs">
<div class="tab active" id="tabChats">Chats</div>
<div class="tab" id="tabUsers">Usuarios</div>
</div>
<div class="box" id="filtersBox">
<div class="row">
<input id="q" style="flex:1" placeholder="Buscar chat_id / teléfono…" />
<button id="refresh">Refresh</button>
</div>
<div class="box" id="userActions" style="display:none; margin-top:8px">
<div class="actions" style="justify-content:flex-start">
<button class="ghost" id="uaSelect">Chat</button>
<button class="ghost" id="uaDeleteConv">Borrar Chat</button>
<button id="uaDeleteUser">Borrar User</button>
</div>
</div>
<div class="row" style="margin-top:8px">
<select id="status" style="flex:1">
@@ -51,7 +71,9 @@ class ConversationList extends HTMLElement {
}
connectedCallback() {
this.shadowRoot.getElementById("refresh").onclick = () => this.refresh();
this.shadowRoot.getElementById("tabChats").onclick = () => this.setTab("chats");
this.shadowRoot.getElementById("tabUsers").onclick = () => this.setTab("users");
this.shadowRoot.getElementById("q").oninput = () => {
clearTimeout(this._t);
this._t = setTimeout(() => this.refresh(), 250);
@@ -59,7 +81,31 @@ class ConversationList extends HTMLElement {
this.shadowRoot.getElementById("status").onchange = () => this.refresh();
this.shadowRoot.getElementById("state").onchange = () => this.refresh();
// acciones del usuario seleccionado (solo en tab Users)
this.shadowRoot.getElementById("uaSelect").onclick = () => {
if (!this.selectedUser) return;
emit("ui:selectedChat", { chat_id: this.selectedUser.chat_id });
this.setTab("chats");
};
this.shadowRoot.getElementById("uaDeleteConv").onclick = async () => {
if (!this.selectedUser) return;
const chat_id = this.selectedUser.chat_id;
if (!confirm(`¿Borrar conversación completa de ${chat_id}?`)) return;
await api.deleteConversation(chat_id);
this.selectedUser = null;
await this.refreshUsers();
};
this.shadowRoot.getElementById("uaDeleteUser").onclick = async () => {
if (!this.selectedUser) return;
const chat_id = this.selectedUser.chat_id;
if (!confirm(`¿Borrar usuario ${chat_id}, sus conversaciones y el customer en Woo?`)) return;
await api.deleteUser(chat_id, { deleteWoo: true });
this.selectedUser = null;
await this.refreshUsers();
};
this._unsub1 = on("conversation:upsert", (conv) => {
if (this.tab !== "chats") return;
const idx = this.conversations.findIndex(x => x.chat_id === conv.chat_id);
if (idx >= 0) this.conversations[idx] = conv;
else this.conversations.unshift(conv);
@@ -79,18 +125,89 @@ class ConversationList extends HTMLElement {
}
async refresh() {
const q = this.shadowRoot.getElementById("q").value || "";
const status = this.shadowRoot.getElementById("status").value || "";
const state = this.shadowRoot.getElementById("state").value || "";
const data = await api.conversations({ q, status, state });
this.conversations = data.items || [];
this.render();
try {
if (this.tab === "users") return await this.refreshUsers();
const q = this.shadowRoot.getElementById("q").value || "";
const status = this.shadowRoot.getElementById("status").value || "";
const state = this.shadowRoot.getElementById("state").value || "";
const data = await api.conversations({ q, status, state });
this.conversations = data.items || [];
this.render();
} catch (e) {
console.warn("[conversation-list] refresh failed", e);
}
}
async refreshUsers() {
try {
const q = this.shadowRoot.getElementById("q").value || "";
const data = await api.users({ q, limit: 200 });
this.users = data.items || [];
this.render();
} catch (e) {
console.warn("[conversation-list] refreshUsers failed", e);
}
}
setTab(tab) {
this.tab = tab;
this.shadowRoot.getElementById("tabChats").classList.toggle("active", tab === "chats");
this.shadowRoot.getElementById("tabUsers").classList.toggle("active", tab === "users");
// en users mantenemos el buscador; ocultamos filtros avanzados (status/state)
this.shadowRoot.getElementById("filtersBox").style.display = "block";
const adv = this.shadowRoot.getElementById("status")?.closest(".row");
if (adv) adv.style.display = tab === "chats" ? "flex" : "none";
const qEl = this.shadowRoot.getElementById("q");
qEl.placeholder = tab === "users" ? "Buscar usuario (chat_id / pushName)…" : "Buscar chat_id / teléfono…";
// acciones visibles solo en users (si no hay selección, quedan disabled)
this.renderSelectedUserActions();
this.refresh();
}
renderSelectedUserActions() {
const box = this.shadowRoot.getElementById("userActions");
const inUsers = this.tab === "users";
box.style.display = inUsers ? "block" : "none";
const hasSel = Boolean(this.selectedUser);
const b1 = this.shadowRoot.getElementById("uaSelect");
const b2 = this.shadowRoot.getElementById("uaDeleteConv");
const b3 = this.shadowRoot.getElementById("uaDeleteUser");
if (b1) b1.disabled = !hasSel;
if (b2) b2.disabled = !hasSel;
if (b3) b3.disabled = !hasSel;
}
render() {
const list = this.shadowRoot.getElementById("list");
list.innerHTML = "";
if (this.tab === "users") {
// panel de acciones
this.renderSelectedUserActions();
for (const u of this.users) {
const el = document.createElement("div");
el.className = "item" + (this.selectedUser?.chat_id === u.chat_id ? " active" : "");
const name = u.push_name || u.chat_id.replace(/@.+$/, "");
el.innerHTML = `
<div class="row">
<div style="flex:1">
<div class="title">${name}</div>
<div class="muted">${u.chat_id}</div>
<div class="muted">woo_customer_id: ${u.external_customer_id || "—"}</div>
</div>
</div>
`;
el.onclick = () => {
this.selectedUser = u;
this.render();
};
list.appendChild(el);
}
return;
}
for (const c of this.conversations) {
const el = document.createElement("div");
el.className = "item" + (c.chat_id === this.selected ? " active" : "");
@@ -101,6 +218,7 @@ class ConversationList extends HTMLElement {
<div class="title">${c.from}</div>
<div class="muted">${c.chat_id}</div>
</div>
<button class="ghost" data-del="1">Borrar</button>
</div>
<div class="chips">
<span class="chip">state: ${c.state}</span>
@@ -108,7 +226,15 @@ class ConversationList extends HTMLElement {
<span class="chip">last: ${new Date(c.last_activity).toLocaleTimeString()}</span>
</div>
`;
el.onclick = () => {
el.onclick = async (e) => {
if (e?.target?.dataset?.del) {
e.stopPropagation();
if (!confirm(`¿Borrar conversación completa de ${c.chat_id}?`)) return;
await api.deleteConversation(c.chat_id);
if (this.selected === c.chat_id) this.selected = null;
await this.refresh();
return;
}
this.selected = c.chat_id;
this.render();
emit("ui:selectedChat", { chat_id: c.chat_id });

View File

@@ -0,0 +1,71 @@
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
class DebugPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host { display:block; padding:12px; }
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; }
.muted { color:#8aa0b5; font-size:12px; }
.kv { display:grid; grid-template-columns:90px 1fr; gap:6px 10px; margin:10px 0 12px; }
.k { color:#8aa0b5; font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
.v { font-size:13px; font-weight:800; color:#e7eef7; }
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
</style>
<div class="box">
<div class="muted">Detalle (click en cualquier burbuja)</div>
<div class="kv">
<div class="k">INTENT</div><div class="v" id="intent">—</div>
<div class="k">STATE</div><div class="v" id="state">—</div>
</div>
<pre id="out">—</pre>
</div>
`;
}
connectedCallback() {
const out = this.shadowRoot.getElementById("out");
const intentEl = this.shadowRoot.getElementById("intent");
const stateEl = this.shadowRoot.getElementById("state");
this._unsub = on("ui:selectedMessage", async ({ message }) => {
if (!message) {
out.textContent = "—";
intentEl.textContent = "—";
stateEl.textContent = "—";
return;
}
const base = { message };
if (message.run_id) {
try {
const run = await api.runById(message.run_id);
base.run = run;
const intent = run?.llm_output?.intent || "—";
const state = run?.llm_output?.next_state || "—";
intentEl.textContent = intent;
stateEl.textContent = state;
} catch (e) {
base.run_error = String(e?.message || e);
intentEl.textContent = "—";
stateEl.textContent = "—";
}
} else {
intentEl.textContent = "—";
stateEl.textContent = "—";
}
out.textContent = JSON.stringify(base, null, 2);
});
}
disconnectedCallback() {
this._unsub?.();
}
}
customElements.define("debug-panel", DebugPanel);

View File

@@ -17,6 +17,9 @@ class OpsShell extends HTMLElement {
.layout { flex:1; display:grid; grid-template-columns:320px 1fr 360px; min-height:0; }
.col { border-right:1px solid var(--line); min-height:0; overflow:auto; }
.col:last-child { border-right:none; }
.mid { display:flex; flex-direction:column; min-height:0; }
.midTop { flex:1; min-height:0; overflow:auto; border-bottom:1px solid var(--line); }
.midBottom { min-height:220px; overflow:auto; }
</style>
<div class="app">
@@ -28,8 +31,11 @@ class OpsShell extends HTMLElement {
<div class="layout">
<div class="col"><conversation-list></conversation-list></div>
<div class="col"><run-timeline></run-timeline></div>
<div class="col"><chat-simulator></chat-simulator></div>
<div class="col mid">
<div class="midTop"><run-timeline></run-timeline></div>
<div class="midBottom"><chat-simulator></chat-simulator></div>
</div>
<div class="col"><debug-panel></debug-panel></div>
</div>
</div>
`;

View File

@@ -1,5 +1,5 @@
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
import { emit, on } from "../lib/bus.js";
class RunTimeline extends HTMLElement {
constructor() {
@@ -7,7 +7,6 @@ class RunTimeline extends HTMLElement {
this.attachShadow({ mode: "open" });
this.chatId = null;
this.items = [];
this.selectedDebug = null;
this.shadowRoot.innerHTML = `
<style>
@@ -22,6 +21,10 @@ class RunTimeline extends HTMLElement {
.bubble.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
.bubble.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
.bubble.err { align-self:flex-start; background:#241214; border-color:#e74c3c; color:#ffe9ea; cursor:pointer; }
.name { display:block; font-size:12px; font-weight:800; margin-bottom:4px; opacity:.95; }
.bubble.user .name { color:#cdebd8; text-align:right; }
.bubble.bot .name { color:#c7d8ee; }
.bubble.err .name { color:#ffd0d4; }
.bubble .meta { display:block; margin-top:6px; font-size:11px; color:#8aa0b5; }
.bubble.user .meta { color:#b9d9c6; opacity:.85; }
.bubble.bot .meta { color:#a9bed6; opacity:.85; }
@@ -42,11 +45,6 @@ class RunTimeline extends HTMLElement {
<div class="chatlog" id="log"></div>
</div>
<div class="box">
<div class="muted">Debug (click en burbuja roja para ver detalles)</div>
<pre id="debug">—</pre>
</div>
`;
}
@@ -79,13 +77,20 @@ class RunTimeline extends HTMLElement {
if (!this.chatId) {
this.shadowRoot.getElementById("meta").textContent = "Seleccioná una conversación.";
this.shadowRoot.getElementById("log").innerHTML = "";
this.shadowRoot.getElementById("debug").textContent = "—";
return;
}
const data = await api.messages({ chat_id: this.chatId, limit: 200 });
this.items = data.items || [];
this.render();
try {
const data = await api.messages({ chat_id: this.chatId, limit: 200 });
this.items = data.items || [];
this.render();
} catch (e) {
this.items = [];
this.shadowRoot.getElementById("meta").textContent = `Error cargando mensajes: ${String(
e?.message || e
)}`;
this.shadowRoot.getElementById("log").innerHTML = "";
}
}
isErrorMsg(m) {
@@ -93,11 +98,19 @@ class RunTimeline extends HTMLElement {
return Boolean(m?.payload?.error) || txt.startsWith("[ERROR]") || txt.toLowerCase().includes("internal_error");
}
displayNameFor(m) {
// Inbound: usar pushName si vino del webhook; fallback al "from" (teléfono) si existe.
const pushName = m?.payload?.raw?.meta?.pushName || m?.payload?.raw?.meta?.pushname || null;
const from = m?.payload?.raw?.from || null;
if (m?.direction === "in") return pushName || from || "test_lucas";
// Outbound: nombre del bot
return "Piaf";
}
render() {
const meta = this.shadowRoot.getElementById("meta");
const count = this.shadowRoot.getElementById("count");
const log = this.shadowRoot.getElementById("log");
const dbg = this.shadowRoot.getElementById("debug");
meta.textContent = `Mostrando historial (últimos ${this.items.length}).`;
count.textContent = this.items.length ? `${this.items.length} msgs` : "";
@@ -109,19 +122,23 @@ class RunTimeline extends HTMLElement {
const isErr = this.isErrorMsg(m);
const bubble = document.createElement("div");
bubble.className = `bubble ${isErr ? "err" : who}`;
bubble.textContent = m.text || (isErr ? "Error" : "—");
const nameEl = document.createElement("span");
nameEl.className = "name";
nameEl.textContent = this.displayNameFor(m);
bubble.appendChild(nameEl);
const textEl = document.createElement("div");
textEl.textContent = m.text || (isErr ? "Error" : "—");
bubble.appendChild(textEl);
const metaEl = document.createElement("span");
metaEl.className = "meta";
metaEl.textContent = `${new Date(m.ts).toLocaleString()}${m.provider}${m.message_id}`;
bubble.appendChild(metaEl);
if (isErr) {
bubble.title = "Click para ver detalles (JSON)";
bubble.onclick = () => {
dbg.textContent = JSON.stringify(m, null, 2);
};
}
bubble.title = "Click para ver detalles (JSON)";
bubble.onclick = () => emit("ui:selectedMessage", { message: m });
log.appendChild(bubble);
}
@@ -129,7 +146,6 @@ class RunTimeline extends HTMLElement {
// auto-scroll
log.scrollTop = log.scrollHeight;
if (!this.items.length) dbg.textContent = "—";
}
}