feat: new UI with sidebar, favicon, copilot instructions, gitignore cleanup

This commit is contained in:
2026-06-04 09:10:33 +03:00
parent 4a3d5d21d1
commit 22ca83adf8
13 changed files with 674 additions and 9 deletions
+658 -9
View File
@@ -1,15 +1,664 @@
<!DOCTYPE html>
<html>
<html lang="ru">
<head>
<title>LLM-UI :: Test</title>
<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>
<cfoutput>
<h1>LLM-UI OK</h1>
<p>Lucee: #server.lucee.version#</p>
<p>Java: #server.java.version#</p>
<p>OS: #server.os.name#</p>
<p>Now: #now()#</p>
</cfoutput>
<!-- 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>