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:
2026-04-02 07:18:55 +00:00
parent 32581739ad
commit 0a44fb5304
5 changed files with 776 additions and 0 deletions

296
pos/static/css/chat.css Normal file
View 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
View 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 = '&#x1F4AC;'; // 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">&times;</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">&#9654;</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>&bull; ' + 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 ? ' &mdash; ' + 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();
}
})();