ryanhlewis commited on
Commit
3aa06f3
·
verified ·
1 Parent(s): 834fab8

Separate thinking and improve load status

Browse files
dist/assets/index-CrZUE5nI.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;color:#f4f0e8;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10100f;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;line-height:1.4}*{box-sizing:border-box}body{background:linear-gradient(#39453a38,#0000 360px),#10100f;min-width:320px;min-height:100vh;margin:0}button,input,textarea,select{font:inherit}button{border:0}.app{width:min(1180px,100vw - 32px);margin:0 auto;padding:24px 0 36px}.topbar{justify-content:space-between;align-items:center;gap:16px;margin-bottom:20px;display:flex}.brand{align-items:center;gap:12px;display:flex}.mark{color:#15170f;background:#d7ff7a;border:1px solid #f4f0e829;border-radius:8px;place-items:center;width:38px;height:38px;font-weight:800;display:grid}h1,h2,h3,p{margin:0}h1{letter-spacing:0;font-size:19px;font-weight:720}.subtle{color:#a7a093;font-size:13px}.actions{align-items:center;gap:8px;display:flex;position:relative}.icon-button,.button{color:#f4f0e8;cursor:pointer;background:#24231f;border:1px solid #f4f0e81f;border-radius:8px;justify-content:center;align-items:center;gap:8px;min-height:38px;padding:0 14px;display:inline-flex}.icon-button{width:38px;padding:0}.icon-button svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.8px;width:18px;height:18px}.settings-popover{z-index:10;width:min(430px,100vw - 32px);padding:16px;position:absolute;top:46px;right:0;box-shadow:0 18px 42px #00000061}.settings-popover h2{margin-bottom:8px;font-size:15px;font-weight:720}.button.primary{color:#15170f;background:#d7ff7a;border-color:#d7ff7a;font-weight:740}.button.danger{color:#ffd9d9;background:#3a2525;border-color:#6e3737}.button:disabled,.icon-button:disabled{cursor:not-allowed;opacity:.55}.shell{grid-template-columns:minmax(0,1fr) 340px;gap:18px;display:grid}.panel{background:#1a1916f0;border:1px solid #f4f0e81f;border-radius:8px}.chat-panel{grid-template-rows:auto minmax(0,1fr) auto;min-height:690px;display:grid}.status-strip{background:#f4f0e814;border-bottom:1px solid #f4f0e81a;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;display:grid}.metric{background:#1a1916;min-width:0;padding:12px 14px}.metric span{color:#a7a093;text-transform:uppercase;font-size:11px;display:block}.metric strong{text-overflow:ellipsis;white-space:nowrap;margin-top:2px;font-size:14px;font-weight:680;display:block;overflow:hidden}.messages{padding:18px;overflow:auto}.message-list{flex-direction:column;gap:14px;display:flex}.message{white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid #f4f0e81a;border-radius:8px;max-width:86%;padding:13px 14px}.message.user{background:#243124;align-self:flex-end}.message.assistant{background:#22211d;align-self:flex-start}.answer-text{white-space:pre-wrap}.thinking-block{border-bottom:1px solid #f4f0e814;margin-bottom:10px;padding-bottom:10px}.thinking-toggle{color:#c9c1b2;cursor:pointer;background:#181815;border-radius:7px;align-items:center;gap:7px;min-height:28px;padding:0 8px;font-size:12px;display:inline-flex}.thinking-arrow{color:#d7ff7a;font-weight:800}.thinking-content{color:#a7a093;white-space:pre-wrap;background:#141411;border-radius:7px;max-height:240px;margin-top:8px;padding:10px;font-size:12px;overflow:auto}.empty{color:#a7a093;text-align:center;place-items:center;min-height:340px;display:grid}.composer{border-top:1px solid #f4f0e81a;gap:10px;padding:14px;display:grid}textarea,input,select{color:#f4f0e8;background:#121210;border:1px solid #f4f0e81f;border-radius:8px;outline:none;width:100%}textarea:focus,input:focus,select:focus{border-color:#d7ff7acc;box-shadow:0 0 0 3px #d7ff7a1f}textarea{resize:vertical;min-height:96px;padding:12px}input,select{min-height:38px;padding:0 10px}.composer-row{justify-content:space-between;align-items:center;gap:10px;display:flex}.file-chip{color:#a7a093;flex:1;align-items:center;gap:8px;min-width:0;font-size:13px;display:flex}.file-chip input{display:none}.chip-button{color:#f4f0e8;cursor:pointer;background:#24231f;border-radius:8px;align-items:center;gap:7px;min-height:34px;padding:0 10px;display:inline-flex}.file-name{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.side{flex-direction:column;gap:14px;display:flex}.card{padding:16px}.card h2{margin-bottom:10px;font-size:15px;font-weight:720}.field{gap:6px;margin-top:12px;display:grid}.field label{color:#c9c1b2;font-size:12px;font-weight:650}.field-grid{grid-template-columns:1fr 1fr;gap:10px;display:grid}.progress{background:#11110f;border-radius:999px;height:10px;overflow:hidden}.bar{border-radius:inherit;background:#d7ff7a;width:0%;height:100%;transition:width .18s}.indeterminate .bar{background:linear-gradient(90deg,#d7ff7a59,#d7ff7a,#d7ff7a59) 0 0/200% 100%;animation:1.1s linear infinite load-sweep}@keyframes load-sweep{0%{background-position:200% 0}to{background-position:0 0}}.log{color:#c9c1b2;white-space:pre-wrap;background:#11110f;border-radius:8px;min-height:106px;max-height:190px;padding:10px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:auto}.pill-row{flex-wrap:wrap;gap:8px;margin-top:12px;display:flex}.pill{color:#c9c1b2;border:1px solid #f4f0e81f;border-radius:999px;padding:5px 9px;font-size:12px}.source{overflow-wrap:anywhere}@media (max-width:900px){.app{width:min(100vw - 20px,720px);padding-top:14px}.shell{grid-template-columns:1fr}.status-strip{grid-template-columns:1fr 1fr}.message{max-width:94%}.topbar{flex-direction:column;align-items:flex-start}.actions{justify-content:flex-end;align-self:stretch}}
dist/assets/index-fRU50Fl0.js ADDED
The diff for this file is too large to render. See raw diff
 
