File size: 13,653 Bytes
7845694
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
import { createApp, reactive } from "https://cdn.jsdelivr.net/npm/vue@3.5.21/dist/vue.esm-browser.prod.js";

const worker = new Worker("worker.js", { type: "module" });

const $ = (selector) => document.querySelector(selector);
const loadButton = $("#load");
const runButton = $("#run");
const modelSelect = $("#model-select");
const uiMode = $("#ui-mode");
const models = {
  "qwen3-0.6b": {
    name: "Qwen3 0.6B", short: "Qwen3 · 0.6B", size: "639 MB",
    url: "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF",
    notice: "Smaller model optimized for small devices. Accuracy may be worse.", noticeClass: "mobile",
  },
  "minicpm5-2b": {
    name: "MiniCPM5 2B", short: "MiniCPM5 · 2B", size: "1.56 GB",
    url: "https://huggingface.co/openbmb/MiniCPM5-2B-GGUF",
    notice: "Larger model. Loading may be slower or may not fit on some low-end devices.", noticeClass: "",
  },
  "qwen3.5-4b": {
    name: "Qwen3.5 4B", short: "Qwen3.5 · 4B", size: "3.01 GB",
    url: "https://huggingface.co/bartowski/Qwen_Qwen3.5-4B-GGUF",
    notice: "High-memory desktop model. Allow several gigabytes of free GPU memory and browser storage.", noticeClass: "desktop-heavy",
  },
};
const presets = {
  account: {
    state: "A customer says a password reset succeeded, but every login attempt still returns ‘account locked’. Two unlock emails were requested and neither arrived.",
    question: "Which queue should handle this request?",
    options: ["Account access support", "Billing support", "Close as resolved"],
  },
  email: {
    state: "An email claims to be from the payroll team and says the recipient’s salary payment will be suspended today. It comes from payroll-review@outlook.com and links to a non-company sign-in page asking for a password and verification code.",
    question: "How should this email be classified?",
    options: ["Legitimate", "Spam", "Phishing"],
  },
};
const MIN_OPTIONS = 2;
const MAX_OPTIONS = 20;
const optionList = $("#option-list");
const addOptionButton = $("#add-option");
const removeOptionButton = $("#remove-option");
const files = new Map();
let ready = false;
const isMobileDevice = navigator.userAgentData?.mobile === true
  || /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
  || window.matchMedia("(max-width: 600px)").matches;

const supportState = reactive({ text: "Checking WebGPU…", kind: "", icon: "memory" });
createApp({ setup: () => supportState }).mount("#support");

function applyUiMode(plain) {
  document.body.classList.toggle("plain-ui", plain);
  uiMode.checked = plain;
  try {
    localStorage.setItem("semif-ui-mode", plain ? "plain" : "original");
  } catch (_) {
    // The preference is optional; inference does not depend on browser storage.
  }
}

let savedUiMode = false;
try {
  savedUiMode = localStorage.getItem("semif-ui-mode") === "plain";
} catch (_) {
  // Some embedded browsers disable local storage.
}
applyUiMode(savedUiMode);
uiMode.addEventListener("change", () => applyUiMode(uiMode.checked));

function seconds(ms) {
  return `${(ms / 1000).toFixed(3)} s`;
}

function setSupport(text, kind = "") {
  supportState.text = text;
  supportState.kind = kind;
  supportState.icon = kind === "error" ? "error" : kind === "ok" ? "check_circle" : "memory";
}

function renderSelectedModel() {
  const selected = models[modelSelect.value];
  $("#selected-model").textContent = selected.short;
  $("#model-size").textContent = `${selected.size} model`;
  $("#model-link").href = selected.url;
  $("#download-detail").textContent = `${selected.size} on first load`;
  const notice = $("#model-notice");
  notice.textContent = selected.notice;
  notice.className = `model-notice ${selected.noticeClass}`.trim();
  document.querySelectorAll("[data-quality-model]").forEach((row) => {
    row.classList.toggle("selected", row.dataset.qualityModel === modelSelect.value);
  });
  loadButton.innerHTML = `<span class="material-symbols-rounded" aria-hidden="true">download</span> load ${selected.name}`;
}

