<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OxiBonsai Chat</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #58a6ff;
--user-bg: #1f6feb;
--bot-bg: #21262d;
--text: #c9d1d9;
--muted: #8b949e;
--radius: 12px;
--font: 'Segoe UI', system-ui, sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 15px;
line-height: 1.5;
height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
}
#app {
width: 100%;
max-width: 760px;
height: 100%;
display: flex;
flex-direction: column;
padding: 0 12px 12px;
}
header {
padding: 18px 4px 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
header h1 { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
header span { font-size: 0.8rem; color: var(--muted); }
#messages {
flex: 1;
overflow-y: auto;
padding: 16px 0;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.msg {
display: flex;
gap: 10px;
max-width: 90%;
}
.msg.user { align-self: flex-end; flex-direction: row-reverse; }
.msg.bot { align-self: flex-start; }
.avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0;
}
.msg.user .avatar { background: var(--user-bg); }
.bubble {
padding: 10px 14px;
border-radius: var(--radius);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.55;
}
.msg.user .bubble { background: var(--user-bg); color: #fff; border-bottom-right-radius: 3px; }
.msg.bot .bubble { background: var(--bot-bg); border: 1px solid var(--border); border-bottom-left-radius: 3px; }
.thinking { color: var(--muted); font-style: italic; font-size: 0.9em; }
#input-row {
display: flex;
gap: 8px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
#prompt {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 15px;
padding: 10px 14px;
resize: none;
outline: none;
height: 48px;
max-height: 160px;
overflow-y: auto;
transition: border-color 0.15s;
}
#prompt:focus { border-color: var(--accent); }
#prompt::placeholder { color: var(--muted); }
#send-btn {
background: var(--accent);
color: #0d1117;
border: none;
border-radius: var(--radius);
padding: 0 20px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
#send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#send-btn:not(:disabled):hover { opacity: 0.85; }
#error-bar {
background: #3d1f1f; border: 1px solid #6e2e2e;
color: #f28b82; border-radius: 6px;
padding: 8px 14px; font-size: 0.85rem;
display: none; margin-top: 8px;
}
</style>
</head>
<body>
<div id="app">
<header>
<div class="avatar" style="background:var(--accent);color:#0d1117">🌿</div>
<div>
<h1>OxiBonsai</h1>
<span>Local LLM inference</span>
</div>
</header>
<div id="messages" role="log" aria-live="polite"></div>
<div id="error-bar"></div>
<div id="input-row">
<textarea id="prompt" placeholder="Send a message…" rows="1" aria-label="Message input"></textarea>
<button id="send-btn" aria-label="Send">Send</button>
</div>
</div>
<script>
(function () {
'use strict';
const messagesEl = document.getElementById('messages');
const promptEl = document.getElementById('prompt');
const sendBtn = document.getElementById('send-btn');
const errorBar = document.getElementById('error-bar');
const history = [];
function showError(msg) {
errorBar.textContent = msg;
errorBar.style.display = 'block';
setTimeout(() => { errorBar.style.display = 'none'; }, 6000);
}
function addMessage(role, text) {
const isUser = role === 'user';
const div = document.createElement('div');
div.className = 'msg ' + (isUser ? 'user' : 'bot');
div.innerHTML =
'<div class="avatar">' + (isUser ? '🧑' : '🤖') + '</div>' +
'<div class="bubble"></div>';
div.querySelector('.bubble').textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div.querySelector('.bubble');
}
function setThinking() {
const div = document.createElement('div');
div.className = 'msg bot';
div.id = 'thinking';
div.innerHTML = '<div class="avatar">🤖</div><div class="bubble thinking">Thinking…</div>';
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function removeThinking() {
const el = document.getElementById('thinking');
if (el) el.remove();
}
async function sendMessage() {
const text = promptEl.value.trim();
if (!text) return;
promptEl.value = '';
promptEl.style.height = '48px';
sendBtn.disabled = true;
errorBar.style.display = 'none';
history.push({ role: 'user', content: text });
addMessage('user', text);
setThinking();
try {
const resp = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: history,
max_tokens: 512,
temperature: 0.7,
stream: false
})
});
removeThinking();
if (!resp.ok) {
const errText = await resp.text().catch(() => resp.statusText);
showError('API error ' + resp.status + ': ' + errText);
history.pop();
return;
}
const data = await resp.json();
const reply = data?.choices?.[0]?.message?.content ?? '(empty response)';
history.push({ role: 'assistant', content: reply });
addMessage('assistant', reply);
} catch (err) {
removeThinking();
showError('Request failed: ' + err.message);
history.pop();
} finally {
sendBtn.disabled = false;
promptEl.focus();
}
}
sendBtn.addEventListener('click', sendMessage);
promptEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
promptEl.addEventListener('input', function () {
this.style.height = '48px';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
promptEl.focus();
})();
</script>
</body>
</html>