feat(pos): chatbot IA con OpenRouter — busqueda de partes por lenguaje natural
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
148
pos/blueprints/chat_bp.py
Normal file
148
pos/blueprints/chat_bp.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# /home/Autopartes/pos/blueprints/chat_bp.py
|
||||||
|
"""Chat blueprint: AI-powered parts lookup via natural language.
|
||||||
|
|
||||||
|
Endpoints (all under /pos/api/chat):
|
||||||
|
POST / — send a message, get AI response + catalog search results
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from middleware import require_auth
|
||||||
|
from tenant_db import get_master_conn, get_tenant_conn
|
||||||
|
from services import catalog_service, ai_chat
|
||||||
|
|
||||||
|
chat_bp = Blueprint("chat", __name__, url_prefix="/pos/api/chat")
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("", methods=["POST"])
|
||||||
|
@require_auth("catalog.view")
|
||||||
|
def chat():
|
||||||
|
body = request.get_json(force=True)
|
||||||
|
user_message = (body.get("message") or "").strip()
|
||||||
|
if not user_message:
|
||||||
|
return jsonify({"error": "message required"}), 400
|
||||||
|
|
||||||
|
history = body.get("history") or []
|
||||||
|
|
||||||
|
# Call AI
|
||||||
|
ai_response = ai_chat.chat(user_message, history)
|
||||||
|
|
||||||
|
search_results = []
|
||||||
|
vehicle_match = None
|
||||||
|
|
||||||
|
master = None
|
||||||
|
tenant = None
|
||||||
|
try:
|
||||||
|
# If AI suggests a search query, run it against the catalog
|
||||||
|
search_query = ai_response.get("search_query")
|
||||||
|
vehicle = ai_response.get("vehicle")
|
||||||
|
|
||||||
|
if search_query or vehicle:
|
||||||
|
master = get_master_conn()
|
||||||
|
tenant = get_tenant_conn(g.tenant_id)
|
||||||
|
branch_id = g.branch_id
|
||||||
|
|
||||||
|
# Try to resolve vehicle to MYE
|
||||||
|
if vehicle and master:
|
||||||
|
vehicle_match = _resolve_vehicle(master, vehicle)
|
||||||
|
|
||||||
|
# Run catalog search if we have a search query
|
||||||
|
if search_query and master and tenant:
|
||||||
|
try:
|
||||||
|
results = catalog_service.smart_search(
|
||||||
|
master, search_query, tenant, branch_id, limit=10
|
||||||
|
)
|
||||||
|
search_results = results if results else []
|
||||||
|
except Exception:
|
||||||
|
pass # search failure is non-fatal
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass # DB failure is non-fatal for chat
|
||||||
|
finally:
|
||||||
|
if master:
|
||||||
|
try:
|
||||||
|
master.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if tenant:
|
||||||
|
try:
|
||||||
|
tenant.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"response": ai_response.get("message", ""),
|
||||||
|
"search_results": search_results,
|
||||||
|
"vehicle": vehicle_match or ai_response.get("vehicle"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_vehicle(master_conn, vehicle):
|
||||||
|
"""Try to resolve AI-extracted vehicle info to brand_id/model_id in DB."""
|
||||||
|
brand_name = (vehicle.get("brand") or "").upper().strip()
|
||||||
|
model_name = (vehicle.get("model") or "").strip()
|
||||||
|
year = vehicle.get("year")
|
||||||
|
|
||||||
|
if not brand_name:
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
cur = master_conn.cursor()
|
||||||
|
result = dict(vehicle)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find brand
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id_brand, name_brand FROM brands WHERE UPPER(name_brand) = %s",
|
||||||
|
(brand_name,),
|
||||||
|
)
|
||||||
|
brand_row = cur.fetchone()
|
||||||
|
if brand_row:
|
||||||
|
result["brand_id"] = brand_row[0]
|
||||||
|
result["brand"] = brand_row[1]
|
||||||
|
|
||||||
|
# Find model
|
||||||
|
if model_name:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT m.id_model, m.name_model
|
||||||
|
FROM models m
|
||||||
|
WHERE m.brand_id = %s
|
||||||
|
AND UPPER(m.name_model) LIKE %s
|
||||||
|
ORDER BY m.name_model
|
||||||
|
LIMIT 5""",
|
||||||
|
(brand_row[0], f"%{model_name.upper()}%"),
|
||||||
|
)
|
||||||
|
model_row = cur.fetchone()
|
||||||
|
if model_row:
|
||||||
|
result["model_id"] = model_row[0]
|
||||||
|
result["model"] = model_row[1]
|
||||||
|
|
||||||
|
# Find year -> MYE
|
||||||
|
if year:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT mye.id_mye, y.year_car, e.name_engine, mye.trim_level
|
||||||
|
FROM model_year_engine mye
|
||||||
|
JOIN years y ON y.id_year = mye.year_id
|
||||||
|
JOIN engines e ON e.id_engine = mye.engine_id
|
||||||
|
WHERE mye.model_id = %s AND y.year_car = %s
|
||||||
|
ORDER BY e.name_engine
|
||||||
|
LIMIT 10""",
|
||||||
|
(model_row[0], int(year)),
|
||||||
|
)
|
||||||
|
mye_rows = cur.fetchall()
|
||||||
|
if mye_rows:
|
||||||
|
result["mye_options"] = [
|
||||||
|
{
|
||||||
|
"mye_id": r[0],
|
||||||
|
"year": r[1],
|
||||||
|
"engine": r[2],
|
||||||
|
"trim": r[3],
|
||||||
|
}
|
||||||
|
for r in mye_rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -19,3 +19,8 @@ PIN_LOCKOUT_THRESHOLD = 10
|
|||||||
PIN_LOCKOUT_MINUTES = 15
|
PIN_LOCKOUT_MINUTES = 15
|
||||||
|
|
||||||
TENANT_TEMPLATE_DB = "tenant_template"
|
TENANT_TEMPLATE_DB = "tenant_template"
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY = os.environ.get(
|
||||||
|
"OPENROUTER_API_KEY",
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|||||||
83
pos/services/ai_chat.py
Normal file
83
pos/services/ai_chat.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# /home/Autopartes/pos/services/ai_chat.py
|
||||||
|
"""AI Chat service using OpenRouter for parts lookup assistance."""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from config import OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
MODEL = "anthropic/claude-haiku-4.5" # Fast + cheap for chat
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
|
||||||
|
|
||||||
|
Cuando el usuario describe lo que necesita, extrae:
|
||||||
|
1. Marca del vehiculo (si la menciona)
|
||||||
|
2. Modelo del vehiculo (si lo menciona)
|
||||||
|
3. Ano del vehiculo (si lo menciona)
|
||||||
|
4. Tipo de parte que busca
|
||||||
|
|
||||||
|
Responde en espanol, de forma breve y directa. Si puedes identificar el numero de parte OEM, incluyelo.
|
||||||
|
Si no tienes suficiente informacion, pregunta lo que falte.
|
||||||
|
|
||||||
|
IMPORTANTE: Responde SIEMPRE en formato JSON con esta estructura:
|
||||||
|
{
|
||||||
|
"message": "Tu respuesta al usuario",
|
||||||
|
"search_query": "texto para buscar en el catalogo" | null,
|
||||||
|
"vehicle": {"brand": "TOYOTA", "model": "Corolla", "year": 2020} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
- "message" es tu respuesta conversacional al usuario.
|
||||||
|
- "search_query" es el texto clave para buscar partes en la base de datos (nombre de parte en ingles, numero OEM, etc). Usa null si no hay busqueda.
|
||||||
|
- "vehicle" extrae marca, modelo y ano si los menciona. Usa null si no hay vehiculo.
|
||||||
|
- La marca debe ir en MAYUSCULAS (NISSAN, TOYOTA, CHEVROLET, etc).
|
||||||
|
- Nombres comunes mexicanos: Tsuru = Sentra/Tsuru, Aveo, Jetta, Pointer, Chevy = Corsa, Vocho = Beetle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def chat(user_message, conversation_history=None):
|
||||||
|
"""Send a message to the AI and get a response with search suggestions."""
|
||||||
|
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
|
if conversation_history:
|
||||||
|
messages.extend(conversation_history)
|
||||||
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
OPENROUTER_URL,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": 500,
|
||||||
|
"temperature": 0.3,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
# Try to parse JSON response
|
||||||
|
try:
|
||||||
|
# Handle markdown-wrapped JSON (```json ... ```)
|
||||||
|
stripped = content.strip()
|
||||||
|
if stripped.startswith("```"):
|
||||||
|
lines = stripped.split("\n")
|
||||||
|
# Remove first and last lines (``` markers)
|
||||||
|
json_str = "\n".join(lines[1:-1])
|
||||||
|
parsed = json.loads(json_str)
|
||||||
|
else:
|
||||||
|
parsed = json.loads(stripped)
|
||||||
|
return parsed
|
||||||
|
except (json.JSONDecodeError, IndexError):
|
||||||
|
return {"message": content, "search_query": None, "vehicle": None}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"message": f"Error de conexion: {str(e)}",
|
||||||
|
"search_query": None,
|
||||||
|
"vehicle": None,
|
||||||
|
}
|
||||||
296
pos/static/css/chat.css
Normal file
296
pos/static/css/chat.css
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
NEXUS POS — AI Chat Widget
|
||||||
|
Uses design system tokens from tokens.css
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ─── Floating Button ─── */
|
||||||
|
|
||||||
|
.chat-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 72px; /* above F-keys footer */
|
||||||
|
right: var(--space-5);
|
||||||
|
z-index: 8000;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transition: transform var(--duration-fast) var(--ease-in-out),
|
||||||
|
background var(--duration-fast) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-fab.has-unread::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Chat Panel ─── */
|
||||||
|
|
||||||
|
.chat-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 72px;
|
||||||
|
right: var(--space-5);
|
||||||
|
z-index: 8001;
|
||||||
|
width: 400px;
|
||||||
|
height: 520px;
|
||||||
|
max-height: calc(100vh - 100px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform var(--duration-normal) var(--ease-in-out),
|
||||||
|
opacity var(--duration-normal) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel.open {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Header ─── */
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h3 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--text-body);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* ─── Messages Area ─── */
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Message Bubbles ─── */
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
max-width: 85%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.45;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg.ai {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-bottom-left-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Typing Indicator ─── */
|
||||||
|
|
||||||
|
.chat-typing {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: none;
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border-bottom-left-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing.visible { display: flex; }
|
||||||
|
|
||||||
|
.chat-typing span {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
animation: chatBounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
.chat-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.chat-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes chatBounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Part Result Cards ─── */
|
||||||
|
|
||||||
|
.chat-parts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-part-card {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-in-out),
|
||||||
|
background var(--duration-fast) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-part-card:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-part-card .part-number {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-part-card .part-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-part-card .part-stock {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-part-card .part-stock.in-stock {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Input Area ─── */
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
min-height: 38px;
|
||||||
|
max-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--duration-fast) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:hover { background: var(--color-accent-hover); }
|
||||||
|
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ─── Vehicle Info Banner ─── */
|
||||||
|
|
||||||
|
.chat-vehicle-banner {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-left: 3px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-vehicle-banner strong {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ─── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.chat-panel {
|
||||||
|
width: calc(100vw - var(--space-4));
|
||||||
|
right: var(--space-2);
|
||||||
|
height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
244
pos/static/js/chat.js
Normal file
244
pos/static/js/chat.js
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// /home/Autopartes/pos/static/js/chat.js
|
||||||
|
// AI Chat Widget for Nexus POS — natural language parts lookup
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ─── State ───
|
||||||
|
let isOpen = false;
|
||||||
|
let isSending = false;
|
||||||
|
const history = []; // conversation history for AI context
|
||||||
|
|
||||||
|
// ─── Build DOM ───
|
||||||
|
function init() {
|
||||||
|
// FAB button
|
||||||
|
const fab = document.createElement('button');
|
||||||
|
fab.className = 'chat-fab';
|
||||||
|
fab.id = 'chatFab';
|
||||||
|
fab.title = 'Asistente IA';
|
||||||
|
fab.innerHTML = '💬'; // speech bubble emoji
|
||||||
|
fab.setAttribute('aria-label', 'Abrir asistente IA');
|
||||||
|
|
||||||
|
// Chat panel
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'chat-panel';
|
||||||
|
panel.id = 'chatPanel';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="chat-header">
|
||||||
|
<h3>Asistente IA — Buscar partes</h3>
|
||||||
|
<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="chat-messages" id="chatMessages">
|
||||||
|
<div class="chat-msg ai">Hola, soy el asistente de Nexus. Dime que refaccion necesitas y te ayudo a encontrarla.</div>
|
||||||
|
<div class="chat-typing" id="chatTyping">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>
|
||||||
|
<button class="chat-send-btn" id="chatSend" aria-label="Enviar">▶</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(fab);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
// Events
|
||||||
|
fab.addEventListener('click', toggleChat);
|
||||||
|
document.getElementById('chatClose').addEventListener('click', toggleChat);
|
||||||
|
document.getElementById('chatSend').addEventListener('click', sendMessage);
|
||||||
|
document.getElementById('chatInput').addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
document.getElementById('chatInput').addEventListener('input', function () {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChat() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
const panel = document.getElementById('chatPanel');
|
||||||
|
const fab = document.getElementById('chatFab');
|
||||||
|
if (isOpen) {
|
||||||
|
panel.classList.add('open');
|
||||||
|
fab.style.display = 'none';
|
||||||
|
document.getElementById('chatInput').focus();
|
||||||
|
} else {
|
||||||
|
panel.classList.remove('open');
|
||||||
|
fab.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
// app-init.js stores token in window.__pos or localStorage
|
||||||
|
if (window.__pos && window.__pos.token) return window.__pos.token;
|
||||||
|
return localStorage.getItem('pos_token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Send message ───
|
||||||
|
async function sendMessage() {
|
||||||
|
if (isSending) return;
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
|
||||||
|
// Add user bubble
|
||||||
|
addBubble(text, 'user');
|
||||||
|
|
||||||
|
// Keep history for context (last 10 exchanges)
|
||||||
|
history.push({ role: 'user', content: text });
|
||||||
|
if (history.length > 20) history.splice(0, 2);
|
||||||
|
|
||||||
|
// Show typing
|
||||||
|
isSending = true;
|
||||||
|
document.getElementById('chatSend').disabled = true;
|
||||||
|
showTyping(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
const resp = await fetch('/pos/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: text,
|
||||||
|
history: history.slice(-10)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
addBubble('Error: ' + (data.error || resp.statusText), 'ai');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI response bubble
|
||||||
|
const aiMsg = data.response || 'Sin respuesta.';
|
||||||
|
addBubble(aiMsg, 'ai');
|
||||||
|
history.push({ role: 'assistant', content: aiMsg });
|
||||||
|
|
||||||
|
// Vehicle info
|
||||||
|
if (data.vehicle && data.vehicle.brand_id) {
|
||||||
|
addVehicleBanner(data.vehicle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search results
|
||||||
|
if (data.search_results && data.search_results.length > 0) {
|
||||||
|
addPartResults(data.search_results);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
addBubble('Error de conexion: ' + err.message, 'ai');
|
||||||
|
} finally {
|
||||||
|
isSending = false;
|
||||||
|
document.getElementById('chatSend').disabled = false;
|
||||||
|
showTyping(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DOM helpers ───
|
||||||
|
function addBubble(text, role) {
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
const typing = document.getElementById('chatTyping');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'chat-msg ' + role;
|
||||||
|
div.textContent = text;
|
||||||
|
container.insertBefore(div, typing);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVehicleBanner(vehicle) {
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
const typing = document.getElementById('chatTyping');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'chat-vehicle-banner';
|
||||||
|
|
||||||
|
let html = '<strong>' + esc(vehicle.brand || '') + ' ' + esc(vehicle.model || '') + '</strong>';
|
||||||
|
if (vehicle.year) html += ' ' + vehicle.year;
|
||||||
|
|
||||||
|
if (vehicle.mye_options && vehicle.mye_options.length > 0) {
|
||||||
|
html += '<br>Motorizaciones encontradas:';
|
||||||
|
vehicle.mye_options.forEach(function (opt) {
|
||||||
|
html += '<br>• ' + esc(opt.engine);
|
||||||
|
if (opt.trim) html += ' (' + esc(opt.trim) + ')';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML = html;
|
||||||
|
container.insertBefore(div, typing);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPartResults(parts) {
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
const typing = document.getElementById('chatTyping');
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'chat-parts';
|
||||||
|
|
||||||
|
parts.slice(0, 8).forEach(function (p) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'chat-part-card';
|
||||||
|
|
||||||
|
const stockQty = p.local_stock || 0;
|
||||||
|
const stockClass = stockQty > 0 ? 'in-stock' : '';
|
||||||
|
const stockText = stockQty > 0 ? (stockQty + ' en stock') : 'Sin stock local';
|
||||||
|
const name = p.name_es || p.name_part || '';
|
||||||
|
const partNum = p.oem_part_number || '';
|
||||||
|
const priceText = p.price_1 ? ('$' + parseFloat(p.price_1).toFixed(2)) : '';
|
||||||
|
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="part-number">' + esc(partNum) + (priceText ? ' — ' + priceText : '') + '</div>' +
|
||||||
|
'<div class="part-name">' + esc(name) + '</div>' +
|
||||||
|
'<div class="part-stock ' + stockClass + '">' + esc(stockText) + '</div>';
|
||||||
|
|
||||||
|
// Click to open detail (if catalog page has a detail function)
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
if (p.id_part && typeof window.openPartDetail === 'function') {
|
||||||
|
window.openPartDetail(p.id_part);
|
||||||
|
toggleChat();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.insertBefore(wrapper, typing);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTyping(show) {
|
||||||
|
const el = document.getElementById('chatTyping');
|
||||||
|
if (el) el.classList.toggle('visible', show);
|
||||||
|
if (show) scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
const el = document.getElementById('chatMessages');
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init when DOM ready ───
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user