Initial commit: PymesBot Demo with IA integration

- FastAPI backend with WebSocket chat
- SQLite database for products
- Z.AI (GLM-4.7) integration for AI responses
- Docker deployment ready
- Caddy proxy configuration
This commit is contained in:
Renato
2026-02-15 17:07:39 +01:00
commit 47264049e6
14 changed files with 4585 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/data /app/templates
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

688
pymesbot/backend/main.py Normal file
View File

@@ -0,0 +1,688 @@
import asyncio
import json
import sqlite3
import os
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Optional
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
from fastapi import (
FastAPI,
WebSocket,
WebSocketDisconnect,
HTTPException,
UploadFile,
File,
Depends,
)
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
OPENCLAW_WS_URL = os.getenv("OPENCLAW_WS_URL", "ws://openclaw_gateway:18789")
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "wlillidan1-demo-token-12345")
async def chat_with_ai(message: str, session_id: str = "pymesbot") -> Optional[str]:
"""Send message to Z.AI API directly"""
import httpx
ZAI_API_KEY = "6fef8efda3d24eb9ad3d718daf1ae9a1.RcFc7QPe5uZLr2mS"
ZAI_API_URL = "https://api.z.ai/api/anthropic/v1/messages"
system_prompt = """Sos el asistente de ventas de Demo Librería en Argentina.
Productos: biromes, lápices, cuadernos, colores, reglas, etc.
Precios en pesos argentinos.
Respondé de forma útil, breve y siempre preguntá si se concretó la venta."""
try:
response = httpx.post(
ZAI_API_URL,
headers={
"Authorization": f"Bearer {ZAI_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": "glm-4.7",
"max_tokens": 200,
"system": system_prompt,
"messages": [{"role": "user", "content": message}],
},
timeout=30.0,
)
if response.status_code == 200:
data = response.json()
content = data.get("content", [])
if content and len(content) > 0:
return content[0].get("text", "")
else:
logger.error(f"[Z.AI] API error: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"[Z.AI] Error: {e}")
return None
return None
# Alias para compatibilidad
chat_with_openclaw = chat_with_ai
DATA_DIR = Path(__file__).parent / "data"
DB_PATH = DATA_DIR / "stock.db"
TEMPLATES_DIR = Path(__file__).parent / "templates"
def get_db():
if not DB_PATH.exists():
init_db()
return DB_PATH
def init_db():
DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS productos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
marca TEXT,
categoria TEXT NOT NULL DEFAULT 'general',
precio REAL NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
variantes TEXT,
codigo TEXT,
activo INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS ventas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
producto_id INTEGER NOT NULL,
cantidad INTEGER NOT NULL,
precio_vendido REAL NOT NULL,
vendedor TEXT,
notas TEXT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (producto_id) REFERENCES productos(id)
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS config (
clave TEXT PRIMARY KEY,
valor TEXT NOT NULL
)
""")
default_config = [
("nombre_negocio", "Demo Librería"),
("moneda", "ARS"),
("moneda_simbolo", "$"),
("alerta_stock_minimo", "5"),
("vendedor_pin", "1234"),
("admin_password", "admin123"),
("rubro", "libreria"),
]
for clave, valor in default_config:
cursor.execute(
"INSERT OR IGNORE INTO config (clave, valor) VALUES (?, ?)", (clave, valor)
)
sample_products = [
(
"Birome Bic Cristal Azul",
"Bic",
"escritura",
850,
50,
'{"color": ["azul"]}',
"7501031311309",
),
(
"Birome Bic Cristal Rojo",
"Bic",
"escritura",
850,
30,
'{"color": ["rojo"]}',
"7501031311316",
),
(
"Birome Bic Cristal Negro",
"Bic",
"escritura",
850,
45,
'{"color": ["negro"]}',
"7501031311323",
),
(
"Cuaderno Rivadavia 48 Hojas",
"Rivadavia",
"cuadernos",
2500,
20,
'{"tipo": ["rayado", "blanco"]}',
None,
),
("Lápiz Faber Castell 2B", "Faber Castell", "escritura", 450, 100, None, None),
("Goma de borrar Staedtler", "Staedtler", "escritura", 320, 25, None, None),
("Regla 30cm", "Maped", "geometria", 650, 15, None, None),
("Compás Prisma", "Prisma", "geometria", 2500, 8, None, None),
("Caja de colores 12", "Maped", "colores", 3200, 18, None, None),
("Papel glasé x 20", "Laprida", "colores", 850, 40, None, None),
]
for nombre, marca, categoria, precio, stock, variantes, codigo in sample_products:
v = variantes if variantes else None
cursor.execute(
"""
INSERT INTO productos (nombre, marca, categoria, precio, stock, variantes, codigo)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(nombre, marca, categoria, precio, stock, v, codigo),
)
conn.commit()
conn.close()
class BuscarStockRequest(BaseModel):
query: str
limit: int = 5
class ConfirmarVentaRequest(BaseModel):
producto_id: int
cantidad: int
precio_vendido: float
vendedor: str = "vendedor"
class LoginRequest(BaseModel):
pin: str
app = FastAPI(title="PymesBot Backend")
if TEMPLATES_DIR.exists():
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
else:
templates = None
@app.on_event("startup")
async def startup():
init_db()
@app.get("/health")
async def health():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("SELECT valor FROM config WHERE clave = 'nombre_negocio'")
nombre = cursor.fetchone()
conn.close()
return {"status": "ok", "negocio": nombre[0] if nombre else "Demo"}
@app.get("/stock/search")
async def buscar_stock(q: str, limit: int = 5):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
q_lower = q.lower()
cursor.execute(
"""
SELECT * FROM productos
WHERE activo = 1 AND (
LOWER(nombre) LIKE ? OR
LOWER(marca) LIKE ? OR
LOWER(categoria) LIKE ?
)
ORDER BY
CASE
WHEN LOWER(nombre) LIKE ? THEN 1
ELSE 2
END,
stock DESC
LIMIT ?
""",
(f"%{q_lower}%", f"%{q_lower}%", f"%{q_lower}%", f"%{q_lower}%", limit),
)
rows = cursor.fetchall()
resultados = []
for row in rows:
resultados.append(
{
"id": row["id"],
"nombre": row["nombre"],
"marca": row["marca"],
"categoria": row["categoria"],
"precio": row["precio"],
"precio_formateado": f"${row['precio']:.0f}",
"stock": row["stock"],
"variantes": json.loads(row["variantes"]) if row["variantes"] else {},
"hay_stock": row["stock"] > 0,
"promo_activa": None,
}
)
conn.close()
return {"resultados": resultados, "total": len(resultados), "query": q}
@app.get("/stock/producto/{producto_id}")
async def get_producto(producto_id: int):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM productos WHERE id = ?", (producto_id,))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(
status_code=404, detail=f"Producto con id {producto_id} no encontrado"
)
return {
"id": row["id"],
"nombre": row["nombre"],
"marca": row["marca"],
"categoria": row["categoria"],
"precio": row["precio"],
"stock": row["stock"],
"variantes": json.loads(row["variantes"]) if row["variantes"] else {},
"codigo": row["codigo"],
"activo": bool(row["activo"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
@app.post("/venta/confirmar")
async def confirmar_venta(req: ConfirmarVentaRequest):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM productos WHERE id = ? AND activo = 1", (req.producto_id,)
)
producto = cursor.fetchone()
if not producto:
conn.close()
raise HTTPException(status_code=404, detail="Producto no encontrado")
if producto[4] < req.cantidad:
conn.close()
raise HTTPException(
status_code=400,
detail=f"Stock insuficiente. Stock actual: {producto[4]}, solicitado: {req.cantidad}",
)
cursor.execute(
"""
INSERT INTO ventas (producto_id, cantidad, precio_vendido, vendedor)
VALUES (?, ?, ?, ?)
""",
(req.producto_id, req.cantidad, req.precio_vendido, req.vendedor),
)
cursor.execute(
"""
UPDATE productos SET stock = stock - ?, updated_at = datetime('now')
WHERE id = ?
""",
(req.cantidad, req.producto_id),
)
conn.commit()
cursor.execute("SELECT stock FROM productos WHERE id = ?", (req.producto_id,))
nuevo_stock = cursor.fetchone()[0]
venta_id = cursor.lastrowid
conn.close()
return {
"venta_id": venta_id,
"producto": producto[1],
"cantidad": req.cantidad,
"precio_vendido": req.precio_vendido,
"total": req.cantidad * req.precio_vendido,
"stock_anterior": producto[4],
"stock_nuevo": nuevo_stock,
"timestamp": datetime.now().isoformat(),
}
@app.get("/stats/ventas")
async def ventas_stats(periodo: str = "hoy"):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
if periodo == "hoy":
cursor.execute("""
SELECT COUNT(*), SUM(cantidad * precio_vendido), AVG(cantidad * precio_vendido)
FROM ventas
WHERE date(timestamp) = date('now')
""")
elif periodo == "semana":
cursor.execute("""
SELECT COUNT(*), SUM(cantidad * precio_vendido), AVG(cantidad * precio_vendido)
FROM ventas
WHERE timestamp >= datetime('now', '-7 days')
""")
else:
cursor.execute("""
SELECT COUNT(*), SUM(cantidad * precio_vendido), AVG(cantidad * precio_vendido)
FROM ventas
WHERE timestamp >= datetime('now', '-30 days')
""")
row = cursor.fetchone()
conn.close()
return {
"periodo": periodo,
"total_ventas": row[0] or 0,
"total_pesos": row[1] or 0,
"ticket_promedio": row[2] or 0,
}
@app.get("/")
async def root():
if templates:
return templates.TemplateResponse(
"chat.html", {"request": {}, "nombre_negocio": "Demo Librería"}
)
return HTMLResponse("""
<html>
<head>
<title>PymesBot - Demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; padding: 20px; }
.chat-box { background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); height: 70vh; display: flex; flex-direction: column; }
.chat-header { padding: 16px 20px; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 12px; }
.chat-header h1 { font-size: 18px; color: #333; }
.chat-messages { flex: 1; overflow-y: auto; padding: 20px; }
.message { margin-bottom: 16px; max-width: 80%; }
.message.bot { margin-right: auto; }
.message.user { margin-left: auto; text-align: right; }
.message .bubble { display: inline-block; padding: 12px 16px; border-radius: 18px; }
.message.bot .bubble { background: #f0f0f0; color: #333; }
.message.user .bubble { background: #007bff; color: white; }
.typing { color: #888; font-style: italic; padding: 8px; }
.chat-input { padding: 16px 20px; border-top: 1px solid #eee; display: flex; gap: 12px; }
.chat-input input { flex: 1; padding: 12px 16px; border: 1px solid #ddd; border-radius: 24px; outline: none; font-size: 16px; }
.chat-input button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 24px; cursor: pointer; font-size: 16px; }
.chat-input button:hover { background: #0056b3; }
.sidebar { background: white; border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.sidebar h2 { font-size: 16px; margin-bottom: 12px; color: #333; }
.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
.stat-value { font-weight: 600; color: #007bff; }
</style>
</head>
<body>
<div class="container">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="chat-box">
<div class="chat-header">
<h1>🛒 Demo Librería</h1>
</div>
<div class="chat-messages" id="messages">
<div class="message bot">
<div class="bubble">¡Hola! Soy el asistente de ventas de Demo Librería. ¿Qué estás buscando?</div>
</div>
</div>
<div class="chat-input">
<input type="text" id="msgInput" placeholder="Escribí tu consulta..." autocomplete="off">
<button onclick="sendMessage()">Enviar</button>
</div>
</div>
<div>
<div class="sidebar">
<h2>📊 Hoy</h2>
<div class="stat">
<span>Ventas</span>
<span class="stat-value" id="ventas-hoy">0</span>
</div>
<div class="stat">
<span>Total</span>
<span class="stat-value" id="total-hoy">$0</span>
</div>
</div>
<div class="sidebar">
<h2>⚠️ Stock bajo</h2>
<div id="alertas-stock"></div>
</div>
</div>
</div>
</div>
<script>
let ws = null;
const sessionId = Math.random().toString(36).substring(7);
function connect() {
ws = new WebSocket(`wss://${window.location.host}/chat/ws/${sessionId}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.msg) {
addMessage(data.msg, 'bot');
}
if (data.stats) {
document.getElementById('ventas-hoy').textContent = data.stats.total_ventas;
document.getElementById('total-hoy').textContent = '$' + data.stats.total_pesos.toLocaleString();
}
};
}
function addMessage(text, type) {
const div = document.createElement('div');
div.className = `message ${type}`;
div.innerHTML = `<div class="bubble">${text}</div>`;
document.getElementById('messages').appendChild(div);
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
}
function sendMessage() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text || !ws) return;
addMessage(text, 'user');
ws.send(JSON.stringify({ msg: text, session_id: sessionId }));
input.value = '';
}
document.getElementById('msgInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
connect();
fetch('/stats/ventas?periodo=hoy')
.then(r => r.json())
.then(data => {
document.getElementById('ventas-hoy').textContent = data.total_ventas;
document.getElementById('total-hoy').textContent = '$' + data.total_pesos.toLocaleString();
});
</script>
</body>
</html>
""")
chat_sessions = {}
@app.websocket("/chat/ws/{session_id}")
async def websocket_chat(websocket: WebSocket, session_id: str):
await websocket.accept()
logger.info(f"[WS] Accepted connection for session: {session_id}")
if session_id not in chat_sessions:
chat_sessions[session_id] = []
try:
while True:
data = await websocket.receive_json()
logger.info(f"[WS] Received: {data}")
mensaje = data.get("msg", "")
session_id = data.get("session_id", session_id)
await websocket.send_json({"typing": True})
logger.info(f"[WS] Processing message: {mensaje}")
# Mejorar búsqueda: manejar plurales y buscar en nombre, marca y categoría
mensaje_lower = mensaje.lower()
# Mapeo de plurales a singular
plurales = {
"lapices": "lapiz",
"lápices": "lápiz",
"lápices": "lápiz",
"cuadernos": "cuaderno",
"biromes": "birome",
"gomas": "goma",
"marcadores": "marcador",
"colores": "colores",
"fibras": "fibras",
"reglas": "regla",
"tijeras": "tijera",
}
for plural, singular in plurales.items():
mensaje_lower = mensaje_lower.replace(plural, singular)
# Extraer palabras clave de búsqueda (más de 3 letras)
palabras = mensaje_lower.split()
stopwords = {
"hola",
"buenos",
"buenas",
"que",
"tiene",
"tenes",
"busco",
"quiero",
"necesito",
"para",
"una",
"clienta",
"vino",
"buscar",
"necesita",
"quanto",
"cuanto",
"cuántos",
"cuántas",
}
terminos = [p for p in palabras if len(p) > 3 and p not in stopwords]
if not terminos:
# Si no hay términos claros, usar el mensaje completo para buscar
terminos = [mensaje_lower]
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# Buscar todos los términos juntos
all_results = []
for term in terminos:
cursor.execute(
"""
SELECT * FROM productos
WHERE activo = 1 AND (
LOWER(nombre) LIKE ? OR
LOWER(marca) LIKE ? OR
LOWER(categoria) LIKE ?
)
ORDER BY stock DESC
LIMIT 10
""",
(f"%{term}%", f"%{term}%", f"%{term}%"),
)
rows = cursor.fetchall()
for row in rows:
if row["id"] not in [r["id"] for r in all_results]:
all_results.append(dict(row))
rows = all_results[:5]
logger.info(f"[WS] Found {len(rows)} rows")
try:
if rows:
resultados = []
for row in rows:
resultados.append(
f"{row['nombre']} - ${row['precio']:.0f} (stock: {row['stock']})"
)
respuesta = f"Encontre estos productos:\n" + "\n".join(resultados)
respuesta += "\n\n¿Se concretó la venta? ¿Cuántas unidades?"
logger.info(f"[WS] Respuesta: {respuesta[:50]}")
else:
# Usar OpenClaw cuando no hay match en DB
logger.info(
f"[WS] No products found, trying OpenClaw for: {mensaje}"
)
respuesta = await chat_with_openclaw(mensaje, session_id)
if not respuesta:
respuesta = "No encontré productos con esa descripción. ¿Podés ser más específico?"
logger.info(
f"[WS] OpenClaw response: {respuesta[:50] if respuesta else 'None'}"
)
chat_sessions[session_id].append({"role": "user", "content": mensaje})
chat_sessions[session_id].append(
{"role": "assistant", "content": respuesta}
)
await websocket.send_json({"typing": False})
await websocket.send_json({"msg": respuesta})
logger.info("[WS] Response sent")
except Exception as e:
logger.error(f"[WS] Error sending response: {e}")
conn.close()
except WebSocketDisconnect:
pass
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,10 @@
fastapi==0.110.0
uvicorn[standard]==0.29.0
jinja2==3.1.3
python-multipart==0.0.9
aiosqlite==0.20.0
openpyxl==3.1.2
pandas==2.2.0
python-dotenv==1.0.1
aiofiles==23.2.1
httpx==0.27.0

View File

@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Demo Librería - PymesBot</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
height: 100%;
}
.app {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
height: calc(100vh - 40px);
}
.chat-box {
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 12px;
}
.chat-header h1 { font-size: 18px; color: #333; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.message { margin-bottom: 16px; max-width: 80%; }
.message.bot { margin-right: auto; }
.message.user { margin-left: auto; text-align: right; }
.message .bubble {
display: inline-block;
padding: 12px 16px;
border-radius: 18px;
white-space: pre-wrap;
}
.message.bot .bubble { background: #f0f0f0; color: #333; }
.message.user .bubble { background: #007bff; color: white; }
.typing { color: #888; font-style: italic; padding: 8px; }
.chat-input {
padding: 16px 20px;
border-top: 1px solid #eee;
display: flex;
gap: 12px;
}
.chat-input input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 24px;
outline: none;
font-size: 16px;
}
.chat-input input:focus { border-color: #007bff; }
.chat-input button {
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 24px;
cursor: pointer;
font-size: 16px;
}
.chat-input button:hover { background: #0056b3; }
.sidebar { display: flex; flex-direction: column; gap: 20px; }
.sidebar-box {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.sidebar-box h2 { font-size: 16px; margin-bottom: 12px; color: #333; }
.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
.stat-value { font-weight: 600; color: #007bff; }
.product-list { max-height: 200px; overflow-y: auto; }
.product-item { padding: 8px 0; border-bottom: 1px solid #eee; font-size: 14px; }
.product-item .name { font-weight: 500; }
.product-item .price { color: #007bff; }
.product-item .stock { color: #28a745; font-size: 12px; }
.product-item .no-stock { color: #dc3545; font-size: 12px; }
@media (max-width: 768px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
</style>
</head>
<body>
<div class="container">
<div class="app">
<div class="chat-box">
<div class="chat-header">
<span style="font-size: 24px;">🛒</span>
<h1>Demo Librería</h1>
</div>
<div class="chat-messages" id="messages">
<div class="message bot">
<div class="bubble">¡Hola! Soy el asistente de ventas de Demo Librería. ¿Qué estás buscando?</div>
</div>
</div>
<div id="typing-indicator" class="typing" style="display: none;">
Escribiendo...
</div>
<div class="chat-input">
<input type="text" id="msgInput" placeholder="Escribí tu consulta..." autocomplete="off">
<button onclick="sendMessage()">Enviar</button>
</div>
</div>
<div class="sidebar">
<div class="sidebar-box">
<h2>📊 Hoy</h2>
<div class="stat">
<span>Ventas</span>
<span class="stat-value" id="ventas-hoy">0</span>
</div>
<div class="stat">
<span>Total</span>
<span class="stat-value" id="total-hoy">$0</span>
</div>
</div>
<div class="sidebar-box">
<h2>📦 Productos</h2>
<div class="product-list" id="product-list">
<div style="color: #888; font-size: 14px;">Escribí para buscar...</div>
</div>
</div>
</div>
</div>
</div>
<script>
let ws = null;
const sessionId = Math.random().toString(36).substring(7);
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/chat/ws/${sessionId}`);
ws.onopen = () => console.log('Conectado');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.typing !== undefined) {
document.getElementById('typing-indicator').style.display = data.typing ? 'block' : 'none';
}
if (data.msg) {
addMessage(data.msg, 'bot');
}
};
ws.onerror = (e) => console.error('WS Error:', e);
ws.onclose = () => {
console.log('Desconectado, reconectando...');
setTimeout(connect, 3000);
};
}
function addMessage(text, type) {
const div = document.createElement('div');
div.className = `message ${type}`;
div.innerHTML = `<div class="bubble">${escapeHtml(text)}</div>`;
document.getElementById('messages').appendChild(div);
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function sendMessage() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
addMessage(text, 'user');
ws.send(JSON.stringify({ msg: text, session_id: sessionId }));
input.value = '';
}
document.getElementById('msgInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
connect();
fetch('/stats/ventas?periodo=hoy')
.then(r => r.json())
.then(data => {
document.getElementById('ventas-hoy').textContent = data.total_ventas || 0;
document.getElementById('total-hoy').textContent = '$' + (data.total_pesos || 0).toLocaleString();
})
.catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
{
"agents": {
"defaults": {
"workspace": "/root/.picoclaw/workspace",
"model": "gpt-4o",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20,
"system": "Sos el asistente de ventas de Demo PymesBot, una librería en Argentina.\n\nREGLAS:\n1. Respondé en español argentino.\n2. Sé conciso (máximo 3 líneas).\n3. Siempre preguntá si se concretó la venta."
}
},
"providers": {
"openai": {
"api_key": "6fef8efda3d24eb9ad3d718daf1ae9a1.RcFc7QPe5uZLr2mS",
"api_base": "https://api.z.ai/v1"
}
}
}