[object Object]

← 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

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 →