feat(pos): add kiosk mode, AI vision, AI part classification, public chatbot (#19 #25 #30 #29)

- Kiosk mode: fullscreen, wake lock, auto-login, context menu block, PWA/Capacitor detection
- AI vision: camera photos analyzed by Gemma 3 27B vision model via OpenRouter
- AI part classification: auto-suggest name/brand/category when entering part number
- Public catalog chatbot: /api/chat endpoint with rate limiting, chat widget on catalog page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 04:18:37 +00:00
parent 4cc2c66208
commit 5a88d7c7ff
13 changed files with 942 additions and 5 deletions

View File

@@ -375,5 +375,9 @@
<script src="/catalog-public.js"></script>
<!-- AI Chat Widget -->
<link rel="stylesheet" href="/chat-public.css">
<script src="/chat-public.js"></script>
</body>
</html>

237
dashboard/chat-public.css Normal file
View File

@@ -0,0 +1,237 @@
/* ==========================================================================
NEXUS — Public Catalog Chat Widget
Reuses design tokens from tokens.css
========================================================================== */
.chat-fab {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 8000;
width: 52px;
height: 52px;
border-radius: var(--radius-full, 50%);
border: none;
cursor: pointer;
background: var(--color-accent, #F5A623);
color: #fff;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-lg, 0 4px 12px rgba(0,0,0,0.3));
transition: transform 0.2s ease, background 0.2s ease;
}
.chat-fab:hover {
transform: scale(1.08);
background: var(--color-primary-hover, #e5952f);
}
.chat-panel {
position: fixed;
bottom: 90px;
right: 24px;
z-index: 8001;
width: 400px;
height: 520px;
max-height: calc(100vh - 100px);
display: flex;
flex-direction: column;
background: var(--color-bg-elevated, #1a1a1a);
border: 1px solid var(--color-border, #333);
border-radius: var(--radius-xl, 16px);
box-shadow: var(--shadow-xl, 0 8px 32px rgba(0,0,0,0.4));
overflow: hidden;
transform: translateY(20px) scale(0.95);
opacity: 0;
pointer-events: none;
transition: transform 0.25s ease, opacity 0.25s ease;
}
.chat-panel.open {
transform: translateY(0) scale(1);
opacity: 1;
pointer-events: all;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--color-accent, #F5A623);
color: #fff;
flex-shrink: 0;
}
.chat-header h3 {
font-family: var(--font-heading, sans-serif);
font-size: 0.95rem;
font-weight: 600;
margin: 0;
}
.chat-header-close {
background: none;
border: none;
color: #fff;
font-size: 1.2rem;
cursor: pointer;
padding: 4px;
line-height: 1;
opacity: 0.8;
}
.chat-header-close:hover { opacity: 1; }
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-msg {
max-width: 85%;
padding: 8px 12px;
border-radius: 12px;
font-size: 0.875rem;
line-height: 1.45;
word-wrap: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--color-accent, #F5A623);
color: #fff;
border-bottom-right-radius: 4px;
}
.chat-msg.ai {
align-self: flex-start;
background: var(--color-surface-2, rgba(255,255,255,0.06));
color: var(--color-text-primary, #fff);
border-bottom-left-radius: 4px;
}
.chat-typing {
align-self: flex-start;
display: none;
gap: 4px;
padding: 8px 12px;
background: var(--color-surface-2, rgba(255,255,255,0.06));
border-radius: 12px;
border-bottom-left-radius: 4px;
}
.chat-typing.visible { display: flex; }
.chat-typing span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-text-muted, #888);
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); }
}
.chat-parts {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.chat-part-card {
background: var(--color-bg-elevated, #1a1a1a);
border: 1px solid var(--color-border, #333);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.chat-part-card:hover {
border-color: var(--color-accent, #F5A623);
background: var(--color-bg-base, #111);
}
.chat-part-card .part-number {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: var(--color-accent, #F5A623);
font-weight: 600;
}
.chat-part-card .part-name {
font-size: 0.875rem;
color: var(--color-text-primary, #fff);
margin-top: 2px;
}
.chat-input-area {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--color-border, #333);
flex-shrink: 0;
}
.chat-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--color-border, #333);
border-radius: 8px;
background: var(--color-bg-base, #111);
color: var(--color-text-primary, #fff);
font-size: 0.875rem;
font-family: var(--font-body, sans-serif);
resize: none;
outline: none;
min-height: 38px;
max-height: 80px;
}
.chat-input:focus {
border-color: var(--color-accent, #F5A623);
}
.chat-input::placeholder {
color: var(--color-text-muted, #888);
}
.chat-send-btn {
width: 38px;
height: 38px;
border-radius: 8px;
border: none;
background: var(--color-accent, #F5A623);
color: #fff;
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s ease;
}
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 16px);
right: 8px;
height: 60vh;
}
}

191
dashboard/chat-public.js Normal file
View File

@@ -0,0 +1,191 @@
// /home/Autopartes/dashboard/chat-public.js
// Public catalog chatbot — no auth required, calls /api/chat
(function () {
'use strict';
var isOpen = false;
var isSending = false;
var history = [];
function init() {
// FAB button
var fab = document.createElement('button');
fab.className = 'chat-fab';
fab.id = 'chatFab';
fab.title = 'Asistente IA';
fab.innerHTML = '&#x1F4AC;';
fab.setAttribute('aria-label', 'Abrir asistente IA');
// Chat panel
var panel = document.createElement('div');
panel.className = 'chat-panel';
panel.id = 'chatPanel';
panel.innerHTML =
'<div class="chat-header">' +
'<h3>Asistente — 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 Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</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);
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();
}
});
document.getElementById('chatInput').addEventListener('input', function () {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
});
}
function toggleChat() {
isOpen = !isOpen;
var panel = document.getElementById('chatPanel');
var 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 sendMessage() {
if (isSending) return;
var input = document.getElementById('chatInput');
var text = input.value.trim();
if (!text) return;
input.value = '';
input.style.height = 'auto';
addBubble(text, 'user');
history.push({ role: 'user', content: text });
if (history.length > 20) history.splice(0, 2);
isSending = true;
document.getElementById('chatSend').disabled = true;
showTyping(true);
fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
history: history.slice(-10)
})
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
if (data.error) {
addBubble('Error: ' + data.error, 'ai');
return;
}
var aiMsg = data.response || 'Sin respuesta.';
addBubble(aiMsg, 'ai');
history.push({ role: 'assistant', content: aiMsg });
if (data.search_results && data.search_results.length > 0) {
addPartResults(data.search_results);
}
})
.catch(function (err) {
addBubble('Error de conexion: ' + err.message, 'ai');
})
.finally(function () {
isSending = false;
document.getElementById('chatSend').disabled = false;
showTyping(false);
});
}
function addBubble(text, role) {
var container = document.getElementById('chatMessages');
var typing = document.getElementById('chatTyping');
var div = document.createElement('div');
div.className = 'chat-msg ' + role;
div.textContent = text;
container.insertBefore(div, typing);
scrollToBottom();
}
function addPartResults(parts) {
var container = document.getElementById('chatMessages');
var typing = document.getElementById('chatTyping');
var wrapper = document.createElement('div');
wrapper.className = 'chat-parts';
parts.slice(0, 8).forEach(function (p) {
var card = document.createElement('div');
card.className = 'chat-part-card';
var name = p.name_es || p.name_part || '';
var partNum = p.oem_part_number || p.part_number || '';
var brand = p.brand || '';
card.innerHTML =
'<div class="part-number">' + esc(partNum) + '</div>' +
'<div class="part-name">' + esc(name) + (brand ? ' <span style="color:var(--color-text-muted);">(' + esc(brand) + ')</span>' : '') + '</div>';
card.style.cursor = 'pointer';
card.addEventListener('click', function () {
// Search in catalog
var searchInput = document.getElementById('searchInput');
if (searchInput && partNum) {
searchInput.value = partNum;
if (typeof window.doSearch === 'function') window.doSearch();
toggleChat();
}
});
wrapper.appendChild(card);
});
container.insertBefore(wrapper, typing);
scrollToBottom();
}
function showTyping(show) {
var el = document.getElementById('chatTyping');
if (el) el.classList.toggle('visible', show);
if (show) scrollToBottom();
}
function scrollToBottom() {
var el = document.getElementById('chatMessages');
if (el) el.scrollTop = el.scrollHeight;
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -249,6 +249,97 @@ def admin_js():
def enhanced_search_js():
return send_from_directory('.', 'enhanced-search.js')
@app.route('/chat-public.js')
def chat_public_js():
return send_from_directory('.', 'chat-public.js')
@app.route('/chat-public.css')
def chat_public_css():
return send_from_directory('.', 'chat-public.css')
# ============================================================================
# Public Chat Endpoint (rate limited, no auth)
# ============================================================================
# Simple in-memory rate limiting for public chat
_chat_rate = {} # ip -> [timestamps]
def _check_chat_rate(ip, max_per_min=10):
"""Return True if request is allowed, False if rate limited."""
import time
now = time.time()
if ip not in _chat_rate:
_chat_rate[ip] = []
# Clean old entries
_chat_rate[ip] = [t for t in _chat_rate[ip] if now - t < 60]
if len(_chat_rate[ip]) >= max_per_min:
return False
_chat_rate[ip].append(now)
return True
@app.route('/api/chat', methods=['POST'])
def public_chat():
"""Public chatbot endpoint — searches the public catalog (no auth required)."""
client_ip = request.remote_addr or '0.0.0.0'
if not _check_chat_rate(client_ip):
return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429
body = request.get_json(force=True) if request.is_json else {}
user_message = (body.get('message') or '').strip()
if not user_message:
return jsonify({'error': 'message required'}), 400
chat_history = body.get('history') or []
# Import AI chat service
from services.ai_chat import chat as ai_chat_fn
ai_response = ai_chat_fn(user_message, chat_history)
search_results = []
try:
search_query = ai_response.get('search_query')
if search_query:
session = Session()
try:
query_terms = [q.strip() for q in search_query.split('|') if q.strip()]
for qt in query_terms[:3]:
# Search parts in public catalog
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part,
pg.name_part_group, pc.name_part_category
FROM parts p
LEFT JOIN part_groups pg ON p.group_id = pg.id_part_group
LEFT JOIN part_categories pc ON pg.category_id = pc.id_part_category
WHERE p.name_part ILIKE :q
OR p.oem_part_number ILIKE :q
OR pg.name_part_group ILIKE :q
ORDER BY p.name_part
LIMIT 5
"""), {'q': f'%{qt}%'}).mappings().all()
for r in rows:
search_results.append({
'id_part': r['id_part'],
'oem_part_number': r['oem_part_number'],
'name_part': r['name_part'],
'name_es': r['name_part_group'],
'category': r['name_part_category'],
'source': 'catalog'
})
finally:
session.close()
except Exception:
pass # search failure is non-fatal
return jsonify({
'response': ai_response.get('message', ''),
'search_results': search_results,
'vehicle': ai_response.get('vehicle')
})
# ============================================================================
# Public Catalog API — No auth required