| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> |
| <title>Gemma-4-E4B Uncensored — Chat</title> |
| <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github-dark.min.css"/> |
| <script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/core.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/python.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/javascript.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/bash.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/json.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/cpp.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/typescript.min.js"></script> |
| <style> |
| :root { --bg:#0b0f19; --panel:#141a2a; --bubble-u:#2563eb; --bubble-a:#1f2839; --text:#e5e7eb; --muted:#9ca3af; --border:#27304a; --code-bg:#0d1117; } |
| * { 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; flex-shrink:0; } |
| header h1 { font-size:15px; margin:0; font-weight:600; } |
| header .dot { width:9px; height:9px; border-radius:50%; background:#6b7280; flex-shrink:0; } |
| 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:780px; width:fit-content; padding:10px 14px; border-radius:12px; line-height:1.6; } |
| .msg.user { align-self:flex-end; background:var(--bubble-u); color:#fff; white-space:pre-wrap; word-wrap:break-word; } |
| .msg.assistant { align-self:flex-start; background:var(--bubble-a); border:1px solid var(--border); min-width:120px; } |
| .answer p { margin:0 0 8px; } .answer p:last-child { margin-bottom:0; } |
| .answer h1,.answer h2,.answer h3,.answer h4 { margin:12px 0 6px; font-weight:600; } |
| .answer h1 { font-size:1.2em; } .answer h2 { font-size:1.1em; } .answer h3 { font-size:1em; } |
| .answer ul,.answer ol { margin:4px 0 8px 20px; padding:0; } .answer li { margin-bottom:2px; } |
| .answer a { color:#60a5fa; } |
| .answer blockquote { border-left:3px solid var(--border); margin:0 0 8px; padding-left:10px; color:var(--muted); } |
| .answer table { border-collapse:collapse; margin-bottom:8px; font-size:13px; } |
| .answer th,.answer td { border:1px solid var(--border); padding:4px 8px; } .answer th { background:#10182a; } |
| .answer code { background:var(--code-bg); border-radius:4px; padding:1px 5px; font-family:"JetBrains Mono",Consolas,"Courier New",monospace; font-size:13px; } |
| .answer pre { background:var(--code-bg); border-radius:8px; margin:0 0 8px; overflow-x:auto; position:relative; } |
| .answer pre code { background:none; padding:12px; display:block; font-size:13px; } |
| .copy-btn { position:absolute; top:6px; right:6px; background:#27304a; border:none; color:var(--muted); font-size:11px; padding:3px 8px; border-radius:4px; cursor:pointer; opacity:0; transition:opacity 0.15s; } |
| .answer pre:hover .copy-btn { opacity:1; } .copy-btn:hover { color:var(--text); } |
| .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); max-height:300px; overflow-y:auto; } |
| .empty { color:var(--muted); text-align:center; margin-top:40px; font-size:14px; } |
| footer { border-top:1px solid var(--border); padding:12px 16px; flex-shrink:0; } |
| .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#send { background:var(--bubble-u); color:#fff; border:none; border-radius:10px; padding:0 18px; font-size:14px; font-weight:600; cursor:pointer; } |
| button#send: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-E4B Uncensored (Q8_K_P)</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. Markdown renders — code, tables, lists all work. Reasoning streams into a collapsible box. (CPU — 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 <code>reasoning_content</code></div> |
| </footer> |
| <script> |
| marked.setOptions({ breaks: true, gfm: true }); |
| const renderer = new marked.Renderer(); |
| const origCode = renderer.code.bind(renderer); |
| renderer.code = function(code, lang) { return origCode(code,lang).replace('<pre>','<pre><button class="copy-btn" onclick="copyCode(this)">copy</button>'); }; |
| marked.use({ renderer }); |
| function copyCode(btn){navigator.clipboard.writeText(btn.parentElement.querySelector('code').innerText).then(()=>{btn.textContent='copied!';setTimeout(()=>btn.textContent='copy',1500);});} |
| function renderMd(text){const div=document.createElement('div');div.innerHTML=marked.parse(text||'');div.querySelectorAll('pre code').forEach(b=>hljs.highlightElement(b));return div;} |
| const chatEl=document.getElementById('chat'),inputEl=document.getElementById('input'),sendBtn=document.getElementById('send'),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(t){chatEl.querySelector('.empty')?.remove();const d=document.createElement('div');d.className='msg user';d.textContent=t;chatEl.appendChild(d);chatEl.scrollTop=chatEl.scrollHeight;} |
| function addAssistant(){chatEl.querySelector('.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({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;ui.thinkBody.scrollTop=ui.thinkBody.scrollHeight;} |
| if(delta.content){answer+=delta.content;ui.answer.innerHTML='';ui.answer.appendChild(renderMd(answer));if(ui.details.open){ui.details.open=false;ui.summary.textContent='Thinking (done) — click to expand';}} |
| chatEl.scrollTop=chatEl.scrollHeight;}catch(_){}}} |
| if(!reasoning&&answer.includes('</think>')){const m=answer.match(/^([\s\S]*?)<\/think>([\s\S]*)$/);if(m){reasoning=m[1].replace(/^<think>/,'').trim();answer=m[2].trim();ui.details.style.display='';ui.details.open=false;ui.summary.textContent='Thinking (done) — click to expand';ui.thinkBody.textContent=reasoning;ui.answer.innerHTML='';ui.answer.appendChild(renderMd(answer));}} |
| 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();}}); |
| inputEl.addEventListener('input',()=>{inputEl.style.height='auto';inputEl.style.height=Math.min(inputEl.scrollHeight,160)+'px';}); |
| </script> |
| </body> |
| </html> |
|
|