[object Object]

← back to Qwen38 Viewer

viewer: motion-graphics thinking feedback (pulsing orb, elapsed timer, adaptive cold-load msg, live-streaming reasoning + caret)

78d2199c3da8b267d5dbded409c14ced4936005a · 2026-08-19 11:00:17 -0700 · steve

Files touched

Diff

commit 78d2199c3da8b267d5dbded409c14ced4936005a
Author: steve <steve@designerwallcoverings.com>
Date:   Wed Aug 19 11:00:17 2026 -0700

    viewer: motion-graphics thinking feedback (pulsing orb, elapsed timer, adaptive cold-load msg, live-streaming reasoning + caret)
---
 mail-bridge.py    | 27 ++++++++++++----
 public/index.html | 93 ++++++++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 103 insertions(+), 17 deletions(-)

diff --git a/mail-bridge.py b/mail-bridge.py
index 45a7b67..11790df 100644
--- a/mail-bridge.py
+++ b/mail-bridge.py
@@ -69,15 +69,30 @@ def is_bulk(msg):
     if msg.get("List-Id") or msg.get("List-Unsubscribe"): return True
     return False
 
+def _decode(part):
+    try: return part.get_content()
+    except Exception:
+        pl = part.get_payload(decode=True)
+        return pl.decode("utf-8", "ignore") if pl else ""
+
+def _strip_html(h):
+    h = re.sub(r"(?is)<(script|style).*?</\1>", " ", h)
+    h = re.sub(r"(?s)<[^>]+>", " ", h)
+    return re.sub(r"[ \t]*\n", "\n", re.sub(r"[ \t]+", " ", h)).strip()
+
 def plain_body(msg):
+    text, html = "", ""
     if msg.is_multipart():
         for part in msg.walk():
-            if part.get_content_type() == "text/plain" and "attachment" not in str(part.get("Content-Disposition")):
-                try: return part.get_content()
-                except Exception: return part.get_payload(decode=True).decode("utf-8", "ignore")
-        return ""
-    try: return msg.get_content()
-    except Exception: return msg.get_payload(decode=True).decode("utf-8", "ignore")
+            ct = part.get_content_type()
+            if "attachment" in str(part.get("Content-Disposition")): continue
+            if ct == "text/plain" and not text: text = _decode(part)
+            elif ct == "text/html" and not html: html = _decode(part)
+    else:
+        (text if msg.get_content_type() == "text/plain" else html) and None
+        if msg.get_content_type() == "text/html": html = _decode(msg)
+        else: text = _decode(msg)
+    return text.strip() or _strip_html(html)
 
 def strip_quotes(body):
     out = []
diff --git a/public/index.html b/public/index.html
index e044647..68f9878 100644
--- a/public/index.html
+++ b/public/index.html
@@ -50,6 +50,30 @@
   .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)}
+
+  /* ---- 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}
+  /* 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>
@@ -97,19 +121,41 @@ fetch('/api/model').then(r=>r.json()).then(m=>{
 
 function esc(s){return s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
 
-// Split a stream chunk into an optional <think> section + visible answer
-function render(text){
-  let think='', body=text;
-  const m = text.match(/<think>([\s\S]*?)(<\/think>|$)/);
-  if(m){ think=m[1]; body = text.slice(0,m.index) + text.slice(m.index + m[0].length); }
+// 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()){
-    html += `<details class="think"><summary>💭 reasoning</summary><div class="t">${esc(think.trim())}</div></details>`;
+    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>`;
   }
-  html += `<div class="bubble">${esc(body.trim())||'<span style="color:#6b7684">…</span>'}</div>`;
   return html;
 }
 
+// Animated "it's working" indicator shown until the first visible tokens arrive.
+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>`;
+}
+
 function addMsg(role, text){
   $('#empty').style.display='none';
   const el = document.createElement('div');
@@ -133,8 +179,28 @@ async function send(){
   addMsg('user', q);
   history.push({role:'user', content:q}); persist();
   const content = addMsg('assistant','');
-  $('#status').textContent = 'generating…';
-  let acc='';
+  content.innerHTML = thinkingIndicator();
+  scroll.scrollTop = scroll.scrollHeight;
+  let acc='', firstToken=false;
+
+  // Motion-graphics loop: elapsed timer + animated dots + adaptive cold-load hint,
+  // running until the first real token shows up.
+  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(!el) return;
+    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);
+
   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)})});
@@ -148,14 +214,19 @@ 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; }
-        content.innerHTML = render(acc);
+        if(!firstToken && acc.trim()!==''){ firstToken=true; }
+        if(firstToken){ content.innerHTML = render(acc, true); }
         scroll.scrollTop = scroll.scrollHeight;
       }
     }
+    clearInterval(timer);
+    content.innerHTML = render(acc, false);   // final, static
     history.push({role:'assistant', content:acc}); persist();
   }catch(e){
-    content.innerHTML = render(acc + '\n[connection error: '+e+']');
+    clearInterval(timer);
+    content.innerHTML = render(acc + '\n[connection error: '+e+']', false);
   }finally{
+    clearInterval(timer);
     busy=false; $('#send').disabled=false; $('#status').textContent=''; ta.focus();
   }
 }

← b4efa8a qwen mail bridge: allowlisted email -> uncensored qwen -> in  ·  back to Qwen38 Viewer  ·  pin model permanently warm (keep_alive -1) — no cold loads 6458573 →