dist/assets/wllama-ByHMPmd7.wasm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a3e827b9fc3536fd1b061eee8b130e46f6366d25b062438a791d8b17b2a72a30
3
+ size 7308965
dist/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Gemma 4 WebGPU</title>
7
+ <link rel="preconnect" href="https://huggingface.co" />
8
+ <script type="module" crossorigin src="./assets/index-fRU50Fl0.js"></script>
9
+ <link rel="stylesheet" crossorigin href="./assets/index-CrZUE5nI.css">
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ </body>
14
+ </html>
src/main.ts CHANGED
@@ -22,17 +22,29 @@ type Role = "user" | "assistant";
22
  type ChatEntry = {
23
  role: Role;
24
  content: string;
 
 
25
  };
26
 
27
- type StreamText = {
28
- text: string;
29
- replace: boolean;
 
 
30
  };
31
 
 
 
 
 
 
 
 
32
  type LoadStats = {
33
  loaded: number;
34
  total: number;
35
  startedAt: number;
 
36
  doneAt?: number;
37
  };
38
 
@@ -44,8 +56,9 @@ const app = root;
44
  let wllama: Wllama | null = null;
45
  let modelLoaded = false;
46
  let loading = false;
 
47
  let generating = false;
48
- let loadStats: LoadStats = { loaded: 0, total: TOTAL_DOWNLOAD_BYTES, startedAt: 0 };
49
  let chat: ChatEntry[] = [];
50
  let imageFile: File | null = null;
51
  let abortController: AbortController | null = null;
