/* ── 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 => `
`).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 = `
${SVG_COPY}
${SVG_THUMB_UP}
${SVG_THUMB_DOWN} `;
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 = `
`;
$('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();
}