Spaces:
Build error
Build error
Download app.js from Dhanrajtz5rt/Qwen3.5-0.8B-Chatbot: direct link, hf CLI and curl.
- Browser
- Download file 31.1 kB
-
https://huggingface.co/spaces/Dhanrajtz5rt/Qwen3.5-0.8B-Chatbot/resolve/main/app.js
- Command line
-
hf download hf://spaces/Dhanrajtz5rt/Qwen3.5-0.8B-Chatbot/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Dhanrajtz5rt/Qwen3.5-0.8B-Chatbot/resolve/main/app.js
31.1 kB
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 `<div class="code-wrapper"><pre><code class="lang-${lang}">${escaped}</code></pre><button class="copy-code-btn" onclick="copyCode(this)">Copy</button></div>`; | |
| }) | |
| // Inline code | |
| .replace(/`([^`]+)`/g, '<code>$1</code>') | |
| // Bold | |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') | |
| // Italic | |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| // Headers | |
| .replace(/^### (.+)$/gm, '<h3>$1</h3>') | |
| .replace(/^## (.+)$/gm, '<h2>$1</h2>') | |
| .replace(/^# (.+)$/gm, '<h1>$1</h1>') | |
| // Blockquotes | |
| .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>') | |
| // HR | |
| .replace(/^---$/gm, '<hr/>') | |
| // Unordered lists | |
| .replace(/^\s*[-*] (.+)$/gm, '<li>$1</li>') | |
| .replace(/(<li>.*<\/li>\n?)+/g, m => `<ul>${m}</ul>`) | |
| // Ordered lists | |
| .replace(/^\d+\. (.+)$/gm, '<li>$1</li>') | |
| // Links | |
| .replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>') | |
| // Paragraphs (double newline) | |
| .split(/\n\n+/) | |
| .map(p => p.startsWith('<') ? p : `<p>${p.replace(/\n/g, '<br>')}</p>`) | |
| .join('\n'); | |
| return html; | |
| } | |
| function escapeHtml(str) { | |
| return str | |
| .replace(/&/g, '&') | |
| .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 = `<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:13px;">No conversations yet</div>`; | |
| return; | |
| } | |
| filtered.forEach(chat => { | |
| const item = el('div', `chat-item${chat.id === state.activeChatId ? ' active' : ''}`); | |
| item.innerHTML = ` | |
| <div class="chat-item-dot"></div> | |
| <span class="chat-item-title">${escapeHtml(chat.title)}</span> | |
| <button class="chat-item-delete" title="Delete"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> | |
| </svg> | |
| </button> | |
| `; | |
| 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' | |
| ? `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:18px;height:18px"> | |
| <path d="M8 16 Q16 7 24 16 Q16 25 8 16Z" fill="currentColor" opacity="0.9"/> | |
| </svg>` | |
| : avatarChar; | |
| if (msg.role === 'user') { | |
| let mediaHtml = ''; | |
| if (msg.images && msg.images.length) { | |
| mediaHtml = msg.images.map(src => `<img src="${src}" alt="attachment" />`).join(''); | |
| mediaHtml = `<div class="message-media">${mediaHtml}</div>`; | |
| } | |
| if (msg.video) { | |
| mediaHtml += `<div class="message-media"><video src="${msg.video}" controls></video></div>`; | |
| } | |
| row.innerHTML = ` | |
| <div class="message-avatar">${avatarChar}</div> | |
| <div class="message-body"> | |
| ${mediaHtml} | |
| <div class="user-bubble">${escapeHtml(msg.content).replace(/\n/g, '<br>')}</div> | |
| </div> | |
| `; | |
| } else { | |
| // Assistant message | |
| const thinkingHtml = msg.thinking ? ` | |
| <div class="thinking-block" id="think-${Date.now()}"> | |
| <div class="thinking-header" onclick="this.parentElement.classList.toggle('collapsed')"> | |
| <div class="thinking-pulse"></div> | |
| <span class="thinking-label">Thinking Process</span> | |
| <div class="thinking-chevron"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <polyline points="6 9 12 15 18 9"/> | |
| </svg> | |
| </div> | |
| </div> | |
| <div class="thinking-content">${escapeHtml(msg.thinking)}</div> | |
| </div> | |
| ` : ''; | |
| const answerHtml = msg.content | |
| ? `<div class="assistant-text">${renderMarkdown(msg.content)}</div>` | |
| : ''; | |
| row.innerHTML = ` | |
| <div class="message-avatar">${avatarSvg}</div> | |
| <div class="message-body"> | |
| ${thinkingHtml} | |
| ${answerHtml} | |
| <div class="message-actions"> | |
| <button class="msg-action-btn" onclick="copyMessage(this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | |
| <rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> | |
| </svg> | |
| Copy | |
| </button> | |
| <button class="msg-action-btn" onclick="regenerateMessage(this)"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | |
| <polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/> | |
| </svg> | |
| Regenerate | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| 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 = `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:18px;height:18px"> | |
| <path d="M8 16 Q16 7 24 16 Q16 25 8 16Z" fill="currentColor" opacity="0.9"/> | |
| </svg>`; | |
| row.innerHTML = ` | |
| <div class="message-avatar">${avatarSvg}</div> | |
| <div class="message-body"> | |
| <div class="streaming-think-block" style="display:none"> | |
| <div class="thinking-block" id="stream-think"> | |
| <div class="thinking-header" onclick="this.parentElement.classList.toggle('collapsed')"> | |
| <div class="thinking-pulse"></div> | |
| <span class="thinking-label">Thinkingβ¦</span> | |
| <div class="thinking-chevron"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <polyline points="6 9 12 15 18 9"/> | |
| </svg> | |
| </div> | |
| </div> | |
| <div class="thinking-content" id="stream-think-content"></div> | |
| </div> | |
| </div> | |
| <div class="streaming-loading"><div class="loading-dots"><span></span><span></span><span></span></div></div> | |
| <div class="streaming-answer" id="stream-answer" style="display:none"> | |
| <div class="assistant-text" id="stream-answer-text"></div> | |
| <span class="cursor-blink" id="stream-cursor"></span> | |
| </div> | |
| </div> | |
| `; | |
| 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 = `<div class="error-msg">β οΈ ${escapeHtml(evt.content)}</div>`; | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| streamRow.querySelector('.message-body').innerHTML = `<div class="error-msg">β οΈ ${escapeHtml(err.message || 'Request failed')}</div>`; | |
| 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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>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 src="${img.dataUrl}" alt="img"/><button class="remove-attach" onclick="removeImage(${i})">β</button>`; | |
| dom.attachmentsPreview.appendChild(thumb); | |
| }); | |
| if (state.pendingVideo) { | |
| const thumb = el('div', 'attachment-thumb'); | |
| thumb.innerHTML = `<video src="${state.pendingVideo.dataUrl}"></video><button class="remove-attach" onclick="removeVideo()">β</button>`; | |
| 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 = ` | |
| <span class="model-dot"></span> | |
| ${info.model.split('/')[1]} Β· ${info.dtype.toUpperCase()}${info.multimodal ? ' Β· π' : ''} | |
| `; | |
| }) | |
| .catch(() => {}); | |
| })(); | |