← back to Qwen38 Viewer
public/index.html
370 lines
<!doctype html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex, nofollow" />
<title>Qwen3.8-27B Heretic · local uncensored</title>
<script src="/vendor/three.min.js"></script>
<script>window.THREE||document.write('<script src="https://unpkg.com/three@0.160.0/build/three.min.js"><\/script>');</script>
<style>
:root{
--bg:#0b0d10; --panel:#12161c; --panel2:#161b22; --line:#232a33;
--ink:#e7ecf2; --dim:#8a94a3; --accent:#c0392b; --accent2:#e07a3f;
--user:#1c2733; --think:#141a14; --thinkline:#2f3d2f;
--radius:14px; --mono:ui-monospace,SFMono-Regular,Menlo,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans)}
body{display:flex;flex-direction:column}
header{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line);background:var(--panel)}
.brand{font-weight:700;letter-spacing:.2px}
.brand .r{color:var(--accent2)}
.badge{font:600 11px/1 var(--mono);color:var(--dim);border:1px solid var(--line);border-radius:999px;padding:6px 10px}
.badge b{color:var(--ink)}
.warn{margin-left:auto;font:600 11px/1.4 var(--sans);color:#f0c36d;border:1px solid #4a3d1a;background:#1c1809;border-radius:8px;padding:6px 10px}
main{flex:1;overflow-y:auto;padding:22px 0}
.wrap{max-width:820px;margin:0 auto;padding:0 18px}
.msg{display:flex;gap:12px;margin:0 0 18px}
.who{flex:0 0 30px;height:30px;border-radius:8px;display:grid;place-items:center;font:700 12px var(--mono)}
.who.u{background:var(--user);color:#8fb8e0}
.who.a{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff}
.body{flex:1;min-width:0}
.name{font:600 12px var(--sans);color:var(--dim);margin:4px 0 6px}
.bubble{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:12px 14px;white-space:pre-wrap;word-wrap:break-word;line-height:1.55}
.msg.u .bubble{background:var(--user)}
details.think{margin:0 0 8px;border:1px dashed var(--thinkline);background:var(--think);border-radius:10px;overflow:hidden}
details.think>summary{cursor:pointer;padding:7px 12px;font:600 12px var(--mono);color:#89b389;list-style:none}
details.think>summary::-webkit-details-marker{display:none}
details.think .t{padding:0 14px 12px;white-space:pre-wrap;color:#9fb39f;font:13px/1.5 var(--mono)}
footer{border-top:1px solid var(--line);background:var(--panel);padding:12px 0}
.composer{max-width:820px;margin:0 auto;padding:0 18px;display:flex;gap:10px;align-items:flex-end}
textarea{flex:1;resize:none;min-height:48px;max-height:200px;background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:12px;padding:12px 14px;font:15px/1.5 var(--sans);outline:none}
textarea:focus{border-color:var(--accent2)}
button{cursor:pointer;border:none;border-radius:12px;font:600 14px var(--sans)}
.send{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;padding:12px 18px;height:48px}
.send:disabled{opacity:.5;cursor:default}
.tools{max-width:820px;margin:8px auto 0;padding:0 18px;display:flex;align-items:center;gap:16px;color:var(--dim);font:12px var(--sans)}
.tools input[type=range]{accent-color:var(--accent2)}
.ghost{background:transparent;color:var(--dim);border:1px solid var(--line);padding:6px 10px;height:auto}
.empty{max-width:820px;margin:8vh auto;text-align:center;color:var(--dim)}
.empty h1{color:var(--ink);font-size:22px;margin:0 0 8px}
.empty .k{font:600 12px var(--mono);color:var(--accent2)}
code{font-family:var(--mono)}
/* ---- 3D motion graphics: three.js "it's thinking" scene ---- */
.thinking{position:relative;height:180px;border-radius:var(--radius);overflow:hidden;
border:1px solid var(--line);background:radial-gradient(ellipse at 50% 35%,#171c24,#0a0c0f)}
.host3d{margin:0 0 10px}
.thinking .t3d{position:absolute;inset:0}
.thinking .t3d canvas{display:block;width:100%!important;height:100%!important}
.thinking .overlay{position:absolute;left:0;right:0;bottom:12px;text-align:center;pointer-events:none;z-index:2}
.thinking .lbl{font:700 14px var(--sans);color:#fff;letter-spacing:.3px;text-shadow:0 1px 8px rgba(0,0,0,.7)}
.thinking .dots{display:inline-block;width:20px;text-align:left;color:var(--accent2);font-weight:800}
.thinking .el{font:600 12px var(--mono);color:#c9d2dd;margin-left:8px;text-shadow:0 1px 6px rgba(0,0,0,.7)}
.thinking .hint{display:block;font:600 11px var(--sans);color:#f0c36d;margin-top:4px;text-shadow:0 1px 6px rgba(0,0,0,.8)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(224,122,63,.55)}70%{box-shadow:0 0 0 14px rgba(224,122,63,0)}100%{box-shadow:0 0 0 0 rgba(224,122,63,0)}}
/* live reasoning while streaming */
details.think.live{border-color:var(--accent2);background:#161a14}
details.think.live>summary{color:var(--accent2)}
details.think.live>summary::after{content:'';display:inline-block;width:6px;height:6px;margin-left:8px;
border-radius:50%;background:var(--accent2);animation:pulse 1s ease-out infinite}
.cursor{display:inline-block;width:8px;height:15px;background:var(--accent2);margin-left:2px;
vertical-align:-2px;animation:blink 1s steps(2) infinite}
@keyframes blink{50%{opacity:0}}
</style>
</head>
<body>
<header>
<span class="brand">Qwen3.8‑27B <span class="r">Heretic</span></span>
<span class="badge" id="badge">local · <b id="msize">…</b></span>
<span class="warn">⚠ Uncensored model — outputs are unfiltered & may be wrong. You are responsible for use.</span>
</header>
<main id="scroll">
<div class="empty" id="empty">
<h1>Local uncensored chat</h1>
<div>Talking to <span class="k">qwen3.8‑27b‑heretic</span> on this Mac via Ollama.<br/>
Nothing leaves the machine. Reasoning shows in a collapsible <b>think</b> block.</div>
</div>
<div class="wrap" id="log"></div>
</main>
<footer>
<div class="composer">
<textarea id="ta" placeholder="Message the model… (Enter to send · Shift+Enter for newline)"></textarea>
<button class="send" id="send">Send</button>
</div>
<div class="tools">
<label>temp <input type="range" id="temp" min="0" max="1.5" step="0.1" value="0.8" /> <span id="tempv">0.8</span></label>
<label>max tokens <input type="range" id="ntok" min="128" max="4096" step="128" value="1024" /> <span id="ntokv">1024</span></label>
<label title="Off = fast direct answers. On = show step-by-step reasoning (slower)."><input type="checkbox" id="think" /> 🧠 deep thinking</label>
<button class="ghost" id="clear">Clear chat</button>
<span id="status" style="margin-left:auto"></span>
</div>
</footer>
<script>
const $ = s => document.querySelector(s);
const log = $('#log'), scroll = $('#scroll'), ta = $('#ta');
let history = JSON.parse(localStorage.getItem('q38.history') || '[]');
let busy = false;
const temp = $('#temp'), ntok = $('#ntok');
temp.oninput = () => $('#tempv').textContent = temp.value;
ntok.oninput = () => $('#ntokv').textContent = ntok.value;
fetch('/api/model').then(r=>r.json()).then(m=>{
$('#msize').textContent = m.size ? (m.size/1e9).toFixed(0)+' GB' : (m.present?'ready':'not found');
}).catch(()=>{ $('#msize').textContent='offline'; });
function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
// Split accumulated text into an optional <think> section + visible answer.
// streaming=true keeps the reasoning block open/pulsing and shows a caret.
function render(text, streaming){
const open = text.indexOf('<think>');
const close = text.indexOf('</think>');
let think='', body=text, thinking=false;
if(open>=0 && close>=0){ // completed think block
think = text.slice(open+7, close);
body = text.slice(0,open) + text.slice(close+8);
}else if(open>=0){ // still inside think (live)
think = text.slice(open+7); body = text.slice(0,open); thinking=true;
}
let html='';
if(think.trim()){
const live = (streaming && thinking) ? ' live' : '';
const openAttr = (streaming && thinking) ? ' open' : '';
const label = (streaming && thinking) ? '💭 thinking…' : '💭 reasoning';
html += `<details class="think${live}"${openAttr}><summary>${label}</summary>`+
`<div class="t">${esc(think.trim())}${live?'<span class="cursor"></span>':''}</div></details>`;
}
const b = body.trim();
if(b || !streaming){
html += `<div class="bubble">${esc(b)}${streaming&&!thinking&&b?'<span class="cursor"></span>':''}</div>`;
}
return html;
}
// Animated "it's working" indicator: a live three.js WebGL scene.
function thinkingIndicator(){
return `<div class="thinking"><div class="t3d"></div>`+
`<div class="overlay"><span class="lbl">Thinking</span><span class="dots"></span>`+
`<span class="el">0.0s</span><span class="hint" style="display:none"></span></div></div>`;
}
// ---- three.js thinking scene: a pulsing wireframe icosahedron core +
// orbiting particle swarm. Renders while the model is thinking. ------------
function makeThink3D(mount){
const NOOP = {stop(){}, energy(){}, setState(){}};
if(!window.THREE || !mount){ return NOOP; } // graceful fallback (same shape)
const W = mount.clientWidth || 700, H = mount.clientHeight || 180;
const scene = new THREE.Scene();
const cam = new THREE.PerspectiveCamera(50, W/H, 0.1, 100); cam.position.z = 5;
const rnd = new THREE.WebGLRenderer({antialias:true, alpha:true});
rnd.setPixelRatio(Math.min(devicePixelRatio,2)); rnd.setSize(W,H); mount.appendChild(rnd.domElement);
const AC = 0xe07a3f, AC2 = 0xc0392b;
// glowing solid core
const core = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.05, 1),
new THREE.MeshStandardMaterial({color:AC2, emissive:AC2, emissiveIntensity:.9, roughness:.35, metalness:.4, flatShading:true}));
scene.add(core);
// wireframe shell
const shell = new THREE.LineSegments(
new THREE.WireframeGeometry(new THREE.IcosahedronGeometry(1.55, 1)),
new THREE.LineBasicMaterial({color:AC, transparent:true, opacity:.55}));
scene.add(shell);
// orbiting particle swarm
const N=420, pos=new Float32Array(N*3);
for(let i=0;i<N;i++){ const r=2.1+Math.random()*1.6, t=Math.random()*Math.PI*2, p=Math.acos(2*Math.random()-1);
pos[i*3]=r*Math.sin(p)*Math.cos(t); pos[i*3+1]=r*Math.sin(p)*Math.sin(t); pos[i*3+2]=r*Math.cos(p); }
const pg=new THREE.BufferGeometry(); pg.setAttribute('position', new THREE.BufferAttribute(pos,3));
const parts=new THREE.Points(pg, new THREE.PointsMaterial({color:AC, size:.045, transparent:true, opacity:.85,
blending:THREE.AdditiveBlending, depthWrite:false}));
scene.add(parts);
// lights
scene.add(new THREE.AmbientLight(0x40484f, 1.2));
const key=new THREE.PointLight(AC,2.2,50); key.position.set(3,3,4); scene.add(key);
const rim=new THREE.PointLight(0x4a90d9,1.0,50); rim.position.set(-4,-2,2); scene.add(rim);
// state machine: color + motion encode the job status
const COLORS = { thinking:0xe07a3f, running:0x2fd3b0, stuck:0xff2d2d }; // orange / teal / red
let raf, t=0, rot=0, boost=1, running=true, state='thinking';
const cur=new THREE.Color(COLORS.thinking), tgt=new THREE.Color(COLORS.thinking);
const onResize=()=>{ const w=mount.clientWidth||W, h=mount.clientHeight||H; rnd.setSize(w,h); cam.aspect=w/h; cam.updateProjectionMatrix(); };
addEventListener('resize', onResize);
(function loop(){ if(!running) return; raf=requestAnimationFrame(loop);
t+=0.016; boost += (1-boost)*0.05; // ease energy back to 1
// color -> state
tgt.setHex(COLORS[state]); cur.lerp(tgt, 0.08);
core.material.color.copy(cur); core.material.emissive.copy(cur);
shell.material.color.copy(cur); parts.material.color.copy(cur); key.color.copy(cur);
// motion speed -> state (stuck = crawl + stutter, running = brisk, thinking = medium)
const spd = state==='stuck' ? 0.12 : state==='running' ? 1.25 : 0.7;
const stutter = state==='stuck' ? (Math.sin(t*22)>0.6?0:1) : 1; // freeze-frames = "stuck"
rot += 0.016 * spd * stutter;
const pulseHz = state==='stuck' ? 6 : 3;
const s = 1 + Math.sin(t*pulseHz)*0.06*boost;
core.scale.setScalar(s); core.rotation.x=rot*0.5; core.rotation.y=rot*0.7;
core.material.emissiveIntensity = 0.7 + Math.sin(t*pulseHz)*0.35*boost;
shell.rotation.x=-rot*0.3; shell.rotation.y=rot*0.42; shell.scale.setScalar(1+Math.sin(t*2+1)*0.03);
shell.material.opacity = state==='stuck' ? (0.35+Math.abs(Math.sin(t*5))*0.5) : 0.55; // red blink
parts.rotation.y=rot*0.15; parts.rotation.x=rot*0.05;
rnd.render(scene,cam);
})();
return {
energy(){ boost = 2.2; }, // pulse on each token
setState(s){ if(COLORS[s]) state=s; }, // 'thinking' | 'running' | 'stuck'
stop(){ running=false; cancelAnimationFrame(raf); removeEventListener('resize',onResize);
pg.dispose(); parts.material.dispose(); core.geometry.dispose(); core.material.dispose();
shell.geometry.dispose(); shell.material.dispose(); rnd.dispose();
try{ mount.removeChild(rnd.domElement); }catch(e){} }
};
}
function addMsg(role, text){
$('#empty').style.display='none';
const el = document.createElement('div');
el.className = 'msg '+(role==='user'?'u':'a');
el.innerHTML = `<div class="who ${role==='user'?'u':'a'}">${role==='user'?'you':'AI'}</div>`+
`<div class="body"><div class="name">${role==='user'?'You':'Qwen3.8 Heretic'}</div>`+
`<div class="content"></div></div>`;
log.appendChild(el);
const content = el.querySelector('.content');
content.innerHTML = role==='user' ? `<div class="bubble">${esc(text)}</div>` : render(text);
scroll.scrollTop = scroll.scrollHeight;
return content;
}
function persist(){ localStorage.setItem('q38.history', JSON.stringify(history.slice(-40))); }
async function send(){
const q = ta.value.trim();
if(!q || busy) return;
busy = true; $('#send').disabled = true; ta.value=''; ta.style.height='auto';
const thinkOn = $('#think').checked; // default OFF = fast direct answers
addMsg('user', q);
history.push({role:'user', content:q}); persist();
const content = addMsg('assistant','');
// 3D status panel stays up for the WHOLE job; text streams below it.
content.innerHTML = `<div class="host3d">${thinkingIndicator()}</div><div class="stream"></div>`;
scroll.scrollTop = scroll.scrollHeight;
const stream = content.querySelector('.stream');
let acc='';
let t3d;
try { t3d = makeThink3D(content.querySelector('.t3d')); } // three.js scene
catch(e){ console.warn('3D init failed:', e); t3d = {stop(){}, energy(){}, setState(){}}; }
let lastTok = performance.now(), lastRender = 0;
const bodyOf = s => { // visible answer (outside <think>)
const o=s.indexOf('<think>'), c=s.indexOf('</think>');
if(o>=0 && c>=0) return s.slice(0,o)+s.slice(c+8);
if(o>=0) return s.slice(0,o);
return s;
};
const STUCK_GAP = 25, STUCK_COLD = 150; // seconds
// Status loop: elapsed + dots + STATE (thinking / running / STUCK) + stall watchdog.
const t0 = performance.now();
const timer = setInterval(()=>{
if(!t3d) return;
const now=performance.now(), s=(now-t0)/1000, gap=(now-lastTok)/1000;
const el=content.querySelector('.el'), lbl=content.querySelector('.lbl'),
dots=content.querySelector('.dots'), hint=content.querySelector('.hint');
if(!el) return;
const started = acc.trim()!=='';
const answering = bodyOf(acc).trim()!=='';
let st, stuck=false;
if(!started){ if(s>STUCK_COLD){ st='stuck'; stuck=true; } else st='thinking'; }
else if(gap>STUCK_GAP){ st='stuck'; stuck=true; }
else st = answering ? 'running' : 'thinking';
t3d.setState(st);
el.textContent = s.toFixed(1)+'s';
dots.textContent = '.'.repeat(Math.floor((now/400)%4));
if(stuck){ lbl.textContent='⚠ Possibly stuck'; hint.style.display='block';
hint.textContent = started ? `no new output for ${gap.toFixed(0)}s`
: `${s.toFixed(0)}s with no output — still loading or wedged`; }
else if(st==='running'){ lbl.textContent='Generating'; hint.style.display='none'; }
else if(s>15 && !started){ lbl.textContent='Loading model'; hint.style.display='block';
hint.textContent='first response after idle can take ~1–2 min — it IS working…'; }
else { lbl.textContent = started?'Reasoning':'Thinking'; hint.style.display='none'; }
$('#status').textContent = (stuck?'⚠ stalled ':'generating… ')+s.toFixed(0)+'s';
}, 100);
const killScene = ()=>{ if(t3d){ t3d.stop(); t3d=null; clearInterval(timer);
const h=content.querySelector('.host3d'); if(h) h.remove(); } };
try{
const r = await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({messages:history, temperature:parseFloat(temp.value),
num_predict:parseInt(ntok.value), think:thinkOn})});
const reader = r.body.getReader(); const dec = new TextDecoder(); let buf='';
for(;;){
const {done,value} = await reader.read(); if(done) break;
buf += dec.decode(value,{stream:true});
let nl; while((nl=buf.indexOf('\n'))>=0){
const line = buf.slice(0,nl).trim(); buf = buf.slice(nl+1);
if(!line) continue;
let j; try{ j=JSON.parse(line); }catch{ continue; }
if(j.error){ acc += '\n[error: '+j.error+']'; }
else if(j.message && j.message.content){ acc += j.message.content; }
lastTok = performance.now();
if(t3d){ t3d.energy(); t3d.setState(bodyOf(acc).trim()!==''?'running':'thinking'); }
if(lastTok - lastRender > 60){ // throttle: avoid O(n) reparse every token
lastRender = lastTok;
stream.innerHTML = render(acc, true); // stream text below the live 3D panel
scroll.scrollTop = scroll.scrollHeight;
}
}
}
killScene();
content.innerHTML = render(acc, false); // final, static (3D removed)
history.push({role:'assistant', content:acc}); persist();
}catch(e){
killScene();
content.innerHTML = render(acc + '\n[connection error: '+e+']', false);
}finally{
killScene();
busy=false; $('#send').disabled=false; $('#status').textContent=''; ta.focus();
}
}
$('#send').onclick = send;
ta.addEventListener('keydown', e=>{ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); }});
ta.addEventListener('input', ()=>{ ta.style.height='auto'; ta.style.height=Math.min(200,ta.scrollHeight)+'px'; });
$('#clear').onclick = ()=>{ history=[]; persist(); log.innerHTML=''; $('#empty').style.display=''; };
// Replay persisted history on load
for(const m of history) addMsg(m.role, m.content);
</script>
</body>
</html>