/* ── Constants ───────────────────────────────────────────── */ const API = ''; /* ── State ───────────────────────────────────────────────── */ let token = localStorage.getItem('mt_token'); let usuario = JSON.parse(localStorage.getItem('mt_usuario') || 'null'); let sessionId = null; let esperando = false; let pendingThumbDn = null; let msgCounter = 0; /* ── SVG templates (shared between addBubble / addEmptyBubble) ── */ const SVG_COPY = ``; const SVG_CHECK = ``; const SVG_THUMB_UP = ``; const SVG_THUMB_DOWN = ``; const SVG_SEND = ``; /* ── Theme ───────────────────────────────────────────────── */ (function initTheme() { const t = localStorage.getItem('mt_theme') || 'light'; document.documentElement.setAttribute('data-theme', t); })(); function toggleTheme() { const cur = document.documentElement.getAttribute('data-theme'); const next = cur === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('mt_theme', next); } /* ── Init ────────────────────────────────────────────────── */ if (token && usuario) mostrarApp(); /* ── Helpers ─────────────────────────────────────────────── */ function $(id) { return document.getElementById(id); } function $$(sel){ return document.querySelectorAll(sel); } function showMsg(id, text, type) { const el = $(id); el.textContent = text; el.className = `auth-msg ${type} show`; } function authHeaders() { return { 'Authorization': `Bearer ${token}` }; } function jsonHeaders() { return { 'Content-Type': 'application/json', ...authHeaders() }; } /* ── Auth tabs ───────────────────────────────────────────── */ function showTab(tab) { $('tab-login').style.display = tab === 'login' ? 'block' : 'none'; $('tab-registro').style.display = tab === 'registro' ? 'block' : 'none'; $$('.auth-tab').forEach((t, i) => { t.classList.toggle('active', (tab === 'login' && i === 0) || (tab === 'registro' && i === 1)); }); } /* ── Login ───────────────────────────────────────────────── */ async function doLogin() { const email = $('login-email').value.trim(); const password = $('login-password').value; if (!email || !password) return showMsg('login-msg', 'Omple tots els camps', 'error'); const btn = $('btn-login'); btn.disabled = true; btn.textContent = 'Entrant...'; try { const res = await fetch(`${API}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const data = await res.json(); if (!res.ok) throw new Error(data.detail); token = data.token; usuario = { nombre: data.nombre, email: data.email, departamento: data.departamento, empresa: data.empresa }; localStorage.setItem('mt_token', token); localStorage.setItem('mt_usuario', JSON.stringify(usuario)); mostrarApp(); } catch (e) { showMsg('login-msg', e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Entrar'; } } /* ── Registro ────────────────────────────────────────────── */ async function doRegistro() { const nombre = $('reg-nombre').value.trim(); const email = $('reg-email').value.trim(); const password = $('reg-password').value; if (!nombre || !email || !password) return showMsg('registro-msg', 'Omple els camps obligatoris', 'error'); if (password.length < 8) return showMsg('registro-msg', 'La contrasenya ha de tenir almenys 8 caràcters', 'error'); const btn = $('btn-registro'); btn.disabled = true; btn.textContent = 'Enviant...'; try { const res = await fetch(`${API}/registro`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nombre, email, password }) }); const data = await res.json(); if (!res.ok) throw new Error(data.detail); showMsg('registro-msg', data.mensaje, 'success'); } catch (e) { showMsg('registro-msg', e.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Sol·licitar accés'; } } /* ── App ─────────────────────────────────────────────────── */ function mostrarApp() { $('auth-screen').style.display = 'none'; $('app-screen').style.display = 'flex'; const ini = usuario.nombre.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase(); $('user-nombre').textContent = usuario.nombre.split(' ')[0]; $('user-avatar').textContent = ini; $('welcome-title').textContent = `Hola, ${usuario.nombre.split(' ')[0]}`; cargarListaConversaciones(); newChat(); } function doLogout() { localStorage.removeItem('mt_token'); localStorage.removeItem('mt_usuario'); token = null; usuario = null; $('auth-screen').style.display = 'flex'; $('app-screen').style.display = 'none'; } /* ── Conversations ───────────────────────────────────────── */ async function cargarListaConversaciones() { try { const res = await fetch(`${API}/conversaciones`, { headers: authHeaders() }); if (!res.ok) return; const data = await res.json(); renderConversaciones(data.conversaciones); } catch (e) { /* silencioso */ } } function renderConversaciones(lista) { const el = $('chat-list'); if (!lista || lista.length === 0) { el.innerHTML = `
Encara no hi ha converses
`; return; } el.innerHTML = lista.map(c => `
${c.titulo}
${c.fecha}
`).join(''); } async function cargarConversacion(sid, el) { if (sid === sessionId) return; sessionId = sid; $$('.chat-item').forEach(i => i.classList.remove('active')); if (el) el.classList.add('active'); $('messages').innerHTML = ''; try { const res = await fetch(`${API}/conversaciones/${sid}`, { headers: authHeaders() }); if (!res.ok) throw new Error(); const data = await res.json(); if (data.mensajes.length === 0) { newChat(); return; } data.mensajes.forEach(m => addBubble(m.rol, m.texto)); scrollBottom(); } catch (e) { $('messages').innerHTML = `

No s'han pogut carregar els missatges.

`; } } async function borrarConversacion(sid, btn) { try { await fetch(`${API}/conversaciones/${sid}`, { method: 'DELETE', headers: authHeaders() }); if (sid === sessionId) newChat(); btn.closest('.chat-item').remove(); } catch (e) { /* silencioso */ } } function newChat() { sessionId = null; $('messages').innerHTML = `
MT

Hola, ${usuario ? usuario.nombre.split(' ')[0] : ''}

Pregunta'm sobre teixits tècnics, fitxes de producte, normatives o qualsevol informació de Marina Textil.

`; $$('.chat-item').forEach(i => i.classList.remove('active')); } /* ── Chat input ──────────────────────────────────────────── */ function handleKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } } function autoResize(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; } /* ── Send message with streaming ─────────────────────────── */ async function sendMessage() { const input = $('user-input'); const pregunta = input.value.trim(); if (!pregunta || esperando) return; input.value = ''; input.style.height = 'auto'; const welcome = $('welcome-msg'); if (welcome) welcome.remove(); addBubble('user', pregunta); esperando = true; $('btn-send').disabled = true; const typingId = addTyping(); let bubbleEl = null; let textoCompleto = ''; let tokenQueue = []; let streamDone = false; function startTypewriter(el) { const CHARS_PER_TICK = 2; const INTERVAL_MS = 16; let rendered = ''; const tick = setInterval(() => { if (tokenQueue.length === 0) { if (streamDone) { clearInterval(tick); el.innerHTML = formatText(rendered); scrollBottom(); } return; } const pending = tokenQueue.join(''); const take = Math.min(CHARS_PER_TICK, pending.length); rendered += pending.slice(0, take); tokenQueue = pending.slice(take) ? [pending.slice(take)] : []; el.innerHTML = formatText(rendered); scrollBottom(); }, INTERVAL_MS); } try { const res = await fetch(`${API}/chat/stream`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify({ pregunta, session_id: sessionId }) }); if (res.status === 401) { doLogout(); return; } if (!res.ok) throw new Error('Error del servidor'); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = JSON.parse(line.slice(6)); if (data.type === 'session') { sessionId = data.session_id; } else if (data.type === 'token') { if (!bubbleEl) { removeTyping(typingId); bubbleEl = addEmptyBubble(); startTypewriter(bubbleEl); } tokenQueue.push(data.content); textoCompleto += data.content; } else if (data.type === 'done') { streamDone = true; if (textoCompleto) guardarConversacion(); } else if (data.type === 'error') { removeTyping(typingId); addBubble('bot', "S'ha produït un error. Si us plau, torna-ho a intentar."); } } } } catch (e) { removeTyping(typingId); addBubble('bot', "S'ha produït un error. Si us plau, torna-ho a intentar."); } finally { streamDone = true; esperando = false; $('btn-send').disabled = false; input.focus(); } } /* ── Build bot action bar ────────────────────────────────── */ function buildActionBar(id) { const bar = document.createElement('div'); bar.className = 'msg-actions'; bar.innerHTML = ` `; return bar; } /* ── Bubble builder ──────────────────────────────────────── */ function addBubble(rol, texto) { const msgs = $('messages'); const ini = rol === 'user' ? (usuario ? usuario.nombre.split(' ').map(n=>n[0]).join('').slice(0,2).toUpperCase() : 'TU') : 'MT'; const id = 'msg-' + (++msgCounter); const row = document.createElement('div'); row.className = 'msg-row'; const msgDiv = document.createElement('div'); msgDiv.className = `msg ${rol}`; msgDiv.innerHTML = `
${ini}
${formatText(texto)}
`; row.appendChild(msgDiv); if (rol === 'bot') row.appendChild(buildActionBar(id)); msgs.appendChild(row); scrollBottom(); } /* ── Empty bubble for streaming ──────────────────────────── */ function addEmptyBubble() { const msgs = $('messages'); const id = 'msg-' + (++msgCounter); const row = document.createElement('div'); row.className = 'msg-row'; const msgDiv = document.createElement('div'); msgDiv.className = 'msg bot'; msgDiv.innerHTML = `
MT
`; row.appendChild(msgDiv); row.appendChild(buildActionBar(id)); msgs.appendChild(row); return $('bubble-' + id); } /* ── Message actions ─────────────────────────────────────── */ function copyMsg(id, btn) { const bubble = $('bubble-' + id); if (!bubble) return; navigator.clipboard.writeText(bubble.innerText).then(() => { btn.classList.add('copied'); btn.title = 'Copiat!'; btn.innerHTML = SVG_CHECK; setTimeout(() => { btn.classList.remove('copied'); btn.title = 'Copiar'; btn.innerHTML = SVG_COPY; }, 2000); }); } function thumbUp(btn) { btn.classList.toggle('thumb-up-active'); } function thumbDown(btn, id) { pendingThumbDn = btn; btn.classList.add('thumb-dn-active'); const bubble = $('bubble-' + id); $('fb-query').value = bubble ? bubble.innerText.slice(0, 120) : ''; $('feedback-modal').classList.add('open'); } /* ── Feedback modal ──────────────────────────────────────── */ function closeFeedback() { $('feedback-modal').classList.remove('open'); if (pendingThumbDn) { pendingThumbDn.classList.remove('thumb-dn-active'); pendingThumbDn = null; } } function overlayClick(e) { if (e.target === $('feedback-modal')) closeFeedback(); } function selectType(el) { $$('.fb-chip').forEach(c => c.classList.remove('active')); el.classList.add('active'); } async function submitFeedback() { const desc = $('fb-desc').value.trim(); if (!desc) { $('fb-desc').focus(); $('fb-desc').style.borderColor = 'var(--red)'; return; } $('fb-desc').style.borderColor = ''; const dept = $('fb-dept').value; const tipus = document.querySelector('.fb-chip.active')?.textContent || 'Error'; const consulta = $('fb-query').value.trim(); const btn = document.querySelector('.btn-submit'); btn.disabled = true; btn.innerHTML = `${SVG_SEND} Enviant...`; try { const res = await fetch(`${API}/feedback`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify({ departament: dept || null, tipus, descripcio: desc, consulta: consulta || null, }) }); if (!res.ok) throw new Error('Error del servidor'); // Reset form $('fb-dept').value = ''; $('fb-desc').value = ''; $('fb-query').value = ''; $$('.fb-chip').forEach((c, i) => c.classList.toggle('active', i === 0)); $('feedback-modal').classList.remove('open'); pendingThumbDn = null; } catch (e) { // Mostrar error sin cerrar el modal $('fb-desc').style.borderColor = 'var(--red)'; const errMsg = document.createElement('div'); errMsg.style.cssText = 'color:var(--red);font-size:12px;margin-top:6px;'; errMsg.textContent = "Error en enviar. Torna-ho a intentar."; $('fb-desc').parentNode.appendChild(errMsg); setTimeout(() => errMsg.remove(), 3000); } finally { btn.disabled = false; btn.innerHTML = `${SVG_SEND} Enviar`; } } /* ── Typing indicator ────────────────────────────────────── */ function addTyping() { const id = 'typing-' + Date.now(); const row = document.createElement('div'); row.className = 'msg-row'; row.id = id; row.innerHTML = `
MT
`; $('messages').appendChild(row); scrollBottom(); return id; } function removeTyping(id) { const el = $(id); if (el) el.remove(); } /* ── Utils ───────────────────────────────────────────────── */ function scrollBottom() { const m = $('messages'); m.scrollTop = m.scrollHeight; } function formatText(t) { return t .replace(/&/g,'&').replace(//g,'>') // Eliminar saltos múltiples .replace(/\n{3,}/g, '\n\n') // Títulos ### y ## .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') // Links markdown [texto](url) .replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g, '$1') // Negrita e itálica .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') // Listas con - o • → con más espacio entre items .replace(/^[-•] (.+)$/gm, '
  • $1
  • ') // Envolver li en ul .replace(/(]*>.*?<\/li>\n?)+/gs, m => ``) // Quitar saltos pegados a elementos de bloque .replace(/\n()\n/g, '$1') .replace(/\n()\n/g, '$1') // Doble salto → separador, simple → espacio .replace(/\n\n/g, '
    ') .replace(/\n/g, ' '); } function guardarConversacion() { // El backend guarda els missatges a PostgreSQL. // Només refresquem el sidebar. cargarListaConversaciones(); }