@@ -62,6 +75,7 @@ const state = {
62
  maxTokens: Number(params.get("maxTokens") || 384),
63
  temperature: Number(params.get("temperature") || 0.7),
64
  topP: Number(params.get("topP") || 0.95),
 
65
  seed: Number(params.get("seed") || -1),
66
  prompt:
67
  params.get("prompt") ||
@@ -91,11 +105,55 @@ function elapsedLoadSeconds(): number {
91
  return (end - loadStats.startedAt) / 1000;
92
  }
93
 
 
 
 
 
 
 
 
 
 
 
 
94
  function loadPercent(): number {
95
  if (!loadStats.total) return 0;
96
  return Math.max(0, Math.min(100, (loadStats.loaded / loadStats.total) * 100));
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  function webgpuStatus(): string {
100
  if (!("gpu" in navigator)) return "unavailable";
101
  return modelLoaded && wllama?.isSupportWebGPU() ? "ready" : "available";
@@ -132,16 +190,38 @@ function appendLog(message: string): void {
132
  el.scrollTop = el.scrollHeight;
133
  }
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  function render(): void {
136
  const progress = loadPercent();
137
- const loadLabel = modelLoaded
138
- ? "Model ready"
139
- : loading
140
- ? `${formatBytes(loadStats.loaded)} / ${formatBytes(loadStats.total)}`
141
- : "Download model assets";
142
- const loadSub = loading
143
- ? `${progress.toFixed(1)}% | ${formatBytes(loadStats.loaded / Math.max(elapsedLoadSeconds(), 0.1))}/s | ${formatSeconds(elapsedLoadSeconds())}`
144
- : "4.34 GB";
145
 
146
  app.innerHTML = `
147
  <main class="app">
@@ -180,7 +260,7 @@ function render(): void {
180
  <section class="shell">
181
  <section class="panel chat-panel">
182
  <div class="status-strip">
183
- <div class="metric"><span>Status</span><strong>${modelLoaded ? "ready" : loading ? "loading" : "idle"}</strong></div>
184
  <div class="metric"><span>WebGPU</span><strong>${webgpuStatus()}</strong></div>
185
  <div class="metric"><span>Prompt</span><strong>${lastTimings ? `${lastTimings.prompt_per_second.toFixed(1)} tok/s` : "-"}</strong></div>
186
  <div class="metric"><span>Generate</span><strong>${lastTimings ? `${lastTimings.predicted_per_second.toFixed(1)} tok/s` : "-"}</strong></div>
@@ -190,7 +270,7 @@ function render(): void {
190
  ${
191
  chat.length
192
  ? `<div class="message-list">${chat
193
- .map((item) => `<div class="message ${item.role}">${escapeHtml(item.content)}</div>`)
194
  .join("")}</div>`
195
  : `<div class="empty"><div><h2>Ask Gemma 4 locally</h2><p class="subtle">Download once, then chat from browser cache.</p></div></div>`
196
  }
@@ -216,11 +296,11 @@ function render(): void {
216
 
217
  <aside class="side">
218
  <section class="panel card">
219
- <h2>${loadLabel}</h2>
220
- <p class="subtle">${loadSub}</p>
221
  <div class="field">
222
- <div class="progress" aria-label="Download progress">
223
- <div class="bar" style="width: ${progress}%"></div>
224
  </div>
225
  </div>
226
  ${!loading && !modelLoaded ? `<div class="field"><button class="button primary" id="loadButton">Start download</button></div>` : ""}
@@ -248,6 +328,13 @@ function render(): void {
248
  <input id="seed" type="number" step="1" value="${state.seed}" />
249
  </div>
250
  </div>
 
 
 
 
 
 
 
251
  </section>
252
 
253
  <section class="panel card">
@@ -316,6 +403,7 @@ function syncInputs(): void {
316
  state.maxTokens = getNumberValue("maxTokens", state.maxTokens);
317
  state.temperature = getNumberValue("temperature", state.temperature);
318
  state.topP = getNumberValue("topP", state.topP);
 
319
  state.seed = getNumberValue("seed", state.seed);
320
  }
321
 
@@ -369,7 +457,23 @@ function bindEvents(): void {
369
  render();
370
  });
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  document.querySelector<HTMLButtonElement>("#googleSingleButton")?.addEventListener("click", () => {
 
373
  state.llmUrl = GOOGLE_SINGLE_GGUF;
374
  modelLoaded = false;
375
  render();
@@ -382,8 +486,10 @@ async function loadModel(): Promise<void> {
382
  syncInputs();
383
 
384
  loading = true;
385
- loadStats = { loaded: 0, total: TOTAL_DOWNLOAD_BYTES, startedAt: performance.now() };
386
- currentLog = "Creating Wllama runtime...";
 
 
387
  render();
388
 
389
  try {
@@ -401,7 +507,7 @@ async function loadModel(): Promise<void> {
401
  appendLog("WebGPU is not available. Wllama may fall back or fail depending on this browser.");
402
  }
403
 
404
- appendLog(`Loading ${state.llmUrl}`);
405
  await wllama.loadModelFromUrl(
406
  {
407
  url: state.llmUrl,
@@ -421,7 +527,21 @@ async function loadModel(): Promise<void> {
421
  progressCallback: ({ loaded, total }) => {
422
  loadStats.loaded = loaded;
423
  loadStats.total = total || loadStats.total || TOTAL_DOWNLOAD_BYTES;
424
- currentLog = `Downloading model assets...\n${formatBytes(loadStats.loaded)} / ${formatBytes(loadStats.total)}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  render();
426
  },
427
  }
@@ -429,6 +549,7 @@ async function loadModel(): Promise<void> {
429
 
430
  loadStats.doneAt = performance.now();
431
  modelLoaded = true;
 
432
  currentLog = [
433
  `Loaded in ${formatSeconds(elapsedLoadSeconds())}`,
434
  `libllama ${Wllama.getLibllamaVersion()}`,
@@ -436,6 +557,7 @@ async function loadModel(): Promise<void> {
436
  `modalities image=${safeSupport("image")} audio=${safeSupport("audio")}`,
437
  ].join("\n");
438
  } catch (error) {
 
439
  currentLog = `Load failed:\n${errorToText(error)}`;
440
  modelLoaded = false;
441
  void wllama?.exit().catch(() => undefined);
@@ -493,6 +615,7 @@ async function sendMessage(): Promise<void> {
493
  abortController = new AbortController();
494
  lastTimings = undefined;
495
  const startedAt = performance.now();
 
496
  render();
497
 
498
  try {
@@ -500,8 +623,18 @@ async function sendMessage(): Promise<void> {
500
  role: "user",
501
  content,
502
  } as ChatCompletionMessage;
 
 
 
 
 
 
 
 
 
503
  const request: ChatCompletionParams = {
504
  messages: [
 
505
  ...history,
506
  userTurn,
507
  ],
@@ -518,16 +651,22 @@ async function sendMessage(): Promise<void> {
518
  stream: true,
519
  abortSignal: abortController.signal,
520
  onData: (chunk: ChatCompletionChunk) => {
521
- const delta = extractStreamText(chunk);
522
  lastTimings = chunk.timings || lastTimings;
523
  const assistant = chat[assistantIndex];
524
- if (assistant?.role === "assistant" && delta.text) {
525
- assistant.content = delta.replace ? delta.text : assistant.content + delta.text;
 
 
 
 
 
526
  }
527
  const elapsed = (performance.now() - startedAt) / 1000;
528
  currentLog = [
529
  `Generating ${formatSeconds(elapsed)}`,
530
- assistant?.content ? `Visible text ${assistant.content.length} chars` : "Waiting for text delta...",
 
531
  lastTimings ? `Prompt ${lastTimings.prompt_per_second.toFixed(1)} tok/s` : "",
532
  lastTimings ? `Output ${lastTimings.predicted_per_second.toFixed(1)} tok/s` : "",
533
  ]
@@ -537,6 +676,11 @@ async function sendMessage(): Promise<void> {
537
  },
538
  });
539
  const assistant = chat[assistantIndex];
 
 
 
 
 
540
  if (assistant?.role === "assistant" && !assistant.content.trim()) {
541
  currentLog = "Stream returned timings but no visible text. Fetching final response...";
542
  render();
@@ -545,7 +689,9 @@ async function sendMessage(): Promise<void> {
545
  stream: false,
546
  abortSignal: abortController.signal,
547
  });
548
- assistant.content = extractFinalText(response);
 
 
549
  }
550
  const elapsed = (performance.now() - startedAt) / 1000;
551
  currentLog = formatDoneLog(elapsed, lastTimings);
@@ -595,7 +741,69 @@ function textFromValue(value: unknown): string {
595
  return "";
596
  }
597
 
598
- function extractStreamText(chunk: unknown): StreamText {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  const data = chunk as Record<string, unknown>;
600
  const choices = Array.isArray(data.choices) ? (data.choices as Array<Record<string, unknown>>) : [];
601
  const choice = choices[0] || {};
@@ -603,9 +811,16 @@ function extractStreamText(chunk: unknown): StreamText {
603
  const message = (choice.message && typeof choice.message === "object" ? choice.message : {}) as Record<string, unknown>;
604
  const token = (data.token && typeof data.token === "object" ? data.token : {}) as Record<string, unknown>;
605
 
 
 
 
 
 
 
 
 
606
  const appendText =
607
  textFromValue(delta.content) ||
608
- textFromValue(delta.reasoning_content) ||
609
  textFromValue(delta.text) ||
610
  textFromValue(choice.text) ||
611
  textFromValue(data.content) ||
@@ -613,18 +828,27 @@ function extractStreamText(chunk: unknown): StreamText {
613
  textFromValue(data.text) ||
614
  textFromValue(token.text);
615
 
616
- if (appendText) return { text: appendText, replace: false };
617
-
618
- const replacementText =
619
- textFromValue(message.content) ||
620
- textFromValue(message.reasoning_content) ||
621
- textFromValue(data.message);
622
 
623
- return { text: replacementText, replace: Boolean(replacementText) };
 
 
 
 
 
624
  }
625
 
626
- function extractFinalText(response: ChatCompletionResponse): string {
627
- return textFromValue(response.choices?.[0]?.message?.content);
 
 
 
 
 
 
 
628
  }
629
 
630
  render();
 
22
  type ChatEntry = {
23
  role: Role;
24
  content: string;
25
+ thinking?: string;
26
+ thinkingOpen?: boolean;
27
  };
28
 
29
+ type StreamParts = {
30
+ answer: string;
31
+ thinking: string;
32
+ replaceAnswer: boolean;
33
+ replaceThinking: boolean;
34
  };
35
 
36
+ type ThinkParseState = {
37
+ inThinking: boolean;
38
+ tagBuffer: string;
39
+ };
40
+
41
+ type LoadPhase = "idle" | "preparing" | "downloading" | "initializing" | "ready" | "error";
42
+
43
  type LoadStats = {
44
  loaded: number;
45
  total: number;
46
  startedAt: number;
47
+ phaseStartedAt: number;
48
  doneAt?: number;
49
  };
50
 
 
56
  let wllama: Wllama | null = null;
57
  let modelLoaded = false;
58
  let loading = false;
59
+ let loadPhase: LoadPhase = "idle";
60
  let generating = false;
61
+ let loadStats: LoadStats = { loaded: 0, total: TOTAL_DOWNLOAD_BYTES, startedAt: 0, phaseStartedAt: 0 };
62
  let chat: ChatEntry[] = [];
63
  let imageFile: File | null = null;
64
  let abortController: AbortController | null = null;
 
75
  maxTokens: Number(params.get("maxTokens") || 384),
76
  temperature: Number(params.get("temperature") || 0.7),
77
  topP: Number(params.get("topP") || 0.95),
78
+ thinking: params.get("thinking") === "hidden" ? "hidden" : "collapsed",
79
  seed: Number(params.get("seed") || -1),
80
  prompt:
81
  params.get("prompt") ||
 
105
  return (end - loadStats.startedAt) / 1000;
106
  }
107
 
108
+ function elapsedPhaseSeconds(): number {
109
+ if (!loadStats.phaseStartedAt) return 0;
110
+ return (performance.now() - loadStats.phaseStartedAt) / 1000;
111
+ }
112
+
113
+ function setLoadPhase(phase: LoadPhase): void {
114
+ if (loadPhase === phase) return;
115
+ loadPhase = phase;
116
+ loadStats.phaseStartedAt = performance.now();
117
+ }
118
+
119
  function loadPercent(): number {
120
  if (!loadStats.total) return 0;
121
  return Math.max(0, Math.min(100, (loadStats.loaded / loadStats.total) * 100));
122
  }
123
 
124
+ function statusLabel(): string {
125
+ if (modelLoaded) return "ready";
126
+ if (!loading) return loadPhase === "error" ? "error" : "idle";
127
+ if (loadPhase === "downloading") return "downloading";
128
+ if (loadPhase === "initializing") return "initializing";
129
+ return "preparing";
130
+ }
131
+
132
+ function loadTitle(): string {
133
+ if (modelLoaded) return "Model ready";
134
+ if (loadPhase === "preparing") return "Preparing runtime";
135
+ if (loadPhase === "downloading") return "Downloading model assets";
136
+ if (loadPhase === "initializing") return "Loading model into WebGPU";
137
+ if (loadPhase === "error") return "Load failed";
138
+ return "Download model assets";
139
+ }
140
+
141
+ function loadDetail(progress: number): string {
142
+ if (modelLoaded) return `Ready in ${formatSeconds(elapsedLoadSeconds())}`;
143
+ if (loading && loadPhase === "preparing") {
144
+ return `Starting browser runtime | ${formatSeconds(elapsedPhaseSeconds())}`;
145
+ }
146
+ if (loading && loadPhase === "downloading") {
147
+ return `${progress.toFixed(1)}% | ${formatBytes(loadStats.loaded)} / ${formatBytes(loadStats.total)} | ${formatBytes(
148
+ loadStats.loaded / Math.max(elapsedLoadSeconds(), 0.1)
149
+ )}/s`;
150
+ }
151
+ if (loading && loadPhase === "initializing") {
152
+ return `Download complete | Initializing ${formatSeconds(elapsedPhaseSeconds())}`;
153
+ }
154
+ return "4.34 GB";
155
+ }
156
+
157
  function webgpuStatus(): string {
158
  if (!("gpu" in navigator)) return "unavailable";
159
  return modelLoaded && wllama?.isSupportWebGPU() ? "ready" : "available";
 
190
  el.scrollTop = el.scrollHeight;
191
  }
192
 
193
+ function renderMessage(item: ChatEntry, index: number): string {
194
+ if (item.role === "user") {
195
+ return `<div class="message user">${escapeHtml(item.content)}</div>`;
196
+ }
197
+
198
+ const thinking = item.thinking?.trim() || "";
199
+ const shouldShowThinking = state.thinking === "collapsed" && thinking.length > 0;
200
+ const arrow = item.thinkingOpen ? "^" : "v";
201
+ const thinkingBlock = shouldShowThinking
202
+ ? `<div class="thinking-block">
203
+ <button class="thinking-toggle" type="button" data-thinking-index="${index}">
204
+ <span>Thinking...</span>
205
+ <span class="thinking-arrow">${arrow}</span>
206
+ </button>
207
+ ${
208
+ item.thinkingOpen
209
+ ? `<div class="thinking-content">${escapeHtml(thinking)}</div>`
210
+ : ""
211
+ }
212
+ </div>`
213
+ : "";
214
+ const answer = item.content.trim()
215
+ ? escapeHtml(item.content)
216
+ : `<span class="subtle">Waiting for response...</span>`;
217
+
218
+ return `<div class="message assistant">${thinkingBlock}<div class="answer-text">${answer}</div></div>`;
219
+ }
220
+
221
  function render(): void {
222
  const progress = loadPercent();
223
+ const progressClass = loadPhase === "initializing" ? "progress indeterminate" : "progress";
224
+ const barWidth = loadPhase === "preparing" && loading ? 8 : loadPhase === "initializing" ? 100 : progress;
 
 
 
 
 
 
225
 
226
  app.innerHTML = `
227
  <main class="app">
 
260
  <section class="shell">
261
  <section class="panel chat-panel">
262
  <div class="status-strip">
263
+ <div class="metric"><span>Status</span><strong>${statusLabel()}</strong></div>
264
  <div class="metric"><span>WebGPU</span><strong>${webgpuStatus()}</strong></div>
265
  <div class="metric"><span>Prompt</span><strong>${lastTimings ? `${lastTimings.prompt_per_second.toFixed(1)} tok/s` : "-"}</strong></div>
266
  <div class="metric"><span>Generate</span><strong>${lastTimings ? `${lastTimings.predicted_per_second.toFixed(1)} tok/s` : "-"}</strong></div>
 
270
  ${
271
  chat.length
272
  ? `<div class="message-list">${chat
273
+ .map((item, index) => renderMessage(item, index))
274
  .join("")}</div>`
275
  : `<div class="empty"><div><h2>Ask Gemma 4 locally</h2><p class="subtle">Download once, then chat from browser cache.</p></div></div>`
276
  }
 
296
 
297
  <aside class="side">
298
  <section class="panel card">
299
+ <h2>${loadTitle()}</h2>
300
+ <p class="subtle">${loadDetail(progress)}</p>
301
  <div class="field">
302
+ <div class="${progressClass}" aria-label="Model loading progress">
303
+ <div class="bar" style="width: ${barWidth}%"></div>
304
  </div>
305
  </div>
306
  ${!loading && !modelLoaded ? `<div class="field"><button class="button primary" id="loadButton">Start download</button></div>` : ""}
 
328
  <input id="seed" type="number" step="1" value="${state.seed}" />
329
  </div>
330
  </div>
331
+ <div class="field">
332
+ <label for="thinking">Thinking</label>
333
+ <select id="thinking">
334
+ <option value="collapsed" ${state.thinking === "collapsed" ? "selected" : ""}>Show collapsed</option>
335
+ <option value="hidden" ${state.thinking === "hidden" ? "selected" : ""}>Hide</option>
336
+ </select>
337
+ </div>
338
  </section>
339
 
340
  <section class="panel card">
 
403
  state.maxTokens = getNumberValue("maxTokens", state.maxTokens);
404
  state.temperature = getNumberValue("temperature", state.temperature);
405
  state.topP = getNumberValue("topP", state.topP);
406
+ state.thinking = getInputValue("thinking", state.thinking) === "hidden" ? "hidden" : "collapsed";
407
  state.seed = getNumberValue("seed", state.seed);
408
  }
409
 
 
457
  render();
458
  });
459
 
460
+ document.querySelector<HTMLSelectElement>("#thinking")?.addEventListener("change", () => {
461
+ syncInputs();
462
+ render();
463
+ });
464
+
465
+ document.querySelectorAll<HTMLButtonElement>("[data-thinking-index]").forEach((button) => {
466
+ button.addEventListener("click", () => {
467
+ const index = Number(button.dataset.thinkingIndex);
468
+ const entry = chat[index];
469
+ if (!entry || entry.role !== "assistant") return;
470
+ entry.thinkingOpen = !entry.thinkingOpen;
471
+ render();
472
+ });
473
+ });
474
+
475
  document.querySelector<HTMLButtonElement>("#googleSingleButton")?.addEventListener("click", () => {
476
+ syncInputs();
477
  state.llmUrl = GOOGLE_SINGLE_GGUF;
478
  modelLoaded = false;
479
  render();
 
486
  syncInputs();
487
 
488
  loading = true;
489
+ const startedAt = performance.now();
490
+ loadStats = { loaded: 0, total: TOTAL_DOWNLOAD_BYTES, startedAt, phaseStartedAt: startedAt };
491
+ setLoadPhase("preparing");
492
+ currentLog = "Preparing browser runtime...";
493
  render();
494
 
495
  try {
 
507
  appendLog("WebGPU is not available. Wllama may fall back or fail depending on this browser.");
508
  }
509
 
510
+ appendLog(`Model source: ${state.llmUrl}`);
511
  await wllama.loadModelFromUrl(
512
  {
513
  url: state.llmUrl,
 
527
  progressCallback: ({ loaded, total }) => {
528
  loadStats.loaded = loaded;
529
  loadStats.total = total || loadStats.total || TOTAL_DOWNLOAD_BYTES;
530
+ if (loadStats.total && loadStats.loaded >= loadStats.total) {
531
+ setLoadPhase("initializing");
532
+ currentLog = [
533
+ "Download complete.",
534
+ `Initializing WebGPU model for ${formatSeconds(elapsedPhaseSeconds())}`,
535
+ "This step loads weights and builds GPU resources.",
536
+ ].join("\n");
537
+ } else {
538
+ setLoadPhase("downloading");
539
+ currentLog = [
540
+ "Downloading model assets...",
541
+ `${formatBytes(loadStats.loaded)} / ${formatBytes(loadStats.total)}`,
542
+ `${formatBytes(loadStats.loaded / Math.max(elapsedLoadSeconds(), 0.1))}/s`,
543
+ ].join("\n");
544
+ }
545
  render();
546
  },
547
  }
 
549
 
550
  loadStats.doneAt = performance.now();
551
  modelLoaded = true;
552
+ setLoadPhase("ready");
553
  currentLog = [
554
  `Loaded in ${formatSeconds(elapsedLoadSeconds())}`,
555
  `libllama ${Wllama.getLibllamaVersion()}`,
 
557
  `modalities image=${safeSupport("image")} audio=${safeSupport("audio")}`,
558
  ].join("\n");
559
  } catch (error) {
560
+ setLoadPhase("error");
561
  currentLog = `Load failed:\n${errorToText(error)}`;
562
  modelLoaded = false;
563
  void wllama?.exit().catch(() => undefined);
 
615
  abortController = new AbortController();
616
  lastTimings = undefined;
617
  const startedAt = performance.now();
618
+ const thinkParser: ThinkParseState = { inThinking: false, tagBuffer: "" };
619
  render();
620
 
621
  try {
 
623
  role: "user",
624
  content,
625
  } as ChatCompletionMessage;
626
+ const thinkingInstruction =
627
+ state.thinking === "hidden"
628
+ ? ([
629
+ {
630
+ role: "system",
631
+ content: "Answer directly. Do not include thinking, reasoning traces, or <think> sections.",
632
+ },
633
+ ] as ChatCompletionMessage[])
634
+ : [];
635
  const request: ChatCompletionParams = {
636
  messages: [
637
+ ...thinkingInstruction,
638
  ...history,
639
  userTurn,
640
  ],
 
651
  stream: true,
652
  abortSignal: abortController.signal,
653
  onData: (chunk: ChatCompletionChunk) => {
654
+ const delta = extractStreamParts(chunk, thinkParser);
655
  lastTimings = chunk.timings || lastTimings;
656
  const assistant = chat[assistantIndex];
657
+ if (assistant?.role === "assistant") {
658
+ if (delta.answer) {
659
+ assistant.content = delta.replaceAnswer ? delta.answer : assistant.content + delta.answer;
660
+ }
661
+ if (delta.thinking) {
662
+ assistant.thinking = delta.replaceThinking ? delta.thinking : `${assistant.thinking || ""}${delta.thinking}`;
663
+ }
664
  }
665
  const elapsed = (performance.now() - startedAt) / 1000;
666
  currentLog = [
667
  `Generating ${formatSeconds(elapsed)}`,
668
+ assistant?.content ? `Answer ${assistant.content.length} chars` : "Waiting for answer text...",
669
+ assistant?.thinking ? `Thinking ${assistant.thinking.length} chars` : "",
670
  lastTimings ? `Prompt ${lastTimings.prompt_per_second.toFixed(1)} tok/s` : "",
671
  lastTimings ? `Output ${lastTimings.predicted_per_second.toFixed(1)} tok/s` : "",
672
  ]
 
676
  },
677
  });
678
  const assistant = chat[assistantIndex];
679
+ const flushed = flushThinkParser(thinkParser);
680
+ if (assistant?.role === "assistant") {
681
+ if (flushed.answer) assistant.content += flushed.answer;
682
+ if (flushed.thinking) assistant.thinking = `${assistant.thinking || ""}${flushed.thinking}`;
683
+ }
684
  if (assistant?.role === "assistant" && !assistant.content.trim()) {
685
  currentLog = "Stream returned timings but no visible text. Fetching final response...";
686
  render();
 
689
  stream: false,
690
  abortSignal: abortController.signal,
691
  });
692
+ const finalParts = extractFinalParts(response);
693
+ assistant.content = finalParts.answer;
694
+ assistant.thinking = finalParts.thinking || assistant.thinking;
695
  }
696
  const elapsed = (performance.now() - startedAt) / 1000;
697
  currentLog = formatDoneLog(elapsed, lastTimings);
 
741
  return "";
742
  }
743
 
744
+ function splitTaggedThinking(text: string, state: ThinkParseState): { answer: string; thinking: string } {
745
+ let answer = "";
746
+ let thinking = "";
747
+ const tags = ["<think>", "</think>", "<thinking>", "</thinking>"];
748
+
749
+ const pushText = (value: string) => {
750
+ if (state.inThinking) {
751
+ thinking += value;
752
+ } else {
753
+ answer += value;
754
+ }
755
+ };
756
+
757
+ for (const char of text) {
758
+ if (state.tagBuffer) {
759
+ state.tagBuffer += char;
760
+ const lower = state.tagBuffer.toLowerCase();
761
+ if (lower === "<think>" || lower === "<thinking>") {
762
+ state.inThinking = true;
763
+ state.tagBuffer = "";
764
+ continue;
765
+ }
766
+ if (lower === "</think>" || lower === "</thinking>") {
767
+ state.inThinking = false;
768
+ state.tagBuffer = "";
769
+ continue;
770
+ }
771
+ if (tags.some((tag) => tag.startsWith(lower))) {
772
+ continue;
773
+ }
774
+ pushText(state.tagBuffer);
775
+ state.tagBuffer = "";
776
+ continue;
777
+ }
778
+
779
+ if (char === "<") {
780
+ state.tagBuffer = char;
781
+ continue;
782
+ }
783
+ pushText(char);
784
+ }
785
+
786
+ return { answer, thinking };
787
+ }
788
+
789
+ function flushThinkParser(state: ThinkParseState): { answer: string; thinking: string } {
790
+ if (!state.tagBuffer) return { answer: "", thinking: "" };
791
+ const pending = state.tagBuffer;
792
+ state.tagBuffer = "";
793
+ return state.inThinking ? { answer: "", thinking: pending } : { answer: pending, thinking: "" };
794
+ }
795
+
796
+ function splitCompleteTaggedThinking(text: string): { answer: string; thinking: string } {
797
+ const parser: ThinkParseState = { inThinking: false, tagBuffer: "" };
798
+ const parts = splitTaggedThinking(text, parser);
799
+ const flushed = flushThinkParser(parser);
800
+ return {
801
+ answer: parts.answer + flushed.answer,
802
+ thinking: parts.thinking + flushed.thinking,
803
+ };
804
+ }
805
+
806
+ function extractStreamParts(chunk: unknown, state: ThinkParseState): StreamParts {
807
  const data = chunk as Record<string, unknown>;
808
  const choices = Array.isArray(data.choices) ? (data.choices as Array<Record<string, unknown>>) : [];
809
  const choice = choices[0] || {};
 
811
  const message = (choice.message && typeof choice.message === "object" ? choice.message : {}) as Record<string, unknown>;
812
  const token = (data.token && typeof data.token === "object" ? data.token : {}) as Record<string, unknown>;
813
 
814
+ const explicitThinking =
815
+ textFromValue(delta.reasoning_content) ||
816
+ textFromValue(delta.thinking) ||
817
+ textFromValue(message.reasoning_content) ||
818
+ textFromValue(message.thinking) ||
819
+ textFromValue(data.reasoning_content) ||
820
+ textFromValue(data.thinking);
821
+
822
  const appendText =
823
  textFromValue(delta.content) ||
 
824
  textFromValue(delta.text) ||
825
  textFromValue(choice.text) ||
826
  textFromValue(data.content) ||
 
828
  textFromValue(data.text) ||
829
  textFromValue(token.text);
830
 
831
+ const replacementText = textFromValue(message.content) || textFromValue(data.message);
832
+ const appendParts = splitTaggedThinking(appendText, state);
833
+ const replacementParts = replacementText ? splitCompleteTaggedThinking(replacementText) : { answer: "", thinking: "" };
 
 
 
834
 
835
+ return {
836
+ answer: appendParts.answer || replacementParts.answer,
837
+ thinking: explicitThinking || appendParts.thinking || replacementParts.thinking,
838
+ replaceAnswer: Boolean(replacementParts.answer),
839
+ replaceThinking: Boolean(explicitThinking || replacementParts.thinking),
840
+ };
841
  }
842
 
843
+ function extractFinalParts(response: ChatCompletionResponse): { answer: string; thinking: string } {
844
+ const message = response.choices?.[0]?.message as unknown as Record<string, unknown> | undefined;
845
+ const content = textFromValue(message?.content);
846
+ const explicitThinking = textFromValue(message?.reasoning_content) || textFromValue(message?.thinking);
847
+ const parsed = splitCompleteTaggedThinking(content);
848
+ return {
849
+ answer: parsed.answer,
850
+ thinking: explicitThinking || parsed.thinking,
851
+ };
852
  }
853
 
854
  render();
src/style.css CHANGED
@@ -236,6 +236,46 @@ h1 {
236
  background: #22211d;
237
  }
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  .empty {
240
  display: grid;
241
  min-height: 340px;
@@ -369,6 +409,21 @@ select {
369
  transition: width 180ms ease;
370
  }
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  .log {
373
  min-height: 106px;
374
  max-height: 190px;
 
236
  background: #22211d;
237
  }
238
 
239
+ .answer-text {
240
+ white-space: pre-wrap;
241
+ }
242
+
243
+ .thinking-block {
244
+ margin-bottom: 10px;
245
+ border-bottom: 1px solid rgba(244, 240, 232, 0.08);
246
+ padding-bottom: 10px;
247
+ }
248
+
249
+ .thinking-toggle {
250
+ display: inline-flex;
251
+ min-height: 28px;
252
+ align-items: center;
253
+ gap: 7px;
254
+ border-radius: 7px;
255
+ padding: 0 8px;
256
+ background: #181815;
257
+ color: #c9c1b2;
258
+ cursor: pointer;
259
+ font-size: 12px;
260
+ }
261
+
262
+ .thinking-arrow {
263
+ color: #d7ff7a;
264
+ font-weight: 800;
265
+ }
266
+
267
+ .thinking-content {
268
+ margin-top: 8px;
269
+ max-height: 240px;
270
+ overflow: auto;
271
+ border-radius: 7px;
272
+ padding: 10px;
273
+ background: #141411;
274
+ color: #a7a093;
275
+ font-size: 12px;
276
+ white-space: pre-wrap;
277
+ }
278
+
279
  .empty {
280
  display: grid;
281
  min-height: 340px;
 
409
  transition: width 180ms ease;
410
  }
411
 
412
+ .indeterminate .bar {
413
+ background: linear-gradient(90deg, rgba(215, 255, 122, 0.35), #d7ff7a, rgba(215, 255, 122, 0.35));
414
+ background-size: 200% 100%;
415
+ animation: load-sweep 1.1s linear infinite;
416
+ }
417
+
418
+ @keyframes load-sweep {
419
+ from {
420
+ background-position: 200% 0;
421
+ }
422
+ to {
423
+ background-position: 0 0;
424
+ }
425
+ }
426
+
427
  .log {
428
  min-height: 106px;
429
  max-height: 190px;