// ============================================================
// 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);
});