Dhanrajtz5rt's picture
Upload app.js
267ea86 verified
Raw History Blame Contribute Delete
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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(() => {});
})();