function renderProgress(event) {
  if (!event.file) return;
  if (event.status === "progress" && Number.isFinite(event.loaded) && Number.isFinite(event.total)) {
    files.set(event.file, { loaded: event.loaded, total: event.total });
  } else if (event.status === "done" && files.has(event.file)) {
    const item = files.get(event.file);
    files.set(event.file, { loaded: item.total, total: item.total });
  }
  const totals = [...files.values()].reduce((sum, item) => ({ loaded: sum.loaded + item.loaded, total: sum.total + item.total }), { loaded: 0, total: 0 });
  if (totals.total > 0) {
    const percent = Math.min(100, (totals.loaded / totals.total) * 100);
    $("#download-meter").style.width = `${percent}%`;
    $("#download-value").textContent = `${percent.toFixed(0)}%`;
    $("#download-detail").textContent = event.text || "model files and WebGPU runtime";
  } else if (event.status === "initiate") {
    $("#download-value").textContent = "cache check";
    $("#download-detail").textContent = event.file;
  }
}

function renderDirect(data) {
  const output = $("#direct-output");
  output.classList.remove("empty");
  output.replaceChildren(...data.options.map((item) => {
    const row = document.createElement("div");
    row.className = "choice";
    const label = document.createElement("span");
    label.className = "choice-label";
    const letter = document.createElement("b");
    letter.textContent = item.label;
    const description = document.createElement("small");
    description.textContent = item.description;
    label.append(letter, description);
    const bar = document.createElement("span");
    bar.className = "bar";
    const fill = document.createElement("i");
    fill.style.width = `${Math.max(1, item.probability * 100)}%`;
    bar.append(fill);
    const score = document.createElement("em");
    score.textContent = item.probability.toFixed(3);
    row.append(label, bar, score);
    return row;
  }));
  $("#direct-total").textContent = seconds(data.totalMs);
  $("#direct-input").textContent = `${data.inputTokens} tok`;
  $("#direct-readouts").textContent = `${data.readouts} readout${data.readouts === 1 ? "" : "s"}`;
}

function resetResults() {
  $("#direct-output").textContent = "running one forward pass…";
  $("#direct-output").className = "output empty";
  $("#generated-output").textContent = "waiting for direct readout…";
  $("#generated-output").className = "output empty";
  for (const id of ["#direct-total", "#direct-input", "#generation-ttft", "#generation-total", "#generation-input", "#generation-tokens"]) $(id).textContent = "—";
  $("#direct-readouts").textContent = "—";
  $("#ratio").textContent = "measuring…";
}

worker.addEventListener("message", ({ data }) => {
  switch (data.type) {
    case "progress":
      renderProgress(data.event);
      break;
    case "loading":
      setSupport(data.message);
      break;
    case "loaded":
      $("#load-value").textContent = seconds(data.loadMs);
      $("#download-meter").style.width = "100%";
      if (!files.size) {
        $("#download-value").textContent = "cached";
        $("#download-detail").textContent = "no network transfer observed";
      }
      break;
    case "ready":
      ready = true;
      $("#warmup-value").textContent = seconds(data.warmupMs);
      setSupport(`Ready. ${data.modelName} is loaded locally on WebGPU.`, "ok");
      loadButton.disabled = true;
      modelSelect.disabled = true;
      loadButton.innerHTML = '<span class="material-symbols-rounded" aria-hidden="true">check</span> model ready';
      runButton.disabled = false;
      break;
    case "direct":
      renderDirect(data);
      $("#generated-output").textContent = "reading the decision…";
      break;
    case "generation-start":
      $("#generated-output").textContent = "";
      $("#generated-output").classList.remove("empty");
      break;
    case "generation-update":
      $("#generated-output").textContent = data.text;
      if (data.ttftMs != null) $("#generation-ttft").textContent = seconds(data.ttftMs);
      $("#generation-tokens").textContent = `${data.tokens} tok`;
      break;
    case "complete": {
      $("#generated-output").textContent = data.generatedText || "(no visible text generated)";
      $("#generation-ttft").textContent = data.ttftMs == null ? "no token" : seconds(data.ttftMs);
      $("#generation-total").textContent = seconds(data.generationMs);
      $("#generation-input").textContent = `${data.inputTokens} tok`;
      $("#generation-tokens").textContent = `${data.generatedTokens} tok`;
      $("#ratio").textContent = `${(data.generationMs / data.directMs).toFixed(2)}× generation / direct`;
      $("#run-note").textContent = `Measured sequentially in this tab. Direct: ${seconds(data.directMs)}. Generation: ${seconds(data.generationMs)}. Order is fixed and the model was warmed before both.`;
      setSupport("Comparison complete. Edit the decision and run again whenever you like.", "ok");
      runButton.disabled = false;
      runButton.innerHTML = '<span class="material-symbols-rounded" aria-hidden="true">replay</span> run again';
      break;
    }
    case "error":
      setSupport(data.message, "error");
      runButton.disabled = !ready;
      loadButton.disabled = ready;
      modelSelect.disabled = ready;
      loadButton.innerHTML = ready
        ? '<span class="material-symbols-rounded" aria-hidden="true">check</span> model ready'
        : '<span class="material-symbols-rounded" aria-hidden="true">refresh</span> retry model load';
      break;
  }
});

