File size: 8,465 Bytes
1944112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/** Minimal test chat. Thin client over the background engine host's port protocol. */
import { ENGINE_STATE, OP, PORT_NAME, PORT_OP, PRIORITY, PROTOCOL, request } from "../adapters/protocol.js";

const $ = (id) => document.getElementById(id);
const els = {
  dot: $("dot"), model: $("model"), load: $("load"), unload: $("unload"),
  progress: $("progress"), status: $("status"), loadHint: $("loadHint"), log: $("log"), input: $("input"),
  send: $("send"), stop: $("stop"), clear: $("clear"), manage: $("manage"),
};

const history = [];
let models = [];
let engineState = { status: ENGINE_STATE.IDLE, modelId: null, pool: null };
let streamId = null;
let streamEl = null;

/**
 * The engine supersedes by `session`, so a cleared conversation has to become a
 * *different* session — otherwise the next message looks to the scheduler like
 * a continuation of the one just thrown away.
 */
const newSession = () => `popup-${crypto.randomUUID()}`;
let session = newSession();

const port = browser.runtime.connect({ name: PORT_NAME });
port.onMessage.addListener(onPortMessage);

// -------------------------------------------------------------- rendering ---

/**
 * The second line of the load guide.
 *
 * Deliberately not WebLLM's stock wording. Its default hint is about populating
 * a cache from the network on first visit, which is never what happens here:
 * the weights were injected into Cache Storage by drag-and-drop, so nothing is
 * downloaded and a "later refreshes are faster" promise would be wrong. What
 * the user is actually waiting on, once the shards are read, is WebGPU shader
 * compilation.
 */
function renderLoadHint(progress) {
  if (!progress) return void (els.loadHint.hidden = true);
  const secs = progress.timeElapsed;
  const parts = [];
  if (progress.engines > 1) parts.push(`${progress.engines} engines, loaded 2 at a time`);
  parts.push("weights come from the local cache — nothing is downloaded");
  if ((progress.progress ?? 0) > 0.99) {
    parts.push("compiling WebGPU shaders, which is most of the wait");
  }
  els.loadHint.textContent = `${secs ? `${secs}s elapsed · ` : ""}${parts.join(" · ")}.`;
  els.loadHint.hidden = false;
}

function renderState() {
  const { status, modelId, progress, error, pool } = engineState;
  els.dot.className = `dot ${status}`;
  els.progress.style.width = `${Math.round((progress?.progress ?? (status === ENGINE_STATE.READY ? 1 : 0)) * 100)}%`;

  els.status.textContent =
    error ? error :
    progress?.text ? progress.text :
    status === ENGINE_STATE.READY ? `Ready — ${modelId}` :
    status === ENGINE_STATE.LOADING ? `Loading ${modelId}…` :
    "Idle — pick a model and press Load";
  if (!error && status === ENGINE_STATE.READY && pool && (pool.busy || pool.queued)) {
    const cap = pool.size < pool.maxSize && !pool.growthBlocked ? `/${pool.maxSize}` : "";
    els.status.textContent += ` · ${pool.busy}/${pool.size}${cap} busy, ${pool.queued} queued`;
  }
  els.status.classList.toggle("banner", Boolean(error));
  els.status.classList.toggle("error", Boolean(error));

  const loading = status === ENGINE_STATE.LOADING;
  // The report is a full sentence from WebLLM ("Loading model from cache[26/58]:
  // 890MB loaded. 51% completed, 121 secs elapsed."). #status is a single
  // ellipsised line by default, which cut it off, so unclip it while loading.
  els.status.classList.toggle("loading", loading && Boolean(progress?.text));
  renderLoadHint(loading ? progress : null);
  els.load.disabled = loading || !els.model.value;
  els.unload.disabled = loading || status !== ENGINE_STATE.READY;
  els.model.disabled = loading;
  els.send.disabled = loading || streamId !== null;
  els.stop.hidden = streamId === null;
  els.load.textContent = status === ENGINE_STATE.READY && modelId === els.model.value ? "Reload" : "Load";
}

function renderEmpty() {
  els.log.replaceChildren(
    Object.assign(document.createElement("div"), {
      className: "empty",
      textContent: models.length
        ? "Load a model, then send a message."
        : "No local models yet. Open Models… and drop a compiled MLC model folder.",
    }),
  );
}

