Skip to content

Instantly share code, notes, and snippets.

@celsowm
Created April 30, 2025 00:12
Show Gist options
  • Save celsowm/be42978f06e68eb5c52196d01afd6b67 to your computer and use it in GitHub Desktop.
Save celsowm/be42978f06e68eb5c52196d01afd6b67 to your computer and use it in GitHub Desktop.
llm chat single page htm (LEVI CHAT)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Levi Chat</title>
<style>
body {
margin: 0;
background: #f7f7f8;
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.chat-wrapper {
width: 600px;
height: 80vh;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ===== HEADER ===== */
.chat-header {
padding: 8px 16px;
border-bottom: 1px solid #e5e5e5;
background: #fafafa;
display: flex;
gap: 8px;
align-items: center;
}
.chat-header select {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
background: #f5f5f7;
appearance: none;
cursor: pointer;
}
.input-with-icon {
display: flex;
align-items: center;
background: #f5f5f7;
border: 1px solid #d1d5db;
border-radius: 6px;
overflow: hidden;
}
.input-with-icon .icon {
padding: 6px 8px;
font-size: 16px;
line-height: 1;
}
.input-with-icon input {
border: none;
background: transparent;
padding: 6px 10px;
font-size: 14px;
width: 100px;
outline: none;
}
/* ===== MESSAGES ===== */
.messages {
flex: 1;
padding: 16px;
overflow-y: auto;
background: #fff;
}
.message {
margin-bottom: 10px;
padding: 10px 15px;
border-radius: 20px;
max-width: 70%;
line-height: 1.4;
position: relative;
word-wrap: break-word;
clear: both;
display: inline-block;
}
.message.user {
background-color: #007bff;
color: #fff;
float: right;
text-align: right;
}
.message.bot {
background-color: #f1f1f1;
color: #000;
float: left;
text-align: left;
}
.message.user .token-count {
position: absolute;
bottom: 4px;
right: 15px;
font-size: 0.7em;
color: rgba(255,255,255,0.6);
}
/* ===== INPUT AREA ===== */
.input-container {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #e5e5e5;
background: #fafafa;
}
.file-icons {
display: flex;
align-items: center;
gap: 6px;
}
.file-icon {
font-size: 20px;
cursor: pointer;
color: #6b7280;
}
.file-name {
font-size: 14px;
color: #333;
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
input[type="file"] {
display: none;
}
textarea {
flex: 1;
resize: none;
min-height: 40px;
max-height: 120px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 15px;
font-size: 14px;
box-sizing: border-box;
}
/* ===== BUTTONS ===== */
#sendBtn {
padding: 10px 16px;
border: none;
border-radius: 15px;
background-color: #28a745;
color: #fff;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
}
#sendBtn:hover {
background-color: #218838;
}
.thinking-btn {
background: none;
border: none;
cursor: pointer;
font-size: 20px;
color: #6b7280;
transition: color 0.2s;
}
.thinking-btn.active {
color: #10b981;
}
</style>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"/>
<!-- PDF.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.6.347/pdf.min.js"></script>
</head>
<body>
<div class="chat-wrapper">
<!-- HEADER with modelo, IP and porta -->
<div class="chat-header">
<select id="modelSelect"></select>
<div class="input-with-icon">
<span class="icon">🖥️</span>
<input
type="text"
id="serverIpInput"
placeholder="0.0.0.0"
value="0.0.0.0"
>
</div>
<div class="input-with-icon">
<span class="icon">🔌</span>
<input
type="text"
id="serverPortInput"
placeholder="8081"
value="8081"
>
</div>
</div>
<!-- MESSAGES -->
<div class="messages" id="messages"></div>
<!-- INPUT AREA -->
<div class="input-container">
<div class="file-icons">
<i class="fa-solid fa-paperclip file-icon" onclick="fileInput.click()"></i>
<i class="fa-solid fa-trash file-icon" id="removeFileIcon" style="display:none" onclick="removeFile()"></i>
<span id="fileName" class="file-name"></span>
<input type="file" id="fileInput" accept=".pdf" onchange="updateFileName()">
</div>
<textarea id="userInput" placeholder="Digite seu prompt aqui..."></textarea>
<button id="thinkingBtn" class="thinking-btn" title="Thinking OFF">
<i class="fa-solid fa-brain"></i>
</button>
<button id="sendBtn">Enviar</button>
</div>
</div>
<script>
const modelSelect = document.getElementById('modelSelect');
const messagesDiv = document.getElementById('messages');
const textarea = document.getElementById('userInput');
const fileInput = document.getElementById('fileInput');
const fileNameSpan = document.getElementById('fileName');
const removeFileIcon = document.getElementById('removeFileIcon');
const thinkingBtn = document.getElementById('thinkingBtn');
const sendBtn = document.getElementById('sendBtn');
const serverIpInput = document.getElementById('serverIpInput');
const serverPortInput = document.getElementById('serverPortInput');
let thinkingEnabled = false;
function getBaseUrl() {
const ip = serverIpInput.value.trim() || '0.0.0.0';
const port = serverPortInput.value.trim() || '8081';
return `http://${ip}:${port}`;
}
function getModelsUrl() { return `${getBaseUrl()}/v1/models`; }
function getChatUrl() { return `${getBaseUrl()}/v1/chat/completions`; }
function getTokenCountUrl() { return `${getBaseUrl()}/tokenize`; }
function fetchModels() {
modelSelect.innerHTML = '';
fetch(getModelsUrl())
.then(r => r.json())
.then(data => {
if (!data.data) return;
data.data.forEach((m, i) => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.id;
if (i === 0) opt.selected = true;
modelSelect.appendChild(opt);
});
})
.catch(console.error);
}
serverIpInput.addEventListener('change', fetchModels);
serverPortInput.addEventListener('change', fetchModels);
window.addEventListener('DOMContentLoaded', fetchModels);
thinkingBtn.addEventListener('click', () => {
thinkingEnabled = !thinkingEnabled;
thinkingBtn.classList.toggle('active', thinkingEnabled);
thinkingBtn.title = thinkingEnabled ? 'Thinking ON' : 'Thinking OFF';
});
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
});
textarea.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
async function extractTextFromPDF(file) {
const pdf = await pdfjsLib.getDocument(URL.createObjectURL(file)).promise;
let txt = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
txt += content.items.map(it => it.str).join(' ') + '\n';
}
return txt;
}
async function countTokens(text) {
try {
const res = await fetch(getTokenCountUrl(), {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({content: text})
});
const js = await res.json();
return js.tokens?.length || 0;
} catch {
return 0;
}
}
function updateFileName() {
const f = fileInput.files[0];
if (f) {
fileNameSpan.textContent = f.name;
removeFileIcon.style.display = 'inline';
}
}
function removeFile() {
fileInput.value = '';
fileNameSpan.textContent = '';
removeFileIcon.style.display = 'none';
}
function scrollToBottom() {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function appendMessage(text, who, tokenCount) {
const div = document.createElement('div');
div.className = `message ${who}`;
div.innerText = text;
if (who === 'user' && typeof tokenCount === 'number') {
const span = document.createElement('span');
span.className = 'token-count';
span.innerText = `Tokens: ${tokenCount}`;
div.appendChild(span);
}
messagesDiv.appendChild(div);
scrollToBottom();
}
async function sendMessage() {
const prompt = textarea.value.trim();
const file = fileInput.files[0];
if (!prompt && !file) return;
let pdfText = '';
if (file) {
pdfText = await extractTextFromPDF(file);
}
const fullPrompt = (pdfText + '\n' + prompt).trim();
const tokens = await countTokens(fullPrompt);
appendMessage(prompt || '[PDF]', 'user', tokens);
textarea.value = '';
textarea.style.height = 'auto';
removeFile();
const botDiv = document.createElement('div');
botDiv.className = 'message bot';
botDiv.innerText = 'Carregando...';
messagesDiv.appendChild(botDiv);
scrollToBottom();
const body = {
model: modelSelect.value,
messages: [{role:'user', content: fullPrompt}],
stream: true,
chat_template_kwargs: { enable_thinking: thinkingEnabled }
};
try {
const res = await fetch(getChatUrl(), {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
const reader = res.body.getReader();
const dec = new TextDecoder('utf-8');
let acc = '';
while (true) {
const {done,value} = await reader.read();
if (done) break;
const chunk = dec.decode(value);
for (let line of chunk.split('\n')) {
if (!line.startsWith('data:')) continue;
const d = line.replace(/^data:\s*/, '');
if (d === '[DONE]') return;
try {
const json = JSON.parse(d);
const c = json.choices[0].delta?.content || '';
acc += c;
botDiv.innerText = acc;
scrollToBottom();
} catch {}
}
}
} catch(err) {
botDiv.innerText = 'Erro: ' + err.message;
}
}
sendBtn.addEventListener('click', sendMessage);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment