JarWithWater commited on
Commit
586b1c8
·
verified ·
1 Parent(s): 44c155a

Upload 23 files

Browse files
icons/assistant.png ADDED
icons/attach.png ADDED
icons/check.png ADDED
icons/close.png ADDED
icons/copy.png ADDED
icons/delete.png ADDED
icons/error.png ADDED
icons/export.png ADDED
icons/import.png ADDED
icons/menu.png ADDED
icons/new-chat.png ADDED
icons/regenerate.png ADDED
icons/search.png ADDED
icons/send.png ADDED
icons/settings.png ADDED
icons/share.png ADDED
icons/stop.png ADDED
icons/theme-dark.png ADDED
icons/theme-light.png ADDED
icons/user.png ADDED
index.html ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ru" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>HBB Chat</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css" id="hljs-theme">
8
+ <link rel="stylesheet" href="style.css">
9
+ </head>
10
+ <body>
11
+
12
+ <div class="app">
13
+ <!-- ===== Боковая панель ===== -->
14
+ <aside class="sidebar" id="sidebar">
15
+ <div class="sidebar-header">
16
+ <div class="logo">
17
+ <img src="icons/assistant.png" class="icon" alt=""> HBB Chat
18
+ </div>
19
+ </div>
20
+ <div class="search-box">
21
+ <input type="text" id="search-input" placeholder="Поиск по чатам..." oninput="filterChats()">
22
+ </div>
23
+ <button class="new-chat-btn" onclick="createNewChat()">
24
+ <img src="icons/new-chat.png" class="icon" alt=""> Новый чат
25
+ </button>
26
+ <div class="chat-list" id="chat-list"></div>
27
+ <div class="sidebar-footer">
28
+ <span><span class="status-dot" id="status-dot"></span><span id="status-text">Проверка...</span></span>
29
+ <a class="settings-link" onclick="openSettings()">
30
+ <img src="icons/settings.png" class="icon" alt=""> Настройки
31
+ </a>
32
+ </div>
33
+ </aside>
34
+
35
+ <!-- ===== Основная область ===== -->
36
+ <main class="main">
37
+ <div class="topbar">
38
+ <button class="toggle-btn" onclick="toggleSidebar()" title="Свернуть/развернуть">
39
+ <img src="icons/menu.png" class="icon" alt="">
40
+ </button>
41
+ <div class="current-title" id="current-title" ondblclick="renameCurrentChat()">Новый чат</div>
42
+ <div class="spacer"></div>
43
+ <div class="token-info" id="token-info"></div>
44
+ <button class="theme-btn" onclick="toggleTheme()" title="Тема">
45
+ <img src="icons/theme-dark.png" class="icon" alt="">
46
+ </button>
47
+ </div>
48
+
49
+ <div class="chat-area" id="chat-area">
50
+ <div class="welcome" id="welcome">
51
+ <div class="title">
52
+ <img src="icons/assistant.png" class="icon" alt=""> Привет! Чем помочь сегодня?
53
+ </div>
54
+ <div class="subtitle">Напишите сообщение, прикрепите файл или картинку.</div>
55
+ </div>
56
+ <div class="chat-inner" id="chat-inner"></div>
57
+ </div>
58
+
59
+ <div class="input-wrap">
60
+ <div class="input-box">
61
+ <div class="attachments-preview" id="attachments-preview"></div>
62
+ <textarea id="user-input" placeholder="Напишите сообщение... (Ctrl+Enter — отправить)" rows="1"></textarea>
63
+ <div class="input-actions">
64
+ <div class="left">
65
+ <button class="icon-btn" id="attach-btn" title="Прикрепить файл">
66
+ <img src="icons/attach.png" class="icon" alt="">
67
+ </button>
68
+ </div>
69
+ <button class="send-btn" id="send-btn" title="Отправить">
70
+ <img src="icons/send.png" class="icon" alt="">
71
+ </button>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </main>
76
+ </div>
77
+
78
+ <!-- ===== Настройки ===== -->
79
+ <div class="settings-overlay" id="settings-overlay">
80
+ <div class="settings-panel">
81
+ <h3>Настройки <button onclick="closeSettings()"><img src="icons/close.png" class="icon" alt=""></button></h3>
82
+
83
+ <div class="field">
84
+ <label>Бэкенд</label>
85
+ <select id="backend-select">
86
+ <option value="llamacpp">llama.cpp (OpenAI API)</option>
87
+ <option value="ollama">Ollama</option>
88
+ </select>
89
+ </div>
90
+ <div class="field">
91
+ <label>API Endpoint</label>
92
+ <input type="text" id="api-url" value="http://localhost:8080">
93
+ </div>
94
+ <div class="field">
95
+ <label>Имя пользователя</label>
96
+ <input type="text" id="user-name" value="Вы" maxlength="32">
97
+ </div>
98
+ <div class="field">
99
+ <label>Имя ассистента</label>
100
+ <input type="text" id="assistant-name" value="Ассистент" maxlength="32">
101
+ </div>
102
+ <div class="field">
103
+ <label>Аватар пользователя</label>
104
+ <input type="text" id="user-avatar" value="user" maxlength="32">
105
+ <div class="hint">Имя файла в icons/ без .png</div>
106
+ </div>
107
+ <div class="field">
108
+ <label>Аватар ассистента</label>
109
+ <input type="text" id="assistant-avatar" value="assistant" maxlength="32">
110
+ <div class="hint">Имя файла в icons/ без .png</div>
111
+ </div>
112
+
113
+ <div class="field">
114
+ <label>Профиль ассистента</label>
115
+ <select id="persona-select">
116
+ <option value="none">Ничего</option>
117
+ <option value="enthusiastic">Восторженный помощник</option>
118
+ <option value="coder">Помощник по программированию</option>
119
+ <option value="explainer">Разжёвывающий помощник</option>
120
+ <option value="sarcastic">Саркастичный помощник</option>
121
+ <option value="teacher">Терпеливый учитель</option>
122
+ </select>
123
+ </div>
124
+
125
+ <div class="field">
126
+ <label>Системный промпт</label>
127
+ <textarea id="system-prompt" placeholder="Ты — полезный ассистент..."></textarea>
128
+ </div>
129
+
130
+ <div class="divider"></div>
131
+
132
+ <div class="field">
133
+ <label>Temperature <span id="temp-val">0.8</span></label>
134
+ <input type="range" id="temp" min="0" max="2" step="0.05" value="0.8">
135
+ </div>
136
+ <div class="field">
137
+ <label>Top P <span id="topp-val">0.9</span></label>
138
+ <input type="range" id="topp" min="0" max="1" step="0.05" value="0.9">
139
+ </div>
140
+ <div class="field">
141
+ <label>Top K <span id="topk-val">40</span></label>
142
+ <input type="range" id="topk" min="1" max="100" step="1" value="40">
143
+ </div>
144
+ <div class="field">
145
+ <label>Repeat Penalty <span id="rep-val">1.1</span></label>
146
+ <input type="range" id="reppen" min="0.5" max="2" step="0.05" value="1.1">
147
+ </div>
148
+ <div class="field">
149
+ <label>Max tokens <span id="np-val">512</span></label>
150
+ <input type="range" id="numpredict" min="32" max="4096" step="32" value="512">
151
+ </div>
152
+ <div class="field">
153
+ <label>Context (n_ctx) <span id="ctx-val">4096</span></label>
154
+ <input type="range" id="numctx" min="512" max="32768" step="512" value="4096">
155
+ </div>
156
+ <div class="field">
157
+ <label>Image min tokens</label>
158
+ <input type="number" id="img-min-tokens" value="256" min="64" max="2048" step="64">
159
+ </div>
160
+
161
+ <div class="divider"></div>
162
+ <h4 style="margin: 0 0 10px 0; font-size: 0.85rem;">Предлагаемые запросы</h4>
163
+ <div class="field">
164
+ <label>
165
+ <input type="checkbox" id="suggest-enabled" style="width:auto;margin-right:6px;" checked>
166
+ Включить генерацию предложений
167
+ </label>
168
+ </div>
169
+ <div class="field">
170
+ <label>Модель для предложений</label>
171
+ <input type="text" id="suggest-model" value="gemma-3-1b-it">
172
+ </div>
173
+ <div class="field">
174
+ <label>Endpoint для предложений</label>
175
+ <input type="text" id="suggest-api-url" value="http://localhost:8080">
176
+ </div>
177
+ <div class="field">
178
+ <label>Кол-во предложений <span id="suggest-count-val">3</span></label>
179
+ <input type="range" id="suggest-count" min="1" max="5" step="1" value="3">
180
+ </div>
181
+
182
+ <div class="divider"></div>
183
+
184
+ <div class="field">
185
+ <label>Макс. сторона картинки, px</label>
186
+ <input type="number" id="img-max-side" value="1280" min="256" max="4096" step="64">
187
+ </div>
188
+ <div class="field">
189
+ <label>Качество JPEG <span id="img-q-val">0.85</span></label>
190
+ <input type="range" id="img-quality" min="0.3" max="1" step="0.05" value="0.85">
191
+ </div>
192
+
193
+ <button class="btn-primary" onclick="saveSettings()">Сохранить</button>
194
+ <div class="btn-row">
195
+ <button class="btn-secondary" onclick="exportChats()">
196
+ <img src="icons/export.png" class="icon" alt=""> Экспорт
197
+ </button>
198
+ <button class="btn-secondary" onclick="importChats()">
199
+ <img src="icons/import.png" class="icon" alt=""> Импорт
200
+ </button>
201
+ </div>
202
+ <button class="btn-secondary" onclick="resetSettings()">Сбросить всё</button>
203
+ </div>
204
+ </div>
205
+
206
+ <div class="lightbox" id="lightbox" onclick="closeLightbox()">
207
+ <img id="lightbox-img" alt="">
208
+ </div>
209
+
210
+ <!-- ===== Атрибуция Icons8 ===== -->
211
+ <div style="
212
+ position: fixed;
213
+ bottom: 2px;
214
+ right: 4px;
215
+ font-size: 6px;
216
+ color: var(--text-dim);
217
+ opacity: 0.4;
218
+ z-index: 9999;
219
+ pointer-events: auto;
220
+ font-family: sans-serif;
221
+ line-height: 1;
222
+ ">
223
+ Icons by <a href="https://icons8.ru" target="_blank" style="color: inherit; text-decoration: none;">icons8.ru</a>
224
+ </div>
225
+
226
+ <!-- ===== Библиотеки ===== -->
227
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.0/marked.min.js"></script>
228
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.9/purify.min.js"></script>
229
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
230
+
231
+ <!-- ===== Основной скрипт ===== -->
232
+ <script src="script.js"></script>
233
+
234
+ </body>
235
+ </html>
script.js ADDED
@@ -0,0 +1,730 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================================
2
+ // HBB Chat — script.js (полный, с фиксом Markdown)
3
+ // ============================================================
4
+
5
+ // ============ СОСТОЯНИЕ ============
6
+ let chats = [];
7
+ let currentChatId = null;
8
+ let isStreaming = false;
9
+ let abortController = null;
10
+ let attachedFiles = [];
11
+ let currentChatAtStart = null;
12
+ let searchQuery = '';
13
+
14
+ const DEFAULTS = {
15
+ backend: 'llamacpp',
16
+ apiUrl: 'http://localhost:8080',
17
+ userName: 'Вы',
18
+ assistantName: 'Ассистент',
19
+ userAvatar: 'user',
20
+ assistantAvatar: 'assistant',
21
+ systemPrompt: 'Ты — полезный ассистент. Отвечай кратко и по делу.',
22
+ temperature: 0.8, top_p: 0.9, top_k: 40,
23
+ repeat_penalty: 1.1, num_predict: 512, num_ctx: 4096,
24
+ imgMinTokens: 256,
25
+ imgMaxSide: 1280, imgQuality: 0.85,
26
+ theme: 'dark',
27
+ persona: 'none',
28
+ suggestEnabled: true,
29
+ suggestModel: 'gemma-3-1b-it',
30
+ suggestApiUrl: 'http://localhost:8080',
31
+ suggestCount: 3
32
+ };
33
+
34
+ const PERSONAS = {
35
+ none: '',
36
+ enthusiastic: 'Ты — восторженный и энергичный помощник. Используй яркие выражения, эмодзи и подбадривай пользователя. ',
37
+ coder: 'Ты — опытный программист. Отвечай технически точно, приводи примеры кода, указывай на подводные камни. ',
38
+ explainer: 'Ты — терпеливый объясняющий помощник. Разжёвывай каждую деталь, приводи аналогии, не предполагай, что пользователь знает контекст. ',
39
+ sarcastic: 'Ты — саркастичный помощник. Отвечай с лёгкой иронией, но по делу. Не переходи границы грубости. ',
40
+ teacher: 'Ты — терпеливый учитель. Задавай наводящие вопросы, помогай пользователю дойти до ответа самому. '
41
+ };
42
+
43
+ const $ = id => document.getElementById(id);
44
+ const chatInner = $('chat-inner');
45
+ const chatArea = $('chat-area');
46
+ const welcome = $('welcome');
47
+ const lightbox = $('lightbox');
48
+ const lightboxImg = $('lightbox-img');
49
+
50
+ // ============ ИКОНКИ ============
51
+ const ICON_PATH = 'icons/';
52
+ function icon(name, alt = '') {
53
+ const img = document.createElement('img');
54
+ img.src = ICON_PATH + name + '.png';
55
+ img.alt = alt;
56
+ img.className = 'icon';
57
+ img.draggable = false;
58
+ return img;
59
+ }
60
+
61
+ // ============ MARKDOWN ============
62
+ function normalizeMarkdown(text) {
63
+ return text.replace(/^(#{1,6})([^\s#])/gm, '$1 $2');
64
+ }
65
+ function renderMarkdown(text) {
66
+ if (typeof marked === 'undefined') return text;
67
+ // ФИКС: если текст уже содержит HTML-теги — не парсим повторно
68
+ if (/<[a-z][\s\S]*>/i.test(text)) {
69
+ return DOMPurify.sanitize(text);
70
+ }
71
+ return DOMPurify.sanitize(marked.parse(normalizeMarkdown(text)));
72
+ }
73
+ function enhanceCodeBlocks(container) {
74
+ container.querySelectorAll('pre').forEach(pre => {
75
+ if (pre.parentElement.classList.contains('code-block-wrapper')) return;
76
+ const code = pre.querySelector('code');
77
+ if (!code) return;
78
+ const lang = [...code.classList].find(c => c.startsWith('language-'))?.replace('language-', '') || 'code';
79
+ const wrapper = document.createElement('div');
80
+ wrapper.className = 'code-block-wrapper';
81
+ const header = document.createElement('div');
82
+ header.className = 'code-header';
83
+ const langSpan = document.createElement('span');
84
+ langSpan.className = 'lang';
85
+ langSpan.textContent = lang;
86
+ header.appendChild(langSpan);
87
+ const copyBtn = document.createElement('button');
88
+ copyBtn.className = 'copy-btn';
89
+ copyBtn.appendChild(icon('copy', 'Копировать'));
90
+ copyBtn.appendChild(document.createTextNode(' Копировать'));
91
+ copyBtn.onclick = () => {
92
+ navigator.clipboard.writeText(code.textContent).then(() => {
93
+ copyBtn.innerHTML = '';
94
+ copyBtn.appendChild(icon('check', 'Скопировано'));
95
+ copyBtn.appendChild(document.createTextNode(' Скопировано'));
96
+ copyBtn.classList.add('copied');
97
+ setTimeout(() => {
98
+ copyBtn.innerHTML = '';
99
+ copyBtn.appendChild(icon('copy', 'Копировать'));
100
+ copyBtn.appendChild(document.createTextNode(' Копировать'));
101
+ copyBtn.classList.remove('copied');
102
+ }, 1500);
103
+ });
104
+ };
105
+ header.appendChild(copyBtn);
106
+ pre.parentNode.insertBefore(wrapper, pre);
107
+ wrapper.appendChild(header);
108
+ wrapper.appendChild(pre);
109
+ if (window.hljs) hljs.highlightElement(code);
110
+ });
111
+ }
112
+
113
+ // ============ ТЕМА ============
114
+ function currentTheme() {
115
+ return document.documentElement.getAttribute('data-theme') || 'dark';
116
+ }
117
+ function toggleTheme() {
118
+ const newTheme = currentTheme() === 'dark' ? 'light' : 'dark';
119
+ document.documentElement.setAttribute('data-theme', newTheme);
120
+ localStorage.setItem('hbb-theme', newTheme);
121
+ const themeBtn = document.querySelector('.theme-btn');
122
+ if (themeBtn) {
123
+ themeBtn.innerHTML = '';
124
+ themeBtn.appendChild(icon(newTheme === 'dark' ? 'theme-dark' : 'theme-light', 'Тема'));
125
+ }
126
+ const hljsTheme = $('hljs-theme');
127
+ if (hljsTheme) {
128
+ hljsTheme.href = newTheme === 'dark'
129
+ ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css'
130
+ : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-light.min.css';
131
+ }
132
+ }
133
+ function loadTheme() {
134
+ const saved = localStorage.getItem('hbb-theme') || 'dark';
135
+ document.documentElement.setAttribute('data-theme', saved);
136
+ }
137
+
138
+ // ============ НАСТРОЙКИ ============
139
+ function loadSettings() {
140
+ const saved = JSON.parse(localStorage.getItem('hbb-settings') || '{}');
141
+ const s = { ...DEFAULTS, ...saved };
142
+ $('backend-select').value = s.backend;
143
+ $('api-url').value = s.apiUrl;
144
+ $('user-name').value = s.userName;
145
+ $('assistant-name').value = s.assistantName;
146
+ $('user-avatar').value = s.userAvatar;
147
+ $('assistant-avatar').value = s.assistantAvatar;
148
+ $('system-prompt').value = s.systemPrompt;
149
+ $('temp').value = s.temperature;
150
+ $('topp').value = s.top_p;
151
+ $('topk').value = s.top_k;
152
+ $('reppen').value = s.repeat_penalty;
153
+ $('numpredict').value = s.num_predict;
154
+ $('numctx').value = s.num_ctx;
155
+ $('img-min-tokens').value = s.imgMinTokens;
156
+ $('img-max-side').value = s.imgMaxSide;
157
+ $('img-quality').value = s.imgQuality;
158
+ if ($('persona-select')) $('persona-select').value = s.persona || 'none';
159
+ if ($('suggest-enabled')) $('suggest-enabled').checked = s.suggestEnabled !== false;
160
+ if ($('suggest-model')) $('suggest-model').value = s.suggestModel || 'gemma-3-1b-it';
161
+ if ($('suggest-api-url')) $('suggest-api-url').value = s.suggestApiUrl || s.apiUrl || 'http://localhost:8080';
162
+ if ($('suggest-count')) $('suggest-count').value = s.suggestCount || 3;
163
+ if ($('suggest-count-val')) $('suggest-count-val').textContent = $('suggest-count').value;
164
+ updateSliderLabels();
165
+ }
166
+ function saveSettings() {
167
+ const s = {
168
+ backend: $('backend-select').value,
169
+ apiUrl: $('api-url').value.replace(/\/$/, ''),
170
+ userName: $('user-name').value.trim() || 'Вы',
171
+ assistantName: $('assistant-name').value.trim() || 'Ассистент',
172
+ userAvatar: $('user-avatar').value.trim() || 'user',
173
+ assistantAvatar: $('assistant-avatar').value.trim() || 'assistant',
174
+ systemPrompt: $('system-prompt').value,
175
+ temperature: parseFloat($('temp').value),
176
+ top_p: parseFloat($('topp').value),
177
+ top_k: parseInt($('topk').value),
178
+ repeat_penalty: parseFloat($('reppen').value),
179
+ num_predict: parseInt($('numpredict').value),
180
+ num_ctx: parseInt($('numctx').value),
181
+ imgMinTokens: parseInt($('img-min-tokens').value) || 256,
182
+ imgMaxSide: parseInt($('img-max-side').value) || 1280,
183
+ imgQuality: parseFloat($('img-quality').value) || 0.85,
184
+ theme: currentTheme(),
185
+ persona: $('persona-select') ? $('persona-select').value : 'none',
186
+ suggestEnabled: $('suggest-enabled') ? $('suggest-enabled').checked : true,
187
+ suggestModel: $('suggest-model') ? $('suggest-model').value.trim() : 'gemma-3-1b-it',
188
+ suggestApiUrl: $('suggest-api-url') ? $('suggest-api-url').value.replace(/\/$/, '') : '',
189
+ suggestCount: $('suggest-count') ? parseInt($('suggest-count').value) : 3
190
+ };
191
+ localStorage.setItem('hbb-settings', JSON.stringify(s));
192
+ closeSettings();
193
+ renderCurrentChat();
194
+ checkConnection();
195
+ }
196
+ function resetSettings() {
197
+ if (!confirm('Сбросить все настройки?')) return;
198
+ localStorage.removeItem('hbb-settings');
199
+ loadSettings();
200
+ renderCurrentChat();
201
+ closeSettings();
202
+ }
203
+ function updateSliderLabels() {
204
+ 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' };
205
+ for (const [labelId, inputId] of Object.entries(map)) {
206
+ const label = $(labelId), input = $(inputId);
207
+ if (label && input) label.textContent = input.value;
208
+ }
209
+ }
210
+ ['temp','topp','topk','reppen','numpredict','numctx','img-quality'].forEach(id => {
211
+ const el = $(id);
212
+ if (el) el.addEventListener('input', updateSliderLabels);
213
+ });
214
+ const suggestCountEl = $('suggest-count');
215
+ if (suggestCountEl) {
216
+ suggestCountEl.addEventListener('input', () => {
217
+ if ($('suggest-count-val')) $('suggest-count-val').textContent = suggestCountEl.value;
218
+ });
219
+ }
220
+ function openSettings() { $('settings-overlay').classList.add('visible'); }
221
+ function closeSettings() { $('settings-overlay').classList.remove('visible'); }
222
+ function getSettings() { return JSON.parse(localStorage.getItem('hbb-settings') || '{}'); }
223
+ function getPersonaPrefix() {
224
+ const s = getSettings();
225
+ return PERSONAS[s.persona || 'none'] || '';
226
+ }
227
+
228
+ // ============ API ============
229
+ async function getApiUrl() { return $('api-url').value.replace(/\/$/, ''); }
230
+ async function getBackend() { return $('backend-select').value; }
231
+
232
+ async function checkConnection() {
233
+ const dot = $('status-dot'), txt = $('status-text');
234
+ if (!dot || !txt) return;
235
+ try {
236
+ const url = await getApiUrl();
237
+ const backend = await getBackend();
238
+ let endpoint = backend === 'ollama' ? `${url}/api/tags` : `${url}/v1/models`;
239
+ const res = await fetch(endpoint);
240
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
241
+ dot.classList.add('online');
242
+ txt.textContent = backend === 'ollama' ? 'Ollama подключён' : 'llama.cpp подключён';
243
+ } catch (e) {
244
+ dot.classList.remove('online');
245
+ txt.textContent = 'Нет соединения';
246
+ }
247
+ }
248
+
249
+ // ============ ФАЙЛЫ ============
250
+ function fileToDataUrl(file) { return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); }); }
251
+ function fileToText(file) { return new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsText(file); }); }
252
+ function loadImage(src) { return new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = rej; i.src = src; }); }
253
+
254
+ async function processImage(file) {
255
+ const maxSide = parseInt($('img-max-side').value) || 1280;
256
+ const quality = parseFloat($('img-quality').value) || 0.85;
257
+ const dataUrl = await fileToDataUrl(file);
258
+ const img = await loadImage(dataUrl);
259
+ let { width, height } = img;
260
+ const longSide = Math.max(width, height);
261
+ let needResize = longSide > maxSide;
262
+ const isPng = file.type === 'image/png';
263
+ if (!needResize && isPng && file.size < 900_000) return { dataUrl, mime: file.type, name: file.name, size: file.size, isImage: true };
264
+ if (needResize) { const k = maxSide / longSide; width = Math.round(width * k); height = Math.round(height * k); }
265
+ const canvas = document.createElement('canvas');
266
+ canvas.width = width; canvas.height = height;
267
+ canvas.getContext('2d').drawImage(img, 0, 0, width, height);
268
+ const outMime = isPng ? 'image/png' : 'image/jpeg';
269
+ const outUrl = isPng ? canvas.toDataURL('image/png') : canvas.toDataURL('image/jpeg', quality);
270
+ const approxSize = Math.round((outUrl.length - outUrl.indexOf(',') - 1) * 3 / 4);
271
+ return { dataUrl: outUrl, mime: outMime, name: file.name, size: approxSize, isImage: true };
272
+ }
273
+ async function processFile(file) {
274
+ if (file.type.startsWith('image/')) return await processImage(file);
275
+ const textExts = ['.txt','.md','.json','.js','.py','.html','.css','.csv','.xml','.yaml','.yml','.log','.sh','.bat','.c','.cpp','.h','.java','.rs','.go','.ts'];
276
+ const isText = file.type.startsWith('text/') || textExts.some(e => file.name.toLowerCase().endsWith(e));
277
+ if (isText) {
278
+ const text = await fileToText(file);
279
+ return { dataUrl: null, mime: file.type || 'text/plain', name: file.name, size: file.size, isImage: false, textContent: text };
280
+ }
281
+ return { dataUrl: null, mime: file.type || 'application/octet-stream', name: file.name, size: file.size, isImage: false, textContent: null };
282
+ }
283
+ async function addFiles(files) {
284
+ for (const f of files) { try { attachedFiles.push(await processFile(f)); } catch (e) { console.error(e); } }
285
+ renderAttachments();
286
+ }
287
+ function renderAttachments() {
288
+ const el = $('attachments-preview');
289
+ if (!el) return;
290
+ el.innerHTML = '';
291
+ if (attachedFiles.length === 0) { el.classList.remove('visible'); $('attach-btn').classList.remove('has-files'); return; }
292
+ el.classList.add('visible');
293
+ $('attach-btn').classList.add('has-files');
294
+ attachedFiles.forEach((f, idx) => {
295
+ const div = document.createElement('div');
296
+ div.className = 'att-preview-item' + (f.isImage ? '' : ' file');
297
+ if (f.isImage) { const img = document.createElement('img'); img.src = f.dataUrl; div.appendChild(img); }
298
+ else { const ic = document.createElement('div'); ic.className = 'icon'; ic.textContent = '📄'; div.appendChild(ic); }
299
+ const rm = document.createElement('button'); rm.className = 'remove'; rm.textContent = '×';
300
+ rm.onclick = () => { attachedFiles.splice(idx, 1); renderAttachments(); };
301
+ div.appendChild(rm); el.appendChild(div);
302
+ });
303
+ }
304
+ function openLightbox(src) { lightboxImg.src = src; lightbox.classList.add('visible'); }
305
+ function closeLightbox() { lightbox.classList.remove('visible'); lightboxImg.src = ''; }
306
+
307
+ // ============ СИСТЕМА ЧАТОВ ============
308
+ function loadChats() {
309
+ chats = JSON.parse(localStorage.getItem('hbb-chats') || '[]');
310
+ chats = chats.filter(c => c.messages && c.messages.length > 0);
311
+ if (chats.length === 0) createNewChat(true);
312
+ else { currentChatId = chats[0].id; renderChatList(); renderCurrentChat(); }
313
+ }
314
+ function saveChats() {
315
+ try { localStorage.setItem('hbb-chats', JSON.stringify(chats)); }
316
+ catch (e) { console.warn('Не удалось сохранить чаты:', e.message); }
317
+ }
318
+ function createNewChat(silent) {
319
+ abortStreaming();
320
+ const chat = { id: 'chat_' + Date.now(), title: 'Новый чат', messages: [], createdAt: Date.now() };
321
+ chats.unshift(chat);
322
+ currentChatId = chat.id;
323
+ saveChats(); renderChatList(); renderCurrentChat();
324
+ if (!silent && $('user-input')) $('user-input').focus();
325
+ }
326
+ function switchChat(id) {
327
+ abortStreaming();
328
+ currentChatId = id;
329
+ renderChatList();
330
+ renderCurrentChat();
331
+ }
332
+ function deleteChat(id, e) {
333
+ e.stopPropagation();
334
+ if (!confirm('Удалить этот чат?')) return;
335
+ chats = chats.filter(c => c.id !== id);
336
+ if (currentChatId === id) {
337
+ if (chats.length === 0) createNewChat(true);
338
+ else { currentChatId = chats[0].id; renderCurrentChat(); }
339
+ }
340
+ saveChats(); renderChatList();
341
+ }
342
+ function renameChat(id) {
343
+ const chat = chats.find(c => c.id === id);
344
+ if (!chat) return;
345
+ const newTitle = prompt('Новое название:', chat.title);
346
+ if (newTitle && newTitle.trim()) {
347
+ chat.title = newTitle.trim();
348
+ saveChats(); renderChatList();
349
+ if (currentChatId === id) $('current-title').textContent = chat.title;
350
+ }
351
+ }
352
+ function renameCurrentChat() { if (currentChatId) renameChat(currentChatId); }
353
+ function getCurrentChat() { return chats.find(c => c.id === currentChatId); }
354
+
355
+ function renderChatList() {
356
+ const el = $('chat-list'); if (!el) return;
357
+ el.innerHTML = '';
358
+ const q = searchQuery.toLowerCase();
359
+ chats.filter(c => !q || c.title.toLowerCase().includes(q)).forEach(c => {
360
+ const div = document.createElement('div');
361
+ div.className = 'chat-item' + (c.id === currentChatId ? ' active' : '');
362
+ div.onclick = () => switchChat(c.id);
363
+ const title = document.createElement('span');
364
+ title.className = 'title'; title.textContent = c.title;
365
+ title.ondblclick = (e) => { e.stopPropagation(); renameChat(c.id); };
366
+ div.appendChild(title);
367
+ const del = document.createElement('button'); del.className = 'del';
368
+ del.appendChild(icon('delete', 'Удалить'));
369
+ del.onclick = (e) => deleteChat(c.id, e);
370
+ div.appendChild(del); el.appendChild(div);
371
+ });
372
+ }
373
+ function filterChats() {
374
+ searchQuery = $('search-input').value;
375
+ renderChatList();
376
+ }
377
+ function renderCurrentChat() {
378
+ const chat = getCurrentChat(); if (!chat) return;
379
+ $('current-title').textContent = chat.title;
380
+ chatInner.innerHTML = '';
381
+ if (chat.messages.length === 0) { welcome.style.display = 'flex'; chatInner.style.display = 'none'; }
382
+ else {
383
+ welcome.style.display = 'none'; chatInner.style.display = 'flex';
384
+ chat.messages.forEach(m => addMessageToDOM(m.role, m.content, m.opts || {}, false));
385
+ chatArea.scrollTop = chatArea.scrollHeight;
386
+ }
387
+ }
388
+
389
+ // ============ СООБЩЕНИЯ ============
390
+ function addMessageToDOM(role, text, opts = {}, save = true) {
391
+ welcome.style.display = 'none'; chatInner.style.display = 'flex';
392
+ const s = getSettings();
393
+ const div = document.createElement('div');
394
+ div.className = 'message ' + role;
395
+ const avatar = document.createElement('div'); avatar.className = 'avatar';
396
+ if (role === 'user') { avatar.appendChild(icon(s.userAvatar || 'user', 'Вы')); }
397
+ else { avatar.appendChild(icon(s.assistantAvatar || 'assistant', 'AI')); }
398
+ div.appendChild(avatar);
399
+ const body = document.createElement('div'); body.className = 'body';
400
+ const name = document.createElement('div'); name.className = 'name';
401
+ name.textContent = role === 'user' ? (s.userName || 'Вы') : (s.assistantName || 'Ассистент');
402
+
403
+ if (role === 'ai') {
404
+ const actions = document.createElement('div');
405
+ actions.className = 'actions';
406
+ const shareBtn = document.createElement('button');
407
+ shareBtn.appendChild(icon('share', 'Поделиться'));
408
+ shareBtn.title = 'Скачать в ShareGPT';
409
+ shareBtn.onclick = () => shareMessage(div);
410
+ actions.appendChild(shareBtn);
411
+ const regenBtn = document.createElement('button');
412
+ regenBtn.appendChild(icon('regenerate', 'Перегенерировать'));
413
+ regenBtn.title = 'Перегенерировать';
414
+ regenBtn.onclick = () => regenerateMessage(div);
415
+ actions.appendChild(regenBtn);
416
+ const copyBtn = document.createElement('button');
417
+ copyBtn.appendChild(icon('copy', 'Копировать'));
418
+ copyBtn.title = 'Копировать';
419
+ copyBtn.onclick = () => navigator.clipboard.writeText(text);
420
+ actions.appendChild(copyBtn);
421
+ name.appendChild(actions);
422
+ }
423
+
424
+ body.appendChild(name);
425
+ if (opts.images?.length || opts.files?.length) {
426
+ const att = document.createElement('div'); att.className = 'msg-attachments';
427
+ (opts.images || []).forEach(im => { const img = document.createElement('img'); img.src = im.dataUrl; img.onclick = () => openLightbox(im.dataUrl); att.appendChild(img); });
428
+ (opts.files || []).forEach(f => { const chip = document.createElement('div'); chip.className = 'file-chip'; chip.textContent = '📄 ' + f.name; att.appendChild(chip); });
429
+ body.appendChild(att);
430
+ }
431
+ const content = document.createElement('div'); content.className = 'content';
432
+ if (role === 'ai') { content.innerHTML = renderMarkdown(text); enhanceCodeBlocks(content); }
433
+ else content.textContent = text;
434
+ body.appendChild(content); div.appendChild(body);
435
+ chatInner.appendChild(div); chatArea.scrollTop = chatArea.scrollHeight;
436
+ return { div, content };
437
+ }
438
+ function updateMessageDOM(el, text) {
439
+ if (!el || !el.content) return;
440
+ el.content.innerHTML = renderMarkdown(text);
441
+ enhanceCodeBlocks(el.content);
442
+ chatArea.scrollTop = chatArea.scrollHeight;
443
+ }
444
+
445
+ function abortStreaming() {
446
+ if (isStreaming && abortController) {
447
+ abortController.abort();
448
+ isStreaming = false;
449
+ abortController = null;
450
+ }
451
+ }
452
+
453
+ function buildContentForApi(msg) {
454
+ const parts = [{ type: 'text', text: msg.content || '' }];
455
+ (msg.opts?.images || []).forEach(im => parts.push({ type: 'image_url', image_url: { url: im.dataUrl } }));
456
+ (msg.opts?.files || []).forEach(f => {
457
+ if (f.textContent) parts.push({ type: 'text', text: `\n\n<file name="${f.name}">\n${f.textContent}\n</file>` });
458
+ else parts.push({ type: 'text', text: `\n\n<file name="${f.name}" size="${f.size}">[бинарный]</file>` });
459
+ });
460
+ return parts;
461
+ }
462
+
463
+ // ============ SHAREGPT / ПЕРЕГЕНЕРАЦИЯ ============
464
+ function shareMessage(el) {
465
+ const chat = getCurrentChat(); if (!chat) return;
466
+ const allMsgs = [...chatInner.querySelectorAll('.message')];
467
+ const idx = allMsgs.indexOf(el); if (idx === -1) return;
468
+ let userText = '';
469
+ for (let i = idx - 1; i >= 0; i--) {
470
+ if (allMsgs[i].classList.contains('user')) {
471
+ const c = allMsgs[i].querySelector('.content');
472
+ userText = c ? c.textContent : '';
473
+ break;
474
+ }
475
+ }
476
+ const aiContent = el.querySelector('.content');
477
+ const aiText = aiContent ? aiContent.textContent : '';
478
+ const sharegpt = { conversations: [{ from: 'human', value: userText }, { from: 'gpt', value: aiText }] };
479
+ const blob = new Blob([JSON.stringify(sharegpt, null, 2)], { type: 'application/json' });
480
+ const url = URL.createObjectURL(blob);
481
+ const a = document.createElement('a');
482
+ a.href = url; a.download = 'sharegpt-' + Date.now() + '.json';
483
+ a.click(); URL.revokeObjectURL(url);
484
+ }
485
+ async function regenerateMessage(el) {
486
+ const chat = getCurrentChat(); if (!chat) return;
487
+ const allMsgs = [...chatInner.querySelectorAll('.message')];
488
+ const idx = allMsgs.indexOf(el); if (idx === -1) return;
489
+ let userMsgIdx = -1;
490
+ for (let i = idx - 1; i >= 0; i--) {
491
+ if (allMsgs[i].classList.contains('user')) { userMsgIdx = i; break; }
492
+ }
493
+ if (userMsgIdx === -1) return;
494
+ const userMsg = chat.messages[userMsgIdx];
495
+ chat.messages = chat.messages.slice(0, userMsgIdx + 1);
496
+ for (let i = allMsgs.length - 1; i >= idx; i--) allMsgs[i].remove();
497
+ $('user-input').value = typeof userMsg.content === 'string' ? userMsg.content : '';
498
+ attachedFiles = [];
499
+ if (userMsg.opts?.images) attachedFiles.push(...userMsg.opts.images);
500
+ if (userMsg.opts?.files) attachedFiles.push(...userMsg.opts.files);
501
+ renderAttachments();
502
+ sendMessage();
503
+ }
504
+
505
+ // ============ ПРЕДЛОЖКА ============
506
+ async function generateSuggestions(chat) {
507
+ const s = getSettings();
508
+ if (s.suggestEnabled === false) return;
509
+ const count = parseInt($('suggest-count')?.value) || 3;
510
+ const suggestModel = s.suggestModel || $('suggest-model')?.value || 'local';
511
+ const suggestUrl = (s.suggestApiUrl || s.apiUrl || 'http://localhost:8080').replace(/\/$/, '');
512
+ const backend = s.backend || 'llamacpp';
513
+ const recent = chat.messages.slice(-3).map(m => {
514
+ const role = m.role === 'user' ? 'User' : 'Assistant';
515
+ const text = typeof m.content === 'string' ? m.content : '[с файлами]';
516
+ return `${role}: ${text.slice(0, 200)}`;
517
+ }).join('\n');
518
+ const prompt = `На основе диалога предложи ${count} коротких варианта следующего вопроса пользователя. Только вопросы, каждый с новой строки, без нумерации и лишних слов.\n\nДиалог:\n${recent}\n\nВарианты:`;
519
+ let endpoint, payload;
520
+ if (backend === 'ollama') {
521
+ endpoint = `${suggestUrl}/api/chat`;
522
+ payload = { model: suggestModel, messages: [{ role: 'user', content: prompt }], stream: false, options: { temperature: 0.9, num_predict: 150 } };
523
+ } else {
524
+ endpoint = `${suggestUrl}/v1/chat/completions`;
525
+ payload = { model: suggestModel, messages: [{ role: 'user', content: prompt }], stream: false, temperature: 0.9, max_tokens: 150 };
526
+ }
527
+ try {
528
+ const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
529
+ if (!res.ok) return;
530
+ const json = await res.json();
531
+ const text = backend === 'ollama' ? (json.message?.content || '') : (json.choices?.[0]?.message?.content || '');
532
+ const suggestions = text.split('\n').map(l => l.replace(/^[\d\-\*\.\)]+\s*/, '').trim()).filter(l => l.length > 5 && l.length < 200).slice(0, count);
533
+ if (suggestions.length > 0) renderSuggestions(suggestions);
534
+ } catch (e) { console.warn('Suggestions failed:', e.message); }
535
+ }
536
+ function renderSuggestions(items) {
537
+ const old = document.querySelector('.suggestions-bar');
538
+ if (old) old.remove();
539
+ const bar = document.createElement('div');
540
+ bar.className = 'suggestions-bar';
541
+ items.forEach(text => {
542
+ const btn = document.createElement('button');
543
+ btn.className = 'suggestion-btn';
544
+ btn.textContent = text;
545
+ btn.onclick = () => { $('user-input').value = text; $('user-input').focus(); bar.remove(); };
546
+ bar.appendChild(btn);
547
+ });
548
+ const inputWrap = document.querySelector('.input-wrap');
549
+ if (inputWrap) inputWrap.insertBefore(bar, inputWrap.firstChild);
550
+ }
551
+
552
+ // ============ ОТПРАВКА ============
553
+ async function sendMessage() {
554
+ if (isStreaming) { abortStreaming(); return; }
555
+ const text = $('user-input').value.trim();
556
+ if (!text && attachedFiles.length === 0) return;
557
+ const chat = getCurrentChat(); if (!chat) return;
558
+
559
+ let contentForApi;
560
+ if (attachedFiles.length > 0) {
561
+ contentForApi = [{ type: 'text', text: text || '' }];
562
+ for (const f of attachedFiles) {
563
+ if (f.isImage) contentForApi.push({ type: 'image_url', image_url: { url: f.dataUrl } });
564
+ else if (f.textContent) contentForApi.push({ type: 'text', text: `\n\n<file name="${f.name}">\n${f.textContent}\n</file>` });
565
+ else contentForApi.push({ type: 'text', text: `\n\n<file name="${f.name}" size="${f.size}">[бинарный]</file>` });
566
+ }
567
+ } else contentForApi = text;
568
+
569
+ const userMsg = { role: 'user', content: text, opts: { images: attachedFiles.filter(f => f.isImage), files: attachedFiles.filter(f => !f.isImage) } };
570
+ chat.messages.push(userMsg);
571
+
572
+ if (chat.title === 'Новый чат' && text) {
573
+ chat.title = text.slice(0, 40) + (text.length > 40 ? '...' : '');
574
+ saveChats(); renderChatList(); $('current-title').textContent = chat.title;
575
+ }
576
+
577
+ addMessageToDOM('user', text, userMsg.opts, false);
578
+ $('user-input').value = ''; $('user-input').style.height = 'auto';
579
+ attachedFiles = []; renderAttachments();
580
+
581
+ const s = getSettings();
582
+ const persona = getPersonaPrefix();
583
+ const systemPrompt = (persona + (s.systemPrompt || $('system-prompt').value)).trim();
584
+ const messages = [];
585
+ if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
586
+ for (const m of chat.messages) {
587
+ if (m.role === 'user') messages.push({ role: 'user', content: m.opts?.images?.length || m.opts?.files?.length ? buildContentForApi(m) : m.content });
588
+ else messages.push({ role: 'assistant', content: m.content });
589
+ }
590
+
591
+ const backend = s.backend || 'llamacpp';
592
+ const url = (s.apiUrl || 'http://localhost:8080').replace(/\/$/, '');
593
+ let endpoint, payload;
594
+ if (backend === 'ollama') {
595
+ endpoint = `${url}/api/chat`;
596
+ 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) } };
597
+ } else {
598
+ endpoint = `${url}/v1/chat/completions`;
599
+ 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) };
600
+ }
601
+
602
+ const aiMsg = addMessageToDOM('ai', '', {}, false);
603
+ if (!aiMsg || !aiMsg.content) return;
604
+ aiMsg.content.classList.add('cursor');
605
+
606
+ isStreaming = true;
607
+ currentChatAtStart = currentChatId;
608
+ const sendBtn = $('send-btn'); sendBtn.textContent = ''; sendBtn.appendChild(icon('stop', 'Стоп')); sendBtn.classList.add('stop');
609
+ abortController = new AbortController();
610
+ const startTime = performance.now();
611
+ let fullText = '', tokenCount = 0;
612
+
613
+ try {
614
+ const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: abortController.signal, body: JSON.stringify(payload) });
615
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
616
+ const reader = response.body.getReader();
617
+ const decoder = new TextDecoder();
618
+ let buffer = '';
619
+ while (true) {
620
+ const { done, value } = await reader.read();
621
+ if (done) break;
622
+ if (currentChatId !== currentChatAtStart) { abortController.abort(); break; }
623
+ buffer += decoder.decode(value, { stream: true });
624
+ const lines = buffer.split('\n'); buffer = lines.pop();
625
+ for (const raw of lines) {
626
+ const line = raw.trim();
627
+ if (!line || !line.startsWith('data:')) continue;
628
+ const data = line.slice(5).trim();
629
+ if (data === '[DONE]') continue;
630
+ try {
631
+ const json = JSON.parse(data);
632
+ let delta = '';
633
+ if (backend === 'ollama') delta = json.message?.content || '';
634
+ else { delta = json.choices?.[0]?.delta?.content || ''; if (json.usage?.completion_tokens) tokenCount = json.usage.completion_tokens; }
635
+ if (delta) { fullText += delta; updateMessageDOM(aiMsg, fullText); }
636
+ } catch (e) {}
637
+ }
638
+ }
639
+ aiMsg.content.classList.remove('cursor');
640
+ if (fullText && fullText.trim()) { chat.messages.push({ role: 'assistant', content: fullText }); saveChats(); }
641
+ else { aiMsg.div.remove(); }
642
+ const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
643
+ const tps = tokenCount > 0 ? (tokenCount / elapsed).toFixed(1) : '—';
644
+ $('token-info').textContent = tokenCount > 0 ? `${tokenCount} токенов · ${elapsed}s · ${tps} tok/s` : `${elapsed}s`;
645
+ generateSuggestions(chat);
646
+ } catch (e) {
647
+ aiMsg.content.classList.remove('cursor');
648
+ if (e.name === 'AbortError') {
649
+ updateMessageDOM(aiMsg, fullText + '\n\n[прервано]');
650
+ if (fullText && fullText.trim()) { chat.messages.push({ role: 'assistant', content: fullText }); saveChats(); }
651
+ else { aiMsg.div.remove(); }
652
+ } else { updateMessageDOM(aiMsg, `❌ Ошибка: ${e.message}`); }
653
+ } finally {
654
+ isStreaming = false;
655
+ sendBtn.textContent = ''; sendBtn.appendChild(icon('send', 'Отправить')); sendBtn.classList.remove('stop');
656
+ abortController = null;
657
+ }
658
+ }
659
+
660
+ function toggleSidebar() { $('sidebar').classList.toggle('collapsed'); }
661
+
662
+ // ============ ЭКСПОРТ/ИМПОРТ ============
663
+ function exportChats() {
664
+ const data = JSON.stringify(chats, null, 2);
665
+ const blob = new Blob([data], { type: 'application/json' });
666
+ const url = URL.createObjectURL(blob);
667
+ const a = document.createElement('a');
668
+ a.href = url; a.download = 'hbb-chats-' + Date.now() + '.json';
669
+ a.click(); URL.revokeObjectURL(url);
670
+ }
671
+ function importChats() {
672
+ const inp = document.createElement('input');
673
+ inp.type = 'file'; inp.accept = '.json';
674
+ inp.onchange = async e => {
675
+ const file = e.target.files[0]; if (!file) return;
676
+ try {
677
+ const text = await file.text();
678
+ const imported = JSON.parse(text);
679
+ if (!Array.isArray(imported)) throw new Error('Не массив');
680
+ chats = [...imported, ...chats];
681
+ saveChats(); renderChatList();
682
+ if (chats.length > 0) { currentChatId = chats[0].id; renderCurrentChat(); }
683
+ } catch (err) { alert('Ошибка импорта: ' + err.message); }
684
+ };
685
+ inp.click();
686
+ }
687
+
688
+ // ============ ОБРАБОТЧИКИ ============
689
+ function setupHandlers() {
690
+ const userInput = $('user-input');
691
+ if (userInput) {
692
+ userInput.addEventListener('keydown', e => {
693
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey || !e.shiftKey)) { e.preventDefault(); sendMessage(); }
694
+ });
695
+ userInput.addEventListener('input', () => {
696
+ userInput.style.height = 'auto';
697
+ userInput.style.height = Math.min(userInput.scrollHeight, 200) + 'px';
698
+ });
699
+ userInput.addEventListener('paste', async e => {
700
+ const items = e.clipboardData?.items; if (!items) return;
701
+ const files = [];
702
+ for (const it of items) { if (it.type.startsWith('image/')) { const f = it.getAsFile(); if (f) files.push(f); } }
703
+ if (files.length) { e.preventDefault(); await addFiles(files); }
704
+ });
705
+ }
706
+ const sendBtn = $('send-btn');
707
+ if (sendBtn) sendBtn.addEventListener('click', sendMessage);
708
+ const attachBtn = $('attach-btn');
709
+ if (attachBtn) attachBtn.addEventListener('click', () => {
710
+ const inp = document.createElement('input'); inp.type = 'file'; inp.multiple = true;
711
+ inp.onchange = async e => { await addFiles(e.target.files); }; inp.click();
712
+ });
713
+ ['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => e.preventDefault()));
714
+ document.addEventListener('drop', async e => { e.preventDefault(); if (e.dataTransfer?.files?.length) await addFiles(e.dataTransfer.files); });
715
+ document.addEventListener('keydown', e => {
716
+ if (e.key === 'Escape') closeLightbox();
717
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); createNewChat(); }
718
+ if ((e.ctrlKey || e.metaKey) && e.key === '/') { e.preventDefault(); openSettings(); }
719
+ });
720
+ }
721
+
722
+ // ============ ИНИЦИАЛИЗАЦИЯ ============
723
+ window.addEventListener('DOMContentLoaded', () => {
724
+ loadTheme();
725
+ loadSettings();
726
+ loadChats();
727
+ setupHandlers();
728
+ checkConnection();
729
+ setInterval(checkConnection, 10000);
730
+ });
style.css ADDED
@@ -0,0 +1,482 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0d0d0d;
3
+ --panel: #1a1a1a;
4
+ --panel-2: #141414;
5
+ --border: #2a2a2a;
6
+ --text: #e5e5e5;
7
+ --text-dim: #888;
8
+ --accent: #4f6ef7;
9
+ --accent-hover: #3d5ce5;
10
+ --user-bg: #1e3a5f;
11
+ --code-bg: #0a0a0a;
12
+ --danger: #dc2626;
13
+ }
14
+ [data-theme="light"] {
15
+ --bg: #f5f5f5;
16
+ --panel: #ffffff;
17
+ --panel-2: #fafafa;
18
+ --border: #e0e0e0;
19
+ --text: #1a1a1a;
20
+ --text-dim: #666;
21
+ --accent: #4f6ef7;
22
+ --accent-hover: #3d5ce5;
23
+ --user-bg: #dbeafe;
24
+ --code-bg: #f0f0f0;
25
+ --danger: #dc2626;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ html, body {
29
+ height: 100%; margin: 0; padding: 0;
30
+ background: var(--bg); color: var(--text);
31
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
32
+ overflow: hidden;
33
+ }
34
+ .app { display: flex; height: 100vh; }
35
+
36
+ /* ===== Иконки ===== */
37
+ .icon {
38
+ width: 16px;
39
+ height: 16px;
40
+ vertical-align: middle;
41
+ pointer-events: none;
42
+ display: inline-block;
43
+ object-fit: contain;
44
+ }
45
+ [data-theme="dark"] .icon {
46
+ filter: invert(1);
47
+ }
48
+ .theme-btn .icon,
49
+ .toggle-btn .icon {
50
+ width: 18px;
51
+ height: 18px;
52
+ }
53
+ .send-btn .icon,
54
+ .attach-btn .icon {
55
+ width: 18px;
56
+ height: 18px;
57
+ }
58
+ .copy-btn .icon {
59
+ width: 12px;
60
+ height: 12px;
61
+ }
62
+ .avatar .icon {
63
+ width: 18px;
64
+ height: 18px;
65
+ }
66
+
67
+ /* ===== Боковая панель ===== */
68
+ .sidebar {
69
+ width: 280px; background: var(--panel-2);
70
+ border-right: 1px solid var(--border);
71
+ display: flex; flex-direction: column;
72
+ flex-shrink: 0;
73
+ transition: margin-left 0.25s ease;
74
+ }
75
+ .sidebar.collapsed { margin-left: -280px; }
76
+ .sidebar-header {
77
+ padding: 14px;
78
+ border-bottom: 1px solid var(--border);
79
+ display: flex; align-items: center; justify-content: space-between;
80
+ }
81
+ .sidebar-header .logo {
82
+ font-size: 0.95rem; font-weight: 600;
83
+ display: flex; align-items: center; gap: 8px;
84
+ }
85
+ .search-box {
86
+ padding: 8px 14px;
87
+ border-bottom: 1px solid var(--border);
88
+ }
89
+ .search-box input {
90
+ width: 100%; background: var(--bg);
91
+ border: 1px solid var(--border); color: var(--text);
92
+ padding: 6px 10px; border-radius: 8px;
93
+ font-size: 0.8rem; outline: none;
94
+ }
95
+ .new-chat-btn {
96
+ padding: 10px 14px;
97
+ background: var(--accent); color: white;
98
+ border: none; border-radius: 10px;
99
+ cursor: pointer; font-weight: 600; font-size: 0.85rem;
100
+ display: flex; align-items: center; justify-content: center; gap: 6px;
101
+ margin: 12px 14px; width: calc(100% - 28px);
102
+ transition: background 0.2s;
103
+ }
104
+ .new-chat-btn:hover { background: var(--accent-hover); }
105
+ .new-chat-btn .icon { filter: invert(1); }
106
+ .chat-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
107
+ .chat-item {
108
+ padding: 10px 12px; border-radius: 8px;
109
+ cursor: pointer; margin-bottom: 4px;
110
+ display: flex; align-items: center; justify-content: space-between;
111
+ gap: 8px; transition: background 0.15s; font-size: 0.85rem;
112
+ }
113
+ .chat-item:hover { background: rgba(127,127,127,0.1); }
114
+ .chat-item.active { background: rgba(79,110,247,0.15); }
115
+ .chat-item .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
116
+ .chat-item .del {
117
+ background: transparent; border: none;
118
+ color: var(--text-dim); cursor: pointer;
119
+ font-size: 0.9rem; padding: 0 4px;
120
+ opacity: 0; transition: opacity 0.15s;
121
+ }
122
+ .chat-item:hover .del { opacity: 1; }
123
+ .chat-item .del:hover { color: var(--danger); }
124
+ .sidebar-footer {
125
+ padding: 12px 14px;
126
+ border-top: 1px solid var(--border);
127
+ font-size: 0.75rem; color: var(--text-dim);
128
+ }
129
+ .status-dot {
130
+ display: inline-block; width: 6px; height: 6px;
131
+ border-radius: 50%; background: #ef4444;
132
+ margin-right: 5px; vertical-align: middle;
133
+ }
134
+ .status-dot.online { background: #22c55e; }
135
+ .sidebar-footer .settings-link {
136
+ display: block; margin-top: 8px;
137
+ color: var(--text-dim); cursor: pointer;
138
+ text-decoration: none; font-size: 0.75rem;
139
+ }
140
+ .sidebar-footer .settings-link:hover { color: var(--accent); }
141
+
142
+ /* ===== Основная область ===== */
143
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
144
+ .topbar {
145
+ padding: 10px 16px;
146
+ border-bottom: 1px solid var(--border);
147
+ display: flex; align-items: center; gap: 12px;
148
+ flex-shrink: 0;
149
+ }
150
+ .topbar .toggle-btn {
151
+ background: transparent; border: 1px solid var(--border);
152
+ color: var(--text-dim); width: 32px; height: 32px;
153
+ border-radius: 8px; cursor: pointer; font-size: 1rem;
154
+ display: flex; align-items: center; justify-content: center;
155
+ transition: all 0.2s;
156
+ }
157
+ .topbar .toggle-btn:hover { color: var(--accent); border-color: var(--accent); }
158
+ .topbar .current-title {
159
+ font-size: 0.9rem; font-weight: 600;
160
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
161
+ cursor: pointer;
162
+ }
163
+ .topbar .current-title:hover { color: var(--accent); }
164
+ .topbar .spacer { flex: 1; }
165
+ .topbar .token-info { font-size: 0.72rem; color: var(--text-dim); }
166
+ .topbar .theme-btn {
167
+ background: transparent; border: 1px solid var(--border);
168
+ color: var(--text-dim); width: 32px; height: 32px;
169
+ border-radius: 8px; cursor: pointer; font-size: 1rem;
170
+ display: flex; align-items: center; justify-content: center;
171
+ }
172
+
173
+ /* ===== Область чата ===== */
174
+ .chat-area {
175
+ flex: 1; overflow-y: auto;
176
+ padding: 24px 0;
177
+ display: flex; flex-direction: column; align-items: center;
178
+ }
179
+ .chat-inner {
180
+ width: 100%; max-width: 780px;
181
+ padding: 0 20px;
182
+ display: flex; flex-direction: column; gap: 20px;
183
+ }
184
+ .welcome {
185
+ display: flex; flex-direction: column;
186
+ align-items: center; justify-content: center;
187
+ flex: 1; padding: 40px 20px; text-align: center;
188
+ }
189
+ .welcome .title {
190
+ font-size: 1.5rem; font-weight: 600;
191
+ display: flex; align-items: center; gap: 10px;
192
+ margin-bottom: 8px;
193
+ }
194
+ .welcome .subtitle { color: var(--text-dim); font-size: 0.9rem; }
195
+
196
+ /* ===== Сообщения ===== */
197
+ .message { display: flex; gap: 12px; animation: fadeIn 0.2s ease; position: relative; }
198
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
199
+ .message .avatar {
200
+ width: 30px; height: 30px; border-radius: 50%;
201
+ display: flex; align-items: center; justify-content: center;
202
+ font-size: 0.9rem; flex-shrink: 0;
203
+ background: var(--panel); border: 1px solid var(--border);
204
+ }
205
+ .message.user .avatar { background: var(--user-bg); border-color: var(--user-bg); }
206
+ .message .body { flex: 1; min-width: 0; padding-top: 4px; }
207
+ .message .name {
208
+ font-size: 0.75rem; color: var(--text-dim);
209
+ margin-bottom: 4px; font-weight: 600;
210
+ display: flex; align-items: center; gap: 8px;
211
+ }
212
+ .message .name .actions {
213
+ opacity: 0; transition: opacity 0.15s;
214
+ display: flex; gap: 4px;
215
+ }
216
+ .message:hover .name .actions { opacity: 1; }
217
+ .message .name .actions button {
218
+ background: transparent; border: none;
219
+ color: var(--text-dim); cursor: pointer;
220
+ font-size: 0.7rem; padding: 0 2px;
221
+ }
222
+ .message .name .actions button:hover { color: var(--accent); }
223
+ .message .content { line-height: 1.65; font-size: 0.94rem; word-wrap: break-word; }
224
+
225
+ /* ===== Markdown ===== */
226
+ .content p { margin: 0 0 10px 0; }
227
+ .content p:last-child { margin-bottom: 0; }
228
+ .content h1, .content h2, .content h3 { margin: 16px 0 8px; font-weight: 600; }
229
+ .content h1 { font-size: 1.3em; }
230
+ .content h2 { font-size: 1.15em; }
231
+ .content h3 { font-size: 1.05em; }
232
+ .content ul, .content ol { margin: 8px 0 8px 22px; padding: 0; }
233
+ .content li { margin: 3px 0; }
234
+ .content code {
235
+ background: rgba(127,127,127,0.15); padding: 2px 6px;
236
+ border-radius: 4px; font-family: 'Consolas', 'Monaco', monospace;
237
+ font-size: 0.88em; color: #fbbf24;
238
+ }
239
+
240
+ /* ===== Блок кода ===== */
241
+ .code-block-wrapper {
242
+ position: relative; margin: 10px 0;
243
+ border: 1px solid var(--border);
244
+ border-radius: 0; overflow: hidden;
245
+ background: var(--code-bg);
246
+ }
247
+ .code-header {
248
+ position: absolute; top: 0; left: 0; right: 0;
249
+ display: flex; justify-content: space-between; align-items: center;
250
+ padding: 4px 10px;
251
+ background: rgba(0,0,0,0.55);
252
+ backdrop-filter: blur(4px);
253
+ border-bottom: 1px solid var(--border);
254
+ font-size: 0.72rem; color: var(--text-dim);
255
+ z-index: 2;
256
+ }
257
+ .code-header .lang { text-transform: lowercase; }
258
+ .code-header .copy-btn {
259
+ background: transparent; border: 1px solid var(--border);
260
+ color: var(--text-dim); padding: 3px 8px;
261
+ border-radius: 0; cursor: pointer; font-size: 0.7rem;
262
+ transition: all 0.2s;
263
+ display: flex; align-items: center; gap: 4px;
264
+ }
265
+ .code-header .copy-btn:hover { color: var(--accent); border-color: var(--accent); }
266
+ .code-header .copy-btn.copied { color: #22c55e; border-color: #22c55e; }
267
+ .code-header .copy-btn .icon { filter: invert(1); }
268
+ [data-theme="light"] .code-header .copy-btn .icon { filter: none; }
269
+ .content pre {
270
+ background: var(--code-bg); border: none;
271
+ border-radius: 0; padding: 0; margin: 0;
272
+ overflow-x: auto;
273
+ }
274
+ .content pre code {
275
+ display: block; padding: 34px 14px 14px 14px;
276
+ background: transparent; color: var(--text);
277
+ font-size: 0.85em; font-family: 'Consolas', 'Monaco', monospace;
278
+ line-height: 1.5;
279
+ }
280
+ .content blockquote {
281
+ border-left: 3px solid var(--accent);
282
+ padding-left: 12px; margin: 8px 0; color: var(--text-dim);
283
+ }
284
+ .content table { border-collapse: collapse; margin: 10px 0; font-size: 0.85em; }
285
+ .content th, .content td { border: 1px solid var(--border); padding: 5px 10px; }
286
+ .content th { background: rgba(127,127,127,0.08); }
287
+ .content a { color: #6ea8fe; text-decoration: none; }
288
+ .content a:hover { text-decoration: underline; }
289
+ .cursor::after {
290
+ content: "▋"; animation: blink 1s steps(2) infinite; color: var(--accent);
291
+ }
292
+ @keyframes blink { 50% { opacity: 0; } }
293
+
294
+ .msg-attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
295
+ .msg-attachments img {
296
+ max-width: 200px; max-height: 200px;
297
+ border-radius: 8px; cursor: zoom-in;
298
+ border: 1px solid var(--border);
299
+ }
300
+ .msg-attachments .file-chip {
301
+ display: inline-flex; align-items: center; gap: 6px;
302
+ background: rgba(127,127,127,0.1);
303
+ padding: 6px 10px; border-radius: 6px;
304
+ font-size: 0.78rem; border: 1px solid var(--border);
305
+ }
306
+
307
+ /* ===== Поле ввода ===== */
308
+ .input-wrap { width: 100%; max-width: 780px; margin: 0 auto; padding: 0 20px 20px; }
309
+ .input-box {
310
+ background: var(--panel);
311
+ border: 1px solid var(--border);
312
+ border-radius: 16px;
313
+ padding: 12px 14px;
314
+ transition: border-color 0.2s;
315
+ position: relative;
316
+ }
317
+ .input-box:focus-within { border-color: var(--accent); }
318
+ textarea#user-input {
319
+ width: 100%; background: transparent; border: none;
320
+ color: var(--text); font-size: 0.95rem;
321
+ resize: none; outline: none; font-family: inherit;
322
+ min-height: 24px; max-height: 200px;
323
+ line-height: 1.5; padding: 0;
324
+ }
325
+ textarea#user-input::placeholder { color: var(--text-dim); }
326
+ .input-actions {
327
+ display: flex; justify-content: space-between; align-items: center;
328
+ margin-top: 10px; gap: 8px;
329
+ }
330
+ .input-actions .left { display: flex; gap: 6px; align-items: center; }
331
+ .icon-btn {
332
+ background: transparent; border: 1px solid var(--border);
333
+ color: var(--text-dim); width: 32px; height: 32px;
334
+ border-radius: 8px; cursor: pointer; font-size: 0.9rem;
335
+ display: flex; align-items: center; justify-content: center;
336
+ transition: all 0.2s;
337
+ }
338
+ .icon-btn:hover { color: var(--accent); border-color: var(--accent); }
339
+ .icon-btn.has-files { color: var(--accent); border-color: var(--accent); }
340
+ .send-btn {
341
+ background: var(--accent); color: white;
342
+ border: none; width: 36px; height: 36px;
343
+ border-radius: 10px; cursor: pointer;
344
+ display: flex; align-items: center; justify-content: center;
345
+ font-size: 1rem; transition: background 0.2s;
346
+ }
347
+ .send-btn:hover:not(:disabled) { background: var(--accent-hover); }
348
+ .send-btn:disabled { background: #333; cursor: not-allowed; }
349
+ .send-btn.stop { background: var(--danger); }
350
+ .send-btn .icon { filter: invert(1); }
351
+ [data-theme="light"] .send-btn .icon { filter: none; }
352
+
353
+ .attachments-preview { display: none; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
354
+ .attachments-preview.visible { display: flex; }
355
+ .att-preview-item {
356
+ position: relative; width: 56px; height: 56px;
357
+ border-radius: 8px; overflow: hidden;
358
+ border: 1px solid var(--border); background: #000;
359
+ }
360
+ .att-preview-item img { width: 100%; height: 100%; object-fit: cover; }
361
+ .att-preview-item.file {
362
+ display: flex; flex-direction: column;
363
+ align-items: center; justify-content: center;
364
+ background: var(--panel); color: var(--text-dim);
365
+ font-size: 0.55rem; text-align: center; padding: 2px;
366
+ }
367
+ .att-preview-item.file .icon { font-size: 1.2rem; }
368
+ .att-preview-item .remove {
369
+ position: absolute; top: 2px; right: 2px;
370
+ width: 16px; height: 16px; background: rgba(0,0,0,0.8);
371
+ color: white; border-radius: 50%; border: none;
372
+ cursor: pointer; font-size: 10px; line-height: 1;
373
+ display: flex; align-items: center; justify-content: center; padding: 0;
374
+ }
375
+ .att-preview-item .remove:hover { background: var(--danger); }
376
+
377
+ /* ===== Предложка ===== */
378
+ .suggestions-bar {
379
+ display: flex;
380
+ flex-wrap: wrap;
381
+ gap: 6px;
382
+ margin-bottom: 10px;
383
+ padding: 0 2px;
384
+ }
385
+ .suggestion-btn {
386
+ background: var(--panel);
387
+ border: 1px solid var(--border);
388
+ color: var(--text-dim);
389
+ padding: 6px 12px;
390
+ border-radius: 16px;
391
+ cursor: pointer;
392
+ font-size: 0.78rem;
393
+ transition: all 0.2s;
394
+ max-width: 100%;
395
+ overflow: hidden;
396
+ text-overflow: ellipsis;
397
+ white-space: nowrap;
398
+ }
399
+ .suggestion-btn:hover {
400
+ color: var(--accent);
401
+ border-color: var(--accent);
402
+ }
403
+
404
+ /* ===== Модалка настроек ===== */
405
+ .settings-overlay {
406
+ position: fixed; inset: 0; z-index: 200;
407
+ background: rgba(0,0,0,0.7);
408
+ display: none; align-items: center; justify-content: center;
409
+ padding: 20px;
410
+ }
411
+ .settings-overlay.visible { display: flex; }
412
+ .settings-panel {
413
+ background: var(--panel); border: 1px solid var(--border);
414
+ border-radius: 16px; padding: 24px;
415
+ width: 100%; max-width: 460px;
416
+ max-height: 85vh; overflow-y: auto;
417
+ }
418
+ .settings-panel h3 {
419
+ margin: 0 0 16px; font-size: 1rem;
420
+ display: flex; justify-content: space-between; align-items: center;
421
+ }
422
+ .settings-panel h3 button {
423
+ background: transparent; border: none;
424
+ color: var(--text-dim); cursor: pointer; font-size: 1.2rem;
425
+ }
426
+ .settings-panel .field { margin-bottom: 14px; }
427
+ .settings-panel .field label {
428
+ display: flex; justify-content: space-between;
429
+ font-size: 0.82rem; color: var(--text-dim); margin-bottom: 6px;
430
+ }
431
+ .settings-panel .field label span { color: var(--accent); font-weight: 600; }
432
+ .settings-panel input[type="text"],
433
+ .settings-panel input[type="number"],
434
+ .settings-panel textarea,
435
+ .settings-panel select {
436
+ width: 100%; background: var(--bg);
437
+ border: 1px solid var(--border); color: var(--text);
438
+ padding: 8px 10px; border-radius: 8px;
439
+ font-size: 0.85rem; outline: none; font-family: inherit;
440
+ }
441
+ .settings-panel textarea { resize: vertical; min-height: 70px; }
442
+ .settings-panel input[type="range"] { width: 100%; accent-color: var(--accent); }
443
+ .settings-panel .hint { font-size: 0.7rem; color: var(--text-dim); opacity: 0.7; margin-top: 4px; }
444
+ .settings-panel .divider { height: 1px; background: var(--border); margin: 16px 0; }
445
+ .settings-panel .btn-primary {
446
+ width: 100%; background: var(--accent); color: white;
447
+ border: none; padding: 10px; border-radius: 8px;
448
+ cursor: pointer; font-weight: 600; font-size: 0.9rem;
449
+ }
450
+ .settings-panel .btn-primary:hover { background: var(--accent-hover); }
451
+ .settings-panel .btn-secondary {
452
+ width: 100%; background: transparent; color: var(--text-dim);
453
+ border: 1px solid var(--border); padding: 8px;
454
+ border-radius: 8px; cursor: pointer; font-size: 0.8rem;
455
+ margin-top: 8px;
456
+ display: flex; align-items: center; justify-content: center; gap: 6px;
457
+ }
458
+ .settings-panel .btn-secondary:hover { color: var(--text); border-color: var(--text-dim); }
459
+ .settings-panel .btn-row { display: flex; gap: 8px; margin-top: 8px; }
460
+ .settings-panel .btn-row button { flex: 1; }
461
+
462
+ .lightbox {
463
+ position: fixed; inset: 0; z-index: 1000;
464
+ background: rgba(0,0,0,0.92);
465
+ display: none; align-items: center; justify-content: center;
466
+ cursor: zoom-out;
467
+ }
468
+ .lightbox.visible { display: flex; }
469
+ .lightbox img { max-width: 95vw; max-height: 95vh; border-radius: 8px; }
470
+
471
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
472
+ ::-webkit-scrollbar-track { background: transparent; }
473
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
474
+ ::-webkit-scrollbar-thumb:hover { background: #666; }
475
+
476
+ @media (max-width: 700px) {
477
+ .sidebar {
478
+ position: absolute; top: 0; bottom: 0; left: 0;
479
+ z-index: 100; box-shadow: 4px 0 20px rgba(0,0,0,0.5);
480
+ }
481
+ .chat-inner, .input-wrap { padding-left: 14px; padding-right: 14px; }
482
+ }