/** * app.js — Sebayhi * * Handles: * - Mode selection (knowledge / i3rab) with sidebar toggle * - Conversation history stored in memory (sidebar list) * - SSE streaming from /chat/stream * - New chat / history switching */ // ── DOM ─────────────────────────────────────────────────────────────────────── const messageList = document.getElementById('messageList'); const chatScroll = document.getElementById('chatScroll'); const userInput = document.getElementById('userInput'); const sendBtn = document.getElementById('sendBtn'); const modeKnowledge = document.getElementById('modeKnowledge'); const modeI3rab = document.getElementById('modeI3rab'); const newChatBtn = document.getElementById('newChatBtn'); const historyList = document.getElementById('historyList'); const historyEmpty = document.getElementById('historyEmpty'); // ── State ───────────────────────────────────────────────────────────────────── let currentMode = 'knowledge'; let previousMode = null; let isStreaming = false; // In-memory history: [{ id, mode, title, messages: [{role, text, mode}] }] let conversations = []; let activeConvId = null; const PLACEHOLDERS = { knowledge: 'Ask a grammar question… / اسأل سؤالاً نحوياً', i3rab: 'Enter a sentence for I3rab… / اكتب الجملة للإعراب', }; const MODE_LABELS = { knowledge: 'Switched to Knowledge · معرفة', i3rab: 'Switched to I3rab · إعراب', }; // ═════════════════════════════════════════════════════════════════════════════ // MODE // ═════════════════════════════════════════════════════════════════════════════ function setMode(mode) { currentMode = mode; document.body.dataset.mode = mode; [modeKnowledge, modeI3rab].forEach(btn => { const active = btn.dataset.mode === mode; btn.classList.toggle('mode-btn--active', active); btn.setAttribute('aria-pressed', active); }); userInput.placeholder = PLACEHOLDERS[mode]; userInput.focus(); } modeKnowledge.addEventListener('click', () => setMode('knowledge')); modeI3rab.addEventListener('click', () => setMode('i3rab')); setMode('knowledge'); // ═════════════════════════════════════════════════════════════════════════════ // HISTORY // ═════════════════════════════════════════════════════════════════════════════ function createConversation(mode, firstMessage) { const id = Date.now().toString(); const conv = { id, mode, title: firstMessage.slice(0, 36) + (firstMessage.length > 36 ? '…' : ''), messages: [], }; conversations.unshift(conv); activeConvId = id; renderHistory(); return conv; } function renderHistory() { // Clear all items but keep the empty placeholder [...historyList.querySelectorAll('.history-item')].forEach(el => el.remove()); if (conversations.length === 0) { historyEmpty.style.display = ''; return; } historyEmpty.style.display = 'none'; conversations.forEach(conv => { const item = document.createElement('div'); item.className = 'history-item' + (conv.id === activeConvId ? ' history-item--active' : ''); item.dataset.id = conv.id; item.innerHTML = ` ${conv.mode === 'i3rab' ? 'I3' : 'KN'} ${escapeHtml(conv.title)}`; item.addEventListener('click', () => loadConversation(conv.id)); historyList.appendChild(item); }); } function loadConversation(id) { const conv = conversations.find(c => c.id === id); if (!conv || id === activeConvId) return; activeConvId = id; previousMode = null; // Restore mode setMode(conv.mode); // Re-render messages messageList.innerHTML = ''; conv.messages.forEach(msg => { if (msg.role === 'user') { _appendUserBubble(msg.text); } else { _appendAssistantBubble(msg.text, msg.mode); } }); renderHistory(); scrollToBottom(); } // ═════════════════════════════════════════════════════════════════════════════ // NEW CHAT // ═════════════════════════════════════════════════════════════════════════════ function startNewChat() { activeConvId = null; previousMode = null; messageList.innerHTML = `
س
أهلاً وسهلاً — I'm Sebayhi, your Arabic grammar assistant.
Select a mode on the left, then type your message below.
`; renderHistory(); userInput.focus(); } newChatBtn.addEventListener('click', startNewChat); // ═════════════════════════════════════════════════════════════════════════════ // SEND // ═════════════════════════════════════════════════════════════════════════════ //* look at this func later async function sendMessage() { const text = userInput.value.trim(); if (!text || isStreaming) return; // Mode switch divider if (previousMode !== null && previousMode !== currentMode) { appendModeDivider(currentMode); } // Start or continue conversation let conv; if (!activeConvId) { conv = createConversation(currentMode, text); } else { conv = conversations.find(c => c.id === activeConvId); } previousMode = currentMode; // Save user message conv.messages.push({ role: 'user', text, mode: currentMode }); // Render user bubble _appendUserBubble(text); userInput.value = ''; autoResizeTextarea(); // Lock isStreaming = true; sendBtn.disabled = true; const thinkingEl = appendThinking(); try { const response = await fetch('/chat/stream', { method: 'POST', credentials: "include", headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, intent: currentMode }), }); if (!response.ok) throw new Error(`Server error: ${response.status}`); thinkingEl.remove(); const { textEl, cursorEl } = _appendStreamingBubble(currentMode); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let replyText = ''; outer: while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop(); for (const part of parts) { if (!part.startsWith('data: ')) continue; const payload = part.slice(6); if (payload === '[DONE]') { cursorEl.remove(); break outer; } textEl.textContent += payload; replyText += payload; scrollToBottom(); } } // Save assistant reply to history conv.messages.push({ role: 'assistant', text: replyText, mode: currentMode }); } catch (err) { thinkingEl?.remove(); appendErrorBubble(err.message); console.error(err); } finally { isStreaming = false; sendBtn.disabled = false; userInput.focus(); scrollToBottom(); } } // ═════════════════════════════════════════════════════════════════════════════ // RENDER HELPERS // ═════════════════════════════════════════════════════════════════════════════ function _appendUserBubble(text) { const el = document.createElement('div'); el.className = 'message message--user'; el.innerHTML = `
أ
${escapeHtml(text)}
`; messageList.appendChild(el); scrollToBottom(); } function _appendAssistantBubble(text, mode) { // Used when replaying history — full text already available const isI3rab = mode === 'i3rab'; const badgeText = isI3rab ? 'إعراب · I3rab' : 'معرفة · Knowledge'; const el = document.createElement('div'); el.className = 'message message--assistant'; el.innerHTML = `
س
${badgeText}
${escapeHtml(text)}
`; messageList.appendChild(el); } function _appendStreamingBubble(mode) { // Used during live streaming — returns live textEl + cursorEl const isI3rab = mode === 'i3rab'; const badgeText = isI3rab ? 'إعراب · I3rab' : 'معرفة · Knowledge'; const textEl = document.createElement('span'); const cursorEl = document.createElement('span'); cursorEl.className = 'streaming-cursor'; const bubble = document.createElement('div'); bubble.className = 'msg-text'; bubble.appendChild(textEl); bubble.appendChild(cursorEl); const body = document.createElement('div'); body.className = 'msg-body'; body.innerHTML = `
${badgeText}
`; body.appendChild(bubble); const msg = document.createElement('div'); msg.className = 'message message--assistant'; msg.innerHTML = `
س
`; msg.appendChild(body); messageList.appendChild(msg); scrollToBottom(); return { textEl, cursorEl }; } function appendThinking() { const el = document.createElement('div'); el.className = 'message message--assistant'; el.innerHTML = `
س
`; messageList.appendChild(el); scrollToBottom(); return el; } function appendModeDivider(mode) { const el = document.createElement('div'); el.className = 'mode-divider'; el.innerHTML = `
${MODE_LABELS[mode]}
`; messageList.appendChild(el); } function appendErrorBubble(detail) { const el = document.createElement('div'); el.className = 'message message--assistant'; el.innerHTML = `
س
⚠️ Something went wrong. Please try again.
${escapeHtml(detail)}
`; messageList.appendChild(el); scrollToBottom(); } // ═════════════════════════════════════════════════════════════════════════════ // INPUT EVENTS // ═════════════════════════════════════════════════════════════════════════════ userInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); sendBtn.addEventListener('click', sendMessage); userInput.addEventListener('input', autoResizeTextarea); function autoResizeTextarea() { userInput.style.height = 'auto'; userInput.style.height = Math.min(userInput.scrollHeight, 160) + 'px'; } function scrollToBottom() { chatScroll.scrollTop = chatScroll.scrollHeight; } function escapeHtml(str) { return str .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // ═════════════════════════════════════════════════════════════════════════════ // THEME TOGGLE // ═════════════════════════════════════════════════════════════════════════════ // ── Theme toggle (desktop sidebar + mobile topbar) ──────────────────────────── const themeToggle = document.getElementById('themeToggle'); const themeIcon = document.getElementById('themeIcon'); const themeLabel = document.getElementById('themeLabel'); const mobileThemeBtn = document.getElementById('mobileThemeBtn'); const mobileThemeIcon = document.getElementById('mobileThemeIcon'); const ICON_MOON = 'M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z'; const ICON_SUN = 'M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.464 5.05l-.707-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z'; function applyTheme(theme) { document.body.dataset.theme = theme; const isLight = theme === 'light'; const iconPath = isLight ? ICON_SUN : ICON_MOON; themeIcon.querySelector('path').setAttribute('d', iconPath); mobileThemeIcon.querySelector('path').setAttribute('d', iconPath); themeLabel.textContent = isLight ? 'Dark mode' : 'Light mode'; } applyTheme(localStorage.getItem('sebayhi-theme') || 'dark'); function toggleTheme() { const next = document.body.dataset.theme === 'light' ? 'dark' : 'light'; applyTheme(next); localStorage.setItem('sebayhi-theme', next); } themeToggle.addEventListener('click', toggleTheme); mobileThemeBtn.addEventListener('click', toggleTheme); userInput.focus(); // ═════════════════════════════════════════════════════════════════════════════ // MOBILE SIDEBAR TOGGLE // ═════════════════════════════════════════════════════════════════════════════ const sidebar = document.getElementById('sidebar'); const sidebarOverlay = document.getElementById('sidebarOverlay'); const mobileMenuBtn = document.getElementById('mobileMenuBtn'); function openSidebar() { sidebar.classList.add('open'); sidebarOverlay.classList.add('active'); } function closeSidebar() { sidebar.classList.remove('open'); sidebarOverlay.classList.remove('active'); } mobileMenuBtn.addEventListener('click', openSidebar); sidebarOverlay.addEventListener('click', closeSidebar); // Close sidebar when a mode or history item is selected on mobile [modeKnowledge, modeI3rab, newChatBtn].forEach(btn => btn.addEventListener('click', () => { if (window.innerWidth <= 680) closeSidebar(); }) );