Mejorar un poquito el diseño y meterle voz usando whisper

This commit is contained in:
Алекс 2026-05-09 00:42:33 +02:00
parent 0366137e96
commit b67d3a8454
14 changed files with 2211 additions and 21 deletions

1029
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"vite": "^7.2.4" "vite": "^7.2.4"
}, },
"dependencies": { "dependencies": {
"@huggingface/transformers": "^3.8.1",
"chart.js": "^4.5.1" "chart.js": "^4.5.1"
} }
} }

View File

@ -4,15 +4,27 @@ export function renderLogin(onLogin) {
loginContainer.innerHTML = ` loginContainer.innerHTML = `
<div class="login-card"> <div class="login-card">
<h2 class="login-title">Iniciar Sesión</h2> <div class="login-brand">
<div class="login-logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
<span class="login-brand-name">Doli</span>
</div>
<h2 class="login-title">Iniciar sesión</h2>
<p class="login-subtitle">Accede a tu cuenta para continuar</p>
<form id="login-form"> <form id="login-form">
<div class="form-group"> <div class="form-group">
<label for="identifier">Usuario o email</label> <label for="identifier">Usuario o email</label>
<input type="text" id="identifier" name="identifier" required autocomplete="username"> <input type="text" id="identifier" name="identifier" required autocomplete="username" placeholder="tu@email.com">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Contraseña</label> <label for="password">Contraseña</label>
<input type="password" id="password" name="password" required autocomplete="current-password"> <input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
</div> </div>
<button type="submit" class="login-button">Ingresar</button> <button type="submit" class="login-button">Ingresar</button>
</form> </form>

View File

@ -6,7 +6,17 @@ export function createSidebar(pages, currentPage) {
const sidebarHeader = document.createElement('div'); const sidebarHeader = document.createElement('div');
sidebarHeader.className = 'sidebar-header'; sidebarHeader.className = 'sidebar-header';
sidebarHeader.innerHTML = /*html*/` sidebarHeader.innerHTML = /*html*/`
<h2>Doli</h2> <div class="sidebar-brand">
<div class="sidebar-logo-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
<h2>Doli</h2>
</div>
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle sidebar"> <button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button> </button>
@ -38,10 +48,13 @@ export function createSidebar(pages, currentPage) {
sidebar.appendChild(sidebarHeader); sidebar.appendChild(sidebarHeader);
sidebar.appendChild(nav); sidebar.appendChild(nav);
// Toggle functionality
const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle'); const toggleBtn = sidebarHeader.querySelector('#sidebar-toggle');
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
sidebar.classList.toggle('collapsed'); if (window.matchMedia('(max-width: 768px)').matches) {
document.dispatchEvent(new CustomEvent('sidebar:close-mobile'));
} else {
sidebar.classList.toggle('collapsed');
}
}); });
return sidebar; return sidebar;

View File

