← back to Qwen38 Viewer
three.js 3D thinking scene w/ state machine: Thinking(orange)/Generating(teal)/STUCK(red+stutter) + stall watchdog; thinking toggle (default off); think passthrough
29dca6a1be922e8c47ff9c96a416eaeaefaa3ad4 · 2026-08-19 11:16:57 -0700 · steve
Files touched
M public/index.htmlM server.js
Diff
commit 29dca6a1be922e8c47ff9c96a416eaeaefaa3ad4
Author: steve <steve@designerwallcoverings.com>
Date: Wed Aug 19 11:16:57 2026 -0700
three.js 3D thinking scene w/ state machine: Thinking(orange)/Generating(teal)/STUCK(red+stutter) + stall watchdog; thinking toggle (default off); think passthrough
---
public/index.html | 179 +++++++++++++++++++++++++++++++++++++++++-------------
server.js | 3 +-
2 files changed, 139 insertions(+), 43 deletions(-)
diff --git a/public/index.html b/public/index.html
index 68f9878..8edf081 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,6 +5,7 @@
<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="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<style>
:root{
--bg:#0b0d10; --panel:#12161c; --panel2:#161b22; --line:#232a33;
@@ -51,21 +52,18 @@
.empty .k{font:600 12px var(--mono);color:var(--accent2)}
code{font-family:var(--mono)}
- /* ---- motion graphics: "it's thinking" feedback ---- */
- .thinking{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:var(--radius);
- background:linear-gradient(100deg,#12161c 30%,#1a2230 50%,#12161c 70%);background-size:200% 100%;
- border:1px solid var(--line);animation:shimmer 1.6s linear infinite}
- @keyframes shimmer{to{background-position:-200% 0}}
- .orb{width:26px;height:26px;flex:0 0 26px;border-radius:50%;position:relative;
- background:radial-gradient(circle at 30% 30%,var(--accent2),var(--accent));
- box-shadow:0 0 0 0 rgba(224,122,63,.6);animation:pulse 1.3s ease-out infinite}
- @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(224,122,63,.55);transform:scale(1)}
- 70%{box-shadow:0 0 0 14px rgba(224,122,63,0);transform:scale(1.08)}
- 100%{box-shadow:0 0 0 0 rgba(224,122,63,0);transform:scale(1)}}
- .thinking .lbl{font:600 13px var(--sans);color:var(--ink)}
- .thinking .dots{display:inline-block;width:22px;text-align:left;color:var(--accent2);font-weight:700}
- .thinking .el{margin-left:auto;font:600 12px var(--mono);color:var(--dim)}
- .thinking .hint{display:block;font:500 11px var(--sans);color:#f0c36d;margin-top:2px}
+ /* ---- 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)}
@@ -100,6 +98,7 @@
<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>
@@ -148,12 +147,81 @@ function render(text, streaming){
return html;
}
-// Animated "it's working" indicator shown until the first visible tokens arrive.
+// Animated "it's working" indicator: a live three.js WebGL scene.
function thinkingIndicator(){
- return `<div class="thinking"><span class="orb"></span>`+
- `<span><span class="lbl">Thinking</span><span class="dots"></span>`+
- `<span class="hint" style="display:none"></span></span>`+
- `<span class="el">0.0s</span></div>`;
+ 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){
+ if(!window.THREE){ return {stop(){}, energy(){}}; } // graceful fallback
+ 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){
@@ -176,34 +244,60 @@ 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','');
- content.innerHTML = thinkingIndicator();
+ // 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;
- let acc='', firstToken=false;
+ const stream = content.querySelector('.stream');
+ let acc='';
+ let t3d = makeThink3D(content.querySelector('.t3d')); // <-- three.js scene
+ let lastTok = performance.now();
+
+ 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
- // Motion-graphics loop: elapsed timer + animated dots + adaptive cold-load hint,
- // running until the first real token shows up.
+ // Status loop: elapsed + dots + STATE (thinking / running / STUCK) + stall watchdog.
const t0 = performance.now();
const timer = setInterval(()=>{
- if(firstToken) return;
- const s = (performance.now()-t0)/1000;
- const el = content.querySelector('.el'), lbl = content.querySelector('.lbl'),
- dots = content.querySelector('.dots'), hint = content.querySelector('.hint');
+ 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((performance.now()/400)%4));
- if(s>12){ lbl.textContent='Loading model into memory'; hint.style.display='block';
- hint.textContent='first response after idle can take up to ~2 min — it IS working…'; }
- else if(s>4){ lbl.textContent='Reasoning'; }
- else { lbl.textContent='Thinking'; }
- $('#status').textContent = 'generating… '+s.toFixed(0)+'s';
- }, 120);
+ 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)})});
+ 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;
@@ -214,19 +308,20 @@ async function send(){
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; }
- if(!firstToken && acc.trim()!==''){ firstToken=true; }
- if(firstToken){ content.innerHTML = render(acc, true); }
+ lastTok = performance.now();
+ if(t3d){ t3d.energy(); t3d.setState(bodyOf(acc).trim()!==''?'running':'thinking'); }
+ stream.innerHTML = render(acc, true); // stream text below the live 3D panel
scroll.scrollTop = scroll.scrollHeight;
}
}
- clearInterval(timer);
- content.innerHTML = render(acc, false); // final, static
+ killScene();
+ content.innerHTML = render(acc, false); // final, static (3D removed)
history.push({role:'assistant', content:acc}); persist();
}catch(e){
- clearInterval(timer);
+ killScene();
content.innerHTML = render(acc + '\n[connection error: '+e+']', false);
}finally{
- clearInterval(timer);
+ killScene();
busy=false; $('#send').disabled=false; $('#status').textContent=''; ta.focus();
}
}
diff --git a/server.js b/server.js
index 090e14f..e6fa079 100644
--- a/server.js
+++ b/server.js
@@ -69,13 +69,14 @@ app.post('/api/chat', async (req, res) => {
temperature: clamp(req.body.temperature, 0, 2, 0.8),
num_predict: clamp(req.body.num_predict, 1, 8192, 1024),
};
+ const think = req.body.think === true; // default false = fast, no reasoning
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Cache-Control', 'no-cache');
try {
const upstream = await fetch(`${OLLAMA}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ model: MODEL, messages, stream: true, options,
+ body: JSON.stringify({ model: MODEL, messages, stream: true, options, think,
keep_alive: KEEP_ALIVE === '-1' ? -1 : KEEP_ALIVE }), // -1 (number) = keep forever
});
if (!upstream.ok || !upstream.body) {
← 411e696 send keep_alive as integer -1 (valid 'keep forever')
·
back to Qwen38 Viewer
·
self-host three.js (local /vendor/three.min.js) with CDN fal 3718af9 →