oxibonsai-runtime 0.1.4

Inference runtime, sampling, tokenizer, and server for OxiBonsai
Documentation
<!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');

  // Conversation history sent to the API on each turn.
  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();
    }
  });

  // Auto-grow textarea up to max-height
  promptEl.addEventListener('input', function () {
    this.style.height = '48px';
    this.style.height = Math.min(this.scrollHeight, 160) + 'px';
  });

  promptEl.focus();
})();
</script>
</body>
</html>