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

@@ -200,8 +200,9 @@
container.insertBefore(div, typing);
scrollToBottom();
// Send to AI as a text description (vision model placeholder)
const photoPrompt = 'El usuario envio una foto de una parte automotriz. Describe que parte podria ser y sugiere busquedas.';
// 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);
@@ -209,7 +210,7 @@
document.getElementById('chatSend').disabled = true;
showTyping(true);
const token = getToken();
var token = getToken();
fetch('/pos/api/chat', {
method: 'POST',
headers: {
@@ -218,6 +219,7 @@
},
body: JSON.stringify({
message: photoPrompt,
image: imageData,
history: history.slice(-10)
})
})

View File

@@ -499,6 +499,21 @@ const Config = (() => {
// Bind UI events
bindEvents();
// Kiosk mode toggle
var kioskToggle = document.getElementById('cfg-kiosk-mode');
if (kioskToggle && window.NexusKiosk) {
kioskToggle.checked = window.NexusKiosk.isEnabled();
kioskToggle.addEventListener('change', function () {
if (this.checked) {
window.NexusKiosk.enable();
toast('Modo Kiosko activado');
} else {
window.NexusKiosk.disable();
toast('Modo Kiosko desactivado');
}
});
}
// Load real data in parallel
loadBranches();
loadEmployees();

View File

@@ -121,7 +121,48 @@
function showCreateModal() {
document.getElementById('createModal').classList.add('is-open');
// Attach AI classification on part number blur
var pnInput = document.getElementById('newPartNumber');
if (pnInput && !pnInput._classifyBound) {
pnInput._classifyBound = true;
pnInput.addEventListener('blur', function () {
var pn = this.value.trim();
if (pn.length < 3) return;
var nameInput = document.getElementById('newName');
// Only auto-classify if name is still empty
if (nameInput && nameInput.value.trim()) return;
classifyPartNumber(pn);
});
}
}
function classifyPartNumber(partNumber) {
var resultEl = document.getElementById('createResult');
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
apiFetch(API + '/classify/' + encodeURIComponent(partNumber)).then(function (data) {
if (!data) return;
if (data.name) {
document.getElementById('newName').value = data.name;
}
if (data.brand) {
document.getElementById('newBrand').value = data.brand;
}
// Show suggestion label
var parts = [];
if (data.name) parts.push(data.name);
if (data.brand) parts.push(data.brand);
if (data.vehicle) parts.push(data.vehicle);
if (data.category) parts.push(data.category);
if (parts.length > 0) {
resultEl.innerHTML = '<span style="color:var(--color-accent);font-size:var(--text-caption);">Sugerido por IA: ' + esc(parts.join(' | ')) + '</span>';
} else {
resultEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">IA no pudo identificar este numero de parte</span>';
}
}).catch(function () {
resultEl.innerHTML = '';
});
}
function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = '';

168
pos/static/js/kiosk.js Normal file
View File

@@ -0,0 +1,168 @@
// /home/Autopartes/pos/static/js/kiosk.js
// Kiosk mode for Nexus POS — fullscreen, wake lock, auto-login, no right-click
(function () {
'use strict';
var STORAGE_KEY = 'pos_kiosk_mode';
// ─── Detection ───
function isPWA() {
return window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
}
function isCapacitor() {
return typeof window.Capacitor !== 'undefined' && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform();
}
function isKioskEnabled() {
// Enabled if explicitly set in localStorage, or if running as PWA/Capacitor
var pref = localStorage.getItem(STORAGE_KEY);
if (pref === 'true') return true;
if (pref === 'false') return false;
// Auto-detect
return isPWA() || isCapacitor();
}
// ─── Fullscreen ───
var fullscreenRequested = false;
function requestFullscreen() {
if (fullscreenRequested) return;
var el = document.documentElement;
var fn = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen;
if (fn) {
fn.call(el).catch(function () { /* user may have denied */ });
fullscreenRequested = true;
}
}
// ─── Wake Lock ───
var wakeLock = null;
async function acquireWakeLock() {
if (!('wakeLock' in navigator)) return;
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', function () {
wakeLock = null;
});
} catch (e) {
// Wake lock may fail if tab not visible
}
}
function releaseWakeLock() {
if (wakeLock) {
wakeLock.release();
wakeLock = null;
}
}
// Re-acquire wake lock when page becomes visible again
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && isKioskEnabled() && !wakeLock) {
acquireWakeLock();
}
});
// ─── Auto-login ───
function tryAutoLogin() {
var token = localStorage.getItem('pos_token');
if (!token) return;
// Check if we are on the login page
var isLoginPage = window.location.pathname.indexOf('/pos/login') !== -1;
if (!isLoginPage) return;
// Validate token by trying to decode expiry (JWT is base64)
try {
var parts = token.split('.');
if (parts.length !== 3) return;
var payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
var exp = payload.exp;
if (exp && (exp * 1000) > Date.now()) {
// Token still valid — skip login
window.location.href = '/pos/';
}
} catch (e) {
// Invalid token, stay on login
}
}
// ─── Activate kiosk mode ───
function activate() {
// Prevent navigation away
window.addEventListener('beforeunload', function (e) {
if (isKioskEnabled()) {
e.preventDefault();
e.returnValue = '';
}
});
// Disable context menu
document.addEventListener('contextmenu', function (e) {
if (isKioskEnabled()) {
e.preventDefault();
}
});
// Request fullscreen on first user interaction
var interactionEvents = ['click', 'touchstart', 'keydown'];
function onFirstInteraction() {
if (isKioskEnabled()) {
requestFullscreen();
acquireWakeLock();
}
interactionEvents.forEach(function (evt) {
document.removeEventListener(evt, onFirstInteraction);
});
}
interactionEvents.forEach(function (evt) {
document.addEventListener(evt, onFirstInteraction, { once: false });
});
// Auto-login if on login page
tryAutoLogin();
}
// ─── Public API ───
window.NexusKiosk = {
isEnabled: isKioskEnabled,
isPWA: isPWA,
isCapacitor: isCapacitor,
enable: function () {
localStorage.setItem(STORAGE_KEY, 'true');
requestFullscreen();
acquireWakeLock();
},
disable: function () {
localStorage.setItem(STORAGE_KEY, 'false');
releaseWakeLock();
if (document.fullscreenElement) {
document.exitFullscreen().catch(function () {});
}
},
toggle: function () {
if (isKioskEnabled()) {
window.NexusKiosk.disable();
} else {
window.NexusKiosk.enable();
}
return isKioskEnabled();
}
};
// ─── Init ───
if (isKioskEnabled()) {
activate();
}
// Also activate if preference changes (e.g. toggled from config)
window.addEventListener('storage', function (e) {
if (e.key === STORAGE_KEY && e.newValue === 'true') {
activate();
}
});
})();