/* ═══════════════════════════════════════════════════════ QWEN AI CHATBOT — Frontend Logic Features: Streaming SSE, Thinking blocks, Image/Video, Context window, System prompt, Multi-chat sessions ═══════════════════════════════════════════════════════ */ // ── State ───────────────────────────────────────────────────────────────────── const state = { chats: JSON.parse(localStorage.getItem('qwen_chats') || '[]'), activeChatId: null, isGenerating: false, settings: { systemPrompt: localStorage.getItem('qwen_system') || 'You are a helpful, harmless, and honest AI assistant. You are powered by Qwen3.5-0.8B and excel at reasoning, coding, writing, and analysis.', contextTokens: parseInt(localStorage.getItem('qwen_ctx') || '8192'), thinking: localStorage.getItem('qwen_thinking') !== 'false', temperature: parseFloat(localStorage.getItem('qwen_temp') || '0.6'), maxNewTokens: parseInt(localStorage.getItem('qwen_maxtok') || '2048'), }, pendingImages: [], // { file, dataUrl } pendingVideo: null, // { file, dataUrl } }; // ── DOM Refs ────────────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); const el = (tag, cls, html='') => { const e = document.createElement(tag); if (cls) e.className = cls; if (html) e.innerHTML = html; return e; }; const dom = { sidebar: $('sidebar'), sidebarToggle: $('sidebarToggle'), chatList: $('chatList'), newChatBtn: $('newChatBtn'), searchInput: $('searchInput'), messagesContainer: $('messagesContainer'), messagesList: $('messagesList'), welcomeScreen: $('welcomeScreen'), chatTitleDisplay: $('chatTitleDisplay'), messageInput: $('messageInput'), sendBtn: $('sendBtn'), attachBtn: $('attachBtn'), attachMenu: $('attachMenu'), attachImageBtn: $('attachImageBtn'), attachVideoBtn: $('attachVideoBtn'), imageInput: $('imageInput'), videoInput: $('videoInput'), attachmentsPreview: $('attachmentsPreview'), contextChip: $('contextChip'), contextLabel: $('contextLabel'), contextQuickPanel: $('contextQuickPanel'), thinkingToggleBtn: $('thinkingToggleBtn'), clearChatBtn: $('clearChatBtn'), settingsTrigger: $('settingsTrigger'), settingsOverlay: $('settingsOverlay'), settingsPanel: $('settingsPanel'), closeSettings: $('closeSettings'), systemPromptInput: $('systemPromptInput'), contextSlider: $('contextSlider'), contextValueDisplay: $('contextValueDisplay'), tempSlider: $('tempSlider'), tempValueDisplay: $('tempValueDisplay'), maxTokensSlider: $('maxTokensSlider'), maxTokensDisplay: $('maxTokensDisplay'), thinkingModeToggle: $('thinkingModeToggle'), applySettings: $('applySettings'), }; // ── Markdown-lite renderer ──────────────────────────────────────────────────── function renderMarkdown(text) { let html = text // Code blocks (must come before inline code) .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { const escaped = escapeHtml(code.trim()); return `
${escaped}
`; }) // Inline code .replace(/`([^`]+)`/g, '$1') // Bold .replace(/\*\*(.+?)\*\*/g, '$1') // Italic .replace(/\*(.+?)\*/g, '$1') // Headers .replace(/^### (.+)$/gm, '

$1

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

$1

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

$1

') // Blockquotes .replace(/^> (.+)$/gm, '
$1
') // HR .replace(/^---$/gm, '
') // Unordered lists .replace(/^\s*[-*] (.+)$/gm, '
  • $1
  • ') .replace(/(
  • .*<\/li>\n?)+/g, m => ``) // Ordered lists .replace(/^\d+\. (.+)$/gm, '
  • $1
  • ') // Links .replace(/\[(.+?)\]\((.+?)\)/g, '$1') // Paragraphs (double newline) .split(/\n\n+/) .map(p => p.startsWith('<') ? p : `

    ${p.replace(/\n/g, '
    ')}

    `) .join('\n'); return html; } function escapeHtml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } window.copyCode = (btn) => { const code = btn.previousElementSibling.querySelector('code').textContent; navigator.clipboard.writeText(code).then(() => { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); }); }; // ── Context formatting ──────────────────────────────────────────────────────── function formatCtx(n) { if (n >= 1024) return `${Math.round(n / 1024)}K`; return `${n}`; } function formatCtxFull(n) { return `${n.toLocaleString()} tokens`; } // ── Chat management ─────────────────────────────────────────────────────────── function createChat(title = 'New Conversation') { const chat = { id: Date.now().toString(), title, messages: [], createdAt: Date.now(), }; state.chats.unshift(chat); saveChats(); return chat; } function saveChats() { localStorage.setItem('qwen_chats', JSON.stringify(state.chats.slice(0, 50))); } function getActiveChat() { return state.chats.find(c => c.id === state.activeChatId); } function switchChat(chatId) { state.activeChatId = chatId; renderChatList(); renderMessages(); } function deleteChat(chatId) { state.chats = state.chats.filter(c => c.id !== chatId); saveChats(); if (state.activeChatId === chatId) { if (state.chats.length > 0) { switchChat(state.chats[0].id); } else { state.activeChatId = null; renderMessages(); } } renderChatList(); } // ── Render sidebar chat list ────────────────────────────────────────────────── function renderChatList() { const q = dom.searchInput.value.toLowerCase(); const filtered = state.chats.filter(c => c.title.toLowerCase().includes(q)); dom.chatList.innerHTML = ''; if (filtered.length === 0) { dom.chatList.innerHTML = `
    No conversations yet
    `; return; } filtered.forEach(chat => { const item = el('div', `chat-item${chat.id === state.activeChatId ? ' active' : ''}`); item.innerHTML = `
    ${escapeHtml(chat.title)} `; item.querySelector('.chat-item-title').onclick = () => switchChat(chat.id); item.querySelector('.chat-item-dot').onclick = () => switchChat(chat.id); item.querySelector('.chat-item-delete').onclick = (e) => { e.stopPropagation(); deleteChat(chat.id); }; dom.chatList.appendChild(item); }); } // ── Render messages ─────────────────────────────────────────────────────────── function renderMessages() { const chat = getActiveChat(); dom.welcomeScreen.style.display = (!chat || chat.messages.length === 0) ? 'flex' : 'none'; dom.messagesList.innerHTML = ''; dom.chatTitleDisplay.textContent = chat ? chat.title : 'New Conversation'; if (!chat) return; chat.messages.forEach(msg => { appendMessage(msg, false); }); scrollToBottom(); } function appendMessage(msg, animate = true) { dom.welcomeScreen.style.display = 'none'; const row = el('div', `message-row ${msg.role}`); const avatarChar = msg.role === 'user' ? 'U' : 'Q'; const avatarSvg = msg.role === 'assistant' ? ` ` : avatarChar; if (msg.role === 'user') { let mediaHtml = ''; if (msg.images && msg.images.length) { mediaHtml = msg.images.map(src => `attachment`).join(''); mediaHtml = `
    ${mediaHtml}
    `; } if (msg.video) { mediaHtml += `
    `; } row.innerHTML = `
    ${avatarChar}
    ${mediaHtml}
    ${escapeHtml(msg.content).replace(/\n/g, '
    ')}
    `; } else { // Assistant message const thinkingHtml = msg.thinking ? `
    Thinking Process
    ${escapeHtml(msg.thinking)}
    ` : ''; const answerHtml = msg.content ? `
    ${renderMarkdown(msg.content)}
    ` : ''; row.innerHTML = `
    ${avatarSvg}
    ${thinkingHtml} ${answerHtml}
    `; } if (!animate) row.style.animation = 'none'; dom.messagesList.appendChild(row); return row; } // ── Streaming message builder ───────────────────────────────────────────────── function createStreamingRow() { dom.welcomeScreen.style.display = 'none'; const row = el('div', 'message-row assistant'); const avatarSvg = ` `; row.innerHTML = `
    ${avatarSvg}
    `; dom.messagesList.appendChild(row); scrollToBottom(); return row; } // ── Send message ────────────────────────────────────────────────────────────── async function sendMessage() { const text = dom.messageInput.value.trim(); if (!text || state.isGenerating) return; // Get or create chat if (!state.activeChatId) { const chat = createChat(text.slice(0, 40) + (text.length > 40 ? '…' : '')); state.activeChatId = chat.id; renderChatList(); } const chat = getActiveChat(); // Build user message const userMsg = { role: 'user', content: text, images: state.pendingImages.map(i => i.dataUrl), video: state.pendingVideo?.dataUrl || null, }; chat.messages.push(userMsg); if (chat.messages.length === 1) { chat.title = text.slice(0, 40) + (text.length > 40 ? '…' : ''); } saveChats(); renderChatList(); appendMessage(userMsg); // Clear input & attachments dom.messageInput.value = ''; dom.messageInput.style.height = 'auto'; dom.sendBtn.disabled = true; const images = [...state.pendingImages]; const video = state.pendingVideo; state.pendingImages = []; state.pendingVideo = null; dom.attachmentsPreview.innerHTML = ''; // Create streaming row state.isGenerating = true; const streamRow = createStreamingRow(); const loadingEl = streamRow.querySelector('.streaming-loading'); const thinkBlock = streamRow.querySelector('.streaming-think-block'); const thinkContent = streamRow.querySelector('#stream-think-content'); const answerWrap = streamRow.querySelector('.streaming-answer'); const answerText = streamRow.querySelector('#stream-answer-text'); const cursor = streamRow.querySelector('#stream-cursor'); scrollToBottom(); let thinkingText = ''; let answerRawText = ''; let thinkingDone = false; try { // Build FormData const formData = new FormData(); // History (exclude images/video from history for model — too heavy) const historyForModel = chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content, })); historyForModel.push({ role: 'user', content: text }); formData.append('messages', JSON.stringify(historyForModel)); formData.append('system_prompt', state.settings.systemPrompt); formData.append('max_context_tokens', state.settings.contextTokens); formData.append('enable_thinking', state.settings.thinking); formData.append('temperature', state.settings.temperature); formData.append('top_p', '0.95'); formData.append('top_k', '20'); formData.append('max_new_tokens', state.settings.maxNewTokens); // Attach images for (const img of images) { formData.append('images', img.file); } // Attach video if (video) { formData.append('video', video.file); } const resp = await fetch('/api/chat/stream', { method: 'POST', body: formData, }); if (!resp.ok) throw new Error(`Server error: ${resp.status}`); const reader = resp.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'); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; const jsonStr = line.slice(6).trim(); if (!jsonStr) continue; let evt; try { evt = JSON.parse(jsonStr); } catch { continue; } if (evt.type === 'thinking_start') { loadingEl.style.display = 'none'; thinkBlock.style.display = 'block'; } else if (evt.type === 'thinking_chunk') { thinkingText += evt.content; thinkContent.textContent = thinkingText; scrollToBottom(); } else if (evt.type === 'thinking_end') { thinkingDone = true; const label = streamRow.querySelector('.thinking-label'); if (label) label.textContent = 'Thinking Process'; const pulse = streamRow.querySelector('.thinking-pulse'); if (pulse) pulse.style.animation = 'none'; } else if (evt.type === 'answer_chunk') { loadingEl.style.display = 'none'; answerWrap.style.display = 'block'; answerRawText += evt.content; answerText.innerHTML = renderMarkdown(answerRawText); scrollToBottom(); } else if (evt.type === 'done') { cursor.style.display = 'none'; break; } else if (evt.type === 'error') { streamRow.querySelector('.message-body').innerHTML = `
    ⚠️ ${escapeHtml(evt.content)}
    `; } } } } catch (err) { streamRow.querySelector('.message-body').innerHTML = `
    ⚠️ ${escapeHtml(err.message || 'Request failed')}
    `; answerRawText = '[Error: ' + err.message + ']'; } // Save assistant message to state chat.messages.push({ role: 'assistant', thinking: thinkingText || null, content: answerRawText, }); saveChats(); // Replace streaming row with final rendered message streamRow.remove(); appendMessage(chat.messages[chat.messages.length - 1]); state.isGenerating = false; updateSendBtn(); scrollToBottom(); } // ── Copy & Regenerate ───────────────────────────────────────────────────────── window.copyMessage = (btn) => { const text = btn.closest('.message-body').querySelector('.assistant-text')?.innerText || ''; navigator.clipboard.writeText(text).then(() => { btn.textContent = 'Copied!'; setTimeout(() => { btn.innerHTML = `Copy`; }, 2000); }); }; window.regenerateMessage = (btn) => { const chat = getActiveChat(); if (!chat) return; // Remove last assistant message, re-trigger const lastAssistant = [...chat.messages].reverse().findIndex(m => m.role === 'assistant'); if (lastAssistant !== -1) { const idx = chat.messages.length - 1 - lastAssistant; chat.messages.splice(idx, 1); saveChats(); // Find last user message and resend const lastUser = [...chat.messages].reverse().find(m => m.role === 'user'); if (lastUser) { dom.messageInput.value = lastUser.content; renderMessages(); // Auto-remove last user msg from chat for resend chat.messages.splice(chat.messages.lastIndexOf(lastUser), 1); saveChats(); sendMessage(); } } }; // ── Scroll ──────────────────────────────────────────────────────────────────── function scrollToBottom() { requestAnimationFrame(() => { dom.messagesContainer.scrollTop = dom.messagesContainer.scrollHeight; }); } // ── Send button state ───────────────────────────────────────────────────────── function updateSendBtn() { const hasText = dom.messageInput.value.trim().length > 0; const hasMedia = state.pendingImages.length > 0 || !!state.pendingVideo; dom.sendBtn.disabled = !(hasText || hasMedia) || state.isGenerating; } // ── Attachment handling ─────────────────────────────────────────────────────── function addImageAttachment(file) { const reader = new FileReader(); reader.onload = (e) => { const dataUrl = e.target.result; state.pendingImages.push({ file, dataUrl }); renderAttachmentPreviews(); updateSendBtn(); }; reader.readAsDataURL(file); } function addVideoAttachment(file) { const reader = new FileReader(); reader.onload = (e) => { const dataUrl = e.target.result; state.pendingVideo = { file, dataUrl }; renderAttachmentPreviews(); updateSendBtn(); }; reader.readAsDataURL(file); } function renderAttachmentPreviews() { dom.attachmentsPreview.innerHTML = ''; state.pendingImages.forEach((img, i) => { const thumb = el('div', 'attachment-thumb'); thumb.innerHTML = `img`; dom.attachmentsPreview.appendChild(thumb); }); if (state.pendingVideo) { const thumb = el('div', 'attachment-thumb'); thumb.innerHTML = ``; dom.attachmentsPreview.appendChild(thumb); } } window.removeImage = (i) => { state.pendingImages.splice(i, 1); renderAttachmentPreviews(); updateSendBtn(); }; window.removeVideo = () => { state.pendingVideo = null; renderAttachmentPreviews(); updateSendBtn(); }; // ── Context quick panel ─────────────────────────────────────────────────────── function updateContextDisplay() { dom.contextLabel.textContent = formatCtx(state.settings.contextTokens); dom.contextValueDisplay.textContent = formatCtxFull(state.settings.contextTokens); dom.contextSlider.value = state.settings.contextTokens; } // ── Settings panel ──────────────────────────────────────────────────────────── function openSettings() { dom.settingsPanel.classList.add('open'); dom.settingsOverlay.classList.add('open'); dom.systemPromptInput.value = state.settings.systemPrompt; dom.contextSlider.value = state.settings.contextTokens; dom.contextValueDisplay.textContent = formatCtxFull(state.settings.contextTokens); dom.tempSlider.value = state.settings.temperature; dom.tempValueDisplay.textContent = state.settings.temperature.toFixed(2); dom.maxTokensSlider.value = state.settings.maxNewTokens; dom.maxTokensDisplay.textContent = state.settings.maxNewTokens; dom.thinkingModeToggle.checked = state.settings.thinking; } function closeSettings() { dom.settingsPanel.classList.remove('open'); dom.settingsOverlay.classList.remove('open'); } // ── Event Listeners ─────────────────────────────────────────────────────────── // Send dom.sendBtn.addEventListener('click', sendMessage); dom.messageInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); // Auto-resize textarea dom.messageInput.addEventListener('input', () => { dom.messageInput.style.height = 'auto'; dom.messageInput.style.height = Math.min(dom.messageInput.scrollHeight, 200) + 'px'; updateSendBtn(); }); // Sidebar toggle dom.sidebarToggle.addEventListener('click', () => { dom.sidebar.classList.toggle('collapsed'); if (window.innerWidth <= 768) { dom.sidebar.classList.toggle('mobile-open'); } }); // New chat dom.newChatBtn.addEventListener('click', () => { state.activeChatId = null; renderMessages(); renderChatList(); }); // Search dom.searchInput.addEventListener('input', renderChatList); // Attach menu dom.attachBtn.addEventListener('click', (e) => { e.stopPropagation(); dom.attachMenu.classList.toggle('open'); }); dom.attachImageBtn.addEventListener('click', () => { dom.imageInput.click(); dom.attachMenu.classList.remove('open'); }); dom.attachVideoBtn.addEventListener('click', () => { dom.videoInput.click(); dom.attachMenu.classList.remove('open'); }); dom.imageInput.addEventListener('change', e => { Array.from(e.target.files).forEach(addImageAttachment); e.target.value = ''; }); dom.videoInput.addEventListener('change', e => { if (e.target.files[0]) addVideoAttachment(e.target.files[0]); e.target.value = ''; }); // Close attach menu on outside click document.addEventListener('click', (e) => { if (!dom.attachBtn.contains(e.target)) dom.attachMenu.classList.remove('open'); if (!dom.contextChip.contains(e.target) && !dom.contextQuickPanel.contains(e.target)) { dom.contextQuickPanel.classList.remove('open'); } }); // Context quick panel dom.contextChip.addEventListener('click', (e) => { e.stopPropagation(); const rect = dom.contextChip.getBoundingClientRect(); const panel = dom.contextQuickPanel; panel.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; panel.style.left = rect.left + 'px'; panel.classList.toggle('open'); }); document.querySelectorAll('.cqp-preset').forEach(btn => { btn.addEventListener('click', () => { const val = parseInt(btn.dataset.val); state.settings.contextTokens = val; localStorage.setItem('qwen_ctx', val); updateContextDisplay(); document.querySelectorAll('.cqp-preset').forEach(b => b.classList.remove('active')); btn.classList.add('active'); dom.contextQuickPanel.classList.remove('open'); }); }); // Thinking toggle button (top bar) dom.thinkingToggleBtn.addEventListener('click', () => { state.settings.thinking = !state.settings.thinking; localStorage.setItem('qwen_thinking', state.settings.thinking); dom.thinkingToggleBtn.classList.toggle('active', state.settings.thinking); dom.thinkingModeToggle.checked = state.settings.thinking; }); // Clear chat dom.clearChatBtn.addEventListener('click', () => { const chat = getActiveChat(); if (chat) { chat.messages = []; saveChats(); renderMessages(); } }); // Settings panel dom.settingsTrigger.addEventListener('click', openSettings); dom.closeSettings.addEventListener('click', closeSettings); dom.settingsOverlay.addEventListener('click', closeSettings); // Settings sliders dom.contextSlider.addEventListener('input', () => { dom.contextValueDisplay.textContent = formatCtxFull(parseInt(dom.contextSlider.value)); }); dom.tempSlider.addEventListener('input', () => { dom.tempValueDisplay.textContent = parseFloat(dom.tempSlider.value).toFixed(2); }); dom.maxTokensSlider.addEventListener('input', () => { dom.maxTokensDisplay.textContent = dom.maxTokensSlider.value; }); // Apply settings dom.applySettings.addEventListener('click', () => { state.settings.systemPrompt = dom.systemPromptInput.value; state.settings.contextTokens = parseInt(dom.contextSlider.value); state.settings.temperature = parseFloat(dom.tempSlider.value); state.settings.maxNewTokens = parseInt(dom.maxTokensSlider.value); state.settings.thinking = dom.thinkingModeToggle.checked; localStorage.setItem('qwen_system', state.settings.systemPrompt); localStorage.setItem('qwen_ctx', state.settings.contextTokens); localStorage.setItem('qwen_temp', state.settings.temperature); localStorage.setItem('qwen_maxtok', state.settings.maxNewTokens); localStorage.setItem('qwen_thinking', state.settings.thinking); updateContextDisplay(); dom.thinkingToggleBtn.classList.toggle('active', state.settings.thinking); closeSettings(); showToast('Settings applied ✓'); }); // Suggestion chips document.querySelectorAll('.chip').forEach(chip => { chip.addEventListener('click', () => { dom.messageInput.value = chip.dataset.prompt; updateSendBtn(); dom.messageInput.focus(); }); }); // ── Toast ───────────────────────────────────────────────────────────────────── function showToast(msg) { const toast = el('div', '', msg); Object.assign(toast.style, { position: 'fixed', bottom: '80px', left: '50%', transform: 'translateX(-50%)', background: 'var(--bg-elevated)', border: '1px solid var(--accent)', color: 'var(--accent)', padding: '8px 18px', borderRadius: 'var(--radius-md)', fontSize: '13px', fontFamily: 'var(--font-ui)', zIndex: '999', animation: 'fadeUp 0.3s ease', pointerEvents: 'none', }); document.body.appendChild(toast); setTimeout(() => toast.remove(), 2000); } // ── Drag & drop ─────────────────────────────────────────────────────────────── document.addEventListener('dragover', e => e.preventDefault()); document.addEventListener('drop', e => { e.preventDefault(); Array.from(e.dataTransfer.files).forEach(file => { if (file.type.startsWith('image/')) addImageAttachment(file); else if (file.type.startsWith('video/')) addVideoAttachment(file); }); }); // ── Init ────────────────────────────────────────────────────────────────────── (function init() { // Init UI from settings dom.thinkingToggleBtn.classList.toggle('active', state.settings.thinking); updateContextDisplay(); // Load most recent chat or show welcome if (state.chats.length > 0) { state.activeChatId = state.chats[0].id; } renderChatList(); renderMessages(); // Fetch model info fetch('/api/info') .then(r => r.json()) .then(info => { document.querySelector('.model-badge').innerHTML = ` ${info.model.split('/')[1]} · ${info.dtype.toUpperCase()}${info.multimodal ? ' · 👁' : ''} `; }) .catch(() => {}); })();