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

@@ -12,6 +12,7 @@ import { makeEvolutionWebhook } from "./src/controllers/evolution.js";
import { makeGetConversationState } from "./src/controllers/conversationState.js";
import { makeListMessages } from "./src/controllers/messages.js";
import { makeSearchProducts } from "./src/controllers/products.js";
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "./src/controllers/admin.js";
async function configureUndiciDispatcher() {
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling.
@@ -77,8 +78,12 @@ app.post("/sim/send", makeSimSend());
app.get("/conversations", makeGetConversations(() => TENANT_ID));
app.get("/conversations/state", makeGetConversationState(() => TENANT_ID));
app.delete("/conversations/:chat_id", makeDeleteConversation(() => TENANT_ID));
app.post("/conversations/:chat_id/retry-last", makeRetryLast(() => TENANT_ID));
app.get("/messages", makeListMessages(() => TENANT_ID));
app.get("/products", makeSearchProducts(() => TENANT_ID));
app.get("/users", makeListUsers(() => TENANT_ID));
app.delete("/users/:chat_id", makeDeleteUser(() => TENANT_ID));
app.get("/runs", makeListRuns(() => TENANT_ID));

View File

@@ -2,6 +2,7 @@ import "./components/ops-shell.js";
import "./components/conversation-list.js";
import "./components/run-timeline.js";
import "./components/chat-simulator.js";
import "./components/debug-panel.js";
import { connectSSE } from "./lib/sse.js";
connectSSE();

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 = "—";
}
}

View File

@@ -7,6 +7,25 @@ export const api = {
return fetch(u).then(r => r.json());
},
async deleteConversation(chat_id) {
if (!chat_id) throw new Error("chat_id_required");
return fetch(`/conversations/${encodeURIComponent(chat_id)}`, { method: "DELETE" }).then(r => r.json());
},
async users({ q = "", limit = 200 } = {}) {
const u = new URL("/users", location.origin);
if (q) u.searchParams.set("q", String(q));
u.searchParams.set("limit", String(limit));
return fetch(u).then(r => r.json());
},
async deleteUser(chat_id, { deleteWoo = false } = {}) {
if (!chat_id) throw new Error("chat_id_required");
const u = new URL(`/users/${encodeURIComponent(chat_id)}`, location.origin);
if (deleteWoo) u.searchParams.set("deleteWoo", "1");
return fetch(u, { method: "DELETE" }).then(r => r.json());
},
async messages({ chat_id, limit = 200 } = {}) {
const u = new URL("/messages", location.origin);
if (chat_id) u.searchParams.set("chat_id", chat_id);
@@ -21,6 +40,11 @@ export const api = {
return fetch(u).then(r => r.json());
},
async runById(run_id) {
if (!run_id) return null;
return fetch(`/runs/${encodeURIComponent(run_id)}`).then(r => r.json());
},
async simEvolution(payload) {
return fetch("/webhook/evolution", {
method: "POST",
@@ -28,4 +52,9 @@ export const api = {
body: JSON.stringify(payload),
}).then(r => r.json());
},
async retryLast(chat_id) {
if (!chat_id) throw new Error("chat_id_required");
return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json());
},
};

48
src/controllers/admin.js Normal file
View File

@@ -0,0 +1,48 @@
import { handleDeleteConversation, handleDeleteUser, handleListUsers, handleRetryLast } from "../handlers/admin.js";
export const makeDeleteConversation = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleDeleteConversation({ tenantId, chat_id: req.params.chat_id });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeListUsers = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleListUsers({ tenantId, q: req.query.q || "", limit: req.query.limit || "200" });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeDeleteUser = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const deleteWoo = String(req.query.deleteWoo || "0") === "1" || String(req.query.deleteWoo || "").toLowerCase() === "true";
const result = await handleDeleteUser({ tenantId, chat_id: req.params.chat_id, deleteWoo });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};
export const makeRetryLast = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const result = await handleRetryLast({ tenantId, chat_id: req.params.chat_id });
res.json(result);
} catch (err) {
console.error(err);
res.status(500).json({ ok: false, error: "internal_error" });
}
};

View File

