Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/channels/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const CHANNEL_LABELS: Record<string, string> = {
instagram: "Instagram",
messenger: "Messenger",
manychat: "ManyChat",
web: "Web",
};

export function channelLabel(channel: string | null | undefined): string {
Expand Down
2 changes: 1 addition & 1 deletion src/channels/shared.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ChannelId = "manychat" | "telegram" | "twilio" | "messenger" | "instagram" | "whatsapp";
export type ChannelId = "manychat" | "telegram" | "twilio" | "messenger" | "instagram" | "whatsapp" | "web";

export interface IncomingMessage {
channel: ChannelId;
Expand Down
39 changes: 39 additions & 0 deletions src/channels/web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared";
import type { Env } from "../env";

/**
* Canal WEB — el chat de /demo.
*
* No hay proveedor al que empujar la respuesta: el agente la deja en D1 y el
* navegador la recoge con GET /demo/poll. sendReply es un no-op a propósito,
* para que este canal reutilice el mismo pipeline (buffer, tools, KB) sin
* ramas especiales.
*/
export const webAdapter: ChannelAdapter = {
async parseIncoming(request: Request, _env: Env): Promise<IncomingMessage> {
const body = (await request.json().catch(() => ({}))) as {
sessionId?: string;
text?: string;
name?: string;
};

const sessionId = (body.sessionId ?? "").trim();
const text = (body.text ?? "").trim();
if (!sessionId || !text) {
throw new Error("web: falta sessionId o text");
}

return {
channel: "web",
// El sessionId lo genera el navegador. Acotado para que no ensucie el
// nombre del Durable Object.
channelUserId: sessionId.slice(0, 64),
displayName: (body.name ?? "").trim().slice(0, 60) || undefined,
text: text.slice(0, 2000),
receivedAt: Date.now(),
rawPayload: body,
};
},

async sendReply(_reply: OutgoingReply, _env: Env): Promise<void> {},
};
277 changes: 277 additions & 0 deletions src/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import type { Context } from "hono";
import type { Env } from "./env";
import { Db } from "./db/client";

/**
* MODO DEMO — chat web público para enseñarle el bot a un prospecto.
*
* Se enciende con `DEMO_MODE = "on"`. Apagado por defecto: un bot de producción
* no debe exponer un chat sin autenticar. El visitante manda POST /demo/send
* (canal `web`, el mismo pipeline del agente) y la página recoge la respuesta
* con GET /demo/poll.
*
* El cursor de ese poll es `created_at` del SERVIDOR. Si el cliente lo arma con
* el reloj del navegador (`Date.now()`), un reloj adelantado pide mensajes
* "del futuro" y el poll vuelve vacío aunque la respuesta ya esté en el panel.
* Por eso la página, al cargar, pide `?after=0`, se queda con el `at` más alto
* que ya existe y no pinta ese historial. Nunca usa el reloj del visitante.
*
* Tampoco corta el sondeo en la primera burbuja: con `max_chunks` las siguientes
* llegan unos segundos después. Sigue pidiendo ~6 s desde la última burbuja.
*/

/** Tope de turnos por sesión: un demo real no pasa de unas pocas preguntas. */
const MAX_TURNS_PER_SESSION = 40;

/** Tras la última burbuja, el sondeo rápido sigue este rato por si hay más. */
export const DEMO_FOLLOW_MS = 6000;

/** Si para entonces no llegó nada, se deja de esperar y se suelta el input. */
export const DEMO_GIVE_UP_MS = 90_000;

export function demoEnabled(env: Env): boolean {
return (env.DEMO_MODE ?? "").toLowerCase() === "on";
}

/** Cuántos mensajes del visitante lleva esta sesión (anti-abuso de costo). */
export async function demoTurnsUsed(env: Env, sessionId: string): Promise<number> {
const db = new Db(env.DB);
const row = await db.first<{ n: number }>(
`SELECT COUNT(*) AS n FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.channel = 'web' AND c.channel_user_id = ? AND m.role = 'user'`,
[sessionId],
);
return row?.n ?? 0;
}

export function demoOverLimit(used: number): boolean {
return used >= MAX_TURNS_PER_SESSION;
}

export interface DemoPollMessage {
id: string;
text: string;
at: number;
}

/** GET /demo/poll?session=…&after=… → mensajes del bot posteriores a `after`. */
export async function demoPoll(c: Context<{ Bindings: Env }>) {
if (!demoEnabled(c.env)) return c.json({ ok: false, error: "demo_off" }, 404);

const sessionId = (c.req.query("session") ?? "").slice(0, 64);
const after = Number(c.req.query("after") ?? 0) || 0;
if (!sessionId) return c.json({ ok: false, error: "missing_session" }, 400);

const db = new Db(c.env.DB);
const conv = await db.first<{ id: string }>(
"SELECT id FROM conversations WHERE channel = 'web' AND channel_user_id = ? LIMIT 1",
[sessionId],
);
if (!conv) return c.json({ ok: true, messages: [] });

const rows = await db.all<{ id: string; content: string; created_at: number }>(
`SELECT id, content, created_at FROM messages
WHERE conversation_id = ? AND created_at > ? AND role = 'assistant'
ORDER BY created_at ASC LIMIT 20`,
[conv.id, after],
);

const messages: DemoPollMessage[] = rows.map((r) => ({
id: r.id,
text: r.content,
at: r.created_at,
}));
return c.json({ ok: true, messages });
}

const esc = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

/**
* JS del chat. El cursor (`since`) solo avanza con `at` que devolvió el
* servidor. `seen` evita pintar dos veces la misma fila si dos sondeos se
* cruzan. La clave es el id; si no viniera, at+texto, para no tirar una
* segunda burbuja que compartiera milisegundo con la primera.
*/
export function demoClientScript(): string {
return `(function(){
var KEY='forja_demo_session';
var sid=localStorage.getItem(KEY);
if(!sid){ sid=(crypto.randomUUID?crypto.randomUUID():String(Date.now())+Math.random().toString(16).slice(2));
localStorage.setItem(KEY,sid); }

var chat=document.getElementById('chat'), ta=document.getElementById('t'), btn=document.getElementById('s');
var since=0, polling=null, followTimer=null, giveUpTimer=null, waiting=false, seen={};

function scroll(){ chat.scrollTop=chat.scrollHeight; }
function add(text,who,cls){
var r=document.createElement('div'); r.className='row '+who;
var b=document.createElement('div'); b.className='bub'+(cls?' '+cls:''); b.textContent=text;
r.appendChild(b); chat.appendChild(r); scroll(); return r;
}
function typing(){
var r=document.createElement('div'); r.className='row bot'; r.id='typing';
r.innerHTML='<div class="bub typing"><i></i><i></i><i></i></div>';
chat.appendChild(r); scroll(); return r;
}
function stopTyping(){ var e=document.getElementById('typing'); if(e) e.remove(); }

ta.addEventListener('input',function(){ ta.style.height='auto'; ta.style.height=Math.min(ta.scrollHeight,120)+'px'; });
ta.addEventListener('keydown',function(e){ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); send(); } });
btn.addEventListener('click',send);

function clave(m){ return m.id || (String(m.at)+"\\n"+m.text); }
function tomar(m, pintar){
var k=clave(m);
if(seen[k]) return false;
seen[k]=1;
if(typeof m.at==='number' && m.at>since) since=m.at;
if(pintar) add(m.text,'bot');
return true;
}

function pararSondeo(){
if(polling){ clearInterval(polling); polling=null; }
}

function poll(){
fetch('/demo/poll?session='+encodeURIComponent(sid)+'&after='+since)
.then(function(r){ return r.json(); })
.then(function(d){
if(!d||!d.ok||!d.messages||!d.messages.length) return;
var nuevos=0;
d.messages.forEach(function(m){ if(tomar(m,true)) nuevos++; });
if(!nuevos) return;
stopTyping();
// No cerrar en la primera burbuja: las siguientes (max_chunks) llegan
// un poco después. El reloj de gracia corre desde la ÚLTIMA.
if(followTimer) clearTimeout(followTimer);
followTimer=setTimeout(function(){
followTimer=null;
waiting=false; btn.disabled=false;
pararSondeo();
}, ${DEMO_FOLLOW_MS});
}).catch(function(){});
}

// Una sola sincronización, al cargar, y el envío espera a que termine.
// Así el cursor sale del servidor y un poll tardío no se traga la respuesta
// que acaba de generar el turno (la marcaría como "ya vista" sin pintarla).
var listo=new Promise(function(resolve){
fetch('/demo/poll?session='+encodeURIComponent(sid)+'&after=0')
.then(function(r){ return r.json(); })
.then(function(d){
if(d&&d.ok&&d.messages) d.messages.forEach(function(m){ tomar(m,false); });
})
.catch(function(){})
.then(resolve);
});

function send(){
var text=ta.value.trim(); if(!text||waiting) return;
waiting=true; btn.disabled=true;
listo.then(function(){
add(text,'me'); ta.value=''; ta.style.height='auto';
typing();
fetch('/demo/send',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sessionId:sid,text:text})})
.then(function(r){ return r.json().catch(function(){ return {ok:r.ok}; }); })
.then(function(d){
if(d&&d.ok===false){
stopTyping(); waiting=false; btn.disabled=false;
add(d.error==='limit'
? ${JSON.stringify("Se acabó la demo 🙂 Escríbele al dueño para seguir la conversación.")}
: ${JSON.stringify("Ups, algo falló. Intenta de nuevo.")},'bot','err');
return;
}
pararSondeo();
if(followTimer){ clearTimeout(followTimer); followTimer=null; }
polling=setInterval(poll,1200);
if(giveUpTimer) clearTimeout(giveUpTimer);
giveUpTimer=setTimeout(function(){
giveUpTimer=null;
if(!waiting) return;
stopTyping(); waiting=false; btn.disabled=false;
pararSondeo();
if(followTimer){ clearTimeout(followTimer); followTimer=null; }
}, ${DEMO_GIVE_UP_MS});
})
.catch(function(){ stopTyping(); waiting=false; btn.disabled=false;
add(${JSON.stringify("Sin conexión. Revisa tu internet.")},'bot','err'); });
});
}
})();`;
}

/** GET /demo → la página de chat, con la marca del negocio. */
export function demoPage(c: Context<{ Bindings: Env }>) {
if (!demoEnabled(c.env)) return c.text("Not found", 404);

const business = esc(c.env.BUSINESS_NAME || "Tu negocio");
const bot = esc(c.env.BOT_NAME || business);
const initial = (c.env.BUSINESS_NAME || c.env.BOT_NAME || "•").trim().charAt(0).toUpperCase() || "•";

return c.html(`<!doctype html>
<html lang="es"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>${bot}</title>
<meta name="theme-color" content="#0e1116">
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
:root{--bg:#0e1116;--panel:#161b22;--line:#232a34;--ink:#e9edf2;--dim:#9aa4b2;
--me:#2f6df6;--bot:#1c222c;--accent:#FF6A00}
html,body{height:100%}
body{background:var(--bg);color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex;flex-direction:column;overscroll-behavior:none}
header{display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--panel);
border-bottom:1px solid var(--line);position:sticky;top:0;z-index:2;
padding-top:calc(14px + env(safe-area-inset-top))}
.avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),#ff9a4d);
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;color:#111;flex:0 0 auto}
.who{min-width:0}
.who b{display:block;font-size:15px;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.who span{font-size:12px;color:#3ddc84;display:flex;align-items:center;gap:5px;margin-top:2px}
.dot{width:7px;height:7px;border-radius:50%;background:#3ddc84;display:inline-block}
main{flex:1;overflow-y:auto;padding:18px 14px 8px;display:flex;flex-direction:column;gap:10px}
.row{display:flex;max-width:82%}
.row.me{align-self:flex-end;justify-content:flex-end}
.row.bot{align-self:flex-start}
.bub{padding:11px 14px;border-radius:18px;font-size:15px;line-height:1.45;white-space:pre-wrap;word-wrap:break-word}
.me .bub{background:var(--me);border-bottom-right-radius:5px}
.bot .bub{background:var(--bot);border:1px solid var(--line);border-bottom-left-radius:5px}
.typing{display:flex;gap:4px;padding:14px 16px}
.typing i{width:7px;height:7px;border-radius:50%;background:var(--dim);animation:b 1.3s infinite}
.typing i:nth-child(2){animation-delay:.18s}.typing i:nth-child(3){animation-delay:.36s}
@keyframes b{0%,60%,100%{opacity:.25;transform:translateY(0)}30%{opacity:1;transform:translateY(-4px)}}
footer{padding:12px 14px calc(12px + env(safe-area-inset-bottom));background:var(--panel);
border-top:1px solid var(--line);display:flex;gap:10px;align-items:flex-end}
textarea{flex:1;resize:none;background:#0e1116;color:var(--ink);border:1px solid var(--line);
border-radius:20px;padding:11px 15px;font:inherit;font-size:15px;max-height:120px;outline:none}
textarea:focus{border-color:var(--me)}
button{background:var(--me);color:#fff;border:none;border-radius:50%;width:42px;height:42px;
font-size:18px;cursor:pointer;flex:0 0 auto;transition:.15s}
button:disabled{opacity:.4;cursor:default}
.note{text-align:center;font-size:11.5px;color:var(--dim);padding:8px 16px 14px}
.err{background:#3a1d1d;border:1px solid #66302f;color:#ffb3ae}
</style></head>
<body>
<header>
<div class="avatar">${esc(initial)}</div>
<div class="who"><b>${bot}</b><span><i class="dot"></i>En línea</span></div>
</header>
<main id="chat">
<div class="row bot"><div class="bub">¡Hola! Soy el asistente de ${business}. ¿En qué te puedo ayudar?</div></div>
</main>
<footer>
<textarea id="t" rows="1" placeholder="Escribe tu mensaje…" autocomplete="off"></textarea>
<button id="s" aria-label="Enviar">➤</button>
</footer>
<div class="note">Demo de ${business} · atendido por IA</div>
<script>
${demoClientScript()}
</script>
</body></html>`);
}
3 changes: 3 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export interface Env {
// re-etiqueta el dashboard, aporta el playbook del giro y sus columnas.
// Ausente/desconocido → pack genérico (comportamiento actual). Ver src/niches/.
BOT_NICHE?: string;
// "on" enciende el chat público de /demo. Apagado por defecto: un bot de
// producción no debe exponer un chat sin autenticar (es acceso a la llave de IA).
DEMO_MODE?: string;
// LLM provider for the chat brain: "anthropic" (default) | "openai".
// If unset and only OPENAI_API_KEY is present, auto-selects "openai".
// (Voice transcription + embeddings always run on Cloudflare Workers AI.)
Expand Down
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { saveCapture, isLearnMode } from "./learn/mapping";
import { tokensMatch, manychatWebhookAllowed } from "./http-auth";
import { apiApp } from "./api";
import { getAgentStub } from "./agentStub";
import { webAdapter } from "./channels/web";
import { demoEnabled, demoOverLimit, demoPage, demoPoll, demoTurnsUsed } from "./demo";

export { SupportAgent } from "./agent";

Expand Down Expand Up @@ -55,6 +57,21 @@ async function routeToAgent(c: { req: { raw: Request }; env: Env; text: (t: stri
}
}

// Chat público de /demo. Apagado salvo DEMO_MODE=on. El cursor del poll lo
// pone el cliente con la hora del servidor (ver src/demo.ts), no con la del
// navegador.
app.get("/demo", (c) => demoPage(c));
app.get("/demo/poll", (c) => demoPoll(c));
app.post("/demo/send", async (c) => {
if (!demoEnabled(c.env)) return c.json({ ok: false, error: "demo_off" }, 404);
const body = (await c.req.raw.clone().json().catch(() => ({}))) as { sessionId?: string };
const sid = String(body.sessionId ?? "").slice(0, 64);
if (sid && demoOverLimit(await demoTurnsUsed(c.env, sid))) {
return c.json({ ok: false, error: "limit" }, 429);
}
return routeToAgent(c, webAdapter);
});

app.post("/webhooks/telegram", (c) => routeToAgent(c, telegramAdapter));
// ManyChat — guarded by the X-Api-Key header the setup guide already asks for.
// No-op until MANYCHAT_WEBHOOK_SECRET is set, so existing bots keep working.
Expand Down
2 changes: 2 additions & 0 deletions src/replies/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { manychatAdapter } from "../channels/manychat";
import { twilioAdapter } from "../channels/twilio";
import { metaAdapter } from "../channels/meta";
import { whatsappAdapter } from "../channels/whatsapp";
import { webAdapter } from "../channels/web";

const MIN_DELAY_MS = 800;
const MAX_DELAY_MS = 1500;
Expand Down Expand Up @@ -41,5 +42,6 @@ export function pickAdapter(channel: ChannelId): ChannelAdapter {
if (channel === "twilio") return twilioAdapter;
if (channel === "whatsapp") return whatsappAdapter;
if (channel === "messenger" || channel === "instagram") return metaAdapter;
if (channel === "web") return webAdapter;
throw new Error(`unknown channel: ${channel}`);
}
Loading
Loading