665 lines
25 KiB
Plaintext
665 lines
25 KiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>LLM-UI — Nubes</title>
|
||
<link rel="icon" type="image/png" href="favicon.png" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #f5f5f5;
|
||
--card: #ffffff;
|
||
--text: #1a1a1a;
|
||
--muted: #6b7280;
|
||
--border: #d1d5db;
|
||
--grey-light: #f3f4f6;
|
||
--blue: #1e3a5f;
|
||
--blue-h: #152d4a;
|
||
--red: #ef4444;
|
||
--green: #16a34a;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
display: flex;
|
||
}
|
||
|
||
/* ---- SIDEBAR ---- */
|
||
.sidebar {
|
||
width: 192px;
|
||
background: var(--card);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
}
|
||
.sidebar-logo {
|
||
height: 80px;
|
||
border-top: 4px solid var(--blue);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 12px;
|
||
}
|
||
.sidebar-logo svg { width: 130px; }
|
||
.sidebar-nav {
|
||
flex: 1;
|
||
padding: 8px 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
text-decoration: none;
|
||
transition: all 0.15s;
|
||
border: none;
|
||
background: none;
|
||
width: 100%;
|
||
text-align: left;
|
||
font-family: inherit;
|
||
}
|
||
.nav-item:hover { background: var(--grey-light); color: var(--text); }
|
||
.nav-item.active { background: var(--blue); color: #fff; }
|
||
.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||
|
||
/* ---- MAIN ---- */
|
||
.main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* ---- TOP BAR ---- */
|
||
.topbar {
|
||
height: 80px;
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
.topbar-title {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
.model-select {
|
||
height: 32px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 0 10px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
background: var(--card);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
min-width: 220px;
|
||
}
|
||
.model-select:focus {
|
||
outline: 2px solid var(--blue);
|
||
outline-offset: -1px;
|
||
}
|
||
|
||
/* ---- TOKEN BAR ---- */
|
||
.token-bar {
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 6px 20px;
|
||
display: flex;
|
||
gap: 24px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
flex-shrink: 0;
|
||
}
|
||
.token-bar b {
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* ---- CHAT ---- */
|
||
.chat-container {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 16px 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
transition: background 0.2s;
|
||
}
|
||
.chat-container.drag-over {
|
||
background: #eff6ff;
|
||
outline: 2px dashed var(--blue);
|
||
outline-offset: -4px;
|
||
}
|
||
|
||
.message {
|
||
display: flex;
|
||
gap: 10px;
|
||
max-width: 80%;
|
||
animation: fadeIn 0.15s ease;
|
||
}
|
||
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||
|
||
.message.user { align-self: flex-end; flex-direction: row-reverse; }
|
||
.message.assistant { align-self: flex-start; }
|
||
|
||
.msg-avatar {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
background: var(--grey-light);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.msg-avatar svg { width: 16px; height: 16px; }
|
||
.message.user .msg-avatar { background: var(--blue); border-color: var(--blue); }
|
||
.message.user .msg-avatar svg { color: #fff; }
|
||
|
||
.msg-bubble {
|
||
background: var(--card);
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||
line-height: 1.6;
|
||
font-size: 14px;
|
||
word-break: break-word;
|
||
}
|
||
.message.user .msg-bubble {
|
||
background: #eff6ff;
|
||
border-color: #dbeafe;
|
||
color: #1e40af;
|
||
}
|
||
.msg-bubble pre {
|
||
background: #1e293b;
|
||
color: #e2e8f0;
|
||
padding: 10px;
|
||
border-radius: 6px;
|
||
overflow-x: auto;
|
||
margin: 6px 0;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.msg-bubble code {
|
||
font-family: 'Menlo', 'Consolas', monospace;
|
||
font-size: 13px;
|
||
}
|
||
.msg-bubble p { margin: 4px 0; }
|
||
|
||
.msg-meta {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
}
|
||
.message.user .msg-meta { text-align: right; }
|
||
|
||
/* ---- INPUT ---- */
|
||
.input-area {
|
||
background: var(--card);
|
||
border-top: 1px solid var(--border);
|
||
padding: 10px 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
.file-preview {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: var(--grey-light);
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
margin-bottom: 8px;
|
||
font-size: 13px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.file-preview .remove-file {
|
||
margin-left: auto;
|
||
cursor: pointer;
|
||
color: var(--red);
|
||
font-weight: bold;
|
||
border: none;
|
||
background: none;
|
||
font-size: 16px;
|
||
}
|
||
.input-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: flex-end;
|
||
}
|
||
.input-row textarea {
|
||
flex: 1;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
line-height: 1.5;
|
||
resize: none;
|
||
min-height: 56px;
|
||
max-height: 150px;
|
||
}
|
||
.input-row textarea:focus {
|
||
outline: none;
|
||
border-color: var(--blue);
|
||
box-shadow: 0 0 0 2px rgba(37,99,235,0.15);
|
||
}
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
height: 32px;
|
||
padding: 0 12px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
background: var(--card);
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
transition: background 0.15s, opacity 0.15s;
|
||
}
|
||
.btn:hover { background: var(--grey-light); }
|
||
.btn-primary {
|
||
background: var(--blue);
|
||
color: #fff;
|
||
border-color: var(--blue);
|
||
}
|
||
.btn-primary:hover { background: var(--blue-h); }
|
||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.btn-icon { width: 36px; padding: 0; justify-content: center; }
|
||
.btn-icon svg { width: 18px; height: 18px; }
|
||
|
||
#fileInput { display: none; }
|
||
|
||
/* ---- TYPING ---- */
|
||
.typing-indicator {
|
||
display: flex; gap: 4px; padding: 6px 0;
|
||
}
|
||
.typing-indicator span {
|
||
width: 6px; height: 6px;
|
||
background: var(--muted);
|
||
border-radius: 50%;
|
||
animation: bounce 1.4s infinite ease-in-out;
|
||
}
|
||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||
.typing-timer {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
@keyframes bounce {
|
||
0%, 80%, 100% { transform: translateY(0); }
|
||
40% { transform: translateY(-6px); }
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.sidebar { display: none; }
|
||
.message { max-width: 95%; }
|
||
.topbar { padding: 0 16px; }
|
||
.chat-container { padding: 12px 16px; }
|
||
.input-area { padding: 10px 16px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- SIDEBAR -->
|
||
<div class="sidebar">
|
||
<div class="sidebar-logo">
|
||
<svg viewBox="0 0 130 34" xmlns="http://www.w3.org/2000/svg">
|
||
<text x="0" y="29" font-family="Nunito,system-ui,sans-serif" font-weight="800" font-size="30" fill="#1e3a5f" letter-spacing="-0.5">nubes</text>
|
||
</svg>
|
||
</div>
|
||
<div class="sidebar-nav">
|
||
<button class="nav-item active">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
Чат
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MAIN -->
|
||
<div class="main">
|
||
<div class="topbar">
|
||
<select class="model-select" id="modelSelect">
|
||
<option value="">Загрузка моделей...</option>
|
||
</select>
|
||
<div class="topbar-title">Чат с LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.12</span></div>
|
||
<div style="width:220px;text-align:right;font-size:11px;color:var(--muted);line-height:1.3;">
|
||
☑️ контекст — включить<br>в следующий запрос
|
||
</div>
|
||
</div>
|
||
|
||
<div class="token-bar">
|
||
<span>Входящие токены: <b id="tokIn">0</b></span>
|
||
<span>Исходящие токены: <b id="tokOut">0</b></span>
|
||
<span>Всего: <b id="tokTotal">0</b></span>
|
||
</div>
|
||
|
||
<div class="chat-container" id="chatContainer">
|
||
<div class="message assistant">
|
||
<div class="msg-avatar">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a4 4 0 0 1 4 4v1h3a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3V6a4 4 0 0 1 4-4z"/></svg>
|
||
</div>
|
||
<div><div class="msg-bubble">LLM-UI готов к работе. Выберите модель и введите запрос.</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="input-area">
|
||
<div id="filePreview"></div>
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
|
||
<span style="font-size:12px;color:var(--muted);">Кратко</span>
|
||
<input type="range" id="brevitySlider" min="0" max="1" value="1" step="1" style="flex:1;max-width:200px;accent-color:var(--blue);" />
|
||
<span style="font-size:12px;color:var(--muted);">Нормально</span>
|
||
</div>
|
||
<div class="input-row">
|
||
<button class="btn btn-primary" id="attachBtn">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||
Файл
|
||
</button>
|
||
<input type="file" id="fileInput" />
|
||
<textarea id="msgInput" placeholder="Введите сообщение или перетащите файл..." rows="2"></textarea>
|
||
<button class="btn btn-primary" id="sendBtn">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||
Отправить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
var chatState = {
|
||
messages: [],
|
||
totalPromptTokens: 0,
|
||
totalCompletionTokens: 0,
|
||
attachedFile: null,
|
||
requestStartTime: 0,
|
||
timerInterval: null
|
||
};
|
||
|
||
function apiCall(method, args) {
|
||
var url = 'api.cfc?method=' + method + '&returnformat=json';
|
||
var body = null;
|
||
if (args) {
|
||
body = new FormData();
|
||
for (var k in args) {
|
||
body.append(k, args[k]);
|
||
}
|
||
}
|
||
return fetch(url, { method: 'POST', body: body }).then(function(r) { return r.json(); });
|
||
}
|
||
|
||
function init() {
|
||
apiCall('getModels').then(function(res) {
|
||
var sel = document.getElementById('modelSelect');
|
||
sel.innerHTML = '';
|
||
var data = res.DATA || res.data || [];
|
||
var ok = res.SUCCESS || res.success || false;
|
||
if (ok && data && data.length > 0) {
|
||
data.forEach(function(m) {
|
||
var id = typeof m === 'string' ? m : (m.ID || m.id || m.NAME || m.name || '');
|
||
var name = typeof m === 'string' ? m : (m.NAME || m.name || m.ID || m.id || '');
|
||
sel.innerHTML += '<option value="' + escHtml(id) + '">' + escHtml(name) + '</option>';
|
||
});
|
||
}
|
||
if (!sel.options.length) sel.innerHTML = '<option value="">Нет моделей</option>';
|
||
}).catch(function() {
|
||
document.getElementById('modelSelect').innerHTML = '<option value="">Ошибка загрузки</option>';
|
||
});
|
||
}
|
||
|
||
function escHtml(s) {
|
||
if (!s) return '';
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function avatarSVG(role) {
|
||
if (role === 'user') {
|
||
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
||
}
|
||
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a4 4 0 0 1 4 4v1h3a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3V6a4 4 0 0 1 4-4z"/></svg>';
|
||
}
|
||
|
||
function addMessage(role, content, tokens, elapsed, charsIn, charsOut, model, msgIndex) {
|
||
var container = document.getElementById('chatContainer');
|
||
var div = document.createElement('div');
|
||
div.className = 'message ' + role;
|
||
|
||
var metaParts = [];
|
||
if (tokens) {
|
||
var pin = tokens.PROMPT_TOKENS || tokens.prompt_tokens || 0;
|
||
var pout = tokens.COMPLETION_TOKENS || tokens.completion_tokens || 0;
|
||
metaParts.push('Вход: ' + pin + ' / Выход: ' + pout);
|
||
}
|
||
if (elapsed) {
|
||
metaParts.push(elapsed + 'с');
|
||
}
|
||
if (charsIn !== undefined && charsOut !== undefined) {
|
||
metaParts.push('Симв: ' + charsIn + '→' + charsOut);
|
||
}
|
||
if (model) {
|
||
metaParts.push(model);
|
||
}
|
||
// context checkbox for assistant messages
|
||
var ctxHtml = '';
|
||
if (role === 'assistant' && msgIndex !== undefined) {
|
||
ctxHtml = ' · <label style="cursor:pointer;font-size:11px;"><input type="checkbox" checked onchange="chatState.messages[' + msgIndex + '].includeInContext=this.checked" style="vertical-align:middle;" /> контекст</label>';
|
||
}
|
||
var metaStr = metaParts.join(' · ') + ctxHtml;
|
||
div.innerHTML =
|
||
'<div class="msg-avatar">' + avatarSVG(role) + '</div>' +
|
||
'<div>' +
|
||
'<div class="msg-bubble">' + formatContent(content) + '</div>' +
|
||
(metaStr ? '<div class="msg-meta">' + metaStr + '</div>' : '') +
|
||
'</div>';
|
||
container.appendChild(div);
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
function addTyping() {
|
||
var container = document.getElementById('chatContainer');
|
||
var div = document.createElement('div');
|
||
div.className = 'message assistant';
|
||
div.id = 'typingMsg';
|
||
div.innerHTML =
|
||
'<div class="msg-avatar">' + avatarSVG('assistant') + '</div>' +
|
||
'<div><div class="msg-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div><div class="typing-timer" id="typingTimer">0.0с</div></div></div>';
|
||
container.appendChild(div);
|
||
container.scrollTop = container.scrollHeight;
|
||
// start live timer
|
||
chatState.requestStartTime = Date.now();
|
||
chatState.timerInterval = setInterval(function() {
|
||
var el = document.getElementById('typingTimer');
|
||
if (el) {
|
||
el.textContent = ((Date.now() - chatState.requestStartTime) / 1000).toFixed(1) + 'с';
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
function removeTyping() {
|
||
var el = document.getElementById('typingMsg');
|
||
if (el) el.remove();
|
||
if (chatState.timerInterval) {
|
||
clearInterval(chatState.timerInterval);
|
||
chatState.timerInterval = null;
|
||
}
|
||
return ((Date.now() - chatState.requestStartTime) / 1000).toFixed(1);
|
||
}
|
||
|
||
function formatContent(text) {
|
||
if (!text) return '';
|
||
var escaped = escHtml(text);
|
||
escaped = escaped.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
|
||
escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
||
escaped = escaped.replace(/\n/g, '<br>');
|
||
return escaped;
|
||
}
|
||
|
||
function sendMessage() {
|
||
var input = document.getElementById('msgInput');
|
||
var text = input.value.trim();
|
||
var model = document.getElementById('modelSelect').value;
|
||
|
||
if (!text && !chatState.attachedFile) return;
|
||
if (!model) { alert('Выберите модель'); return; }
|
||
|
||
var content = text;
|
||
if (chatState.attachedFile) {
|
||
var fileInfo = '\n\n[Файл: ' + chatState.attachedFile.name + ']\n' + chatState.attachedFile.content;
|
||
content = text ? text + fileInfo : fileInfo;
|
||
}
|
||
|
||
chatState.messages.push({ role: 'user', content: content, includeInContext: true });
|
||
var userIdx = chatState.messages.length - 1;
|
||
var inputChars = content.length;
|
||
addMessage('user', text || ('Файл: ' + chatState.attachedFile.name));
|
||
|
||
// filter messages by includeInContext
|
||
var ctxMessages = chatState.messages.filter(function(m) { return m.includeInContext !== false; });
|
||
|
||
// brevity slider
|
||
var brevity = parseInt(document.getElementById('brevitySlider').value);
|
||
var systemPrompt = '';
|
||
if (brevity === 0) { systemPrompt = 'Отвечай кратко, только суть. Не более 2-3 предложений.'; }
|
||
|
||
var apiArgs = {
|
||
model: model,
|
||
messages: JSON.stringify(ctxMessages)
|
||
};
|
||
if (systemPrompt) apiArgs.system = systemPrompt;
|
||
|
||
input.value = '';
|
||
clearFile();
|
||
input.style.height = 'auto';
|
||
|
||
setSending(true);
|
||
addTyping();
|
||
|
||
apiCall('chat', apiArgs).then(function(res) {
|
||
var elapsed = removeTyping();
|
||
var ok = res.SUCCESS || res.success || false;
|
||
if (ok) {
|
||
var content = res.CONTENT || res.content || '';
|
||
var usage = res.USAGE || res.usage || {};
|
||
chatState.messages.push({ role: 'assistant', content: content, includeInContext: true });
|
||
var asstIdx = chatState.messages.length - 1;
|
||
addMessage('assistant', content, usage, elapsed, inputChars, content.length, model, asstIdx);
|
||
if (usage) {
|
||
chatState.totalPromptTokens += (usage.PROMPT_TOKENS || usage.prompt_tokens || 0);
|
||
chatState.totalCompletionTokens += (usage.COMPLETION_TOKENS || usage.completion_tokens || 0);
|
||
updateTokenDisplay();
|
||
}
|
||
} else {
|
||
var err = res.ERROR || res.error || 'неизвестная ошибка';
|
||
addMessage('assistant', 'Ошибка: ' + err + ' [' + model + ' | ~' + Math.round(inputChars/4) + ' tok | ' + inputChars + ' симв]', null, elapsed);
|
||
chatState.messages.pop();
|
||
}
|
||
setSending(false);
|
||
}).catch(function() {
|
||
var elapsed = removeTyping();
|
||
addMessage('assistant', 'Сеть: нет ответа от прокси [' + model + ' | ~' + Math.round(inputChars/4) + ' tok | ' + inputChars + ' симв]', null, elapsed);
|
||
chatState.messages.pop();
|
||
setSending(false);
|
||
});
|
||
}
|
||
|
||
function setSending(sending) {
|
||
document.getElementById('sendBtn').disabled = sending;
|
||
document.getElementById('msgInput').disabled = sending;
|
||
}
|
||
|
||
function updateTokenDisplay() {
|
||
document.getElementById('tokIn').textContent = chatState.totalPromptTokens;
|
||
document.getElementById('tokOut').textContent = chatState.totalCompletionTokens;
|
||
document.getElementById('tokTotal').textContent =
|
||
chatState.totalPromptTokens + chatState.totalCompletionTokens;
|
||
}
|
||
|
||
function handleFile(file) {
|
||
var reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
chatState.attachedFile = { name: file.name, content: e.target.result, type: file.type };
|
||
document.getElementById('filePreview').innerHTML =
|
||
'<div class="file-preview">Файл: ' + escHtml(file.name) +
|
||
' <button class="remove-file" onclick="clearFile()">×</button></div>';
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function clearFile() {
|
||
chatState.attachedFile = null;
|
||
document.getElementById('filePreview').innerHTML = '';
|
||
document.getElementById('fileInput').value = '';
|
||
}
|
||
|
||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||
document.getElementById('msgInput').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||
});
|
||
document.getElementById('msgInput').addEventListener('input', function() {
|
||
this.style.height = 'auto';
|
||
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
|
||
});
|
||
document.getElementById('attachBtn').addEventListener('click', function() {
|
||
document.getElementById('fileInput').click();
|
||
});
|
||
document.getElementById('fileInput').addEventListener('change', function(e) {
|
||
if (e.target.files.length > 0) handleFile(e.target.files[0]);
|
||
});
|
||
|
||
// Drag & drop
|
||
var dropTarget = document.getElementById('chatContainer');
|
||
dropTarget.addEventListener('dragover', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.classList.add('drag-over');
|
||
});
|
||
dropTarget.addEventListener('dragleave', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.classList.remove('drag-over');
|
||
});
|
||
dropTarget.addEventListener('drop', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.classList.remove('drag-over');
|
||
if (e.dataTransfer.files.length > 0) {
|
||
handleFile(e.dataTransfer.files[0]);
|
||
}
|
||
});
|
||
|
||
init();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|