- 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>
437 lines
14 KiB
JavaScript
437 lines
14 KiB
JavaScript
// /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;
|
|
let isListening = false;
|
|
let recognition = null;
|
|
const history = []; // conversation history for AI context
|
|
const hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
|
|
|
|
// ─── 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>
|
|
<input type="file" id="chatImageInput" accept="image/*" capture="environment" style="display:none;">
|
|
<button class="chat-cam-btn" id="chatCam" aria-label="Enviar foto de parte" title="Identificar parte por foto">📷</button>
|
|
${hasSpeechAPI ? '<button class="chat-mic-btn" id="chatMic" aria-label="Entrada por voz" title="Entrada por voz">🎤</button>' : ''}
|
|
<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();
|
|
}
|
|
});
|
|
|
|
// Camera button — identify part by photo
|
|
document.getElementById('chatCam').addEventListener('click', function () {
|
|
document.getElementById('chatImageInput').click();
|
|
});
|
|
document.getElementById('chatImageInput').addEventListener('change', handleImageUpload);
|
|
|
|
// Mic button (only if Speech API available)
|
|
if (hasSpeechAPI) {
|
|
document.getElementById('chatMic').addEventListener('click', toggleVoice);
|
|
}
|
|
|
|
// Auto-resize textarea
|
|
document.getElementById('chatInput').addEventListener('input', function () {
|
|
this.style.height = 'auto';
|
|
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
|
|
});
|
|
}
|
|
|
|
// ─── Voice Input (Web Speech API) ───
|
|
function toggleVoice() {
|
|
if (isListening) {
|
|
stopVoice();
|
|
return;
|
|
}
|
|
startVoice();
|
|
}
|
|
|
|
function startVoice() {
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
if (!SpeechRecognition) return;
|
|
|
|
recognition = new SpeechRecognition();
|
|
recognition.lang = 'es-MX';
|
|
recognition.continuous = false;
|
|
recognition.interimResults = true;
|
|
|
|
const input = document.getElementById('chatInput');
|
|
const micBtn = document.getElementById('chatMic');
|
|
const savedPlaceholder = input.placeholder;
|
|
|
|
recognition.onstart = function () {
|
|
isListening = true;
|
|
micBtn.classList.add('listening');
|
|
input.placeholder = 'Escuchando...';
|
|
input.value = '';
|
|
};
|
|
|
|
recognition.onresult = function (e) {
|
|
let interim = '';
|
|
let finalTranscript = '';
|
|
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
if (e.results[i].isFinal) {
|
|
finalTranscript += e.results[i][0].transcript;
|
|
} else {
|
|
interim += e.results[i][0].transcript;
|
|
}
|
|
}
|
|
if (finalTranscript) {
|
|
input.value = finalTranscript;
|
|
} else {
|
|
input.value = interim;
|
|
}
|
|
};
|
|
|
|
recognition.onend = function () {
|
|
isListening = false;
|
|
micBtn.classList.remove('listening');
|
|
input.placeholder = savedPlaceholder;
|
|
recognition = null;
|
|
// Auto-send if we got text
|
|
if (input.value.trim()) {
|
|
sendMessage();
|
|
}
|
|
};
|
|
|
|
recognition.onerror = function (e) {
|
|
isListening = false;
|
|
micBtn.classList.remove('listening');
|
|
input.placeholder = savedPlaceholder;
|
|
recognition = null;
|
|
if (e.error === 'no-speech' || e.error === 'audio-capture' || e.error === 'not-allowed') {
|
|
showVoiceToast('No se detecto voz');
|
|
}
|
|
};
|
|
|
|
recognition.start();
|
|
}
|
|
|
|
function stopVoice() {
|
|
if (recognition) {
|
|
recognition.abort();
|
|
recognition = null;
|
|
}
|
|
isListening = false;
|
|
const micBtn = document.getElementById('chatMic');
|
|
if (micBtn) micBtn.classList.remove('listening');
|
|
}
|
|
|
|
function showVoiceToast(msg) {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'chat-voice-toast';
|
|
toast.textContent = msg;
|
|
document.body.appendChild(toast);
|
|
setTimeout(function () { toast.classList.add('visible'); }, 10);
|
|
setTimeout(function () {
|
|
toast.classList.remove('visible');
|
|
setTimeout(function () { toast.remove(); }, 300);
|
|
}, 2000);
|
|
}
|
|
|
|
// ─── Image Upload (Part identification placeholder) ───
|
|
function handleImageUpload(e) {
|
|
const file = e.target.files && e.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Reset input so the same file can be selected again
|
|
e.target.value = '';
|
|
|
|
// Validate file type and size (max 5MB)
|
|
if (!file.type.startsWith('image/')) {
|
|
addBubble('Solo se permiten imagenes.', 'ai');
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
addBubble('La imagen es muy grande (max 5MB).', 'ai');
|
|
return;
|
|
}
|
|
|
|
// Show image thumbnail in chat
|
|
const reader = new FileReader();
|
|
reader.onload = function (ev) {
|
|
const container = document.getElementById('chatMessages');
|
|
const typing = document.getElementById('chatTyping');
|
|
const div = document.createElement('div');
|
|
div.className = 'chat-msg user chat-msg-image';
|
|
div.innerHTML = '<img src="' + ev.target.result + '" alt="Foto de parte" style="max-width:180px;max-height:140px;border-radius:8px;display:block;margin-bottom:4px;">'
|
|
+ '<span>Identificar esta parte</span>';
|
|
container.insertBefore(div, typing);
|
|
scrollToBottom();
|
|
|
|
// Send image to AI vision model for real analysis
|
|
var imageData = ev.target.result; // full data URL
|
|
var photoPrompt = 'Identifica esta parte automotriz y sugiere terminos de busqueda.';
|
|
history.push({ role: 'user', content: photoPrompt });
|
|
if (history.length > 20) history.splice(0, 2);
|
|
|
|
isSending = true;
|
|
document.getElementById('chatSend').disabled = true;
|
|
showTyping(true);
|
|
|
|
var token = getToken();
|
|
fetch('/pos/api/chat', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + token
|
|
},
|
|
body: JSON.stringify({
|
|
message: photoPrompt,
|
|
image: imageData,
|
|
history: history.slice(-10)
|
|
})
|
|
})
|
|
.then(function (resp) { return resp.json(); })
|
|
.then(function (data) {
|
|
const aiMsg = data.response || 'No pude identificar la parte. Intenta describirla con texto.';
|
|
addBubble(aiMsg, 'ai');
|
|
history.push({ role: 'assistant', content: aiMsg });
|
|
|
|
if (data.vehicle && data.vehicle.brand_id) {
|
|
addVehicleBanner(data.vehicle);
|
|
}
|
|
if (data.search_results && data.search_results.length > 0) {
|
|
addPartResults(data.search_results);
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
addBubble('Error al procesar imagen: ' + err.message, 'ai');
|
|
})
|
|
.finally(function () {
|
|
isSending = false;
|
|
document.getElementById('chatSend').disabled = false;
|
|
showTyping(false);
|
|
});
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
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 isLocal = p.source === 'local';
|
|
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 || p.part_number || '';
|
|
const brand = p.brand || '';
|
|
const priceText = p.price_1 ? ('$' + parseFloat(p.price_1).toFixed(2)) : '';
|
|
const sourceTag = isLocal
|
|
? '<span style="background:var(--color-success);color:#fff;padding:1px 6px;border-radius:4px;font-size:0.65rem;margin-left:6px;">MI INVENTARIO</span>'
|
|
: '<span style="background:var(--color-primary);color:#fff;padding:1px 6px;border-radius:4px;font-size:0.65rem;margin-left:6px;">CATÁLOGO</span>';
|
|
|
|
card.innerHTML =
|
|
'<div class="part-number">' + esc(partNum) + sourceTag + (priceText ? ' — ' + priceText : '') + '</div>' +
|
|
'<div class="part-name">' + esc(name) + (brand ? ' <span style="color:var(--color-text-muted);">(' + esc(brand) + ')</span>' : '') + '</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();
|
|
}
|
|
})();
|