Sebayhi_new / static /app.js
MM58-crypto
wooo another commit
1214561
Raw
History Blame Contribute Delete
16.8 kB
/**
* 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ═════════════════════════════════════════════════════════════════════════════
// 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();
})
);