| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> |
| <title>Gemma-4 E2B Uncensored — Chat</title> |
| <style> |
| :root { --bg:#0b0f19; --panel:#141a2a; --bubble-u:#2563eb; --bubble-a:#1f2839; --text:#e5e7eb; --muted:#9ca3af; --border:#27304a; } |
| * { box-sizing:border-box; } |
| body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--text); height:100vh; display:flex; flex-direction:column; } |
| header { padding:12px 16px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; } |
| header h1 { font-size:15px; margin:0; font-weight:600; } |
| header .dot { width:9px; height:9px; border-radius:50%; background:#6b7280; } |
| header .dot.ok { background:#22c55e; } |
| header .dot.err { background:#ef4444; } |
| header .spacer { flex:1; } |
| header a { color:var(--muted); font-size:12px; text-decoration:none; } |
| header a:hover { color:var(--text); } |
| #chat { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; } |
| .msg { max-width:760px; width:fit-content; padding:10px 14px; border-radius:12px; white-space:pre-wrap; word-wrap:break-word; line-height:1.5; } |
| .msg.user { align-self:flex-end; background:var(--bubble-u); color:#fff; } |
| .msg.assistant { align-self:flex-start; background:var(--bubble-a); border:1px solid var(--border); } |
| .think-box { margin-bottom:8px; border:1px solid var(--border); border-radius:8px; overflow:hidden; } |
| .think-box summary { cursor:pointer; padding:6px 10px; font-size:12px; color:var(--muted); background:#10182a; user-select:none; } |
| .think-box summary::marker { color:var(--muted); } |
| .think-body { padding:8px 10px; color:var(--muted); font-style:italic; font-size:13px; white-space:pre-wrap; border-top:1px solid var(--border); } |
| .answer { white-space:pre-wrap; } |
| .empty { color:var(--muted); text-align:center; margin-top:40px; font-size:14px; } |
| footer { border-top:1px solid var(--border); padding:12px 16px; } |
| .row { display:flex; gap:8px; max-width:900px; margin:0 auto; } |
| textarea { flex:1; resize:none; background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:10px; padding:10px 12px; font-size:14px; font-family:inherit; min-height:44px; max-height:160px; } |
| textarea:focus { outline:none; border-color:var(--bubble-u); } |
| button { background:var(--bubble-u); color:#fff; border:none; border-radius:10px; padding:0 18px; font-size:14px; font-weight:600; cursor:pointer; } |
| button:disabled { background:#374151; cursor:not-allowed; } |
| .hint { max-width:900px; margin:6px auto 0; color:var(--muted); font-size:11px; text-align:center; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <span id="status" class="dot"></span> |
| <h1>Gemma-4 E2B Uncensored</h1> |
| <span class="spacer"></span> |
| <a href="/v1/models" target="_blank">API · /v1/models</a> |
| </header> |
| <div id="chat"><div class="empty">Type a message to start. Reasoning is on — the model's thinking streams into a collapsible box, then the answer. (CPU model — slow but streams live.)</div></div> |
| <footer> |
| <div class="row"> |
| <textarea id="input" placeholder="Send a message… (Enter to send, Shift+Enter for newline)"></textarea> |
| <button id="send">Send</button> |
| </div> |
| <div class="hint">OpenAI-compatible API at <code>/v1/chat/completions</code> — reasoning streams as the <code>reasoning_content</code> delta field.</div> |
| </footer> |
|
|
| <script> |
| const chatEl = document.getElementById('chat'); |
| const inputEl = document.getElementById('input'); |
| const sendBtn = document.getElementById('send'); |
| const statusEl = document.getElementById('status'); |
| const history = []; |
| let busy = false; |
| |
| fetch('/v1/models').then(r => r.ok ? statusEl.classList.add('ok') : statusEl.classList.add('err')) |
| .catch(() => statusEl.classList.add('err')); |
| |
| function addUser(text) { |
| const empty = chatEl.querySelector('.empty'); |
| if (empty) empty.remove(); |
| const d = document.createElement('div'); |
| d.className = 'msg user'; |
| d.textContent = text; |
| chatEl.appendChild(d); |
| chatEl.scrollTop = chatEl.scrollHeight; |
| } |
| |
| function addAssistant() { |
| const empty = chatEl.querySelector('.empty'); |
| if (empty) empty.remove(); |
| const wrap = document.createElement('div'); |
| wrap.className = 'msg assistant'; |
| |
| const details = document.createElement('details'); |
| details.className = 'think-box'; |
| details.open = true; |
| details.style.display = 'none'; |
| const summary = document.createElement('summary'); |
| summary.textContent = 'Thinking…'; |
| const thinkBody = document.createElement('div'); |
| thinkBody.className = 'think-body'; |
| details.appendChild(summary); |
| details.appendChild(thinkBody); |
| |
| const answer = document.createElement('div'); |
| answer.className = 'answer'; |
| answer.textContent = '…'; |
| |
| wrap.appendChild(details); |
| wrap.appendChild(answer); |
| chatEl.appendChild(wrap); |
| chatEl.scrollTop = chatEl.scrollHeight; |
| return { wrap, details, summary, thinkBody, answer }; |
| } |
| |
| async function send() { |
| if (busy) return; |
| const text = inputEl.value.trim(); |
| if (!text) return; |
| inputEl.value = ''; |
| busy = true; sendBtn.disabled = true; |
| |
| addUser(text); |
| history.push({ role: 'user', content: text }); |
| |
| const ui = addAssistant(); |
| let reasoning = '', answer = ''; |
| |
| try { |
| const resp = await fetch('/v1/chat/completions', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ model: 'gemma', messages: history, max_tokens: 2048, stream: true }) |
| }); |
| if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status); |
| |
| const reader = resp.body.getReader(); |
| const dec = new TextDecoder(); |
| let buf = ''; |
| while (true) { |
| const { value, done } = await reader.read(); |
| if (done) break; |
| buf += dec.decode(value, { stream: true }); |
| const lines = buf.split('\n'); |
| buf = lines.pop(); |
| for (const line of lines) { |
| const s = line.trim(); |
| if (!s.startsWith('data:')) continue; |
| const data = s.slice(5).trim(); |
| if (data === '[DONE]') continue; |
| try { |
| const j = JSON.parse(data); |
| const delta = j.choices?.[0]?.delta || {}; |
| if (delta.reasoning_content) { |
| reasoning += delta.reasoning_content; |
| ui.details.style.display = ''; |
| ui.thinkBody.textContent = reasoning; |
| } |
| if (delta.content) { |
| answer += delta.content; |
| ui.answer.textContent = answer; |
| if (ui.details.open) { |
| ui.details.open = false; |
| ui.summary.textContent = 'Thinking (done) — click to expand'; |
| } |
| } |
| chatEl.scrollTop = chatEl.scrollHeight; |
| } catch (_) {} |
| } |
| } |
| if (!answer) ui.answer.textContent = '(no answer text returned)'; |
| history.push({ role: 'assistant', content: answer }); |
| } catch (e) { |
| ui.answer.textContent = 'Error: ' + e.message; |
| } finally { |
| busy = false; sendBtn.disabled = false; |
| inputEl.focus(); |
| } |
| } |
| |
| sendBtn.addEventListener('click', send); |
| inputEl.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } |
| }); |
| </script> |
| </body> |
| </html> |
|
|