Spaces:
Running
Running
Upload 23 files
Browse files- icons/assistant.png +0 -0
- icons/attach.png +0 -0
- icons/check.png +0 -0
- icons/close.png +0 -0
- icons/copy.png +0 -0
- icons/delete.png +0 -0
- icons/error.png +0 -0
- icons/export.png +0 -0
- icons/import.png +0 -0
- icons/menu.png +0 -0
- icons/new-chat.png +0 -0
- icons/regenerate.png +0 -0
- icons/search.png +0 -0
- icons/send.png +0 -0
- icons/settings.png +0 -0
- icons/share.png +0 -0
- icons/stop.png +0 -0
- icons/theme-dark.png +0 -0
- icons/theme-light.png +0 -0
- icons/user.png +0 -0
- index.html +235 -0
- script.js +730 -0
- style.css +482 -0
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 |
+
}
|