Files
LLM-UI/index.cfm
T

665 lines
25 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 = ' &middot; <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(' &middot; ') + 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()">&times;</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>