Spaces:
Running
Running
| /** | |
| * 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 = ` | |
| <span class="history-item-badge history-item-badge--${conv.mode}">${conv.mode === 'i3rab' ? 'I3' : 'KN'}</span> | |
| <span class="history-item-text">${escapeHtml(conv.title)}</span>`; | |
| 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 = ` | |
| <div class="message message--assistant"> | |
| <div class="msg-avatar">Ψ³</div> | |
| <div class="msg-body"> | |
| <div class="msg-text"> | |
| Ψ£ΩΩΨ§Ω ΩΨ³ΩΩΨ§Ω β I'm Sebayhi, your Arabic grammar assistant.<br> | |
| Select a mode on the left, then type your message below. | |
| </div> | |
| </div> | |
| </div>`; | |
| 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 = ` | |
| <div class="msg-avatar">Ψ£</div> | |
| <div class="msg-body"> | |
| <div class="msg-text">${escapeHtml(text)}</div> | |
| </div>`; | |
| 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 = ` | |
| <div class="msg-avatar">Ψ³</div> | |
| <div class="msg-body"> | |
| <div class="intent-badge"><span class="badge-dot"></span>${badgeText}</div> | |
| <div class="msg-text">${escapeHtml(text)}</div> | |
| </div>`; | |
| 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 = `<div class="intent-badge"><span class="badge-dot"></span>${badgeText}</div>`; | |
| body.appendChild(bubble); | |
| const msg = document.createElement('div'); | |
| msg.className = 'message message--assistant'; | |
| msg.innerHTML = `<div class="msg-avatar">Ψ³</div>`; | |
| msg.appendChild(body); | |
| messageList.appendChild(msg); | |
| scrollToBottom(); | |
| return { textEl, cursorEl }; | |
| } | |
| function appendThinking() { | |
| const el = document.createElement('div'); | |
| el.className = 'message message--assistant'; | |
| el.innerHTML = ` | |
| <div class="msg-avatar">Ψ³</div> | |
| <div class="msg-body"> | |
| <div class="thinking"> | |
| <div class="thinking-dot"></div> | |
| <div class="thinking-dot"></div> | |
| <div class="thinking-dot"></div> | |
| </div> | |
| </div>`; | |
| messageList.appendChild(el); | |
| scrollToBottom(); | |
| return el; | |
| } | |
| function appendModeDivider(mode) { | |
| const el = document.createElement('div'); | |
| el.className = 'mode-divider'; | |
| el.innerHTML = ` | |
| <div class="mode-divider-line"></div> | |
| <span class="mode-divider-label">${MODE_LABELS[mode]}</span> | |
| <div class="mode-divider-line"></div>`; | |
| messageList.appendChild(el); | |
| } | |
| function appendErrorBubble(detail) { | |
| const el = document.createElement('div'); | |
| el.className = 'message message--assistant'; | |
| el.innerHTML = ` | |
| <div class="msg-avatar">Ψ³</div> | |
| <div class="msg-body"> | |
| <div class="msg-text msg-text--error"> | |
| β οΈ Something went wrong. Please try again.<br> | |
| <small style="opacity:0.5">${escapeHtml(detail)}</small> | |
| </div> | |
| </div>`; | |
| 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, '"').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(); | |
| }) | |
| ); |