function addMessage(role, text) {
  if (els.log.querySelector(".empty")) els.log.replaceChildren();
  const el = document.createElement("div");
  el.className = `msg ${role}`;
  el.textContent = text;
  els.log.append(el);
  els.log.scrollTop = els.log.scrollHeight;
  return el;
}

// ---------------------------------------------------------------- actions ---

async function ask(op, payload) {
  const res = await browser.runtime.sendMessage(request(op, payload));
  if (!res?.ok) throw new Error(res?.error ?? "Engine did not respond.");
  return res;
}

async function refreshModels() {
  const res = await ask(OP.LIST_MODELS);
  models = res.models;
  engineState = res.state;
  els.model.replaceChildren(
    ...models.map((m) => new Option(`${m.model_id}  ·  ${(m.sizeBytes / 1e9).toFixed(1)} GB`, m.model_id)),
  );
  if (res.state.modelId) els.model.value = res.state.modelId;
  if (!els.log.children.length || els.log.querySelector(".empty")) renderEmpty();
  renderState();
}

function send() {
  const text = els.input.value.trim();
  if (!text) return;
  els.input.value = "";
  history.push({ role: "user", content: text });
  addMessage("user", text);

  streamId = crypto.randomUUID();
  streamEl = addMessage("assistant", "");
  renderState();

  port.postMessage({
    protocol: PROTOCOL,
    op: PORT_OP.CHAT_STREAM,
    id: streamId,
    modelId: els.model.value || undefined,
    messages: history,
    priority: PRIORITY.INTERACTIVE,
    session,
  });
}

function onPortMessage(msg) {
  if (msg?.protocol !== PROTOCOL) return;
  switch (msg.op) {
    case PORT_OP.ENGINE_STATE:
      engineState = msg.state;
      if (msg.state.modelId && !els.model.value) els.model.value = msg.state.modelId;
      renderState();
      return;
    case PORT_OP.CHUNK:
      if (msg.id !== streamId) return;
      streamEl.textContent += msg.delta;
      els.log.scrollTop = els.log.scrollHeight;
      return;
    case PORT_OP.DONE: {
      if (msg.id !== streamId) return;
      const text = streamEl.textContent;
      history.push({ role: "assistant", content: text });
      if (msg.usage) {
        const meta = document.createElement("span");
        meta.className = "meta";
        meta.textContent = `${msg.usage.completion_tokens} tok · ${(msg.usage.extra?.decode_tokens_per_s ?? 0).toFixed(1)} tok/s`;
        streamEl.append(meta);
      }
      streamId = null;
      streamEl = null;
      renderState();
      return;
    }
    case PORT_OP.ERROR:
      if (streamEl && !streamEl.textContent) streamEl.remove();
      addMessage("error", msg.error);
      streamId = null;
      streamEl = null;
      renderState();
      return;
  }
}

// ------------------------------------------------------------------ wiring ---

els.load.addEventListener("click", () =>
  ask(OP.LOAD, { modelId: els.model.value }).catch((err) => {
    engineState = { ...engineState, status: ENGINE_STATE.ERROR, error: err.message };
    renderState();
  }),
);
els.unload.addEventListener("click", () => ask(OP.UNLOAD).catch(() => {}));
els.send.addEventListener("click", send);
els.stop.addEventListener("click", () =>
  port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session }),
);

/**
 * Discard the conversation and start a fresh session.
 *
 * All three parts matter. Dropping `history` alone left every rendered message
 * on screen, which read as the button doing nothing; and leaving the old
 * session key in place meant a request that was still streaming kept writing
 * into a conversation the user had already thrown away.
 */
els.clear.addEventListener("click", () => {
  if (streamId) {
    port.postMessage({ protocol: PROTOCOL, op: PORT_OP.ABORT, id: streamId, session });
    streamId = null;
    streamEl = null;
  }
  history.length = 0;
  session = newSession();
  renderEmpty();
  els.input.value = "";
  renderState();
});
els.manage.addEventListener("click", () => {
  browser.runtime.openOptionsPage();
  window.close();
});
els.model.addEventListener("change", renderState);
els.input.addEventListener("keydown", (e) => {
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    if (!els.send.disabled) send();
  }
});

refreshModels().catch((err) => {
  els.status.textContent = err.message;
  els.status.classList.add("banner", "error");
});