worker.addEventListener("error", (event) => {
  setSupport(`Worker failed: ${event.message}`, "error");
  loadButton.disabled = false;
});

function optionRows() {
  return [...optionList.querySelectorAll(".option-row")];
}

function syncOptionControls() {
  const rows = optionRows();
  rows.forEach((row, index) => { row.querySelector("b").textContent = String.fromCharCode(65 + index); });
  $("#option-count").textContent = `${rows.length} / ${MAX_OPTIONS}`;
  removeOptionButton.disabled = rows.length <= MIN_OPTIONS;
  addOptionButton.disabled = rows.length >= MAX_OPTIONS;
}

function appendOption(value = "") {
  if (optionRows().length >= MAX_OPTIONS) return;
  const row = document.createElement("label");
  row.className = "option-row";
  const label = document.createElement("b");
  const input = document.createElement("input");
  input.className = "option";
  input.value = value;
  input.placeholder = "Describe this option";
  row.append(label, input);
  optionList.append(row);
  syncOptionControls();
  return input;
}

function setOptions(values) {
  while (optionRows().length > values.length) optionRows().at(-1).remove();
  while (optionRows().length < values.length) appendOption();
  optionRows().forEach((row, index) => { row.querySelector(".option").value = values[index]; });
  syncOptionControls();
}

addOptionButton.addEventListener("click", () => appendOption()?.focus());
removeOptionButton.addEventListener("click", () => {
  const rows = optionRows();
  if (rows.length > MIN_OPTIONS) rows.at(-1).remove();
  syncOptionControls();
});
syncOptionControls();

async function checkWebGPU() {
  if (!navigator.gpu) {
    setSupport("WebGPU is unavailable. Use a current WebGPU-capable browser over HTTPS or localhost.", "error");
    loadButton.disabled = true;
    return;
  }
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) {
    setSupport("WebGPU exists, but no GPU adapter is available in this browser.", "error");
    loadButton.disabled = true;
    return;
  }
  setSupport("WebGPU is ready. The model does not download until you click load.", "ok");
}

loadButton.addEventListener("click", () => {
  loadButton.disabled = true;
  modelSelect.disabled = true;
  loadButton.innerHTML = '<span class="material-symbols-rounded spin" aria-hidden="true">progress_activity</span> loading…';
  worker.postMessage({
    type: "load",
    modelId: modelSelect.value,
    useLocal:
      ["127.0.0.1", "localhost"].includes(location.hostname) &&
      new URLSearchParams(location.search).has("local"),
  });
});

document.querySelectorAll("[data-preset]").forEach((button) => {
  button.addEventListener("click", () => {
    const preset = presets[button.dataset.preset];
    if (!preset) return;
    $("#state").value = preset.state;
    $("#question").value = preset.question;
    setOptions(preset.options);
    $("#state").focus();
  });
});

modelSelect.addEventListener("change", renderSelectedModel);
modelSelect.value = "minicpm5-2b";
$("#device-note").textContent = isMobileDevice
  ? "Phone or small device detected · MiniCPM5 2B is selected. Switch to Qwen3 0.6B in the model box if loading is too heavy."
  : "Desktop detected · MiniCPM5 2B selected by default.";
renderSelectedModel();

runButton.addEventListener("click", () => {
  const state = $("#state").value.trim();
  const question = $("#question").value.trim();
  const options = [...document.querySelectorAll(".option")].map((input) => input.value.trim());
  if (!state || !question || options.some((option) => !option)) {
    setSupport("State, question and every option must be nonempty.", "error");
    return;
  }
  resetResults();
  runButton.disabled = true;
  runButton.innerHTML = '<span class="material-symbols-rounded spin" aria-hidden="true">progress_activity</span> running…';
  setSupport("Running direct readout, then autoregressive generation…");
  worker.postMessage({ type: "compare", data: { state, question, options } });
});

checkWebGPU().catch((error) => setSupport(`WebGPU check failed: ${error.message}`, "error"));