Spaces:
Running
Running
Download script.js from HBB-Community/ChatBot: direct link, hf CLI and curl.
- Browser
- Download file 36.6 kB
-
https://huggingface.co/spaces/HBB-Community/ChatBot/resolve/main/script.js
- Command line
-
hf download hf://spaces/HBB-Community/ChatBot/script.js
-
curl -L -o script.js https://huggingface.co/spaces/HBB-Community/ChatBot/resolve/main/script.js
36.6 kB
| // ============================================================ | |
| // 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<file name="${f.name}">\n${f.textContent}\n</file>` }); | |
| else parts.push({ type: 'text', text: `\n\n<file name="${f.name}" size="${f.size}">[бинарный]</file>` }); | |
| }); | |
| 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<file name="${f.name}">\n${f.textContent}\n</file>` }); | |
| else contentForApi.push({ type: 'text', text: `\n\n<file name="${f.name}" size="${f.size}">[бинарный]</file>` }); | |
| } | |
| } 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); | |
| }); |