← 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 &amp; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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>