- 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:
@@ -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
237
dashboard/chat-public.css
Normal 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
191
dashboard/chat-public.js
Normal 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 = '💬';
|
||||
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">×</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">▶</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();
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user