@ -0,0 +1,332 @@
import { pipeline } from '@huggingface/transformers';
// ── Modelo (singleton entre sesiones) ─────────────────────────────────────────
// Xenova/whisper-tiny + transformers.js v3 = WASM puro, sin WebGPU, todos los navegadores
let transcriberPromise = null;
function loadModel(onProgress) {
if (!transcriberPromise) {
transcriberPromise = pipeline(
'automatic-speech-recognition',
'Xenova/whisper-tiny',
{ progress_callback: onProgress }
).catch(err => { transcriberPromise = null; throw err; });
}
return transcriberPromise;
}
// ── Comandos de voz ───────────────────────────────────────────────────────────
const COMMANDS = [
{
label: 'Nueva factura',
hint: '"nueva factura" · "crear factura"',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>`,
patterns: ['nueva factura', 'crear factura', 'factura nueva', 'añadir factura', 'nueva facturas'],
action: () => { window.location.hash = '#create-invoice'; },
},
{
label: 'Ver facturas',
hint: '"facturas" · "ver facturas"',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
patterns: ['facturas', 'ver facturas', 'ir a facturas', 'lista facturas', 'mis facturas', 'listado facturas'],
action: () => { window.location.hash = '#invoices'; },
},
{
label: 'Ver clientes',
hint: '"clientes" · "ver clientes"',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
patterns: ['clientes', 'ver clientes', 'ir a clientes', 'lista clientes', 'mis clientes'],
action: () => { window.location.hash = '#clients'; },
},
{
label: 'Dashboard',
hint: '"dashboard" · "inicio" · "resumen"',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
patterns: ['dashboard', 'inicio', 'ir al inicio', 'ir al dashboard', 'resumen', 'home', 'panel', 'principal'],
action: () => { window.location.hash = '#dashboard'; },
},
{
label: 'Configuración',
hint: '"configuración" · "ajustes"',
icon: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
patterns: ['configuración', 'configuracion', 'ajustes', 'settings', 'preferencias'],
action: () => { window.location.hash = '#settings'; },
},
];
function matchCommand(text) {
const norm = text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim();
for (const cmd of COMMANDS) {
for (const pattern of cmd.patterns) {
const normPattern = pattern.normalize('NFD').replace(/[̀-ͯ]/g, '');
if (norm.includes(normPattern)) return cmd;
}
}
return null;
}
// ── Grabación ─────────────────────────────────────────────────────────────────
async function startRecording() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
const mimeType = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
.find(t => MediaRecorder.isTypeSupported(t)) || '';
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
const chunks = [];
recorder.ondataavailable = e => { if (e.data?.size > 0) chunks.push(e.data); };
recorder.start(200);
return {
stop: () => new Promise(resolve => {
recorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
resolve(new Blob(chunks, { type: mimeType || 'audio/webm' }));
};
if (recorder.state !== 'inactive') recorder.stop();
else resolve(new Blob(chunks));
}),
};
}
async function blobToFloat32(blob) {
const arrayBuffer = await blob.arrayBuffer();
if (arrayBuffer.byteLength === 0) throw new Error('Blob de audio vacío');
// 1) Decodificar al sample rate nativo del archivo (p.ej. 44100 Hz)
const decodeCtx = new AudioContext();
let nativeBuffer;
try {
nativeBuffer = await decodeCtx.decodeAudioData(arrayBuffer);
} finally {
await decodeCtx.close();
}
const srcRate = nativeBuffer.sampleRate;
const srcData = nativeBuffer.getChannelData(0); // mono
// 2) Si ya está a 16 kHz no hace falta resampling
if (srcRate === 16000) return srcData;
// 3) Resampling a 16 kHz con OfflineAudioContext
const targetLength = Math.ceil(srcData.length * (16000 / srcRate));
const offCtx = new OfflineAudioContext(1, targetLength, 16000);
const srcBuf = offCtx.createBuffer(1, srcData.length, srcRate);
srcBuf.getChannelData(0).set(srcData);
const source = offCtx.createBufferSource();
source.buffer = srcBuf;
source.connect(offCtx.destination);
source.start(0);
const resampled = await offCtx.startRendering();
return resampled.getChannelData(0);
}
// ── Componente ────────────────────────────────────────────────────────────────
let assistantEl = null;
export function mountVoiceAssistant() {
if (assistantEl) { assistantEl.style.display = ''; return; }
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return;
// ── DOM ────────────────────────────────────────────────────────────────────
assistantEl = document.createElement('div');
assistantEl.className = 'va-root';
assistantEl.innerHTML = /*html*/`
<div class="va-panel" id="va-panel" hidden>
<div class="va-panel-header">
<span class="va-panel-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
Asistente de voz
</span>
<button class="va-close-btn" id="va-close" aria-label="Cerrar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="va-status-area" id="va-status-area">
<div class="va-status-icon" id="va-status-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</div>
<p class="va-status-text" id="va-status-text">Di un comando…</p>
<p class="va-status-sub" id="va-status-sub"></p>
</div>
<div class="va-result" id="va-result" hidden>
<span class="va-result-label">Escuché:</span>
<span class="va-result-text" id="va-result-text"></span>
<span class="va-result-action" id="va-result-action"></span>
</div>
<div class="va-commands">
<p class="va-commands-title">Comandos disponibles</p>
<ul class="va-commands-list" id="va-commands-list"></ul>
</div>
</div>
<button class="va-fab" id="va-fab" aria-label="Asistente de voz">
<svg class="va-fab-mic" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
<svg class="va-fab-stop" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="display:none"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
</button>
`;
document.body.appendChild(assistantEl);
// ── Poblar lista de comandos ────────────────────────────────────────────────
const cmdList = assistantEl.querySelector('#va-commands-list');
COMMANDS.forEach(cmd => {
const li = document.createElement('li');
li.className = 'va-cmd-item';
li.innerHTML = /*html*/`
<span class="va-cmd-icon">${cmd.icon}</span>
<span class="va-cmd-info">
<span class="va-cmd-label">${cmd.label}</span>
<span class="va-cmd-hint">${cmd.hint}</span>
</span>
`;
li.addEventListener('click', () => { cmd.action(); closePanel(); });
cmdList.appendChild(li);
});
// ── Referencias ────────────────────────────────────────────────────────────
const fab = assistantEl.querySelector('#va-fab');
const panel = assistantEl.querySelector('#va-panel');
const closeBtn = assistantEl.querySelector('#va-close');
const statusIcon = assistantEl.querySelector('#va-status-icon');
const statusText = assistantEl.querySelector('#va-status-text');
const statusSub = assistantEl.querySelector('#va-status-sub');
const resultBox = assistantEl.querySelector('#va-result');
const resultText = assistantEl.querySelector('#va-result-text');
const resultAction = assistantEl.querySelector('#va-result-action');
const fabMic = fab.querySelector('.va-fab-mic');
const fabStop = fab.querySelector('.va-fab-stop');
// ── Estado ─────────────────────────────────────────────────────────────────
let state = 'idle'; // idle | recording | processing
let recorder = null;
let panelOpen = false;
function setStatus(s, text, sub = '') {
state = s;
statusText.textContent = text;
statusSub.textContent = sub;
statusIcon.className = `va-status-icon va-status-icon--${s}`;
fab.className = `va-fab va-fab--${s}`;
fabMic.style.display = (s === 'recording') ? 'none' : '';
fabStop.style.display = (s === 'recording') ? '' : 'none';
}
function showResult(heard, actionLabel) {
resultBox.hidden = false;
resultText.textContent = `"${heard}"`;
resultAction.textContent = actionLabel ? `→ ${actionLabel}` : '→ Comando no reconocido';
resultAction.className = 'va-result-action' + (actionLabel ? '' : ' va-result-action--unknown');
}
function openPanel() {
panel.hidden = false;
panelOpen = true;
resultBox.hidden = true;
fab.setAttribute('aria-label', 'Hablar');
}
function closePanel() {
panel.hidden = true;
panelOpen = false;
if (state === 'recording' && recorder) {
recorder.stop().catch(() => {});
recorder = null;
}
setStatus('idle', 'Di un comando…');
}
// ── Lógica principal ───────────────────────────────────────────────────────
fab.addEventListener('click', async () => {
if (!panelOpen) { openPanel(); return; }
if (state === 'recording') {
// Detener grabación
setStatus('processing', 'Procesando audio…');
const blob = await recorder.stop();
recorder = null;
await transcribe(blob);
return;
}
if (state !== 'idle') return;
// Iniciar grabación
try {
recorder = await startRecording();
setStatus('recording', 'Escuchando…', 'Clic para detener');
resultBox.hidden = true;
// Pre-carga el modelo mientras graba (sin bloquear)
if (!transcriberPromise) loadModel();
} catch (e) {
console.error('[VA] Micrófono:', e);
setStatus('idle', 'Sin acceso al micrófono');
}
});
closeBtn.addEventListener('click', closePanel);
async function transcribe(blob) {
try {
// Carga el modelo si aún no está listo
setStatus('processing', 'Cargando modelo…', 'Solo la primera vez (~40 MB)');
const transcriber = await loadModel(prog => {
if (prog.status === 'progress') {
const pct = Math.round(prog.progress ?? 0);
statusSub.textContent = `Descargando: ${pct}%`;
}
});
setStatus('processing', 'Transcribiendo…', '');
let audioData;
try {
audioData = await blobToFloat32(blob);
} catch (e) {
console.error('[VA] Error decodificando audio:', e);
setStatus('idle', 'Error al leer el audio');
return;
}
if (audioData.length < 3200) { // < 0.2s de audio
setStatus('idle', 'Muy corto, inténtalo de nuevo');
return;
}
const result = await transcriber(audioData, { language: 'spanish', task: 'transcribe' });
const text = (result?.text ?? '').trim();
if (!text) {
setStatus('idle', 'No se detectó voz');
return;
}
const cmd = matchCommand(text);
showResult(text, cmd?.label ?? '');
setStatus('idle', 'Di un comando…');
if (cmd) {
setTimeout(() => { cmd.action(); }, 600);
}
} catch (e) {
console.error('[VA] Error en transcripción:', e?.message ?? e);
const msg = e?.message?.includes('vacío') ? 'Audio vacío, graba más tiempo'
: e?.message?.includes('session') ? 'Error cargando el modelo'
: e?.message?.includes('decodeAudioData') ? 'Formato de audio no soportado'
: 'Error al procesar';
setStatus('idle', msg);
}
}
}
export function hideVoiceAssistant() {
if (assistantEl) assistantEl.style.display = 'none';
}
export function showVoiceAssistant() {
if (assistantEl) assistantEl.style.display = '';
else mountVoiceAssistant();
}

View File

@ -6,6 +6,7 @@ import './styles/facturas.css'
import './styles/clients.css' import './styles/clients.css'
import './styles/create-invoice.css' import './styles/create-invoice.css'
import './styles/settings.css' import './styles/settings.css'
import './styles/voice-assistant.css'
import { initRouter } from './router.js' import { initRouter } from './router.js'
import { initSessionManager } from './services/session.js' import { initSessionManager } from './services/session.js'
import { initTheme } from './services/theme.js' import { initTheme } from './services/theme.js'

View File

@ -4,6 +4,7 @@ import { createSidebar } from './components/Sidebar.js';
import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js'; import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js';
import { showConfirmExitModal } from './components/ConfirmExitModal.js'; import { showConfirmExitModal } from './components/ConfirmExitModal.js';
import { applyLoginTheme, applySavedTheme } from './services/theme.js'; import { applyLoginTheme, applySavedTheme } from './services/theme.js';
import { hideVoiceAssistant, showVoiceAssistant } from './components/VoiceAssistant.js';
// 🔧 Modo DEV: cambiar a false para activar login // 🔧 Modo DEV: cambiar a false para activar login
const DEV_MODE = false; const DEV_MODE = false;
@ -80,8 +81,10 @@ export function initRouter() {
if (hash === '#login') { if (hash === '#login') {
applyLoginTheme(); applyLoginTheme();
hideVoiceAssistant();
} else { } else {
applySavedTheme(); applySavedTheme();
showVoiceAssistant();
} }
app.innerHTML = ''; app.innerHTML = '';
@ -118,12 +121,47 @@ export function initRouter() {
// Render page content using registry // Render page content using registry
const pageContent = page.render(); const pageContent = page.render();
if (pageContent && typeof pageContent.cleanup === 'function') { // Mobile sidebar: backdrop + topbar
currentPageCleanup = pageContent.cleanup; const sidebarBackdrop = document.createElement('div');
} sidebarBackdrop.className = 'sidebar-backdrop';
const openMobileSidebar = () => {
sidebar.classList.add('mobile-open');
sidebarBackdrop.classList.add('visible');
};
const closeMobileSidebar = () => {
sidebar.classList.remove('mobile-open');
sidebarBackdrop.classList.remove('visible');
};
const handleMobileClose = () => closeMobileSidebar();
document.addEventListener('sidebar:close-mobile', handleMobileClose);
sidebarBackdrop.addEventListener('click', closeMobileSidebar);
const mobileTopbar = document.createElement('header');
mobileTopbar.className = 'mobile-topbar';
mobileTopbar.innerHTML = /*html*/`
<button class="mobile-menu-btn" aria-label="Abrir menú">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<span class="mobile-topbar-title">${page.name}</span>
`;
mobileTopbar.querySelector('.mobile-menu-btn').addEventListener('click', openMobileSidebar);
// Chain page cleanup with sidebar event listener cleanup
const pageCleanup = pageContent && typeof pageContent.cleanup === 'function' ? pageContent.cleanup : null;
currentPageCleanup = () => {
document.removeEventListener('sidebar:close-mobile', handleMobileClose);
if (pageCleanup) pageCleanup();
};
mainContent.appendChild(mobileTopbar);
mainContent.appendChild(pageContent); mainContent.appendChild(pageContent);
layout.appendChild(sidebar); layout.appendChild(sidebar);
layout.appendChild(sidebarBackdrop);
layout.appendChild(mainContent); layout.appendChild(mainContent);
app.appendChild(layout); app.appendChild(layout);
} else { } else {

190
src/services/voiceInput.js Normal file
View File

@ -0,0 +1,190 @@
import { pipeline } from '@huggingface/transformers';
// ── Iconos ────────────────────────────────────────────────────────────────────
const MIC_ICON = /*html*/`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>`;
const STOP_ICON = /*html*/`
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>`;
const SPIN_ICON = /*html*/`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 2a10 10 0 0 1 10 10" class="voice-spin"/>
</svg>`;
// ── Modelo (singleton) ────────────────────────────────────────────────────────
let transcriberPromise = null;
function loadModel(onProgress) {
if (!transcriberPromise) {
transcriberPromise = pipeline(
'automatic-speech-recognition',
'Xenova/whisper-tiny',
{ progress_callback: onProgress }
).catch(err => {
transcriberPromise = null; // permitir reintento
throw err;
});
}
return transcriberPromise;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function showTip(btn, text, duration = 3000) {
btn.querySelectorAll('.voice-tip').forEach(t => t.remove());
const tip = document.createElement('span');
tip.className = 'voice-tip';
tip.textContent = text;
btn.appendChild(tip);
if (duration > 0) setTimeout(() => tip.remove(), duration);
return tip;
}
async function startMediaRecorder() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
const mimeType = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
.find(t => MediaRecorder.isTypeSupported(t)) || '';
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});
const chunks = [];
recorder.ondataavailable = e => { if (e.data?.size > 0) chunks.push(e.data); };
recorder.start(200);
return {
stop: () => new Promise(resolve => {
recorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
resolve(new Blob(chunks, { type: mimeType || 'audio/webm' }));
};
if (recorder.state !== 'inactive') recorder.stop();
else resolve(new Blob(chunks));
})
};
}
async function blobToFloat32(blob) {
const arrayBuffer = await blob.arrayBuffer();
const ctx = new AudioContext({ sampleRate: 16000 });
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
await ctx.close();
return audioBuffer.getChannelData(0); // mono Float32Array a 16kHz
}
// ── API pública ───────────────────────────────────────────────────────────────
/**
* Adjunta un botón de voz (Whisper local) a un input.
* Devuelve el botón, o null si el navegador no soporta MediaRecorder.
*/
export function attachVoiceInput(inputEl, { lang = 'es' } = {}) {
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
console.warn('[VoiceInput] MediaRecorder no disponible en este navegador.');
return null;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'voice-btn';
btn.setAttribute('aria-label', 'Buscar por voz');
btn.title = 'Buscar por voz (Whisper local)';
btn.innerHTML = MIC_ICON;
let mediaRecorder = null; // { stop() }
let state = 'idle'; // idle | recording | processing
const setState = (s) => {
state = s;
btn.className = `voice-btn voice-btn--${s}`;
if (s === 'idle') { btn.innerHTML = MIC_ICON; btn.disabled = false; }
if (s === 'recording') { btn.innerHTML = STOP_ICON; btn.disabled = false; }
if (s === 'processing') { btn.innerHTML = SPIN_ICON; btn.disabled = true; }
};
btn.addEventListener('click', async () => {
// ── Detener grabación ──────────────────────────────────────────────────
if (state === 'recording') {
showTip(btn, 'Procesando…', 0);
setState('processing');
const blob = await mediaRecorder.stop();
mediaRecorder = null;
await transcribe(blob);
return;
}
if (state !== 'idle') return;
// ── Iniciar grabación ──────────────────────────────────────────────────
try {
mediaRecorder = await startMediaRecorder();
setState('recording');
showTip(btn, 'Hablando… clic para detener');
// Pre-carga el modelo en paralelo mientras el usuario habla
if (!transcriberPromise) loadModel();
} catch (e) {
console.error('[VoiceInput] Error al acceder al micrófono:', e);
showTip(btn, 'Sin acceso al micrófono');
setState('idle');
}
});
async function transcribe(blob) {
try {
// Carga / espera el modelo (primera vez descarga ~40 MB, se cachea)
const tipLoad = showTip(btn, 'Cargando modelo…', 0);
const transcriber = await loadModel(progress => {
if (progress.status === 'progress') {
const pct = Math.round(progress.progress ?? 0);
btn.title = `Cargando modelo: ${pct}%`;
}
});
tipLoad.remove();
showTip(btn, 'Transcribiendo…', 0);
const audioData = await blobToFloat32(blob);
const result = await transcriber(audioData, { language: lang, task: 'transcribe' });
const text = (result.text ?? '').trim();
if (text) {
inputEl.value = text;
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
inputEl.focus();
showTip(btn, `"${text}"`);
} else {
showTip(btn, 'No se detectó texto');
}
} catch (e) {
console.error('[VoiceInput] Error en transcripción:', e);
showTip(btn, 'Error al transcribir');
} finally {
btn.title = 'Buscar por voz';
setState('idle');
}
}
return btn;
}
/**
* Envuelve el input en .search-wrapper y adjunta el botón de voz.
*/
export function wrapWithVoice(inputEl, options = {}) {
const voiceBtn = attachVoiceInput(inputEl, options);
if (!voiceBtn) return;
const wrapper = document.createElement('div');
wrapper.className = 'search-wrapper';
inputEl.parentNode.insertBefore(wrapper, inputEl);
wrapper.appendChild(inputEl);
wrapper.appendChild(voiceBtn);
}

View File

@ -333,6 +333,97 @@ button:disabled {
} }
/* ============================================
Voice Input
============================================ */
.search-wrapper {
position: relative;
display: flex;
align-items: center;
flex: 1;
max-width: 320px;
}
.search-wrapper .search-input {
width: 100%;
max-width: 100%;
flex: none;
padding-right: 34px;
}
.clientes-filters .search-wrapper {
max-width: 400px;
}
.voice-btn {
position: absolute;
right: 6px;
background: none;
border: none;
padding: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--gray-400);
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
transition: color var(--transition-fast), background-color var(--transition-fast);
flex-shrink: 0;
}
.voice-btn:hover:not(:disabled) {
color: var(--primary);
background: var(--blue-50);
border-color: transparent;
}
.voice-btn:disabled {
cursor: default;
opacity: 0.7;
}
/* Estado: grabando */
.voice-btn--recording {
color: var(--red-500);
animation: voice-pulse 1.1s ease-in-out infinite;
}
/* Estado: procesando */
.voice-btn--processing .voice-spin {
transform-origin: center;
animation: voice-spin 0.8s linear infinite;
}
@keyframes voice-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.25); }
}
@keyframes voice-spin {
to { transform: rotate(360deg); }
}
/* Tooltip flotante del botón de voz */
.voice-tip {
position: absolute;
bottom: calc(100% + 7px);
left: 50%;
transform: translateX(-50%);
background: var(--gray-900);
color: #fff;
font-size: 11px;
white-space: nowrap;
padding: 3px 8px;
border-radius: 4px;
pointer-events: none;
z-index: 9999;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.logout-button { .logout-button {
background-color: transparent; background-color: transparent;
color: var(--text-secondary); color: var(--text-secondary);

View File

@ -236,6 +236,12 @@
background-color: rgba(30, 58, 110, 0.24); background-color: rgba(30, 58, 110, 0.24);
} }
@media (max-width: 1024px) {
.facturas-page {
padding: var(--space-4);
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.facturas-filters { .facturas-filters {
flex-direction: column; flex-direction: column;
@ -263,8 +269,26 @@
} }
} }
@media (max-width: 1024px) { @media (max-width: 640px) {
.facturas-page { .facturas-page {
padding: var(--space-4); padding: var(--space-3);
}
/* Scroll horizontal suave en la tabla */
.invoices-table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.invoices-table {
min-width: 560px;
}
/* Ocultar columnas menos importantes en pantallas pequeñas */
.invoices-table th.invoice-date,
.invoices-table td.invoice-date,
.invoices-table th.invoice-remain,
.invoices-table td.invoice-remain {
display: none;
} }
} }

View File

@ -8,27 +8,60 @@
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
width: 100%; width: 100%;
background: var(--bg-page); background: linear-gradient(145deg, var(--bg-page) 0%, var(--blue-50) 100%);
} }
.login-card { .login-card {
background: var(--card-bg); background: var(--card-bg);
padding: var(--space-8); padding: var(--space-8);
border-radius: var(--radius-lg); border-radius: 12px;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-md);
width: 100%; width: 100%;
max-width: 380px; max-width: 390px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-bottom: var(--space-6);
}
.login-logo-icon {
width: 40px;
height: 40px;
background: var(--primary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.login-brand-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.login-title { .login-title {
color: var(--text-primary); color: var(--text-primary);
margin: 0 0 var(--space-6) 0; margin: 0 0 var(--space-1) 0;
font-size: 1.25rem; font-size: 1.125rem;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
} }
.login-subtitle {
color: var(--text-secondary);
font-size: 13px;
text-align: center;
margin: 0 0 var(--space-5) 0;
}
.form-group { .form-group {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
@ -169,6 +202,11 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.login-card {
margin: var(--space-4);
padding: var(--space-6);
}
.session-warning-actions { .session-warning-actions {
flex-direction: column; flex-direction: column;
} }
@ -177,3 +215,7 @@
width: 100%; width: 100%;
} }
} }
:root[data-theme='dark'] .login-container {
background: linear-gradient(145deg, var(--bg-page) 0%, rgba(30, 58, 110, 0.2) 100%);
}

View File

@ -29,6 +29,26 @@
height: 52px; height: 52px;
} }
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
overflow: hidden;
}
.sidebar-logo-icon {
width: 26px;
height: 26px;
background: var(--primary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.sidebar-header h2 { .sidebar-header h2 {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
@ -137,7 +157,7 @@
.sidebar.collapsed { width: 56px; } .sidebar.collapsed { width: 56px; }
.sidebar.collapsed .sidebar-header h2, .sidebar.collapsed .sidebar-brand h2,
.sidebar.collapsed .sidebar-text { .sidebar.collapsed .sidebar-text {
opacity: 0; opacity: 0;
width: 0; width: 0;
@ -164,22 +184,110 @@
padding: var(--space-6); padding: var(--space-6);
} }
/* ── Mobile topbar (hidden on desktop) ── */
.mobile-topbar {
display: none;
}
/* ── Sidebar backdrop ── */
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 999;
animation: backdropFadeIn 150ms ease;
}
.sidebar-backdrop.visible {
display: block;
}
@keyframes backdropFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
/* Sidebar: oculta por defecto, se muestra con .mobile-open */
.sidebar { .sidebar {
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
z-index: 1000; z-index: 1000;
transform: translateX(0); transform: translateX(-100%);
width: 220px !important;
transition: transform var(--transition-base), box-shadow var(--transition-base);
box-shadow: none;
} }
.sidebar.mobile-open {
transform: translateX(0);
box-shadow: var(--shadow-lg);
}
/* Anular collapsed en móvil para que el contenido se vea bien cuando se abre */
.sidebar.collapsed { .sidebar.collapsed {
transform: translateX(-100%); width: 220px !important;
width: 220px; }
.sidebar.collapsed .sidebar-brand h2,
.sidebar.collapsed .sidebar-text {
opacity: 1;
width: auto;
overflow: visible;
}
.sidebar.collapsed .sidebar-link {
justify-content: flex-start;
padding: var(--space-2) var(--space-3);
} }
.main-content { .main-content {
margin-left: 0; margin-left: 0;
} }
/* Topbar móvil */
.mobile-topbar {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0 var(--space-4);
height: 52px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
}
.mobile-menu-btn {
background: transparent;
border: none;
padding: var(--space-2);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.mobile-menu-btn:hover {
background: var(--gray-100);
color: var(--text-primary);
border-color: transparent;
}
.mobile-topbar-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
} }

View File

@ -0,0 +1,302 @@
/* ============================================================
Voice Assistant — Floating button + panel
============================================================ */
.va-root {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 600;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
/* ── FAB ─────────────────────────────────────────────────── */
.va-fab {
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--primary);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.45);
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
flex-shrink: 0;
}
.va-fab:hover {
background: var(--primary-hover);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.55);
transform: scale(1.06);
border-color: transparent;
}
.va-fab--recording {
background: var(--red-500) !important;
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45) !important;
animation: va-pulse-fab 1.2s ease-in-out infinite;
}
.va-fab--processing {
background: var(--amber-600) !important;
box-shadow: 0 4px 14px rgba(217, 119, 6, 0.4) !important;
}
@keyframes va-pulse-fab {
0%, 100% { box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45); }
50% { box-shadow: 0 4px 22px rgba(239, 68, 68, 0.75); transform: scale(1.06); }
}
/* ── Panel ───────────────────────────────────────────────── */
.va-panel {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
width: 290px;
box-shadow: var(--shadow-lg);
overflow: hidden;
animation: va-slide-up 0.18s ease;
}
@keyframes va-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.va-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 10px;
border-bottom: 1px solid var(--border-color);
}
.va-panel-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.va-close-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
padding: 0;
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.va-close-btn:hover {
background: var(--gray-100);
color: var(--text-primary);
border-color: transparent;
}
/* ── Área de estado ──────────────────────────────────────── */
.va-status-area {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px 16px;
gap: 8px;
}
.va-status-icon {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--gray-100);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.va-status-icon--recording {
background: rgba(239, 68, 68, 0.12);
color: var(--red-500);
animation: va-pulse-icon 1.2s ease-in-out infinite;
}
.va-status-icon--processing {
background: rgba(217, 119, 6, 0.1);
color: var(--amber-600);
}
@keyframes va-pulse-icon {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
.va-status-text {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
.va-status-sub {
margin: 0;
font-size: 11px;
color: var(--text-secondary);
text-align: center;
min-height: 14px;
}
/* ── Resultado ───────────────────────────────────────────── */
.va-result {
margin: 0 12px 12px;
background: var(--gray-50);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.va-result-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.va-result-text {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.va-result-action {
font-size: 12px;
color: var(--green-600);
font-weight: 500;
}
.va-result-action--unknown {
color: var(--text-secondary);
}
/* ── Lista de comandos ───────────────────────────────────── */
.va-commands {
border-top: 1px solid var(--border-color);
padding: 10px 0 4px;
}
.va-commands-title {
margin: 0 0 6px;
padding: 0 14px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.va-commands-list {
list-style: none;
margin: 0;
padding: 0;
}
.va-cmd-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 14px;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.va-cmd-item:hover {
background: var(--gray-50);
}
.va-cmd-icon {
width: 26px;
height: 26px;
background: var(--blue-50);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
flex-shrink: 0;
}
.va-cmd-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.va-cmd-label {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.va-cmd-hint {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Dark mode ───────────────────────────────────────────── */
:root[data-theme='dark'] .va-panel {
background: #081226;
border-color: #1e2f4d;
}
:root[data-theme='dark'] .va-close-btn:hover,
:root[data-theme='dark'] .va-cmd-item:hover {
background: rgba(30, 58, 110, 0.3);
}
:root[data-theme='dark'] .va-result {
background: rgba(15, 23, 42, 0.8);
border-color: #1e2f4d;
}
:root[data-theme='dark'] .va-status-icon {
background: rgba(148, 163, 184, 0.1);
}
:root[data-theme='dark'] .va-cmd-icon {
background: rgba(30, 58, 110, 0.4);
}
/* ── Móvil ───────────────────────────────────────────────── */
@media (max-width: 480px) {
.va-root {
bottom: 16px;
right: 16px;
}
.va-panel {
width: calc(100vw - 32px);
max-width: 290px;
}
}

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
exclude: ['@huggingface/transformers'],
},
});