feat(whatsapp): QWEN primary AI backend, Hermes fallback, conversation history, vehicle persistence, demo prompts
- Add QWEN (qwen3.6) as primary AI backend with short system prompt - Hermes remains as fallback with 45s timeout - Increase QWEN timeout to 35s, max_tokens to 4000 - Add conversation history loading from whatsapp_messages (last 4 msgs) - Persist detected vehicle in whatsapp_sessions table - Add 'limpiar chat' / 'nuevo chat' / 'reset' commands to clear history - Fix CSS conflict: rename whatsapp chat-panel classes to wa-chat-panel - Fix JS ID conflicts with chat.js widget (waChatPanel, waChatMessages, etc.) - Improve no-stock response: conversational with alternatives - Split search_query by | for multi-part lookups - Add DEMO_PROMPTS.md and DEMO_PROMPTS_V2.md
This commit is contained in:
489
pos/static/js/whatsapp2.js
Normal file
489
pos/static/js/whatsapp2.js
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* whatsapp.js — WhatsApp via Evolution API
|
||||
*
|
||||
* Connection flow: Create instance -> Scan QR -> Connected
|
||||
* Left panel: conversation list (phone numbers + last message preview)
|
||||
* Right panel: chat view with message bubbles
|
||||
* Bottom: text input + send button
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var token = localStorage.getItem('pos_token');
|
||||
if (!token) { window.location.href = '/pos/login'; return; }
|
||||
|
||||
var API = '/pos/api/whatsapp';
|
||||
var activePhone = null;
|
||||
var pollTimer = null;
|
||||
var statusPollTimer = null;
|
||||
var connectionState = 'unknown'; // 'open', 'close', 'connecting', 'unknown'
|
||||
|
||||
// -- Helpers ---------------------------------------------------------------
|
||||
|
||||
function authHeaders() {
|
||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
function api(method, path, body) {
|
||||
var opts = { method: method, headers: authHeaders() };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
return fetch(API + path, opts).then(function (r) {
|
||||
if (r.status === 401) { window.location.href = '/pos/login'; }
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
var d = new Date(iso);
|
||||
var now = new Date();
|
||||
var isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' }) +
|
||||
' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtPhone(phone) {
|
||||
if (!phone) return '';
|
||||
if (phone.length === 13 && phone.startsWith('521')) {
|
||||
return '+52 1 ' + phone.slice(3, 5) + ' ' + phone.slice(5, 9) + ' ' + phone.slice(9);
|
||||
}
|
||||
if (phone.length === 12 && phone.startsWith('52')) {
|
||||
return '+52 ' + phone.slice(2, 4) + ' ' + phone.slice(4, 8) + ' ' + phone.slice(8);
|
||||
}
|
||||
return '+' + phone;
|
||||
}
|
||||
|
||||
// -- DOM refs --------------------------------------------------------------
|
||||
|
||||
var convList = document.getElementById('convList');
|
||||
var chatMessages = document.getElementById('waChatMessages') || document.getElementById('chatMessages');
|
||||
var chatHeader = document.getElementById('chatHeaderPhone');
|
||||
var chatInput = document.getElementById('waChatInput') || document.getElementById('chatInput');
|
||||
var sendBtn = document.getElementById('waSendBtn') || document.getElementById('sendBtn');
|
||||
var newChatBtn = document.getElementById('newChatBtn');
|
||||
var emptyState = document.getElementById('emptyState');
|
||||
var chatPanel = document.getElementById('waChatPanel') || document.getElementById('chatPanel');
|
||||
var statusDot = document.getElementById('statusDot');
|
||||
var statusText = document.getElementById('statusText');
|
||||
var connectSection = document.getElementById('connectSection');
|
||||
var messengerArea = document.getElementById('messengerArea');
|
||||
var qrImg = document.getElementById('qrImg');
|
||||
var qrPlaceholder = document.getElementById('qrPlaceholder');
|
||||
var connectBtn = document.getElementById('connectBtn');
|
||||
var disconnectBtn = document.getElementById('disconnectBtn');
|
||||
var refreshQrBtn = document.getElementById('refreshQrBtn');
|
||||
|
||||
// -- Connection management -------------------------------------------------
|
||||
|
||||
function checkInstanceStatus() {
|
||||
api('GET', '/status').then(function (data) {
|
||||
var state = (data.instance || data).state || data.state || 'close';
|
||||
updateConnectionUI(state);
|
||||
}).catch(function () {
|
||||
updateConnectionUI('close');
|
||||
});
|
||||
}
|
||||
|
||||
function updateConnectionUI(state) {
|
||||
connectionState = state;
|
||||
|
||||
if (state === 'open') {
|
||||
statusDot.className = 'status-dot status-dot--ok';
|
||||
statusText.textContent = 'Conectado';
|
||||
connectSection.style.display = 'none';
|
||||
messengerArea.style.display = 'flex';
|
||||
disconnectBtn.style.display = '';
|
||||
connectBtn.style.display = 'none';
|
||||
// Load conversations + start polling on page load / reconnect
|
||||
loadConversations();
|
||||
startPolling();
|
||||
} else if (state === 'connecting') {
|
||||
statusDot.className = 'status-dot status-dot--warn';
|
||||
statusText.textContent = 'Escaneando QR...';
|
||||
connectSection.style.display = 'flex';
|
||||
messengerArea.style.display = 'none';
|
||||
disconnectBtn.style.display = 'none';
|
||||
connectBtn.style.display = 'none';
|
||||
refreshQrBtn.style.display = '';
|
||||
} else {
|
||||
// close or unknown
|
||||
statusDot.className = 'status-dot status-dot--error';
|
||||
statusText.textContent = 'Desconectado';
|
||||
connectSection.style.display = 'flex';
|
||||
messengerArea.style.display = 'none';
|
||||
disconnectBtn.style.display = 'none';
|
||||
connectBtn.style.display = '';
|
||||
refreshQrBtn.style.display = 'none';
|
||||
qrImg.style.display = 'none';
|
||||
qrPlaceholder.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function doConnect() {
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = 'Creando instancia...';
|
||||
|
||||
api('POST', '/connect').then(function (data) {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Conectar WhatsApp';
|
||||
|
||||
if (data.error) {
|
||||
alert('Error: ' + (data.error.message || data.error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Instance created, now fetch QR
|
||||
fetchQR();
|
||||
}).catch(function () {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Conectar WhatsApp';
|
||||
alert('Error de red al crear instancia');
|
||||
});
|
||||
}
|
||||
|
||||
function fetchQR() {
|
||||
qrPlaceholder.textContent = 'Generando QR...';
|
||||
|
||||
api('GET', '/qr').then(function (data) {
|
||||
var base64 = data.qr || data.base64 || data.qrcode || '';
|
||||
if (base64) {
|
||||
qrImg.src = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64;
|
||||
qrImg.style.display = 'block';
|
||||
qrPlaceholder.style.display = 'none';
|
||||
refreshQrBtn.style.display = '';
|
||||
updateConnectionUI('connecting');
|
||||
|
||||
// Start polling for connection state while QR is shown
|
||||
startStatusPolling();
|
||||
} else if ((data.instance && data.instance.state === 'open') || data.state === 'open') {
|
||||
// Already connected
|
||||
updateConnectionUI('open');
|
||||
loadConversations();
|
||||
} else {
|
||||
qrPlaceholder.textContent = 'No se pudo generar el QR. Intenta de nuevo.';
|
||||
qrPlaceholder.style.display = '';
|
||||
qrImg.style.display = 'none';
|
||||
}
|
||||
}).catch(function () {
|
||||
qrPlaceholder.textContent = 'Error al obtener QR';
|
||||
});
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (!confirm('Desconectar WhatsApp?')) return;
|
||||
api('POST', '/logout').then(function () {
|
||||
updateConnectionUI('close');
|
||||
stopStatusPolling();
|
||||
});
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling();
|
||||
statusPollTimer = setInterval(function () {
|
||||
api('GET', '/status').then(function (data) {
|
||||
var state = (data.instance || data).state || data.state || 'close';
|
||||
if (state === 'open') {
|
||||
updateConnectionUI('open');
|
||||
stopStatusPolling();
|
||||
loadConversations();
|
||||
startPolling();
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
connectBtn.addEventListener('click', doConnect);
|
||||
disconnectBtn.addEventListener('click', doDisconnect);
|
||||
refreshQrBtn.addEventListener('click', fetchQR);
|
||||
|
||||
// -- Load conversations ----------------------------------------------------
|
||||
|
||||
function loadConversations() {
|
||||
api('GET', '/conversations').then(function (data) {
|
||||
var convs = data.conversations || [];
|
||||
if (convs.length === 0) {
|
||||
convList.innerHTML = '<div class="conv-empty">No hay conversaciones</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
convs.forEach(function (c) {
|
||||
var isActive = c.phone === activePhone;
|
||||
var dirIcon = c.last_direction === 'outgoing' ? '↗ ' : '↙ ';
|
||||
// Show contact name if available, otherwise try to format the phone.
|
||||
// LID numbers (15+ digits, no country code pattern) show as "Contacto"
|
||||
var displayName = c.contact_name || '';
|
||||
if (!displayName) {
|
||||
var isLid = c.phone.length > 13 || !/^(52|1|44|34)/.test(c.phone);
|
||||
displayName = isLid ? 'Contacto WhatsApp' : fmtPhone(c.phone);
|
||||
}
|
||||
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
|
||||
+ '<div class="conv-item__phone">' + escHtml(displayName) + '</div>'
|
||||
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message || '(sin texto)') + '</div>'
|
||||
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
|
||||
+ '<button class="conv-item__delete" data-del-phone="' + escHtml(c.phone) + '" title="Borrar conversacion">×</button>'
|
||||
+ '</div>';
|
||||
});
|
||||
// "Borrar todo" button at the bottom
|
||||
html += '<div style="padding:8px;text-align:center;">'
|
||||
+ '<button class="conv-delete-all" style="background:none;border:1px dashed var(--color-border,#444);color:var(--color-text-muted);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px;" onclick="deleteAllConversations()">Borrar todas las conversaciones</button>'
|
||||
+ '</div>';
|
||||
convList.innerHTML = html;
|
||||
|
||||
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
||||
el.addEventListener('click', function (e) {
|
||||
if (e.target.classList.contains('conv-item__delete')) return;
|
||||
var name = el.querySelector('.conv-item__phone') ? el.querySelector('.conv-item__phone').textContent : '';
|
||||
openConversation(el.getAttribute('data-phone'), name);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire delete buttons
|
||||
convList.querySelectorAll('.conv-item__delete').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var phone = btn.getAttribute('data-del-phone');
|
||||
if (confirm('Borrar conversacion con ' + fmtPhone(phone) + '?')) {
|
||||
deleteConversation(phone);
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch(function () {
|
||||
convList.innerHTML = '<div class="conv-empty">Error cargando conversaciones</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConversation(phone) {
|
||||
api('DELETE', '/conversations/' + encodeURIComponent(phone)).then(function (res) {
|
||||
if (res.ok) {
|
||||
if (activePhone === phone) {
|
||||
activePhone = null;
|
||||
chatPanel.style.display = 'none';
|
||||
emptyState.style.display = '';
|
||||
if (messengerArea) messengerArea.classList.remove('has-active-chat');
|
||||
}
|
||||
loadConversations();
|
||||
} else {
|
||||
alert('Error: ' + (res.error || 'unknown'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.deleteAllConversations = function () {
|
||||
if (!confirm('Borrar TODAS las conversaciones? Esta accion no se puede deshacer.')) return;
|
||||
api('DELETE', '/conversations').then(function (res) {
|
||||
if (res.ok) {
|
||||
activePhone = null;
|
||||
chatPanel.style.display = 'none';
|
||||
emptyState.style.display = '';
|
||||
loadConversations();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// -- Open a conversation ---------------------------------------------------
|
||||
|
||||
var activeContactName = '';
|
||||
|
||||
function openConversation(phone, contactName) {
|
||||
try {
|
||||
console.log('[WA-UI] Opening conversation:', phone, contactName);
|
||||
activePhone = phone;
|
||||
// Use contact name if available; fall back to formatted phone
|
||||
var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone);
|
||||
activeContactName = contactName || '';
|
||||
chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone));
|
||||
emptyState.style.display = 'none';
|
||||
chatPanel.style.display = 'flex';
|
||||
console.log('[WA-UI] chatPanel display set to flex. chatPanel element:', chatPanel ? 'exists' : 'null');
|
||||
// Add has-active-chat class for mobile responsive layout
|
||||
if (messengerArea) messengerArea.classList.add('has-active-chat');
|
||||
|
||||
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
||||
el.classList.toggle('is-active', el.getAttribute('data-phone') === phone);
|
||||
});
|
||||
|
||||
loadMessages(phone);
|
||||
startPolling();
|
||||
} catch (e) {
|
||||
console.error('[WA-UI] openConversation error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadMessages(phone) {
|
||||
console.log('[WA-UI] loadMessages start:', phone);
|
||||
api('GET', '/conversations/' + encodeURIComponent(phone)).then(function (data) {
|
||||
console.log('[WA-UI] loadMessages response:', data);
|
||||
if (data.error) {
|
||||
console.error('[WA-UI] loadMessages error:', data.error);
|
||||
chatMessages.innerHTML = '<div class="chat-empty">Error cargando mensajes: ' + escHtml(data.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
var msgs = data.messages || [];
|
||||
console.log('[WA-UI] loadMessages messages count:', msgs.length);
|
||||
renderMessages(msgs);
|
||||
}).catch(function (err) {
|
||||
console.error('[WA-UI] loadMessages network error:', err);
|
||||
chatMessages.innerHTML = '<div class="chat-empty">Error de red al cargar mensajes</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(msgs) {
|
||||
console.log('[WA-UI] renderMessages called with', msgs.length, 'messages');
|
||||
if (!chatMessages) {
|
||||
console.error('[WA-UI] chatMessages element is null!');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
msgs.forEach(function (m) {
|
||||
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
|
||||
var text = m.message_text || m.text || '';
|
||||
var time = m.created_at || m.date || '';
|
||||
html += '<div class="msg-bubble ' + cls + '">'
|
||||
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
|
||||
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
console.log('[WA-UI] renderMessages HTML length:', html.length);
|
||||
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
// -- Send message ----------------------------------------------------------
|
||||
|
||||
function doSend() {
|
||||
var text = chatInput.value.trim();
|
||||
if (!text || !activePhone) return;
|
||||
|
||||
chatInput.value = '';
|
||||
sendBtn.disabled = true;
|
||||
|
||||
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
|
||||
sendBtn.disabled = false;
|
||||
if (res.error) {
|
||||
alert('Error: ' + res.error);
|
||||
} else {
|
||||
loadMessages(activePhone);
|
||||
loadConversations();
|
||||
}
|
||||
}).catch(function () {
|
||||
sendBtn.disabled = false;
|
||||
alert('Error de red al enviar mensaje');
|
||||
});
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', doSend);
|
||||
chatInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
doSend();
|
||||
}
|
||||
});
|
||||
|
||||
// -- New conversation ------------------------------------------------------
|
||||
|
||||
newChatBtn.addEventListener('click', function () {
|
||||
var phone = prompt('Numero de telefono (formato: 5215512345678):');
|
||||
if (phone) {
|
||||
phone = phone.replace(/[\s\-\+\(\)]/g, '');
|
||||
openConversation(phone);
|
||||
loadConversations();
|
||||
}
|
||||
});
|
||||
|
||||
// -- Send quotation --------------------------------------------------------
|
||||
|
||||
var quoteBtn = document.getElementById('sendQuoteBtn');
|
||||
if (quoteBtn) {
|
||||
quoteBtn.addEventListener('click', function () {
|
||||
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
|
||||
|
||||
// Fetch available quotations and let user pick one
|
||||
fetch('/pos/api/quotations?per_page=20', { headers: authHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
var quotes = (d.data || []).filter(function (q) { return q.status === 'active'; });
|
||||
if (quotes.length === 0) {
|
||||
alert('No hay cotizaciones activas. Crea una desde el POS (F4) o via WhatsApp.');
|
||||
return;
|
||||
}
|
||||
var msg = 'Cotizaciones activas:\n';
|
||||
quotes.forEach(function (q) {
|
||||
msg += '#' + q.id + ' — $' + q.total.toFixed(2) + ' (' + (q.customer_name || q.source || 'sin cliente') + ')\n';
|
||||
});
|
||||
var quoteId = prompt(msg + '\nEscribe el ID de la cotizacion a enviar:');
|
||||
if (!quoteId) return;
|
||||
|
||||
// Fetch the quotation detail and send it formatted
|
||||
fetch('/pos/api/quotations/' + quoteId, { headers: authHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (q) {
|
||||
if (q.error) { alert('Error: ' + q.error); return; }
|
||||
// Format the quotation as a WhatsApp message
|
||||
var lines = ['📄 *COTIZACIÓN #' + q.id + '*', ''];
|
||||
(q.items || []).forEach(function (it, i) {
|
||||
lines.push((i + 1) + '. ' + it.name);
|
||||
lines.push(' #' + it.part_number + ' × ' + it.quantity + ' = $' + it.subtotal.toFixed(2));
|
||||
});
|
||||
lines.push('─────────────');
|
||||
lines.push('Subtotal: $' + q.subtotal.toFixed(2));
|
||||
lines.push('IVA: $' + q.tax_total.toFixed(2));
|
||||
lines.push('*TOTAL: $' + q.total.toFixed(2) + '*');
|
||||
|
||||
var text = lines.join('\n');
|
||||
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
|
||||
if (res.error) {
|
||||
alert('Error enviando: ' + res.error);
|
||||
} else {
|
||||
loadMessages(activePhone);
|
||||
loadConversations();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -- Polling for new messages ----------------------------------------------
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(function () {
|
||||
if (activePhone) loadMessages(activePhone);
|
||||
loadConversations();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// -- Init ------------------------------------------------------------------
|
||||
|
||||
checkInstanceStatus();
|
||||
|
||||
// Also check periodically (every 30s) in case connection drops
|
||||
setInterval(checkInstanceStatus, 30000);
|
||||
|
||||
// -- User info for sidebar -------------------------------------------------
|
||||
try {
|
||||
var payload = JSON.parse(atob(token.split('.')[1]));
|
||||
window.POS_USER = {
|
||||
name: payload.name || 'Usuario',
|
||||
roleLabel: (payload.role || '').charAt(0).toUpperCase() + (payload.role || '').slice(1),
|
||||
initials: (payload.name || 'U').split(' ').map(function(w){return w[0]}).join('').slice(0,2).toUpperCase()
|
||||
};
|
||||
} catch(e) {}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user