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