// ============================================================ // HBB Chat — script.js (полный, с фиксом Markdown) // ============================================================ // ============ СОСТОЯНИЕ ============ let chats = []; let currentChatId = null; let isStreaming = false; let abortController = null; let attachedFiles = []; let currentChatAtStart = null; let searchQuery = ''; const DEFAULTS = { backend: 'llamacpp', apiUrl: 'http://localhost:8080', userName: 'Вы', assistantName: 'Ассистент', userAvatar: 'user', assistantAvatar: 'assistant', systemPrompt: 'Ты — полезный ассистент. Отвечай кратко и по делу.', temperature: 0.8, top_p: 0.9, top_k: 40, repeat_penalty: 1.1, num_predict: 512, num_ctx: 4096, imgMinTokens: 256, imgMaxSide: 1280, imgQuality: 0.85, theme: 'dark', persona: 'none', suggestEnabled: true, suggestModel: 'gemma-3-1b-it', suggestApiUrl: 'http://localhost:8080', suggestCount: 3 }; const PERSONAS = { none: '', enthusiastic: 'Ты — восторженный и энергичный помощник. Используй яркие выражения, эмодзи и подбадривай пользователя. ', coder: 'Ты — опытный программист. Отвечай технически точно, приводи примеры кода, указывай на подводные камни. ', explainer: 'Ты — терпеливый объясняющий помощник. Разжёвывай каждую деталь, приводи аналогии, не предполагай, что пользователь знает контекст. ', sarcastic: 'Ты — саркастичный помощник. Отвечай с лёгкой иронией, но по делу. Не переходи границы грубости. ', teacher: 'Ты — терпеливый учитель. Задавай наводящие вопросы, помогай пользователю дойти до ответа самому. ' }; const $ = id => document.getElementById(id); const chatInner = $('chat-inner'); const chatArea = $('chat-area'); const welcome = $('welcome'); const lightbox = $('lightbox'); const lightboxImg = $('lightbox-img'); // ============ ИКОНКИ ============ const ICON_PATH = 'icons/'; function icon(name, alt = '') { const img = document.createElement('img'); img.src = ICON_PATH + name + '.png'; img.alt = alt; img.className = 'icon'; img.draggable = false; return img; } // ============ MARKDOWN ============ function normalizeMarkdown(text) { return text.replace(/^(#{1,6})([^\s#])/gm, '$1 $2'); } function renderMarkdown(text) { if (typeof marked === 'undefined') return text; return DOMPurify.sanitize(marked.parse(normalizeMarkdown(text))); } function enhanceCodeBlocks(container) { container.querySelectorAll('pre').forEach(pre => { if (pre.parentElement.classList.contains('code-block-wrapper')) return; const code = pre.querySelector('code'); if (!code) return; const lang = [...code.classList].find(c => c.startsWith('language-'))?.replace('language-', '') || 'code'; const wrapper = document.createElement('div'); wrapper.className = 'code-block-wrapper'; const header = document.createElement('div'); header.className = 'code-header'; const langSpan = document.createElement('span'); langSpan.className = 'lang'; langSpan.textContent = lang; header.appendChild(langSpan); const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.appendChild(icon('copy', 'Копировать')); copyBtn.appendChild(document.createTextNode(' Копировать')); copyBtn.onclick = () => { navigator.clipboard.writeText(code.textContent).then(() => { copyBtn.innerHTML = ''; copyBtn.appendChild(icon('check', 'Скопировано')); copyBtn.appendChild(document.createTextNode(' Скопировано')); copyBtn.classList.add('copied'); setTimeout(() => { copyBtn.innerHTML = ''; copyBtn.appendChild(icon('copy', 'Копировать')); copyBtn.appendChild(document.createTextNode(' Копировать')); copyBtn.classList.remove('copied'); }, 1500); }); }; header.appendChild(copyBtn); pre.parentNode.insertBefore(wrapper, pre); wrapper.appendChild(header); wrapper.appendChild(pre); if (window.hljs) hljs.highlightElement(code); }); } // ============ ТЕМА ============ function currentTheme() { return document.documentElement.getAttribute('data-theme') || 'dark'; } function toggleTheme() { const newTheme = currentTheme() === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem('hbb-theme', newTheme); const themeBtn = document.querySelector('.theme-btn'); if (themeBtn) { themeBtn.innerHTML = ''; themeBtn.appendChild(icon(newTheme === 'dark' ? 'theme-dark' : 'theme-light', 'Тема')); } const hljsTheme = $('hljs-theme'); if (hljsTheme) { hljsTheme.href = newTheme === 'dark' ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css' : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-light.min.css'; } } function loadTheme() { const saved = localStorage.getItem('hbb-theme') || 'dark'; document.documentElement.setAttribute('data-theme', saved); } // ============ НАСТРОЙКИ ============ function loadSettings() { const saved = JSON.parse(localStorage.getItem('hbb-settings') || '{}'); const s = { ...DEFAULTS, ...saved }; $('backend-select').value = s.backend; $('api-url').value = s.apiUrl; $('user-name').value = s.userName; $('assistant-name').value = s.assistantName; $('user-avatar').value = s.userAvatar; $('assistant-avatar').value = s.assistantAvatar; $('system-prompt').value = s.systemPrompt; $('temp').value = s.temperature; $('topp').value = s.top_p; $('topk').value = s.top_k; $('reppen').value = s.repeat_penalty; $('numpredict').value = s.num_predict; $('numctx').value = s.num_ctx; $('img-min-tokens').value = s.imgMinTokens; $('img-max-side').value = s.imgMaxSide; $('img-quality').value = s.imgQuality; if ($('persona-select')) $('persona-select').value = s.persona || 'none'; if ($('suggest-enabled')) $('suggest-enabled').checked = s.suggestEnabled !== false; if ($('suggest-model')) $('suggest-model').value = s.suggestModel || 'gemma-3-1b-it'; if ($('suggest-api-url')) $('suggest-api-url').value = s.suggestApiUrl || s.apiUrl || 'http://localhost:8080'; if ($('suggest-count')) $('suggest-count').value = s.suggestCount || 3; if ($('suggest-count-val')) $('suggest-count-val').textContent = $('suggest-count').value; updateSliderLabels(); } function saveSettings() { const s = { backend: $('backend-select').value, apiUrl: $('api-url').value.replace(/\/$/, ''), userName: $('user-name').value.trim() || 'Вы', assistantName: $('assistant-name').value.trim() || 'Ассистент', userAvatar: $('user-avatar').value.trim() || 'user', assistantAvatar: $('assistant-avatar').value.trim() || 'assistant', systemPrompt: $('system-prompt').value, temperature: parseFloat($('temp').value), top_p: parseFloat($('topp').value), top_k: parseInt($('topk').value), repeat_penalty: parseFloat($('reppen').value), num_predict: parseInt($('numpredict').value), num_ctx: parseInt($('numctx').value), imgMinTokens: parseInt($('img-min-tokens').value) || 256, imgMaxSide: parseInt($('img-max-side').value) || 1280, imgQuality: parseFloat($('img-quality').value) || 0.85, theme: currentTheme(), persona: $('persona-select') ? $('persona-select').value : 'none', suggestEnabled: $('suggest-enabled') ? $('suggest-enabled').checked : true, suggestModel: $('suggest-model') ? $('suggest-model').value.trim() : 'gemma-3-1b-it', suggestApiUrl: $('suggest-api-url') ? $('suggest-api-url').value.replace(/\/$/, '') : '', suggestCount: $('suggest-count') ? parseInt($('suggest-count').value) : 3 }; localStorage.setItem('hbb-settings', JSON.stringify(s)); closeSettings(); renderCurrentChat(); checkConnection(); } function resetSettings() { if (!confirm('Сбросить все настройки?')) return; localStorage.removeItem('hbb-settings'); loadSettings(); renderCurrentChat(); closeSettings(); } function updateSliderLabels() { const map = { 'temp-val': 'temp', 'topp-val': 'topp', 'topk-val': 'topk', 'rep-val': 'reppen', 'np-val': 'numpredict', 'ctx-val': 'numctx', 'img-q-val': 'img-quality' }; for (const [labelId, inputId] of Object.entries(map)) { const label = $(labelId), input = $(inputId); if (label && input) label.textContent = input.value; } } ['temp','topp','topk','reppen','numpredict','numctx','img-quality'].forEach(id => { const el = $(id); if (el) el.addEventListener('input', updateSliderLabels); }); const suggestCountEl = $('suggest-count'); if (suggestCountEl) { suggestCountEl.addEventListener('input', () => { if ($('suggest-count-val')) $('suggest-count-val').textContent = suggestCountEl.value; }); } function openSettings() { $('settings-overlay').classList.add('visible'); } function closeSettings() { $('settings-overlay').classList.remove('visible'); } function getSettings() { return JSON.parse(localStorage.getItem('hbb-settings') || '{}'); } function getPersonaPrefix() { const s = getSettings(); return PERSONAS[s.persona || 'none'] || ''; } // ============ API ============ async function getApiUrl() { return $('api-url').value.replace(/\/$/, ''); } async function getBackend() { return $('backend-select').value; } async function checkConnection() { const dot = $('status-dot'), txt = $('status-text'); if (!dot || !txt) return; try { const url = await getApiUrl(); const backend = await getBackend(); let endpoint = backend === 'ollama' ? `${url}/api/tags` : `${url}/v1/models`; const res = await fetch(endpoint); if (!res.ok) throw new Error(`HTTP ${res.status}`); dot.classList.add('online'); txt.textContent = backend === 'ollama' ? 'Ollama подключён' : 'llama.cpp подключён'; } catch (e) { dot.classList.remove('online'); txt.textContent = 'Нет соединения'; } } // ============ ФАЙЛЫ ============ function fileToDataUrl(file) { return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); }); } function fileToText(file) { return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsText(file); }); } function loadImage(src) { return new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = rej; i.src = src; }); } async function processImage(file) { const maxSide = parseInt($('img-max-side').value) || 1280; const quality = parseFloat($('img-quality').value) || 0.85; const dataUrl = await fileToDataUrl(file); const img = await loadImage(dataUrl); let { width, height } = img; const longSide = Math.max(width, height); let needResize = longSide > maxSide; const isPng = file.type === 'image/png'; if (!needResize && isPng && file.size < 900_000) return { dataUrl, mime: file.type, name: file.name, size: file.size, isImage: true }; if (needResize) { const k = maxSide / longSide; width = Math.round(width * k); height = Math.round(height * k); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; canvas.getContext('2d').drawImage(img, 0, 0, width, height); const outMime = isPng ? 'image/png' : 'image/jpeg'; const outUrl = isPng ? canvas.toDataURL('image/png') : canvas.toDataURL('image/jpeg', quality); const approxSize = Math.round((outUrl.length - outUrl.indexOf(',') - 1) * 3 / 4); return { dataUrl: outUrl, mime: outMime, name: file.name, size: approxSize, isImage: true }; } async function processFile(file) { if (file.type.startsWith('image/')) return await processImage(file); const textExts = ['.txt','.md','.json','.js','.py','.html','.css','.csv','.xml','.yaml','.yml','.log','.sh','.bat','.c','.cpp','.h','.java','.rs','.go','.ts']; const isText = file.type.startsWith('text/') || textExts.some(e => file.name.toLowerCase().endsWith(e)); if (isText) { const text = await fileToText(file); return { dataUrl: null, mime: file.type || 'text/plain', name: file.name, size: file.size, isImage: false, textContent: text }; } return { dataUrl: null, mime: file.type || 'application/octet-stream', name: file.name, size: file.size, isImage: false, textContent: null }; } async function addFiles(files) { for (const f of files) { try { attachedFiles.push(await processFile(f)); } catch (e) { console.error(e); } } renderAttachments(); } function renderAttachments() { const el = $('attachments-preview'); if (!el) return; el.innerHTML = ''; if (attachedFiles.length === 0) { el.classList.remove('visible'); $('attach-btn').classList.remove('has-files'); return; } el.classList.add('visible'); $('attach-btn').classList.add('has-files'); attachedFiles.forEach((f, idx) => { const div = document.createElement('div'); div.className = 'att-preview-item' + (f.isImage ? '' : ' file'); if (f.isImage) { const img = document.createElement('img'); img.src = f.dataUrl; div.appendChild(img); } else { const ic = document.createElement('div'); ic.className = 'icon'; ic.textContent = '📄'; div.appendChild(ic); } const rm = document.createElement('button'); rm.className = 'remove'; rm.textContent = '×'; rm.onclick = () => { attachedFiles.splice(idx, 1); renderAttachments(); }; div.appendChild(rm); el.appendChild(div); }); } function openLightbox(src) { lightboxImg.src = src; lightbox.classList.add('visible'); } function closeLightbox() { lightbox.classList.remove('visible'); lightboxImg.src = ''; } // ============ СИСТЕМА ЧАТОВ ============ function loadChats() { chats = JSON.parse(localStorage.getItem('hbb-chats') || '[]'); chats = chats.filter(c => c.messages && c.messages.length > 0); if (chats.length === 0) createNewChat(true); else { currentChatId = chats[0].id; renderChatList(); renderCurrentChat(); } } function saveChats() { try { localStorage.setItem('hbb-chats', JSON.stringify(chats)); } catch (e) { console.warn('Не удалось сохранить чаты:', e.message); } } function createNewChat(silent) { abortStreaming(); const chat = { id: 'chat_' + Date.now(), title: 'Новый чат', messages: [], createdAt: Date.now() }; chats.unshift(chat); currentChatId = chat.id; saveChats(); renderChatList(); renderCurrentChat(); if (!silent && $('user-input')) $('user-input').focus(); } function switchChat(id) { abortStreaming(); currentChatId = id; renderChatList(); renderCurrentChat(); } function deleteChat(id, e) { e.stopPropagation(); if (!confirm('Удалить этот чат?')) return; chats = chats.filter(c => c.id !== id); if (currentChatId === id) { if (chats.length === 0) createNewChat(true); else { currentChatId = chats[0].id; renderCurrentChat(); } } saveChats(); renderChatList(); } function renameChat(id) { const chat = chats.find(c => c.id === id); if (!chat) return; const newTitle = prompt('Новое название:', chat.title); if (newTitle && newTitle.trim()) { chat.title = newTitle.trim(); saveChats(); renderChatList(); if (currentChatId === id) $('current-title').textContent = chat.title; } } function renameCurrentChat() { if (currentChatId) renameChat(currentChatId); } function getCurrentChat() { return chats.find(c => c.id === currentChatId); } function renderChatList() { const el = $('chat-list'); if (!el) return; el.innerHTML = ''; const q = searchQuery.toLowerCase(); chats.filter(c => !q || c.title.toLowerCase().includes(q)).forEach(c => { const div = document.createElement('div'); div.className = 'chat-item' + (c.id === currentChatId ? ' active' : ''); div.onclick = () => switchChat(c.id); const title = document.createElement('span'); title.className = 'title'; title.textContent = c.title; title.ondblclick = (e) => { e.stopPropagation(); renameChat(c.id); }; div.appendChild(title); const del = document.createElement('button'); del.className = 'del'; del.appendChild(icon('delete', 'Удалить')); del.onclick = (e) => deleteChat(c.id, e); div.appendChild(del); el.appendChild(div); }); } function filterChats() { searchQuery = $('search-input').value; renderChatList(); } function renderCurrentChat() { const chat = getCurrentChat(); if (!chat) return; $('current-title').textContent = chat.title; chatInner.innerHTML = ''; if (chat.messages.length === 0) { welcome.style.display = 'flex'; chatInner.style.display = 'none'; } else { welcome.style.display = 'none'; chatInner.style.display = 'flex'; chat.messages.forEach(m => addMessageToDOM(m.role, m.content, m.opts || {}, false)); chatArea.scrollTop = chatArea.scrollHeight; } } // ============ СООБЩЕНИЯ ============ function addMessageToDOM(role, text, opts = {}, save = true) { welcome.style.display = 'none'; chatInner.style.display = 'flex'; const s = getSettings(); const div = document.createElement('div'); div.className = 'message ' + role; const avatar = document.createElement('div'); avatar.className = 'avatar'; if (role === 'user') { avatar.appendChild(icon(s.userAvatar || 'user', 'Вы')); } else { avatar.appendChild(icon(s.assistantAvatar || 'assistant', 'AI')); } div.appendChild(avatar); const body = document.createElement('div'); body.className = 'body'; const name = document.createElement('div'); name.className = 'name'; name.textContent = role === 'user' ? (s.userName || 'Вы') : (s.assistantName || 'Ассистент'); if (role === 'ai') { const actions = document.createElement('div'); actions.className = 'actions'; const shareBtn = document.createElement('button'); shareBtn.appendChild(icon('share', 'Поделиться')); shareBtn.title = 'Скачать в ShareGPT'; shareBtn.onclick = () => shareMessage(div); actions.appendChild(shareBtn); const regenBtn = document.createElement('button'); regenBtn.appendChild(icon('regenerate', 'Перегенерировать')); regenBtn.title = 'Перегенерировать'; regenBtn.onclick = () => regenerateMessage(div); actions.appendChild(regenBtn); const copyBtn = document.createElement('button'); copyBtn.appendChild(icon('copy', 'Копировать')); copyBtn.title = 'Копировать'; copyBtn.onclick = () => navigator.clipboard.writeText(text); actions.appendChild(copyBtn); name.appendChild(actions); } body.appendChild(name); if (opts.images?.length || opts.files?.length) { const att = document.createElement('div'); att.className = 'msg-attachments'; (opts.images || []).forEach(im => { const img = document.createElement('img'); img.src = im.dataUrl; img.onclick = () => openLightbox(im.dataUrl); att.appendChild(img); }); (opts.files || []).forEach(f => { const chip = document.createElement('div'); chip.className = 'file-chip'; chip.textContent = '📄 ' + f.name; att.appendChild(chip); }); body.appendChild(att); } const content = document.createElement('div'); content.className = 'content'; if (role === 'ai') { content.innerHTML = renderMarkdown(text); enhanceCodeBlocks(content); } else content.textContent = text; body.appendChild(content); div.appendChild(body); chatInner.appendChild(div); chatArea.scrollTop = chatArea.scrollHeight; return { div, content }; } function updateMessageDOM(el, text) { if (!el || !el.content) return; el.content.innerHTML = renderMarkdown(text); enhanceCodeBlocks(el.content); chatArea.scrollTop = chatArea.scrollHeight; } function abortStreaming() { if (isStreaming && abortController) { abortController.abort(); isStreaming = false; abortController = null; } } function buildContentForApi(msg) { const parts = [{ type: 'text', text: msg.content || '' }]; (msg.opts?.images || []).forEach(im => parts.push({ type: 'image_url', image_url: { url: im.dataUrl } })); (msg.opts?.files || []).forEach(f => { if (f.textContent) parts.push({ type: 'text', text: `\n\n\n${f.textContent}\n` }); else parts.push({ type: 'text', text: `\n\n[бинарный]` }); }); return parts; } // ============ SHAREGPT / ПЕРЕГЕНЕРАЦИЯ ============ function shareMessage(el) { const chat = getCurrentChat(); if (!chat) return; const allMsgs = [...chatInner.querySelectorAll('.message')]; const idx = allMsgs.indexOf(el); if (idx === -1) return; let userText = ''; for (let i = idx - 1; i >= 0; i--) { if (allMsgs[i].classList.contains('user')) { const c = allMsgs[i].querySelector('.content'); userText = c ? c.textContent : ''; break; } } const aiContent = el.querySelector('.content'); const aiText = aiContent ? aiContent.textContent : ''; const sharegpt = { conversations: [{ from: 'human', value: userText }, { from: 'gpt', value: aiText }] }; const blob = new Blob([JSON.stringify(sharegpt, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'sharegpt-' + Date.now() + '.json'; a.click(); URL.revokeObjectURL(url); } async function regenerateMessage(el) { const chat = getCurrentChat(); if (!chat) return; const allMsgs = [...chatInner.querySelectorAll('.message')]; const idx = allMsgs.indexOf(el); if (idx === -1) return; let userMsgIdx = -1; for (let i = idx - 1; i >= 0; i--) { if (allMsgs[i].classList.contains('user')) { userMsgIdx = i; break; } } if (userMsgIdx === -1) return; const userMsg = chat.messages[userMsgIdx]; chat.messages = chat.messages.slice(0, userMsgIdx + 1); for (let i = allMsgs.length - 1; i >= idx; i--) allMsgs[i].remove(); $('user-input').value = typeof userMsg.content === 'string' ? userMsg.content : ''; attachedFiles = []; if (userMsg.opts?.images) attachedFiles.push(...userMsg.opts.images); if (userMsg.opts?.files) attachedFiles.push(...userMsg.opts.files); renderAttachments(); sendMessage(); } // ============ ПРЕДЛОЖКА ============ async function generateSuggestions(chat) { const s = getSettings(); if (s.suggestEnabled === false) return; const count = parseInt($('suggest-count')?.value) || 3; const suggestModel = s.suggestModel || $('suggest-model')?.value || 'local'; const suggestUrl = (s.suggestApiUrl || s.apiUrl || 'http://localhost:8080').replace(/\/$/, ''); const backend = s.backend || 'llamacpp'; const recent = chat.messages.slice(-3).map(m => { const role = m.role === 'user' ? 'User' : 'Assistant'; const text = typeof m.content === 'string' ? m.content : '[с файлами]'; return `${role}: ${text.slice(0, 200)}`; }).join('\n'); const prompt = `На основе диалога предложи ${count} коротких варианта следующего вопроса пользователя. Только вопросы, каждый с новой строки, без нумерации и лишних слов.\n\nДиалог:\n${recent}\n\nВарианты:`; let endpoint, payload; if (backend === 'ollama') { endpoint = `${suggestUrl}/api/chat`; payload = { model: suggestModel, messages: [{ role: 'user', content: prompt }], stream: false, options: { temperature: 0.9, num_predict: 150 } }; } else { endpoint = `${suggestUrl}/v1/chat/completions`; payload = { model: suggestModel, messages: [{ role: 'user', content: prompt }], stream: false, temperature: 0.9, max_tokens: 150 }; } try { const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) return; const json = await res.json(); const text = backend === 'ollama' ? (json.message?.content || '') : (json.choices?.[0]?.message?.content || ''); const suggestions = text.split('\n').map(l => l.replace(/^[\d\-\*\.\)]+\s*/, '').trim()).filter(l => l.length > 5 && l.length < 200).slice(0, count); if (suggestions.length > 0) renderSuggestions(suggestions); } catch (e) { console.warn('Suggestions failed:', e.message); } } function renderSuggestions(items) { const old = document.querySelector('.suggestions-bar'); if (old) old.remove(); const bar = document.createElement('div'); bar.className = 'suggestions-bar'; items.forEach(text => { const btn = document.createElement('button'); btn.className = 'suggestion-btn'; btn.textContent = text; btn.onclick = () => { $('user-input').value = text; $('user-input').focus(); bar.remove(); }; bar.appendChild(btn); }); const inputWrap = document.querySelector('.input-wrap'); if (inputWrap) inputWrap.insertBefore(bar, inputWrap.firstChild); } // ============ ОТПРАВКА ============ async function sendMessage() { if (isStreaming) { abortStreaming(); return; } const text = $('user-input').value.trim(); if (!text && attachedFiles.length === 0) return; const chat = getCurrentChat(); if (!chat) return; let contentForApi; if (attachedFiles.length > 0) { contentForApi = [{ type: 'text', text: text || '' }]; for (const f of attachedFiles) { if (f.isImage) contentForApi.push({ type: 'image_url', image_url: { url: f.dataUrl } }); else if (f.textContent) contentForApi.push({ type: 'text', text: `\n\n\n${f.textContent}\n` }); else contentForApi.push({ type: 'text', text: `\n\n[бинарный]` }); } } else contentForApi = text; const userMsg = { role: 'user', content: text, opts: { images: attachedFiles.filter(f => f.isImage), files: attachedFiles.filter(f => !f.isImage) } }; chat.messages.push(userMsg); if (chat.title === 'Новый чат' && text) { chat.title = text.slice(0, 40) + (text.length > 40 ? '...' : ''); saveChats(); renderChatList(); $('current-title').textContent = chat.title; } addMessageToDOM('user', text, userMsg.opts, false); $('user-input').value = ''; $('user-input').style.height = 'auto'; attachedFiles = []; renderAttachments(); const s = getSettings(); const persona = getPersonaPrefix(); const systemPrompt = (persona + (s.systemPrompt || $('system-prompt').value)).trim(); const messages = []; if (systemPrompt) messages.push({ role: 'system', content: systemPrompt }); for (const m of chat.messages) { if (m.role === 'user') messages.push({ role: 'user', content: m.opts?.images?.length || m.opts?.files?.length ? buildContentForApi(m) : m.content }); else messages.push({ role: 'assistant', content: m.content }); } const backend = s.backend || 'llamacpp'; const url = (s.apiUrl || 'http://localhost:8080').replace(/\/$/, ''); let endpoint, payload; if (backend === 'ollama') { endpoint = `${url}/api/chat`; payload = { model: s.suggestModel || 'local', messages, stream: true, options: { temperature: parseFloat($('temp').value), top_p: parseFloat($('topp').value), top_k: parseInt($('topk').value), repeat_penalty: parseFloat($('reppen').value), num_predict: parseInt($('numpredict').value) } }; } else { endpoint = `${url}/v1/chat/completions`; payload = { model: 'local', messages, stream: true, stream_options: { include_usage: true }, temperature: parseFloat($('temp').value), top_p: parseFloat($('topp').value), top_k: parseInt($('topk').value), repeat_penalty: parseFloat($('reppen').value), max_tokens: parseInt($('numpredict').value) }; } const aiMsg = addMessageToDOM('ai', '', {}, false); if (!aiMsg || !aiMsg.content) return; aiMsg.content.classList.add('cursor'); isStreaming = true; currentChatAtStart = currentChatId; const sendBtn = $('send-btn'); sendBtn.textContent = ''; sendBtn.appendChild(icon('stop', 'Стоп')); sendBtn.classList.add('stop'); abortController = new AbortController(); const startTime = performance.now(); let fullText = '', tokenCount = 0; try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: abortController.signal, body: JSON.stringify(payload) }); if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; if (currentChatId !== currentChatAtStart) { abortController.abort(); break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const raw of lines) { const line = raw.trim(); if (!line || !line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (data === '[DONE]') continue; try { const json = JSON.parse(data); let delta = ''; if (backend === 'ollama') delta = json.message?.content || ''; else { delta = json.choices?.[0]?.delta?.content || ''; if (json.usage?.completion_tokens) tokenCount = json.usage.completion_tokens; } if (delta) { fullText += delta; updateMessageDOM(aiMsg, fullText); } } catch (e) {} } } aiMsg.content.classList.remove('cursor'); if (fullText && fullText.trim()) { chat.messages.push({ role: 'assistant', content: fullText }); saveChats(); } else { aiMsg.div.remove(); } const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); const tps = tokenCount > 0 ? (tokenCount / elapsed).toFixed(1) : '—'; $('token-info').textContent = tokenCount > 0 ? `${tokenCount} токенов · ${elapsed}s · ${tps} tok/s` : `${elapsed}s`; generateSuggestions(chat); } catch (e) { aiMsg.content.classList.remove('cursor'); if (e.name === 'AbortError') { updateMessageDOM(aiMsg, fullText + '\n\n[прервано]'); if (fullText && fullText.trim()) { chat.messages.push({ role: 'assistant', content: fullText }); saveChats(); } else { aiMsg.div.remove(); } } else { updateMessageDOM(aiMsg, `❌ Ошибка: ${e.message}`); } } finally { isStreaming = false; sendBtn.textContent = ''; sendBtn.appendChild(icon('send', 'Отправить')); sendBtn.classList.remove('stop'); abortController = null; } } function toggleSidebar() { $('sidebar').classList.toggle('collapsed'); } // ============ ЭКСПОРТ/ИМПОРТ ============ function exportChats() { const data = JSON.stringify(chats, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'hbb-chats-' + Date.now() + '.json'; a.click(); URL.revokeObjectURL(url); } function importChats() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json'; inp.onchange = async e => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); const imported = JSON.parse(text); if (!Array.isArray(imported)) throw new Error('Не массив'); chats = [...imported, ...chats]; saveChats(); renderChatList(); if (chats.length > 0) { currentChatId = chats[0].id; renderCurrentChat(); } } catch (err) { alert('Ошибка импорта: ' + err.message); } }; inp.click(); } // ============ ОБРАБОТЧИКИ ============ function setupHandlers() { const userInput = $('user-input'); if (userInput) { userInput.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey || !e.shiftKey)) { e.preventDefault(); sendMessage(); } }); userInput.addEventListener('input', () => { userInput.style.height = 'auto'; userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px'; }); userInput.addEventListener('paste', async e => { const items = e.clipboardData?.items; if (!items) return; const files = []; for (const it of items) { if (it.type.startsWith('image/')) { const f = it.getAsFile(); if (f) files.push(f); } } if (files.length) { e.preventDefault(); await addFiles(files); } }); } const sendBtn = $('send-btn'); if (sendBtn) sendBtn.addEventListener('click', sendMessage); const attachBtn = $('attach-btn'); if (attachBtn) attachBtn.addEventListener('click', () => { const inp = document.createElement('input'); inp.type = 'file'; inp.multiple = true; inp.onchange = async e => { await addFiles(e.target.files); }; inp.click(); }); ['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => e.preventDefault())); document.addEventListener('drop', async e => { e.preventDefault(); if (e.dataTransfer?.files?.length) await addFiles(e.dataTransfer.files); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); createNewChat(); } if ((e.ctrlKey || e.metaKey) && e.key === '/') { e.preventDefault(); openSettings(); } }); } // ============ ИНИЦИАЛИЗАЦИЯ ============ window.addEventListener('DOMContentLoaded', () => { loadTheme(); loadSettings(); loadChats(); setupHandlers(); checkConnection(); setInterval(checkConnection, 10000); });