sitammeur commited on
Commit
ac6bfbb
·
verified ·
1 Parent(s): 9617b21

Upload 13 files

Browse files
public/gemma-logo.png ADDED
public/vite.svg ADDED
src/App.jsx ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useRef } from "react";
2
+
3
+ import Chat from "./components/Chat";
4
+ import ArrowRightIcon from "./components/icons/ArrowRightIcon";
5
+ import StopIcon from "./components/icons/StopIcon";
6
+ import Progress from "./components/Progress";
7
+
8
+ const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
9
+ const STICKY_SCROLL_THRESHOLD = 120;
10
+ const EXAMPLES = [
11
+ "Give me tips to improve my time management skills.",
12
+ "What is the difference between AI and ML?",
13
+ "What is the Fibonacci sequence?",
14
+ ];
15
+
16
+ /**
17
+ * The main application component that handles the interaction with a Web Worker
18
+ * to load and run a language model directly in the browser using WebGPU.
19
+ */
20
+ function App() {
21
+ // Create a reference to the worker object.
22
+ const worker = useRef(null);
23
+
24
+ const textareaRef = useRef(null);
25
+ const chatContainerRef = useRef(null);
26
+
27
+ // Model loading and progress
28
+ const [status, setStatus] = useState(null);
29
+ const [error, setError] = useState(null);
30
+ const [loadingMessage, setLoadingMessage] = useState("");
31
+ const [progressItems, setProgressItems] = useState([]);
32
+ const [isRunning, setIsRunning] = useState(false);
33
+
34
+ // Inputs and outputs
35
+ const [input, setInput] = useState("");
36
+ const [messages, setMessages] = useState([]);
37
+ const [systemPrompt, setSystemPrompt] = useState(
38
+ "You are a helpful assistant."
39
+ );
40
+ const [showSystemPromptInput, setShowSystemPromptInput] = useState(false);
41
+ const [tps, setTps] = useState(null);
42
+ const [numTokens, setNumTokens] = useState(null);
43
+
44
+ function onEnter(message) {
45
+ setMessages((prev) => [...prev, { role: "user", content: message }]);
46
+ setTps(null);
47
+ setIsRunning(true);
48
+ setInput("");
49
+ }
50
+
51
+ function onInterrupt() {
52
+ // NOTE: We do not set isRunning to false here because the worker
53
+ // will send a 'complete' message when it is done.
54
+ worker.current.postMessage({ type: "interrupt" });
55
+ }
56
+
57
+ useEffect(() => {
58
+ resizeInput();
59
+ }, [input]);
60
+
61
+ function resizeInput() {
62
+ if (!textareaRef.current) {
63
+ return;
64
+ }
65
+
66
+ const target = textareaRef.current;
67
+ target.style.height = "auto";
68
+ const newHeight = Math.min(Math.max(target.scrollHeight, 24), 200);
69
+ target.style.height = `${newHeight}px`;
70
+ }
71
+
72
+ // We use the `useEffect` hook to setup the worker as soon as the `App` component is mounted.
73
+ useEffect(() => {
74
+ // Create the worker if it does not yet exist.
75
+ if (!worker.current) {
76
+ worker.current = new Worker(new URL("./worker.js", import.meta.url), {
77
+ type: "module",
78
+ });
79
+ worker.current.postMessage({ type: "check" }); // Do a feature check
80
+ }
81
+
82
+ // Create a callback function for messages from the worker thread.
83
+ const onMessageReceived = (e) => {
84
+ switch (e.data.status) {
85
+ case "loading":
86
+ // Model file start load: add a new progress item to the list.
87
+ setStatus("loading");
88
+ setLoadingMessage(e.data.data);
89
+ break;
90
+
91
+ case "initiate":
92
+ setProgressItems((prev) => [...prev, e.data]);
93
+ break;
94
+
95
+ case "progress":
96
+ // Model file progress: update one of the progress items.
97
+ setProgressItems((prev) =>
98
+ prev.map((item) => {
99
+ if (item.file === e.data.file) {
100
+ return { ...item, ...e.data };
101
+ }
102
+ return item;
103
+ })
104
+ );
105
+ break;
106
+
107
+ case "done":
108
+ // Model file loaded: remove the progress item from the list.
109
+ setProgressItems((prev) =>
110
+ prev.filter((item) => item.file !== e.data.file)
111
+ );
112
+ break;
113
+
114
+ case "ready":
115
+ // Pipeline ready: the worker is ready to accept messages.
116
+ setStatus("ready");
117
+ break;
118
+
119
+ case "start":
120
+ {
121
+ // Start generation
122
+ setMessages((prev) => [
123
+ ...prev,
124
+ { role: "assistant", content: "" },
125
+ ]);
126
+ }
127
+ break;
128
+
129
+ case "update":
130
+ {
131
+ // Generation update: update the output text.
132
+ // Parse messages
133
+ const { output, tps, numTokens } = e.data;
134
+ setTps(tps);
135
+ setNumTokens(numTokens);
136
+ setMessages((prev) => {
137
+ const cloned = [...prev];
138
+ const last = cloned.at(-1);
139
+ cloned[cloned.length - 1] = {
140
+ ...last,
141
+ content: last.content + output,
142
+ };
143
+ return cloned;
144
+ });
145
+ }
146
+ break;
147
+
148
+ case "complete":
149
+ // Generation complete: re-enable the "Generate" button
150
+ setIsRunning(false);
151
+ break;
152
+
153
+ case "error":
154
+ setError(e.data.data);
155
+ break;
156
+ }
157
+ };
158
+
159
+ const onErrorReceived = (e) => {
160
+ console.error("Worker error:", e);
161
+ };
162
+
163
+ // Attach the callback function as an event listener.
164
+ worker.current.addEventListener("message", onMessageReceived);
165
+ worker.current.addEventListener("error", onErrorReceived);
166
+
167
+ // Define a cleanup function for when the component is unmounted.
168
+ return () => {
169
+ worker.current.removeEventListener("message", onMessageReceived);
170
+ worker.current.removeEventListener("error", onErrorReceived);
171
+ };
172
+ }, []);
173
+
174
+ // Send the messages to the worker thread whenever the `messages` state changes.
175
+ useEffect(() => {
176
+ if (messages.filter((x) => x.role === "user").length === 0) {
177
+ // No user messages yet: do nothing.
178
+ return;
179
+ }
180
+ if (messages.at(-1).role === "assistant") {
181
+ // Do not update if the last message is from the assistant
182
+ return;
183
+ }
184
+ setTps(null);
185
+
186
+ // Include system prompt as the first message
187
+ const messagesWithSystem = [
188
+ { role: "system", content: systemPrompt },
189
+ ...messages,
190
+ ];
191
+
192
+ worker.current.postMessage({ type: "generate", data: messagesWithSystem });
193
+ }, [messages, isRunning, systemPrompt]);
194
+
195
+ useEffect(() => {
196
+ if (!chatContainerRef.current || !isRunning) {
197
+ return;
198
+ }
199
+ const element = chatContainerRef.current;
200
+ if (
201
+ element.scrollHeight - element.scrollTop - element.clientHeight <
202
+ STICKY_SCROLL_THRESHOLD
203
+ ) {
204
+ element.scrollTop = element.scrollHeight;
205
+ }
206
+ }, [messages, isRunning]);
207
+
208
+ return IS_WEBGPU_AVAILABLE ? (
209
+ <div className="flex flex-col h-screen mx-auto items justify-end text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-900">
210
+ {status === null && messages.length === 0 && (
211
+ <div className="h-full overflow-auto scrollbar-thin flex justify-center items-center flex-col relative">
212
+ <div className="flex flex-col items-center mb-1 max-w-[340px] text-center">
213
+ <img
214
+ src="gemma-logo.png"
215
+ width="80%"
216
+ height="auto"
217
+ className="block drop-shadow-lg bg-transparent"
218
+ />
219
+ <h1 className="text-4xl font-bold mb-1">Gemma 3 Web 🌐</h1>
220
+ <h2 className="font-semibold">
221
+ A LLM that runs directly in your browser. 🚀
222
+ </h2>
223
+ </div>
224
+
225
+ <div className="flex flex-col items-center px-4">
226
+ <p className="max-w-[514px] mb-4">
227
+ <br />
228
+ You are about to load{" "}
229
+ <a
230
+ href="https://huggingface.co/onnx-community/gemma-3-270m-it-ONNX"
231
+ target="_blank"
232
+ rel="noreferrer"
233
+ className="font-medium underline"
234
+ >
235
+ gemma-3-270m-it-ONNX
236
+ </a>
237
+ , a 270 million parameter LLM that is optimized for inference on
238
+ the web. Once downloaded, the model will be cached and reused when
239
+ you revisit the page.
240
+ <br />
241
+ <br />
242
+ Everything runs directly in your browser using{" "}
243
+ <a
244
+ href="https://huggingface.co/docs/transformers.js"
245
+ target="_blank"
246
+ rel="noreferrer"
247
+ className="underline"
248
+ >
249
+ 🤗&nbsp;Transformers.js
250
+ </a>{" "}
251
+ and ONNX Runtime Web, meaning your conversations are not sent to a
252
+ server. You can even disconnect from the internet after the model
253
+ has loaded!
254
+ <br />
255
+ Want to learn more? Check out the source code on{" "}
256
+ <a
257
+ href="https://github.com/google-gemini/gemma-cookbook/tree/main/Demos/Gemma3-on-Web"
258
+ target="_blank"
259
+ rel="noreferrer"
260
+ className="underline"
261
+ >
262
+ GitHub
263
+ </a>
264
+ !
265
+ </p>
266
+
267
+ {error && (
268
+ <div className="text-red-500 text-center mb-2">
269
+ <p className="mb-1">
270
+ Unable to load model due to the following error:
271
+ </p>
272
+ <p className="text-sm">{error}</p>
273
+ </div>
274
+ )}
275
+
276
+ <button
277
+ className="border px-4 py-2 rounded-lg bg-blue-400 text-white hover:bg-blue-500 disabled:bg-blue-100 disabled:cursor-not-allowed select-none"
278
+ onClick={() => {
279
+ worker.current.postMessage({ type: "load" });
280
+ setStatus("loading");
281
+ }}
282
+ disabled={status !== null || error !== null}
283
+ >
284
+ Load model
285
+ </button>
286
+ </div>
287
+ </div>
288
+ )}
289
+ {status === "loading" && (
290
+ <>
291
+ <div className="w-full max-w-[500px] text-left mx-auto p-4 bottom-0 mt-auto">
292
+ <p className="text-center mb-1">{loadingMessage}</p>
293
+ {progressItems.map(({ file, progress, total }, i) => (
294
+ <Progress
295
+ key={i}
296
+ text={file}
297
+ percentage={progress}
298
+ total={total}
299
+ />
300
+ ))}
301
+ </div>
302
+ </>
303
+ )}
304
+
305
+ {status === "ready" && (
306
+ <div
307
+ ref={chatContainerRef}
308
+ className="overflow-y-auto scrollbar-thin w-full flex flex-col items-center h-full"
309
+ >
310
+ {/* System Prompt Configuration */}
311
+ {messages.length === 0 && (
312
+ <div className="w-full max-w-[600px] p-4 mb-4">
313
+ <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
314
+ <div className="flex items-center justify-between mb-2">
315
+ <h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
316
+ System Prompt
317
+ </h3>
318
+ <button
319
+ onClick={() =>
320
+ setShowSystemPromptInput(!showSystemPromptInput)
321
+ }
322
+ className="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 underline"
323
+ >
324
+ {showSystemPromptInput ? "Hide" : "Edit"}
325
+ </button>
326
+ </div>
327
+ {showSystemPromptInput ? (
328
+ <div className="space-y-2">
329
+ <textarea
330
+ value={systemPrompt}
331
+ onChange={(e) => setSystemPrompt(e.target.value)}
332
+ className="w-full px-3 py-2 text-sm border border-yellow-300 dark:border-yellow-700 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 resize-none"
333
+ placeholder="Enter your system prompt..."
334
+ rows={3}
335
+ />
336
+ <div className="flex gap-2">
337
+ <button
338
+ onClick={() => setShowSystemPromptInput(false)}
339
+ className="px-3 py-1 text-sm bg-yellow-600 hover:bg-yellow-700 text-white rounded-md"
340
+ >
341
+ Save
342
+ </button>
343
+ <button
344
+ onClick={() => {
345
+ setSystemPrompt("You are a helpful assistant.");
346
+ setShowSystemPromptInput(false);
347
+ }}
348
+ className="px-3 py-1 text-sm bg-gray-500 hover:bg-gray-600 text-white rounded-md"
349
+ >
350
+ Reset to Default
351
+ </button>
352
+ </div>
353
+ </div>
354
+ ) : (
355
+ <p className="text-sm text-yellow-700 dark:text-yellow-300 italic">
356
+ &ldquo;{systemPrompt}&rdquo;
357
+ </p>
358
+ )}
359
+ </div>
360
+ </div>
361
+ )}
362
+
363
+ <Chat messages={messages} />
364
+ {messages.length === 0 && (
365
+ <div>
366
+ {EXAMPLES.map((msg, i) => (
367
+ <div
368
+ key={i}
369
+ className="m-1 border dark:border-gray-600 rounded-md p-2 bg-gray-100 dark:bg-gray-700 cursor-pointer"
370
+ onClick={() => onEnter(msg)}
371
+ >
372
+ {msg}
373
+ </div>
374
+ ))}
375
+ </div>
376
+ )}
377
+ <p className="text-center text-sm min-h-6 text-gray-500 dark:text-gray-300">
378
+ {tps && messages.length > 0 && (
379
+ <>
380
+ {!isRunning && (
381
+ <span>
382
+ Generated {numTokens} tokens in{" "}
383
+ {(numTokens / tps).toFixed(2)} seconds&nbsp;&#40;
384
+ </span>
385
+ )}
386
+ {
387
+ <>
388
+ <span className="font-medium text-center mr-1 text-black dark:text-white">
389
+ {tps.toFixed(2)}
390
+ </span>
391
+ <span className="text-gray-500 dark:text-gray-300">
392
+ tokens/second
393
+ </span>
394
+ </>
395
+ }
396
+ {!isRunning && (
397
+ <>
398
+ <span className="mr-1">&#41;.</span>
399
+ <span
400
+ className="underline cursor-pointer"
401
+ onClick={() => {
402
+ worker.current.postMessage({ type: "reset" });
403
+ setMessages([]);
404
+ }}
405
+ >
406
+ Reset
407
+ </span>
408
+ </>
409
+ )}
410
+ </>
411
+ )}
412
+ </p>
413
+ </div>
414
+ )}
415
+
416
+ <div className="mt-2 border dark:bg-gray-700 rounded-lg w-[600px] max-w-[80%] max-h-[200px] mx-auto relative mb-3 flex">
417
+ <textarea
418
+ ref={textareaRef}
419
+ className="scrollbar-thin w-[550px] dark:bg-gray-700 px-3 py-4 rounded-lg bg-transparent border-none outline-none text-gray-800 disabled:text-gray-400 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 disabled:placeholder-gray-200 resize-none disabled:cursor-not-allowed"
420
+ placeholder="Type your message..."
421
+ type="text"
422
+ rows={1}
423
+ value={input}
424
+ disabled={status !== "ready"}
425
+ title={status === "ready" ? "Model is ready" : "Model not loaded yet"}
426
+ onKeyDown={(e) => {
427
+ if (
428
+ input.length > 0 &&
429
+ !isRunning &&
430
+ e.key === "Enter" &&
431
+ !e.shiftKey
432
+ ) {
433
+ e.preventDefault(); // Prevent default behavior of Enter key
434
+ onEnter(input);
435
+ }
436
+ }}
437
+ onInput={(e) => setInput(e.target.value)}
438
+ />
439
+ {isRunning ? (
440
+ <div className="cursor-pointer" onClick={onInterrupt}>
441
+ <StopIcon className="h-8 w-8 p-1 rounded-md text-gray-800 dark:text-gray-100 absolute right-3 bottom-3" />
442
+ </div>
443
+ ) : input.length > 0 ? (
444
+ <div className="cursor-pointer" onClick={() => onEnter(input)}>
445
+ <ArrowRightIcon
446
+ className={`h-8 w-8 p-1 bg-gray-800 dark:bg-gray-100 text-white dark:text-black rounded-md absolute right-3 bottom-3`}
447
+ />
448
+ </div>
449
+ ) : (
450
+ <div>
451
+ <ArrowRightIcon
452
+ className={`h-8 w-8 p-1 bg-gray-200 dark:bg-gray-600 text-gray-50 dark:text-gray-800 rounded-md absolute right-3 bottom-3`}
453
+ />
454
+ </div>
455
+ )}
456
+ </div>
457
+
458
+ <p className="text-xs text-gray-400 text-center mb-3">
459
+ Disclaimer: Generated content may be inaccurate or false.
460
+ </p>
461
+ </div>
462
+ ) : (
463
+ <div className="fixed w-screen h-screen bg-black z-10 bg-opacity-[92%] text-white text-2xl font-semibold flex justify-center items-center text-center">
464
+ WebGPU is not supported
465
+ <br />
466
+ by this browser :&#40;
467
+ </div>
468
+ );
469
+ }
470
+
471
+ export default App;
src/components/Chat.jsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { marked } from "marked";
2
+ import DOMPurify from "dompurify";
3
+ import { useEffect } from "react";
4
+
5
+ import BotIcon from "./icons/BotIcon";
6
+ import UserIcon from "./icons/UserIcon";
7
+ import "../styles/Chat.css";
8
+
9
+ function render(text) {
10
+ return DOMPurify.sanitize(marked.parse(text));
11
+ }
12
+
13
+ /**
14
+ * Chat component renders a chat interface with messages.
15
+ */
16
+ export default function Chat({ messages }) {
17
+ const empty = messages.length === 0;
18
+
19
+ useEffect(() => {
20
+ window.MathJax.typeset();
21
+ }, [messages]);
22
+
23
+ return (
24
+ <div
25
+ className={`flex-1 p-6 max-w-[960px] w-full ${
26
+ empty ? "flex flex-col items-center justify-end" : "space-y-4"
27
+ }`}
28
+ >
29
+ {empty ? (
30
+ <div className="text-xl">
31
+ <span className="text-gray-500 dark:text-gray-300">
32
+ Hi there! How can I assist you today? 😊
33
+ </span>
34
+ </div>
35
+ ) : (
36
+ messages.map((msg, i) => (
37
+ <div key={`message-${i}`} className="flex items-start space-x-4">
38
+ {msg.role === "assistant" ? (
39
+ <>
40
+ <BotIcon className="h-6 w-6 min-h-6 min-w-6 my-3 text-gray-500 dark:text-gray-300" />
41
+ <div className="bg-gray-200 dark:bg-gray-700 rounded-lg p-4">
42
+ <p className="min-h-6 text-gray-800 dark:text-gray-200 overflow-wrap-anywhere">
43
+ {msg.content.length > 0 ? (
44
+ <span
45
+ className="markdown"
46
+ dangerouslySetInnerHTML={{
47
+ __html: render(msg.content),
48
+ }}
49
+ />
50
+ ) : (
51
+ <span className="h-6 flex items-center gap-1">
52
+ <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse"></span>
53
+ <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse animation-delay-200"></span>
54
+ <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse animation-delay-400"></span>
55
+ </span>
56
+ )}
57
+ </p>
58
+ </div>
59
+ </>
60
+ ) : (
61
+ <>
62
+ <UserIcon className="h-6 w-6 min-h-6 min-w-6 my-3 text-gray-500 dark:text-gray-300" />
63
+ <div className="bg-blue-500 text-white rounded-lg p-4">
64
+ <p className="min-h-6 overflow-wrap-anywhere">
65
+ {msg.content}
66
+ </p>
67
+ </div>
68
+ </>
69
+ )}
70
+ </div>
71
+ ))
72
+ )}
73
+ </div>
74
+ );
75
+ }
src/components/Progress.jsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function formatBytes(size) {
2
+ const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
3
+ return (
4
+ +(size / Math.pow(1024, i)).toFixed(2) * 1 +
5
+ ["B", "kB", "MB", "GB", "TB"][i]
6
+ );
7
+ }
8
+
9
+ /**
10
+ * Progress component to display a progress bar with text and percentage.
11
+ */
12
+ export default function Progress({ text, percentage, total }) {
13
+ percentage ??= 0;
14
+ return (
15
+ <div className="w-full bg-gray-100 dark:bg-gray-700 text-left rounded-lg overflow-hidden mb-0.5">
16
+ <div
17
+ className="bg-blue-400 whitespace-nowrap px-1 text-sm"
18
+ style={{ width: `${percentage}%` }}
19
+ >
20
+ {text} ({percentage.toFixed(2)}%
21
+ {isNaN(total) ? "" : ` of ${formatBytes(total)}`})
22
+ </div>
23
+ </div>
24
+ );
25
+ }
src/components/icons/ArrowRightIcon.jsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ArrowRightIcon component renders an SVG icon of a right arrow.
3
+ */
4
+ export default function ArrowRightIcon(props) {
5
+ return (
6
+ <svg
7
+ {...props}
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="24"
10
+ height="24"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="2"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ >
18
+ <path d="M5 12h14" />
19
+ <path d="m12 5 7 7-7 7" />
20
+ </svg>
21
+ );
22
+ }
src/components/icons/BotIcon.jsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * BotIcon component renders an SVG icon representing a bot.
3
+ */
4
+ export default function BotIcon(props) {
5
+ return (
6
+ <svg
7
+ {...props}
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="24"
10
+ height="24"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="2"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ >
18
+ <path d="M12 8V4H8" />
19
+ <rect width="16" height="12" x="4" y="8" rx="2" />
20
+ <path d="M2 14h2" />
21
+ <path d="M20 14h2" />
22
+ <path d="M15 13v2" />
23
+ <path d="M9 13v2" />
24
+ </svg>
25
+ );
26
+ }
src/components/icons/StopIcon.jsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * A React component that renders a stop icon using SVG.
3
+ */
4
+ export default function StopIcon(props) {
5
+ return (
6
+ <svg
7
+ {...props}
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="24"
10
+ height="24"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="2"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ >
18
+ <path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
19
+ <path
20
+ fill="currentColor"
21
+ d="M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 0 1 9 14.437V9.564Z"
22
+ />
23
+ </svg>
24
+ );
25
+ }
src/components/icons/UserIcon.jsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UserIcon component renders a user icon using SVG.
3
+ */
4
+ export default function UserIcon(props) {
5
+ return (
6
+ <svg
7
+ {...props}
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="24"
10
+ height="24"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="2"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ >
18
+ <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
19
+ <circle cx="12" cy="7" r="4" />
20
+ </svg>
21
+ );
22
+ }
src/main.jsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Entry point of the React application.
3
+ *
4
+ * This file sets up the root of the React application and renders the main App component
5
+ * within a StrictMode wrapper for highlighting potential problems in the application.
6
+ */
7
+ import { StrictMode } from "react";
8
+ import { createRoot } from "react-dom/client";
9
+ import "./styles/index.css";
10
+ import App from "./App.jsx";
11
+
12
+ createRoot(document.getElementById("root")).render(
13
+ <StrictMode>
14
+ <App />
15
+ </StrictMode>
16
+ );
src/styles/Chat.css ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @scope (.markdown) {
2
+ /* Code blocks */
3
+ pre {
4
+ margin: 0.5rem 0;
5
+ white-space: break-spaces;
6
+ }
7
+
8
+ code {
9
+ padding: 0.2em 0.4em;
10
+ border-radius: 4px;
11
+ font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
12
+ font-size: 0.9em;
13
+ }
14
+
15
+ pre,
16
+ code {
17
+ background-color: #f2f2f2;
18
+ }
19
+
20
+ @media (prefers-color-scheme: dark) {
21
+ pre,
22
+ code {
23
+ background-color: #333;
24
+ }
25
+ }
26
+
27
+ pre:has(code) {
28
+ padding: 1rem 0.5rem;
29
+ }
30
+
31
+ pre > code {
32
+ padding: 0;
33
+ }
34
+
35
+ /* Headings */
36
+ h1,
37
+ h2,
38
+ h3,
39
+ h4,
40
+ h5,
41
+ h6 {
42
+ font-weight: 600;
43
+ line-height: 1.2;
44
+ }
45
+
46
+ h1 {
47
+ font-size: 2em;
48
+ margin: 1rem 0;
49
+ }
50
+
51
+ h2 {
52
+ font-size: 1.5em;
53
+ margin: 0.83rem 0;
54
+ }
55
+
56
+ h3 {
57
+ font-size: 1.25em;
58
+ margin: 0.67rem 0;
59
+ }
60
+
61
+ h4 {
62
+ font-size: 1em;
63
+ margin: 0.5rem 0;
64
+ }
65
+
66
+ h5 {
67
+ font-size: 0.875em;
68
+ margin: 0.33rem 0;
69
+ }
70
+
71
+ h6 {
72
+ font-size: 0.75em;
73
+ margin: 0.25rem 0;
74
+ }
75
+
76
+ h1,
77
+ h2,
78
+ h3,
79
+ h4,
80
+ h5,
81
+ h6:first-child {
82
+ margin-top: 0;
83
+ }
84
+
85
+ /* Unordered List */
86
+ ul {
87
+ list-style-type: disc;
88
+ margin-left: 1.5rem;
89
+ }
90
+
91
+ /* Ordered List */
92
+ ol {
93
+ list-style-type: decimal;
94
+ margin-left: 1.5rem;
95
+ }
96
+
97
+ /* List Items */
98
+ li {
99
+ margin: 0.25rem 0;
100
+ }
101
+
102
+ p:not(:first-child) {
103
+ margin-top: 0.75rem;
104
+ }
105
+
106
+ p:not(:last-child) {
107
+ margin-bottom: 0.75rem;
108
+ }
109
+
110
+ ul > li {
111
+ margin-left: 1rem;
112
+ }
113
+
114
+ /* Table */
115
+ table,
116
+ th,
117
+ td {
118
+ border: 1px solid lightgray;
119
+ padding: 0.25rem;
120
+ }
121
+
122
+ @media (prefers-color-scheme: dark) {
123
+ table,
124
+ th,
125
+ td {
126
+ border: 1px solid #f2f2f2;
127
+ }
128
+ }
129
+ }
src/styles/index.css ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer utilities {
6
+ .scrollbar-thin::-webkit-scrollbar {
7
+ @apply w-2;
8
+ }
9
+
10
+ .scrollbar-thin::-webkit-scrollbar-track {
11
+ @apply rounded-full bg-gray-100 dark:bg-gray-700;
12
+ }
13
+
14
+ .scrollbar-thin::-webkit-scrollbar-thumb {
15
+ @apply rounded-full bg-gray-300 dark:bg-gray-600;
16
+ }
17
+
18
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
19
+ @apply bg-gray-500;
20
+ }
21
+
22
+ .animation-delay-200 {
23
+ animation-delay: 200ms;
24
+ }
25
+ .animation-delay-400 {
26
+ animation-delay: 400ms;
27
+ }
28
+
29
+ .overflow-wrap-anywhere {
30
+ overflow-wrap: anywhere;
31
+ }
32
+ }
src/worker.js ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AutoTokenizer,
3
+ AutoModelForCausalLM,
4
+ TextStreamer,
5
+ InterruptableStoppingCriteria,
6
+ } from "@huggingface/transformers";
7
+
8
+ /**
9
+ * This class uses the Singleton pattern to enable lazy-loading of the pipeline
10
+ */
11
+ class TextGenerationPipeline {
12
+ static model_id = "onnx-community/gemma-3-270m-it-ONNX";
13
+
14
+ static async getInstance(progress_callback = null) {
15
+ this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
16
+ progress_callback,
17
+ });
18
+
19
+ this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
20
+ dtype: "q4", // Choose better quants like fp32
21
+ device: "webgpu",
22
+ progress_callback,
23
+ });
24
+
25
+ return Promise.all([this.tokenizer, this.model]);
26
+ }
27
+ }
28
+
29
+ const stopping_criteria = new InterruptableStoppingCriteria();
30
+
31
+ let past_key_values_cache = null;
32
+ /**
33
+ * Generate text based on the input messages
34
+ */
35
+ async function generate(messages) {
36
+ // Retrieve the text-generation pipeline.
37
+ const [tokenizer, model] = await TextGenerationPipeline.getInstance();
38
+
39
+ const inputs = tokenizer.apply_chat_template(messages, {
40
+ add_generation_prompt: true,
41
+ return_dict: true,
42
+ });
43
+
44
+ let startTime;
45
+ let numTokens = 0;
46
+ let tps;
47
+ const token_callback_function = () => {
48
+ startTime ??= performance.now();
49
+
50
+ if (numTokens++ > 0) {
51
+ tps = (numTokens / (performance.now() - startTime)) * 1000;
52
+ }
53
+ };
54
+ const callback_function = (output) => {
55
+ self.postMessage({
56
+ status: "update",
57
+ output,
58
+ tps,
59
+ numTokens,
60
+ });
61
+ };
62
+
63
+ const streamer = new TextStreamer(tokenizer, {
64
+ skip_prompt: true,
65
+ skip_special_tokens: true,
66
+ callback_function,
67
+ token_callback_function,
68
+ });
69
+
70
+ // Tell the main thread we are starting
71
+ self.postMessage({ status: "start" });
72
+
73
+ const { past_key_values, sequences } = await model.generate({
74
+ ...inputs,
75
+ past_key_values: past_key_values_cache,
76
+
77
+ // Sampling
78
+ do_sample: false,
79
+ temperature: 0.3,
80
+
81
+ max_new_tokens: 512,
82
+ streamer,
83
+ stopping_criteria,
84
+ return_dict_in_generate: true,
85
+ });
86
+ past_key_values_cache = past_key_values;
87
+
88
+ const decoded = tokenizer.batch_decode(sequences, {
89
+ skip_special_tokens: true,
90
+ });
91
+
92
+ // Send the output back to the main thread
93
+ self.postMessage({
94
+ status: "complete",
95
+ output: decoded,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Helper function to perform feature detection for WebGPU
101
+ */
102
+ async function check() {
103
+ try {
104
+ const adapter = await navigator.gpu.requestAdapter();
105
+ if (!adapter) {
106
+ throw new Error("WebGPU is not supported (no adapter found)");
107
+ }
108
+ } catch (e) {
109
+ self.postMessage({
110
+ status: "error",
111
+ data: e.toString(),
112
+ });
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Helper function to load the model and tokenizer
118
+ */
119
+ async function load() {
120
+ self.postMessage({
121
+ status: "loading",
122
+ data: "Loading model...",
123
+ });
124
+
125
+ // Load the pipeline and save it for future use.
126
+ const [tokenizer, model] = await TextGenerationPipeline.getInstance((x) => {
127
+ // We also add a progress callback to the pipeline so that we can
128
+ // track model loading.
129
+ self.postMessage(x);
130
+ });
131
+
132
+ self.postMessage({
133
+ status: "loading",
134
+ data: "Compiling shaders and warming up the model...",
135
+ });
136
+
137
+ // Run model with dummy input to compile shaders
138
+ const inputs = tokenizer("a");
139
+ await model.generate({ ...inputs, max_new_tokens: 1 });
140
+ self.postMessage({ status: "ready" });
141
+ }
142
+
143
+ // Listen for messages from the main thread
144
+ self.addEventListener("message", async (e) => {
145
+ const { type, data } = e.data;
146
+
147
+ switch (type) {
148
+ case "check":
149
+ check();
150
+ break;
151
+
152
+ case "load":
153
+ load();
154
+ break;
155
+
156
+ case "generate":
157
+ stopping_criteria.reset();
158
+ generate(data);
159
+ break;
160
+
161
+ case "interrupt":
162
+ stopping_criteria.interrupt();
163
+ break;
164
+
165
+ case "reset":
166
+ past_key_values_cache = null;
167
+ stopping_criteria.reset();
168
+ break;
169
+ }
170
+ });