@@ -55,6 +55,22 @@ export async function upsertConversationState({
return rows[0];
}
// Crea la conversación si no existe y, si existe, solo “toca” updated_at (no pisa state/context).
export async function touchConversationState({ tenant_id, wa_chat_id }) {
const q = `
insert into wa_conversation_state
(tenant_id, wa_chat_id, state, state_updated_at, last_intent, last_order_id, context, updated_at)
values
($1, $2, 'IDLE', now(), 'other', null, '{}'::jsonb, now())
on conflict (tenant_id, wa_chat_id)
do update set
updated_at = now()
returning tenant_id, wa_chat_id, state, last_intent, context, updated_at
`;
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
return rows[0] || null;
}
export async function insertMessage({
tenant_id,
wa_chat_id,
@@ -299,6 +315,146 @@ export async function listMessages({ tenant_id, wa_chat_id, limit = 200 }) {
}));
}
export async function deleteConversationData({ tenant_id, wa_chat_id }) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const r1 = await client.query(`delete from wa_messages where tenant_id=$1 and wa_chat_id=$2`, [
tenant_id,
wa_chat_id,
]);
const r2 = await client.query(`delete from conversation_runs where tenant_id=$1 and wa_chat_id=$2`, [
tenant_id,
wa_chat_id,
]);
const r3 = await client.query(`delete from wa_conversation_state where tenant_id=$1 and wa_chat_id=$2`, [
tenant_id,
wa_chat_id,
]);
await client.query("COMMIT");
return { ok: true, deleted: { messages: r1.rowCount, runs: r2.rowCount, state: r3.rowCount } };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
export async function listUsers({ tenant_id, q = "", limit = 200 }) {
const lim = Math.max(1, Math.min(500, parseInt(limit, 10) || 200));
const qstr = String(q || "").trim();
// Lista de “usuarios” = conversaciones existentes (wa_conversation_state), con join al mapping Woo si existe.
// Esto permite ver usuarios aunque nunca se haya creado el customer en Woo.
const sql = `
select *
from (
select s.wa_chat_id,
'woo' as provider,
m.external_customer_id,
lastmsg.ts as last_ts,
nullif(coalesce(lastmsg.payload #>> '{raw,meta,pushName}', lastmsg.payload #>> '{raw,meta,pushname}', ''), '') as push_name
from wa_conversation_state s
left join wa_identity_map m
on m.tenant_id = s.tenant_id
and m.wa_chat_id = s.wa_chat_id
and m.provider = 'woo'
left join lateral (
select ts, payload
from wa_messages
where tenant_id = s.tenant_id
and wa_chat_id = s.wa_chat_id
and direction = 'in'
order by ts desc
limit 1
) lastmsg on true
where s.tenant_id = $1
) t
where ($2 = '' or t.wa_chat_id ilike $3 or coalesce(t.push_name,'') ilike $3)
order by coalesce(t.last_ts, now()) desc
limit $4
`;
const like = qstr ? `%${qstr}%` : "";
const { rows } = await pool.query(sql, [tenant_id, qstr, like, lim]);
return rows.map((r) => ({
chat_id: r.wa_chat_id,
provider: r.provider,
external_customer_id: r.external_customer_id,
push_name: r.push_name || null,
last_ts: r.last_ts || null,
}));
}
export async function getLastInboundMessage({ tenant_id, wa_chat_id }) {
const q = `
select provider, message_id, ts, text, payload
from wa_messages
where tenant_id=$1 and wa_chat_id=$2 and direction='in'
order by ts desc
limit 1
`;
const { rows } = await pool.query(q, [tenant_id, wa_chat_id]);
return rows[0] || null;
}
export async function cleanupLastRunForRetry({ tenant_id, wa_chat_id }) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query(
`
select id
from conversation_runs
where tenant_id=$1 and wa_chat_id=$2
order by ts desc
limit 1
`,
[tenant_id, wa_chat_id]
);
const run_id = rows[0]?.id || null;
if (!run_id) {
await client.query("COMMIT");
return { ok: true, run_id: null, deleted_out_messages: 0, deleted_runs: 0 };
}
const r1 = await client.query(
`delete from wa_messages where tenant_id=$1 and wa_chat_id=$2 and run_id=$3 and direction='out'`,
[tenant_id, wa_chat_id, run_id]
);
const r2 = await client.query(`delete from conversation_runs where tenant_id=$1 and id=$2`, [tenant_id, run_id]);
await client.query("COMMIT");
return { ok: true, run_id, deleted_out_messages: r1.rowCount || 0, deleted_runs: r2.rowCount || 0 };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
export async function getIdentityMapByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
const q = `
select tenant_id, wa_chat_id, provider, external_customer_id, created_at, updated_at
from wa_identity_map
where tenant_id=$1 and wa_chat_id=$2 and provider=$3
limit 1
`;
const { rows } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
return rows[0] || null;
}
export async function deleteIdentityMapByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
const q = `delete from wa_identity_map where tenant_id=$1 and wa_chat_id=$2 and provider=$3`;
const { rowCount } = await pool.query(q, [tenant_id, wa_chat_id, provider]);
return rowCount || 0;
}
export async function getTenantByKey(key) {
const { rows } = await pool.query(`select id, key, name from tenants where key=$1`, [key]);
return rows[0] || null;

91
src/handlers/admin.js Normal file
View File

@@ -0,0 +1,91 @@
import crypto from "crypto";
import {
cleanupLastRunForRetry,
deleteConversationData,
deleteIdentityMapByChat,
getIdentityMapByChat,
getLastInboundMessage,
listUsers,
} from "../db/repo.js";
import { deleteWooCustomer } from "../services/woo.js";
import { processMessage } from "../services/pipeline.js";
export async function handleDeleteConversation({ tenantId, chat_id }) {
if (!chat_id) return { ok: false, error: "chat_id_required" };
const result = await deleteConversationData({ tenant_id: tenantId, wa_chat_id: String(chat_id) });
return { ok: true, ...result };
}
export async function handleListUsers({ tenantId, q = "", limit = "200" }) {
const items = await listUsers({ tenant_id: tenantId, q: String(q || ""), limit: parseInt(limit, 10) || 200 });
return { ok: true, items };
}
export async function handleDeleteUser({ tenantId, chat_id, deleteWoo = false }) {
const wa_chat_id = String(chat_id || "");
if (!wa_chat_id) return { ok: false, error: "chat_id_required" };
const mapping = await getIdentityMapByChat({ tenant_id: tenantId, wa_chat_id, provider: "woo" });
const external_customer_id = mapping?.external_customer_id || null;
// borrar conversaciones (mensajes + runs + state)
const convo = await deleteConversationData({ tenant_id: tenantId, wa_chat_id });
// borrar mapping
const deletedMap = await deleteIdentityMapByChat({ tenant_id: tenantId, wa_chat_id, provider: "woo" });
// borrar customer en woo (best-effort)
let woo = null;
if (deleteWoo && external_customer_id) {
try {
woo = await deleteWooCustomer({ tenantId, id: external_customer_id, force: true });
} catch (e) {
woo = { ok: false, error: String(e?.message || e), status: e?.status || e?.cause?.status || null };
}
}
return {
ok: true,
chat_id: wa_chat_id,
external_customer_id,
deleted: {
conversations: convo?.deleted || null,
identity_map: deletedMap,
woo,
},
};
}
export async function handleRetryLast({ tenantId, chat_id }) {
const wa_chat_id = String(chat_id || "");
if (!wa_chat_id) return { ok: false, error: "chat_id_required" };
const lastIn = await getLastInboundMessage({ tenant_id: tenantId, wa_chat_id });
if (!lastIn) return { ok: false, error: "no_inbound_message" };
// 1) borrar la última respuesta/run
const cleanup = await cleanupLastRunForRetry({ tenant_id: tenantId, wa_chat_id });
// 2) reinyectar el último mensaje del usuario como un nuevo message_id
const raw = lastIn.payload?.raw || {};
const text = String(lastIn.text || raw.text || "").trim();
if (!text) return { ok: false, error: "last_inbound_text_empty", cleanup };
const from = String(raw.from || wa_chat_id.replace(/@.+$/, ""));
const pushName = raw?.meta?.pushName || raw?.meta?.pushname || null;
const pm = await processMessage({
tenantId,
chat_id: wa_chat_id,
from,
text,
provider: lastIn.provider || "evolution",
message_id: crypto.randomUUID(),
displayName: pushName,
meta: { ...(raw.meta || {}), source: "retry_last" },
});
return { ok: true, cleanup, run_id: pm?.run_id || null };
}

View File

@@ -30,7 +30,7 @@ export async function handleEvolutionWebhook(body) {
text: parsed.text,
provider: "evolution",
message_id: parsed.message_id || crypto.randomUUID(),
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key },
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
});
console.log("[perf] evolution.webhook.end", {

View File

@@ -40,6 +40,7 @@ export function parseEvolutionWebhook(reqBody) {
// metadata
const pushName = data.pushName || null;
const ts = data.messageTimestamp ? new Date(Number(data.messageTimestamp) * 1000).toISOString() : null;
const source = data.source || null; // e.g. "sim"
return {
ok: true,
@@ -50,6 +51,7 @@ export function parseEvolutionWebhook(reqBody) {
from_name: pushName,
message_type: messageType || null,
ts,
source,
raw: body, // para log/debug si querés
};
}

View File

@@ -1,9 +1,25 @@
import OpenAI from "openai";
import { z } from "zod";
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
let _client = null;
let _clientKey = null;
export const openai = new OpenAI({ apiKey });
function getApiKey() {
return process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY || null;
}
function getClient() {
const apiKey = getApiKey();
if (!apiKey) {
const err = new Error("OPENAI_API_KEY is not set");
err.code = "OPENAI_NO_KEY";
throw err;
}
if (_client && _clientKey === apiKey) return _client;
_clientKey = apiKey;
_client = new OpenAI({ apiKey });
return _client;
}
const NextStateSchema = z.enum([
"IDLE",
@@ -53,6 +69,19 @@ const PlanSchema = z
})
.strict();
const ExtractItemSchema = z.object({
label: z.string().min(1),
quantity: z.number().positive(),
unit: z.enum(["kg", "g", "unit"]),
});
const ExtractSchema = z
.object({
intent: IntentSchema,
items: z.array(ExtractItemSchema).default([]),
})
.strict();
function extractJsonObject(text) {
const s = String(text || "");
const i = s.indexOf("{");
@@ -61,36 +90,29 @@ function extractJsonObject(text) {
return null;
}
/**
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
*
* - `promptSystem`: instrucciones del bot
* - `input`: { last_user_message, conversation_history, current_conversation_state, context }
*/
export async function llmPlan({ promptSystem, input, model } = {}) {
if (!apiKey) {
const err = new Error("OPENAI_API_KEY is not set");
err.code = "OPENAI_NO_KEY";
throw err;
}
async function jsonCompletion({ system, user, model }) {
const openai = getClient();
const chosenModel = model || process.env.OPENAI_MODEL || "gpt-4o-mini";
const debug = String(process.env.LLM_DEBUG || "") === "1";
if (debug) console.log("[llm] openai.request", { model: chosenModel });
const resp = await openai.chat.completions.create({
model: chosenModel,
temperature: 0.2,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
`${promptSystem}\n\n` +
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.",
},
{ role: "user", content: JSON.stringify(input ?? {}) },
{ role: "system", content: system },
{ role: "user", content: user },
],
});
if (debug)
console.log("[llm] openai.response", {
id: resp?.id || null,
model: resp?.model || null,
usage: resp?.usage || null,
});
const text = resp?.choices?.[0]?.message?.content || "";
let parsed;
try {
@@ -100,12 +122,51 @@ export async function llmPlan({ promptSystem, input, model } = {}) {
if (!extracted) throw new Error("openai_invalid_json");
parsed = JSON.parse(extracted);
}
return { parsed, raw_text: text, model: chosenModel, usage: resp?.usage || null };
}
/**
* Genera un "plan" de conversación (salida estructurada) usando OpenAI.
*
* - `promptSystem`: instrucciones del bot
* - `input`: { last_user_message, conversation_history, current_conversation_state, context }
*/
export async function llmPlan({ promptSystem, input, model } = {}) {
const system =
`${promptSystem}\n\n` +
"Respondé SOLO con un JSON válido (sin markdown). Respetá estrictamente el formato requerido.";
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
system,
user: JSON.stringify(input ?? {}),
model,
});
const plan = PlanSchema.parse(parsed);
return {
plan,
raw_text: text,
raw_text,
model: chosenModel,
usage: resp?.usage || null,
usage,
};
}
/**
* Paso 1: extracción de intención + items mencionados (sin resolver IDs).
* Devuelve SOLO: intent + items[{label, quantity, unit}]
*/
export async function llmExtract({ input, model } = {}) {
const system =
"Extraé intención e items del mensaje del usuario.\n" +
"Respondé SOLO JSON válido (sin markdown) con keys EXACTAS:\n" +
`intent (one of: ${IntentSchema.options.join("|")}), items (array of {label, quantity, unit(kg|g|unit)}).\n` +
"Si no hay items claros, devolvé items: [].";
const { parsed, raw_text, model: chosenModel, usage } = await jsonCompletion({
system,
user: JSON.stringify(input ?? {}),
model,
});
const extracted = ExtractSchema.parse(parsed);
return { extracted, raw_text, model: chosenModel, usage };
}

File diff suppressed because it is too large Load Diff

View File

@@ -377,3 +377,42 @@ export async function getWooCustomerById({ tenantId, id }) {
}
}
export async function deleteWooCustomer({ tenantId, id, force = true }) {
if (!id) return { ok: false, error: "missing_id" };
const encryptionKey = process.env.APP_ENCRYPTION_KEY;
if (!encryptionKey) throw new Error("APP_ENCRYPTION_KEY is required to decrypt Woo credentials");
const cfg = await getDecryptedTenantEcommerceConfig({
tenant_id: tenantId,
provider: "woo",
encryption_key: encryptionKey,
});
if (!cfg) throw new Error("Woo config not found for tenant");
const consumerKey =
cfg.consumer_key ||
process.env.WOO_CONSUMER_KEY ||
(() => {
throw new Error("consumer_key not set");
})();
const consumerSecret =
cfg.consumer_secret ||
process.env.WOO_CONSUMER_SECRET ||
(() => {
throw new Error("consumer_secret not set");
})();
const base = cfg.base_url.replace(/\/+$/, "");
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");
const timeout = Math.max(cfg.timeout_ms ?? 20000, 20000);
const url = `${base}/customers/${encodeURIComponent(id)}${force ? "?force=true" : ""}`;
const data = await fetchWoo({
url,
method: "DELETE",
timeout,
headers: { Authorization: `Basic ${auth}` },
});
return { ok: true, raw: data };
}

View File

@@ -93,6 +93,19 @@ function normalizeWooProduct(p) {
sku: p?.sku || null,
price: parsePrice(p?.price ?? p?.regular_price ?? p?.sale_price),
currency: null,
type: p?.type || null, // simple | variable | grouped | external
attributes: Array.isArray(p?.attributes)
? p.attributes.map((a) => ({
name: a?.name || null,
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
}))
: [],
raw_price: {
price: p?.price ?? null,
regular_price: p?.regular_price ?? null,
sale_price: p?.sale_price ?? null,
price_html: p?.price_html ?? null,
},
payload: p,
};
}
@@ -104,6 +117,7 @@ export async function searchProducts({
maxAgeMs = 24 * 60 * 60 * 1000,
forceWoo = false,
}) {
const debug = String(process.env.WOO_PRODUCTS_DEBUG || "") === "1";
const lim = Math.max(1, Math.min(50, parseInt(limit, 10) || 10));
const query = String(q || "").trim();
if (!query) return { items: [], source: "none" };
@@ -121,6 +135,22 @@ export async function searchProducts({
const data = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
wooItems = Array.isArray(data) ? data : [];
if (debug) {
console.log("[wooProducts] search", {
tenantId,
query,
count: wooItems.length,
sample: wooItems.slice(0, 5).map((p) => ({
id: p?.id,
name: p?.name,
sku: p?.sku,
price: p?.price,
regular_price: p?.regular_price,
sale_price: p?.sale_price,
})),
});
}
for (const p of wooItems) {
const n = normalizeWooProduct(p);
if (!n.woo_product_id || !n.name) continue;
@@ -145,6 +175,17 @@ export async function searchProducts({
try {
const url = `${client.base}/products/${encodeURIComponent(c.woo_product_id)}`;
const p = await fetchWoo({ url, method: "GET", timeout: client.timeout, headers: client.authHeader });
if (debug) {
console.log("[wooProducts] refresh", {
tenantId,
woo_product_id: c.woo_product_id,
name: p?.name,
sku: p?.sku,
price: p?.price,
regular_price: p?.regular_price,
sale_price: p?.sale_price,
});
}
const n = normalizeWooProduct(p);
if (!n.woo_product_id || !n.name) continue;
// Si cambió el precio (o faltaba), actualizamos.
@@ -185,6 +226,9 @@ export async function searchProducts({
sku: p.sku,
price: p.price,
currency: p.currency,
type: p.type,
attributes: p.attributes,
raw_price: p.raw_price,
source: "woo",
}))
: toReturn.map((c) => ({
@@ -194,6 +238,19 @@ export async function searchProducts({
price: c.price == null ? null : Number(c.price),
currency: c.currency,
refreshed_at: c.refreshed_at,
type: c?.payload?.type || null,
attributes: Array.isArray(c?.payload?.attributes)
? c.payload.attributes.map((a) => ({
name: a?.name || null,
options: Array.isArray(a?.options) ? a.options.slice(0, 20) : [],
}))
: [],
raw_price: {
price: c?.payload?.price ?? null,
regular_price: c?.payload?.regular_price ?? null,
sale_price: c?.payload?.sale_price ?? null,
price_html: c?.payload?.price_html ?? null,
},
source: isStale(c.refreshed_at, maxAgeMs) ? "cache_stale" : "cache",
}));