diff --git a/db/migrations/.keep b/db/migrations/.keep
new file mode 100644
index 0000000..139597f
--- /dev/null
+++ b/db/migrations/.keep
@@ -0,0 +1,2 @@
+
+
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..fdb8131
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,52 @@
+services:
+ app:
+ image: node:20-alpine
+ working_dir: /usr/src/app
+ command: sh -c "npm install && npm run dev"
+ ports:
+ - "3000:3000"
+ environment:
+ - PORT=3000
+ - DATABASE_URL=postgres://${POSTGRES_USER:-botino}:${POSTGRES_PASSWORD:-botino}@db:5432/${POSTGRES_DB:-botino}
+ - REDIS_URL=redis://redis:6379
+ volumes:
+ - .:/usr/src/app
+ - /usr/src/app/node_modules
+ depends_on:
+ - db
+ - redis
+ restart: unless-stopped
+
+ db:
+ image: postgres:16-alpine
+ environment:
+ - POSTGRES_DB=${POSTGRES_DB:-botino}
+ - POSTGRES_USER=${POSTGRES_USER:-botino}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-botino}
+ ports:
+ - "5432:5432"
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-botino}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ db_data:
+ redis_data:
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index cdb5fc9..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-services:
- app:
- image: node:20-alpine
- working_dir: /usr/src/app
- command: sh -c "npm install && npm run dev"
- ports:
- - "3000:3000"
- environment:
- - PORT=3000
- volumes:
- - .:/usr/src/app
- - /usr/src/app/node_modules
- restart: unless-stopped
diff --git a/index.js b/index.js
index 9dc3c1c..4dfcaaa 100644
--- a/index.js
+++ b/index.js
@@ -1,18 +1,180 @@
-"use strict";
-
-const express = require("express");
+import express from "express";
+import cors from "cors";
+import crypto from "crypto";
const app = express();
-const PORT = process.env.PORT || 3000;
+app.use(cors());
+app.use(express.json({ limit: "1mb" }));
+app.use(express.static("public"));
-app.use(express.json());
+/**
+ * In-memory store (MVP). Luego lo pasás a Postgres.
+ */
+const conversations = new Map(); // chat_id -> { chat_id, from, state, intent, last_activity, status, last_run_id }
+const runs = new Map(); // run_id -> run payload
-app.get("/health", (_req, res) => {
- res.json({ status: "ok" });
+/**
+ * SSE clients
+ */
+const sseClients = new Set();
+function sseSend(event, data) {
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ for (const res of sseClients) res.write(payload);
+}
+
+/**
+ * Helpers
+ */
+function nowIso() {
+ return new Date().toISOString();
+}
+function newId(prefix = "run") {
+ return `${prefix}_${crypto.randomUUID()}`;
+}
+function upsertConversation(chat_id, patch) {
+ const prev = conversations.get(chat_id) || {
+ chat_id,
+ from: patch.from || chat_id,
+ state: "IDLE",
+ intent: "other",
+ last_activity: nowIso(),
+ status: "ok",
+ last_run_id: null,
+ };
+ const next = { ...prev, ...patch, last_activity: nowIso() };
+ conversations.set(chat_id, next);
+ return next;
+}
+
+/**
+ * TODO: Reemplazar esto por tu pipeline real:
+ * - loadState (Postgres)
+ * - llm.plan (Structured Output)
+ * - tools: searchProducts (limitado), createOrder, createPaymentLink
+ * - invariants
+ * - saveState
+ */
+async function processMessage({ chat_id, from, text }) {
+ // Simulación mínima: reemplazá por tu lógica real
+ const prevConv = conversations.get(chat_id) || null;
+
+ const run_id = newId("run");
+ const started_at = Date.now();
+
+ // Ejemplo de “plan” simulado
+ const plan = {
+ reply: `Recibido: "${text}". (Sim) ¿Querés retiro o envío?`,
+ next_state: "BUILDING_ORDER",
+ intent: "create_order",
+ missing_fields: ["delivery_or_pickup"],
+ order_action: "none",
+ basket_resolved: { items: [] },
+ };
+
+ const invariants = {
+ ok: true,
+ checks: [
+ { name: "required_keys_present", ok: true },
+ { name: "no_checkout_without_payment_link", ok: true },
+ ],
+ };
+
+ const run = {
+ run_id,
+ ts: nowIso(),
+ chat_id,
+ from,
+ status: "ok",
+ prev_state: prevConv?.state || "IDLE",
+ input: { text },
+ llm_output: plan,
+ tools: [], // acá metés searchProducts/createOrder/createPaymentLink (con inputs/outputs recortados)
+ invariants,
+ final_reply: plan.reply,
+ order_id: null,
+ payment_link: null,
+ latency_ms: Date.now() - started_at,
+ };
+
+ runs.set(run_id, run);
+
+ const conv = upsertConversation(chat_id, {
+ from,
+ state: plan.next_state,
+ intent: plan.intent,
+ status: run.status,
+ last_run_id: run_id,
+ });
+
+ // Push realtime
+ sseSend("conversation.upsert", conv);
+ sseSend("run.created", run);
+
+ return run;
+}
+
+/**
+ * SSE stream
+ */
+app.get("/stream", (req, res) => {
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+ res.flushHeaders?.();
+
+ res.write(`event: hello\ndata: ${JSON.stringify({ ts: nowIso() })}\n\n`);
+ sseClients.add(res);
+
+ req.on("close", () => {
+ sseClients.delete(res);
+ res.end();
+ });
});
-app.listen(PORT, () => {
- // Registrar puerto para facilitar el seguimiento en logs.
- console.log(`Servidor escuchando en http://localhost:${PORT}`);
+/**
+ * Simulated WhatsApp send
+ */
+app.post("/sim/send", async (req, res) => {
+ const { chat_id, from_phone, text } = req.body || {};
+ if (!chat_id || !from_phone || !text) {
+ return res.status(400).json({ error: "chat_id, from_phone, text are required" });
+ }
+ const run = await processMessage({ chat_id, from: from_phone, text });
+ res.json({ ok: true, run_id: run.run_id, reply: run.final_reply });
});
+/**
+ * List conversations
+ */
+app.get("/conversations", (req, res) => {
+ const { q = "", status = "", state = "" } = req.query;
+ const items = [...conversations.values()]
+ .filter(c => (q ? (c.chat_id.includes(q) || c.from.includes(q)) : true))
+ .filter(c => (status ? c.status === status : true))
+ .filter(c => (state ? c.state === state : true))
+ .sort((a, b) => (a.last_activity < b.last_activity ? 1 : -1));
+ res.json({ items });
+});
+
+/**
+ * Runs by chat
+ */
+app.get("/runs", (req, res) => {
+ const { chat_id, limit = "50" } = req.query;
+ const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50));
+ let items = [...runs.values()].sort((a, b) => (a.ts < b.ts ? 1 : -1));
+ if (chat_id) items = items.filter(r => r.chat_id === chat_id);
+ res.json({ items: items.slice(0, lim) });
+});
+
+/**
+ * Run detail
+ */
+app.get("/runs/:run_id", (req, res) => {
+ const run = runs.get(req.params.run_id);
+ if (!run) return res.status(404).json({ error: "not_found" });
+ res.json(run);
+});
+
+const port = process.env.PORT || 3000;
+app.listen(port, () => console.log(`UI: http://localhost:${port}`));
diff --git a/package-lock.json b/package-lock.json
index 7a21c75..5b9c84f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,12 +9,112 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
+ "cors": "^2.8.5",
"express": "^4.19.2"
},
"devDependencies": {
+ "dbmate": "^2.0.0",
"nodemon": "^3.0.3"
}
},
+ "node_modules/@dbmate/darwin-arm64": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/@dbmate/darwin-arm64/-/darwin-arm64-2.28.0.tgz",
+ "integrity": "sha512-ZFjSS9ceCF6Gr87qUZ96Ow08P5k1l0kiMNZEwni66gVIPseZFDLLIeaHBGOaVbcDyevFBlrWQb8+LESOKmGsOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@dbmate/darwin-x64": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/@dbmate/darwin-x64/-/darwin-x64-2.28.0.tgz",
+ "integrity": "sha512-7LA94QnQk9pnaC+tMOwHzx6wloZ160vGcW02mCz+6dHFdFk/Bha/zUgQFClg+wGlbqLKXqYfKLKYJnDjCEHa8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@dbmate/linux-arm": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/@dbmate/linux-arm/-/linux-arm-2.28.0.tgz",
+ "integrity": "sha512-mxZ5dd1RxiIGbh5l8Yc0r8/NXNRf0tz9UJ2hXZ63cmPoKwq9EQoPC6OChz3ahWO1/qdlgiCLy8iTeuvAl4RScA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@dbmate/linux-arm64": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/@dbmate/linux-arm64/-/linux-arm64-2.28.0.tgz",
+ "integrity": "sha512-K8XY3L0RB0qZ//Ct4ZuB5T3zwFGegbV2QUtvM7Dak5C178E0LQDl8uD2E6WURvV7oSpyjpCh9iq+4LoZ6N+EkQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@dbmate/linux-ia32": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/@dbmate/linux-ia32/-/linux-ia32-2.28.0.tgz",
+ "integrity": "sha512-O+3AZT/aVa4qD7FQ44MNBhx2enel3f+Ioh4IXDvDdPqbzm4wjNj8DlHF3d9hJ0AwfEuq27KWyPqZi9pRmxdM6Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@dbmate/linux-x64": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/@dbmate/linux-x64/-/linux-x64-2.28.0.tgz",
+ "integrity": "sha512-S4pV5HstfN/uLtrMcouxhn0zcErUGco98LWfzjY4Ylcv2pnc6rfzS6bBO6q0dHFbSPPZWOlsTY6Dw7WU4Uqcaw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@dbmate/win32-x64": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/@dbmate/win32-x64/-/win32-x64-2.28.0.tgz",
+ "integrity": "sha512-AmP8/XkG8vXGhE0UcTxIujJcNSHuUpEGBG4hzKoAdvcWUKX6CYGyskiWfhBBe13O8xUBmii+kQ79uG75NUYlTw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -222,6 +322,38 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/dbmate": {
+ "version": "2.28.0",
+ "resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
+ "integrity": "sha512-kbJ+Aqna/SOsS86RuimX8X/qmo9ItG00EYMUfUQmo3xGoXBac4+PZwOYV0fVPIGakIRljNolhfJySkPAFjmZsA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "dbmate": "dist/cli.js"
+ },
+ "optionalDependencies": {
+ "@dbmate/darwin-arm64": "2.28.0",
+ "@dbmate/darwin-x64": "2.28.0",
+ "@dbmate/linux-arm": "2.28.0",
+ "@dbmate/linux-arm64": "2.28.0",
+ "@dbmate/linux-ia32": "2.28.0",
+ "@dbmate/linux-x64": "2.28.0",
+ "@dbmate/win32-x64": "2.28.0"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -800,6 +932,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
diff --git a/package.json b/package.json
index 3759825..6b19ca0 100644
--- a/package.json
+++ b/package.json
@@ -3,19 +3,24 @@
"version": "1.0.0",
"description": "API base con Express",
"main": "index.js",
- "type": "commonjs",
+ "type": "module",
"scripts": {
"start": "node index.js",
- "dev": "nodemon index.js"
+ "dev": "nodemon index.js",
+ "migrate:up": "dbmate up",
+ "migrate:down": "dbmate down",
+ "migrate:redo": "dbmate rollback && dbmate up",
+ "migrate:status": "dbmate status"
},
"keywords": [],
- "author": "",
+ "author": "Lucas Tettamanti",
"license": "MIT",
"dependencies": {
+ "cors": "^2.8.5",
"express": "^4.19.2"
},
"devDependencies": {
+ "dbmate": "^2.0.0",
"nodemon": "^3.0.3"
}
}
-
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..efb63db
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,7 @@
+import "./components/ops-shell.js";
+import "./components/conversation-list.js";
+import "./components/run-timeline.js";
+import "./components/chat-simulator.js";
+import { connectSSE } from "./lib/sse.js";
+
+connectSSE();
diff --git a/public/components/chat-simulator.js b/public/components/chat-simulator.js
new file mode 100644
index 0000000..bfa97dc
--- /dev/null
+++ b/public/components/chat-simulator.js
@@ -0,0 +1,112 @@
+import { api } from "../lib/api.js";
+import { emit, on } from "../lib/bus.js";
+
+class ChatSimulator extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.innerHTML = `
+
+
+
+
Simulador WhatsApp (local)
+
+
+
+
+
+
+
+
+
+
+
+
+
Última respuesta (raw)
+
—
+
+ `;
+ }
+
+ connectedCallback() {
+ const fromEl = this.shadowRoot.getElementById("from");
+ const chatEl = this.shadowRoot.getElementById("chat");
+ const resetEl = this.shadowRoot.getElementById("reset");
+ const sendEl = this.shadowRoot.getElementById("send");
+
+ resetEl.onclick = () => {
+ this.shadowRoot.getElementById("log").innerHTML = "";
+ this.shadowRoot.getElementById("raw").textContent = "—";
+ const phone = (fromEl.value || "+5491100000000").trim();
+ chatEl.value = `sim:${phone}`;
+ this.append("bot", "Chat reseteado (solo UI). Enviá un mensaje para generar runs.");
+ };
+
+ sendEl.onclick = async () => {
+ const text = this.shadowRoot.getElementById("text").value.trim();
+ if (!text) return;
+
+ const from_phone = fromEl.value.trim();
+ const chat_id = chatEl.value.trim();
+ if (!from_phone || !chat_id) return alert("Falta teléfono o chat_id");
+
+ this.append("user", text);
+ this.shadowRoot.getElementById("text").value = "";
+
+ const data = await api.simSend({ chat_id, from_phone, text });
+ this.shadowRoot.getElementById("raw").textContent = JSON.stringify(data, null, 2);
+
+ if (!data.ok) {
+ this.append("bot", "Error en simulación.");
+ return;
+ }
+
+ this.append("bot", data.reply);
+ emit("ui:selectedChat", { chat_id });
+ };
+
+ // si querés, cuando llega un upsert de conversación simulada, podés auto-seleccionarla
+ this._unsub = on("conversation:upsert", (c) => {
+ const chat_id = this.shadowRoot.getElementById("chat").value.trim();
+ if (c.chat_id === chat_id) {
+ // no-op, pero podrías reflejar estado/intent acá si querés
+ }
+ });
+ }
+
+ disconnectedCallback() {
+ this._unsub?.();
+ }
+
+ 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;
+ }
+}
+
+customElements.define("chat-simulator", ChatSimulator);
diff --git a/public/components/conversation-list.js b/public/components/conversation-list.js
new file mode 100644
index 0000000..e4fc541
--- /dev/null
+++ b/public/components/conversation-list.js
@@ -0,0 +1,121 @@
+import { api } from "../lib/api.js";
+import { emit, on } from "../lib/bus.js";
+
+class ConversationList extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.conversations = [];
+ this.selected = null;
+
+ this.shadowRoot.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ this.shadowRoot.getElementById("refresh").onclick = () => this.refresh();
+ this.shadowRoot.getElementById("q").oninput = () => {
+ clearTimeout(this._t);
+ this._t = setTimeout(() => this.refresh(), 250);
+ };
+ this.shadowRoot.getElementById("status").onchange = () => this.refresh();
+ this.shadowRoot.getElementById("state").onchange = () => this.refresh();
+
+ this._unsub1 = on("conversation:upsert", (conv) => {
+ const idx = this.conversations.findIndex(x => x.chat_id === conv.chat_id);
+ if (idx >= 0) this.conversations[idx] = conv;
+ else this.conversations.unshift(conv);
+ this.render();
+ });
+
+ this.refresh();
+ }
+
+ disconnectedCallback() {
+ this._unsub1?.();
+ }
+
+ dot(status) {
+ const cls = status === "ok" ? "ok" : (status === "warn" ? "warn" : "err");
+ return ``;
+ }
+
+ 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();
+ }
+
+ render() {
+ const list = this.shadowRoot.getElementById("list");
+ list.innerHTML = "";
+
+ for (const c of this.conversations) {
+ const el = document.createElement("div");
+ el.className = "item" + (c.chat_id === this.selected ? " active" : "");
+ el.innerHTML = `
+
+ ${this.dot(c.status)}
+
+
${c.from}
+
${c.chat_id}
+
+
+
+ state: ${c.state}
+ intent: ${c.intent}
+ last: ${new Date(c.last_activity).toLocaleTimeString()}
+
+ `;
+ el.onclick = () => {
+ this.selected = c.chat_id;
+ this.render();
+ emit("ui:selectedChat", { chat_id: c.chat_id });
+ };
+ list.appendChild(el);
+ }
+ }
+}
+
+customElements.define("conversation-list", ConversationList);
diff --git a/public/components/ops-shell.js b/public/components/ops-shell.js
new file mode 100644
index 0000000..1179482
--- /dev/null
+++ b/public/components/ops-shell.js
@@ -0,0 +1,50 @@
+import { on } from "../lib/bus.js";
+
+class OpsShell extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+
+ this.shadowRoot.innerHTML = `
+
+
+
+
+ Bot Ops Console
+
+ SSE: connecting…
+
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ this._unsub = on("sse:status", (s) => {
+ const el = this.shadowRoot.getElementById("sseStatus");
+ el.textContent = s.ok ? "SSE: connected" : "SSE: disconnected (retrying…)";
+ });
+ }
+
+ disconnectedCallback() {
+ this._unsub?.();
+ }
+}
+
+customElements.define("ops-shell", OpsShell);
diff --git a/public/components/run-timeline.js b/public/components/run-timeline.js
new file mode 100644
index 0000000..7276418
--- /dev/null
+++ b/public/components/run-timeline.js
@@ -0,0 +1,82 @@
+import { api } from "../lib/api.js";
+import { on } from "../lib/bus.js";
+
+class RunTimeline extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ this.chatId = null;
+
+ this.shadowRoot.innerHTML = `
+
+
+
+
Conversación
+
—
+
Seleccioná una conversación.
+
+
+
+
+
+ `;
+ }
+
+ connectedCallback() {
+ this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => {
+ this.chatId = chat_id;
+ await this.loadLatest();
+ });
+
+ this._unsubRun = on("run:created", (run) => {
+ if (this.chatId && run.chat_id === this.chatId) {
+ this.show(run);
+ }
+ });
+ }
+
+ disconnectedCallback() {
+ this._unsubSel?.();
+ this._unsubRun?.();
+ }
+
+ async loadLatest() {
+ this.shadowRoot.getElementById("chat").textContent = this.chatId || "—";
+ this.shadowRoot.getElementById("meta").textContent = "Cargando…";
+
+ const data = await api.runs({ chat_id: this.chatId, limit: 1 });
+ const run = (data.items || [])[0];
+
+ if (!run) {
+ this.shadowRoot.getElementById("meta").textContent = "Sin runs aún.";
+ this.shadowRoot.getElementById("run").textContent = "—";
+ this.shadowRoot.getElementById("inv").textContent = "—";
+ return;
+ }
+ this.show(run);
+ }
+
+ show(run) {
+ this.shadowRoot.getElementById("chat").textContent = run.chat_id;
+ this.shadowRoot.getElementById("meta").textContent =
+ `run_id=${run.run_id} • ${run.latency_ms}ms • ${new Date(run.ts).toLocaleString()}`;
+
+ this.shadowRoot.getElementById("run").textContent = JSON.stringify(run, null, 2);
+ this.shadowRoot.getElementById("inv").textContent = JSON.stringify(run.invariants, null, 2);
+ }
+}
+
+customElements.define("run-timeline", RunTimeline);
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..163e23e
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Bot Ops Console
+
+
+
+
+
+
diff --git a/public/lib/api.js b/public/lib/api.js
new file mode 100644
index 0000000..cefb417
--- /dev/null
+++ b/public/lib/api.js
@@ -0,0 +1,24 @@
+export const api = {
+ async conversations({ q = "", status = "", state = "" } = {}) {
+ const u = new URL("/conversations", location.origin);
+ if (q) u.searchParams.set("q", q);
+ if (status) u.searchParams.set("status", status);
+ if (state) u.searchParams.set("state", state);
+ return fetch(u).then(r => r.json());
+ },
+
+ async runs({ chat_id, limit = 1 } = {}) {
+ const u = new URL("/runs", location.origin);
+ if (chat_id) u.searchParams.set("chat_id", chat_id);
+ u.searchParams.set("limit", String(limit));
+ return fetch(u).then(r => r.json());
+ },
+
+ async simSend({ chat_id, from_phone, text }) {
+ return fetch("/sim/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ chat_id, from_phone, text }),
+ }).then(r => r.json());
+ },
+};
diff --git a/public/lib/bus.js b/public/lib/bus.js
new file mode 100644
index 0000000..e1a9eb7
--- /dev/null
+++ b/public/lib/bus.js
@@ -0,0 +1,17 @@
+const listeners = new Map();
+
+export function on(event, fn) {
+ const arr = listeners.get(event) || [];
+ arr.push(fn);
+ listeners.set(event, arr);
+ return () => off(event, fn);
+}
+
+export function off(event, fn) {
+ const arr = listeners.get(event) || [];
+ listeners.set(event, arr.filter(x => x !== fn));
+}
+
+export function emit(event, payload) {
+ (listeners.get(event) || []).forEach(fn => fn(payload));
+}
diff --git a/public/lib/sse.js b/public/lib/sse.js
new file mode 100644
index 0000000..0d50d60
--- /dev/null
+++ b/public/lib/sse.js
@@ -0,0 +1,13 @@
+import { emit } from "./bus.js";
+
+export function connectSSE() {
+ const es = new EventSource("/stream");
+
+ es.addEventListener("hello", () => emit("sse:status", { ok: true }));
+ es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
+ es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data)));
+
+ es.onerror = () => emit("sse:status", { ok: false });
+
+ return es;
+}
diff --git a/public/main.js b/public/main.js
new file mode 100644
index 0000000..e9bceb2
--- /dev/null
+++ b/public/main.js
@@ -0,0 +1,224 @@
+// main.js
+// Express server for: Ops UI (static) + SSE stream + Local WhatsApp Simulator + simple in-memory stores
+// Run: npm i express cors && node main.js
+// Open: http://localhost:3000
+
+import express from "express";
+import cors from "cors";
+import crypto from "crypto";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const app = express();
+app.use(cors());
+app.use(express.json({ limit: "1mb" }));
+
+// Serve /public as static (UI + webcomponents)
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const publicDir = path.join(__dirname, "public");
+app.use(express.static(publicDir));
+
+/**
+ * In-memory store (MVP). Replace with Postgres later.
+ */
+const conversations = new Map(); // chat_id -> conversation snapshot
+const runs = new Map(); // run_id -> run object
+
+/**
+ * SSE clients (Server-Sent Events)
+ */
+const sseClients = new Set();
+
+function sseSend(event, data) {
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ for (const res of sseClients) res.write(payload);
+}
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function newId(prefix = "run") {
+ return `${prefix}_${crypto.randomUUID()}`;
+}
+
+function upsertConversation(chat_id, patch) {
+ const prev = conversations.get(chat_id) || {
+ chat_id,
+ from: patch.from || chat_id,
+ state: "IDLE",
+ intent: "other",
+ last_activity: nowIso(),
+ status: "ok",
+ last_run_id: null,
+ };
+
+ const next = {
+ ...prev,
+ ...patch,
+ last_activity: nowIso(),
+ };
+
+ conversations.set(chat_id, next);
+ return next;
+}
+
+/**
+ * TODO: Replace this with your real pipeline later:
+ * - loadState from Postgres
+ * - call LLM (structured output)
+ * - product search (LIMITED) + resolve ids
+ * - create/update Woo order
+ * - create MercadoPago link
+ * - save state
+ */
+async function processMessage({ chat_id, from, text }) {
+ const prevConv = conversations.get(chat_id) || null;
+
+ const run_id = newId("run");
+ const started_at = Date.now();
+
+ // Minimal simulated LLM output (replace later)
+ const plan = {
+ reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
+ next_state: "BUILDING_ORDER",
+ intent: "create_order",
+ missing_fields: ["delivery_or_pickup"],
+ order_action: "none",
+ basket_resolved: { items: [] },
+ };
+
+ const invariants = {
+ ok: true,
+ checks: [
+ { name: "required_keys_present", ok: true },
+ { name: "no_checkout_without_payment_link", ok: true },
+ { name: "no_order_action_without_items", ok: true },
+ ],
+ };
+
+ const run = {
+ run_id,
+ ts: nowIso(),
+ chat_id,
+ from,
+ status: "ok", // ok|warn|error
+ prev_state: prevConv?.state || "IDLE",
+ input: { text },
+ llm_output: plan,
+ tools: [], // later: [{name, ok, ms, input_summary, output_summary}]
+ invariants,
+ final_reply: plan.reply,
+ order_id: null,
+ payment_link: null,
+ latency_ms: Date.now() - started_at,
+ };
+
+ runs.set(run_id, run);
+
+ const conv = upsertConversation(chat_id, {
+ from,
+ state: plan.next_state,
+ intent: plan.intent,
+ status: run.status,
+ last_run_id: run_id,
+ });
+
+ // Push realtime events to UI
+ sseSend("conversation.upsert", conv);
+ sseSend("run.created", run);
+
+ return run;
+}
+
+/**
+ * SSE endpoint (UI connects here)
+ */
+app.get("/stream", (req, res) => {
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+
+ // If compression middleware ever added, you'd need res.flushHeaders()
+ res.flushHeaders?.();
+
+ // hello event
+ res.write(`event: hello\ndata: ${JSON.stringify({ ts: nowIso() })}\n\n`);
+
+ sseClients.add(res);
+
+ req.on("close", () => {
+ sseClients.delete(res);
+ res.end();
+ });
+});
+
+/**
+ * Local WhatsApp Simulator:
+ * POST /sim/send { chat_id, from_phone, text }
+ */
+app.post("/sim/send", async (req, res) => {
+ const { chat_id, from_phone, text } = req.body || {};
+ if (!chat_id || !from_phone || !text) {
+ return res.status(400).json({ ok: false, error: "chat_id, from_phone, text are required" });
+ }
+ try {
+ const run = await processMessage({ chat_id, from: from_phone, text });
+ res.json({ ok: true, run_id: run.run_id, reply: run.final_reply });
+ } catch (err) {
+ res.status(500).json({ ok: false, error: "internal_error", detail: String(err?.message || err) });
+ }
+});
+
+/**
+ * List conversations
+ * GET /conversations?q=&status=&state=
+ */
+app.get("/conversations", (req, res) => {
+ const { q = "", status = "", state = "" } = req.query;
+
+ const items = [...conversations.values()]
+ .filter(c => (q ? (String(c.chat_id).includes(q) || String(c.from).includes(q)) : true))
+ .filter(c => (status ? c.status === status : true))
+ .filter(c => (state ? c.state === state : true))
+ .sort((a, b) => (a.last_activity < b.last_activity ? 1 : -1));
+
+ res.json({ items });
+});
+
+/**
+ * List runs (optionally by chat_id)
+ * GET /runs?chat_id=&limit=50
+ */
+app.get("/runs", (req, res) => {
+ const { chat_id, limit = "50" } = req.query;
+ const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50));
+
+ let items = [...runs.values()].sort((a, b) => (a.ts < b.ts ? 1 : -1));
+ if (chat_id) items = items.filter(r => r.chat_id === chat_id);
+
+ res.json({ items: items.slice(0, lim) });
+});
+
+/**
+ * Run detail
+ * GET /runs/:run_id
+ */
+app.get("/runs/:run_id", (req, res) => {
+ const run = runs.get(req.params.run_id);
+ if (!run) return res.status(404).json({ ok: false, error: "not_found" });
+ res.json(run);
+});
+
+/**
+ * Root: serve UI
+ */
+app.get("/", (req, res) => {
+ res.sendFile(path.join(publicDir, "index.html"));
+});
+
+const port = process.env.PORT || 3000;
+app.listen(port, () => {
+ console.log(`UI: http://localhost:${port}`);
+});