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)
+
+ +
+
+ + +
+
+ +
+
Chat
+
+ +
+ +
+
+ +
+
Ú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.
+
+ +
+
Último run (raw)
+
+
+ +
+
Invariantes
+
+
+ `; + } + + 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}`); +});