Upload demo files

#1
by Xenova HF Staff - opened
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf filter=lfs diff=lfs merge=lfs -text
37
+ public/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf filter=lfs diff=lfs merge=lfs -text
38
+ public/fonts/Söhne/Söhne-Buch.otf filter=lfs diff=lfs merge=lfs -text
39
+ public/fonts/Söhne/Söhne-Kräftig.otf filter=lfs diff=lfs merge=lfs -text
40
+ public/fonts/Söhne/Söhne-Leicht.otf filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,17 @@
1
  ---
2
  title: LFM2.5 1.2B Thinking WebGPU
3
- emoji: 🌍
4
  colorFrom: pink
5
  colorTo: blue
6
  sdk: static
7
  pinned: false
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: LFM2.5 1.2B Thinking WebGPU
3
+ emoji: 💧
4
  colorFrom: pink
5
  colorTo: blue
6
  sdk: static
7
  pinned: false
8
+ short_description: Run LFM2.5-1.2B-Thinking directly in your browser on WebGPU
9
+ app_build_command: npm run build
10
+ app_file: dist/index.html
11
+ models:
12
+ - LiquidAI/LFM2.5-1.2B-Thinking
13
+ - LiquidAI/LFM2.5-1.2B-Thinking-ONNX
14
+ thumbnail: https://cdn-uploads.huggingface.co/production/uploads/61b253b7ac5ecaae3d1efe0c/2wI4YdFiZos3ecNzK8-DT.png
15
  ---
16
 
17
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import reactHooks from "eslint-plugin-react-hooks";
4
+ import reactRefresh from "eslint-plugin-react-refresh";
5
+ import tseslint from "typescript-eslint";
6
+ import { defineConfig, globalIgnores } from "eslint/config";
7
+
8
+ export default defineConfig([
9
+ globalIgnores(["dist"]),
10
+ {
11
+ files: ["**/*.{ts,tsx}"],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ]);
index.html CHANGED
@@ -1,19 +1,16 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
  </html>
 
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
+ <link
7
+ rel="icon"
8
+ href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💧</text></svg>"
9
+ />
10
+ <title>LFM2.5 WebGPU</title>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
 
 
 
16
  </html>
package.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lfm2-webgpu",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@huggingface/transformers": "^4.0.0-next.3",
14
+ "@streamdown/math": "^1.0.2",
15
+ "@tailwindcss/vite": "^4.1.18",
16
+ "lucide-react": "^0.563.0",
17
+ "react": "^19.2.0",
18
+ "react-dom": "^19.2.0",
19
+ "streamdown": "^2.2.0",
20
+ "tailwindcss": "^4.1.18"
21
+ },
22
+ "devDependencies": {
23
+ "@eslint/js": "^9.39.1",
24
+ "@types/node": "^24.10.1",
25
+ "@types/react": "^19.2.7",
26
+ "@types/react-dom": "^19.2.3",
27
+ "@vitejs/plugin-react": "^5.1.1",
28
+ "eslint": "^9.39.1",
29
+ "eslint-plugin-react-hooks": "^7.0.1",
30
+ "eslint-plugin-react-refresh": "^0.4.24",
31
+ "globals": "^16.5.0",
32
+ "typescript": "~5.9.3",
33
+ "typescript-eslint": "^8.48.0",
34
+ "vite": "^8.0.0-beta.13"
35
+ },
36
+ "overrides": {
37
+ "vite": "^8.0.0-beta.13"
38
+ }
39
+ }
public/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d2a1563e89aa3c3816abfbca03e295abcdca11d9cbd689a7754cc1c5f454d18f
3
+ size 191988
public/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b6490e1a902e56fc84050bee9aad91509e6f45aa00f96f882dab53c9abaf83eb
3
+ size 187860
public/fonts/Söhne/Söhne-Buch.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d3e050e7df5a5695e1ba1691633f2a8767ea9c6ac747fccf7b23a38e4ca02cc2
3
+ size 191552
public/fonts/Söhne/Söhne-Kräftig.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7f17003124700a22684c3f83ac8252793f1e6e902842e385d4bd4220f94a79cb
3
+ size 245976
public/fonts/Söhne/Söhne-Leicht.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:366970f59ef3332afd6d0a2a5bc84e71c002c2a351a93a8a66f315e5892be028
3
+ size 191884
public/liquid-dark.webp ADDED
public/liquid.svg ADDED
src/App.tsx ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+
3
+ import { LiquidIntro } from "./components/LiquidIntro";
4
+ import { LandingPage } from "./components/LandingPage";
5
+ import { ChatApp } from "./components/ChatApp";
6
+ import { useLLM } from "./hooks/useLLM";
7
+ import "katex/dist/katex.min.css";
8
+
9
+ function App() {
10
+ const { status, loadModel } = useLLM();
11
+
12
+ const [stage, setStage] = useState<"intro" | "app">("intro");
13
+ const [hasStarted, setHasStarted] = useState(false);
14
+ const [showChat, setShowChat] = useState(false);
15
+
16
+ const isReady = status.state === "ready";
17
+ const isLoading = hasStarted && !isReady && status.state !== "error";
18
+
19
+ const handleStart = () => {
20
+ setHasStarted(true);
21
+ loadModel();
22
+ };
23
+
24
+ const handleGoHome = () => {
25
+ setShowChat(false);
26
+ setTimeout(() => setHasStarted(false), 700);
27
+ };
28
+
29
+ useEffect(() => {
30
+ if (isReady && hasStarted) {
31
+ setShowChat(true);
32
+ }
33
+ }, [isReady, hasStarted]);
34
+
35
+ return (
36
+ <div className="relative h-screen w-screen brand-surface">
37
+ {stage === "intro" && (
38
+ <LiquidIntro onEnter={() => setStage("app")} />
39
+ )}
40
+
41
+ {stage === "app" && (
42
+ <>
43
+ <div
44
+ className={`absolute inset-0 z-10 transition-all duration-700 ${
45
+ showChat ? "opacity-0 pointer-events-none" : "opacity-100"
46
+ }`}
47
+ >
48
+ <LandingPage
49
+ onStart={handleStart}
50
+ status={status}
51
+ isLoading={isLoading}
52
+ showChat={showChat}
53
+ />
54
+ </div>
55
+
56
+ <div
57
+ className={`absolute inset-0 transition-all duration-700 ${
58
+ showChat ? "opacity-100" : "opacity-0 pointer-events-none"
59
+ }`}
60
+ >
61
+ {hasStarted && <ChatApp onGoHome={handleGoHome} />}
62
+ </div>
63
+ </>
64
+ )}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export default App;
src/components/ChatApp.tsx ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { Send, Square, Plus } from "lucide-react";
3
+
4
+ import { useLLM } from "../hooks/useLLM";
5
+ import { MessageBubble } from "./MessageBubble";
6
+ import { StatusBar } from "./StatusBar";
7
+
8
+ const EXAMPLE_PROMPTS = [
9
+ {
10
+ label: "Solve x² + x - 12 = 0",
11
+ prompt: "Solve x^2 + x - 12 = 0",
12
+ },
13
+ {
14
+ label: "Explain quantum computing",
15
+ prompt:
16
+ "Explain quantum computing in simple terms. What makes it different from classical computing, and what are some real-world applications?",
17
+ },
18
+ {
19
+ label: "Write a Python quicksort",
20
+ prompt:
21
+ "Write a clean, well-commented Python implementation of the quicksort algorithm. Include an example of how to use it.",
22
+ },
23
+ {
24
+ label: "Solve a logic puzzle",
25
+ prompt: "Five people were eating apples, A finished before B, but behind C. D finished before E, but behind B. What was the finishing order?",
26
+ },
27
+ ] as const;
28
+
29
+ interface ChatInputProps {
30
+ showDisclaimer: boolean;
31
+ animated?: boolean;
32
+ }
33
+
34
+ function ChatInput({ showDisclaimer, animated }: ChatInputProps) {
35
+ const { send, stop, status, isGenerating } = useLLM();
36
+ const isReady = status.state === "ready";
37
+ const [input, setInput] = useState("");
38
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
39
+
40
+ const handleSubmit = useCallback(
41
+ (e?: React.FormEvent) => {
42
+ e?.preventDefault();
43
+ const text = input.trim();
44
+ if (!text || !isReady || isGenerating) return;
45
+ setInput("");
46
+ if (textareaRef.current) {
47
+ textareaRef.current.style.height = "7.5rem";
48
+ }
49
+ send(text);
50
+ },
51
+ [input, isReady, isGenerating, send],
52
+ );
53
+
54
+ const handleKeyDown = useCallback(
55
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
56
+ if (e.key === "Enter" && !e.shiftKey) {
57
+ e.preventDefault();
58
+ handleSubmit();
59
+ }
60
+ },
61
+ [handleSubmit],
62
+ );
63
+
64
+ return (
65
+ <div className={`w-full ${animated ? "animate-rise-in-delayed" : ""}`}>
66
+ <form onSubmit={handleSubmit} className="mx-auto max-w-3xl">
67
+ <div className="relative">
68
+ <textarea
69
+ ref={textareaRef}
70
+ className="w-full rounded-xl border border-[#0000001f] bg-white px-4 py-3 pb-11 text-[15px] text-black placeholder-[#6d6d6d] focus:border-[#5505af] focus:outline-none focus:ring-1 focus:ring-[#5505af] disabled:opacity-50 resize-none max-h-40 shadow-sm"
71
+ style={{ minHeight: "7.5rem", height: "7.5rem" }}
72
+ placeholder={isReady ? "Type a message…" : "Loading model…"}
73
+ value={input}
74
+ onChange={(e) => {
75
+ setInput(e.target.value);
76
+ e.target.style.height = "7.5rem";
77
+ e.target.style.height =
78
+ Math.max(e.target.scrollHeight, 120) + "px";
79
+ }}
80
+ onKeyDown={handleKeyDown}
81
+ disabled={!isReady}
82
+ autoFocus
83
+ />
84
+
85
+ <div className="absolute bottom-2 left-2 right-2 flex items-center justify-end pb-3 px-2">
86
+ {isGenerating ? (
87
+ <button
88
+ type="button"
89
+ onClick={stop}
90
+ className="flex items-center justify-center rounded-lg text-[#6d6d6d] hover:text-black transition-colors cursor-pointer"
91
+ title="Stop generating"
92
+ >
93
+ <Square className="h-4 w-4 fill-current" />
94
+ </button>
95
+ ) : (
96
+ <button
97
+ type="submit"
98
+ disabled={!isReady || !input.trim()}
99
+ className="flex items-center justify-center rounded-lg text-[#6d6d6d] hover:text-black disabled:opacity-30 transition-colors cursor-pointer"
100
+ title="Send message"
101
+ >
102
+ <Send className="h-4 w-4" />
103
+ </button>
104
+ )}
105
+ </div>
106
+ </div>
107
+ </form>
108
+
109
+ {showDisclaimer && (
110
+ <p className="mx-auto max-w-3xl mt-1 text-center text-xs text-[#6d6d6d]">
111
+ No chats are sent to a server. Everything runs locally in your
112
+ browser. AI can make mistakes. Check important info.
113
+ </p>
114
+ )}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ interface ChatAppProps {
120
+ onGoHome: () => void;
121
+ }
122
+
123
+ export function ChatApp({ onGoHome }: ChatAppProps) {
124
+ const { messages, isGenerating, send, status, clearChat } = useLLM();
125
+ const scrollRef = useRef<HTMLElement>(null);
126
+
127
+ const [thinkingSeconds, setThinkingSeconds] = useState(0);
128
+ const thinkingStartRef = useRef<number | null>(null);
129
+ const thinkingSecondsMapRef = useRef<Map<number, number>>(new Map());
130
+ const prevIsGeneratingRef = useRef(false);
131
+ const messagesRef = useRef(messages);
132
+ const thinkingSecondsRef = useRef(thinkingSeconds);
133
+ messagesRef.current = messages;
134
+ thinkingSecondsRef.current = thinkingSeconds;
135
+
136
+ const isReady = status.state === "ready";
137
+ const hasMessages = messages.length > 0;
138
+ const showNewChat = isReady && hasMessages && !isGenerating;
139
+
140
+ useEffect(() => {
141
+ const el = scrollRef.current;
142
+ if (el) {
143
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
144
+ }
145
+ }, [messages]);
146
+
147
+ useEffect(() => {
148
+ if (prevIsGeneratingRef.current && !isGenerating) {
149
+ const lastMsg = messagesRef.current.at(-1);
150
+ if (lastMsg?.role === "assistant" && lastMsg.reasoning && thinkingSecondsRef.current > 0) {
151
+ thinkingSecondsMapRef.current.set(lastMsg.id, thinkingSecondsRef.current);
152
+ }
153
+ }
154
+ prevIsGeneratingRef.current = isGenerating;
155
+ }, [isGenerating]);
156
+
157
+ useEffect(() => {
158
+ if (!isGenerating) {
159
+ thinkingStartRef.current = null;
160
+ return;
161
+ }
162
+
163
+ thinkingStartRef.current = Date.now();
164
+ setThinkingSeconds(0);
165
+
166
+ const interval = setInterval(() => {
167
+ if (thinkingStartRef.current) {
168
+ setThinkingSeconds(
169
+ Math.round((Date.now() - thinkingStartRef.current) / 1000),
170
+ );
171
+ }
172
+ }, 500);
173
+
174
+ return () => clearInterval(interval);
175
+ }, [isGenerating]);
176
+
177
+ const lastAssistant = messages.at(-1);
178
+ useEffect(() => {
179
+ if (isGenerating && lastAssistant?.role === "assistant" && lastAssistant.content) {
180
+ thinkingStartRef.current = null;
181
+ }
182
+ }, [isGenerating, lastAssistant?.role, lastAssistant?.content]);
183
+
184
+ return (
185
+ <div className="flex h-full flex-col brand-surface text-black">
186
+ <header className="flex-none flex items-center justify-between border-b border-[#0000001f] px-6 py-3 h-14">
187
+ <button
188
+ onClick={onGoHome}
189
+ className="cursor-pointer transition-transform duration-300 hover:scale-[1.02]"
190
+ title="Back to home"
191
+ >
192
+ <img
193
+ src="/liquid.svg"
194
+ alt="Liquid AI"
195
+ className="h-6 w-auto"
196
+ draggable={false}
197
+ />
198
+ </button>
199
+ <button
200
+ onClick={clearChat}
201
+ className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[#6d6d6d] hover:text-black hover:bg-[#f5f5f5] transition-opacity duration-300 cursor-pointer ${
202
+ showNewChat ? "opacity-100" : "opacity-0 pointer-events-none"
203
+ }`}
204
+ title="New chat"
205
+ >
206
+ <Plus className="h-3.5 w-3.5" />
207
+ New chat
208
+ </button>
209
+ </header>
210
+
211
+ {!hasMessages ? (
212
+ <div className="flex flex-1 flex-col items-center justify-center px-4">
213
+ <div className="mb-8 text-center animate-rise-in">
214
+ <p className="text-3xl font-medium text-black">
215
+ What can I help you with?
216
+ </p>
217
+ </div>
218
+
219
+ <ChatInput showDisclaimer={false} animated />
220
+
221
+ <div className="mt-6 flex flex-wrap justify-center gap-2 max-w-3xl animate-rise-in-delayed">
222
+ {EXAMPLE_PROMPTS.map(({ label, prompt }) => (
223
+ <button
224
+ key={label}
225
+ onClick={() => send(prompt)}
226
+ className="rounded-lg border border-[#0000001f] bg-white px-3 py-2 text-xs text-[#6d6d6d] hover:text-black hover:border-[#5505af] transition-colors cursor-pointer shadow-sm"
227
+ >
228
+ {label}
229
+ </button>
230
+ ))}
231
+ </div>
232
+ </div>
233
+ ) : (
234
+ <>
235
+ <main
236
+ ref={scrollRef}
237
+ className="min-h-0 flex-1 overflow-y-auto px-4 py-6 animate-fade-in"
238
+ >
239
+ <div className="mx-auto flex max-w-3xl flex-col gap-4">
240
+ {!isReady && <StatusBar />}
241
+
242
+ {messages.map((msg, i) => {
243
+ const isLast = i === messages.length - 1 && msg.role === "assistant";
244
+ return (
245
+ <MessageBubble
246
+ key={msg.id}
247
+ msg={msg}
248
+ index={i}
249
+ isStreaming={isGenerating && isLast}
250
+ thinkingSeconds={isLast ? thinkingSeconds : thinkingSecondsMapRef.current.get(msg.id)}
251
+ isGenerating={isGenerating}
252
+ />
253
+ );
254
+ })}
255
+ </div>
256
+ </main>
257
+
258
+ <footer className="flex-none px-4 py-3 animate-fade-in relative">
259
+ {isReady && (
260
+ <div className="absolute bottom-full left-0 right-0 flex justify-center pointer-events-none mb-[-8px]">
261
+ <div className="pointer-events-auto">
262
+ <StatusBar />
263
+ </div>
264
+ </div>
265
+ )}
266
+ <ChatInput showDisclaimer animated />
267
+ </footer>
268
+ </>
269
+ )}
270
+ </div>
271
+ );
272
+ }
src/components/HfIcon.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type React from "react";
2
+
3
+ export default (props: React.SVGProps<SVGSVGElement>) => (
4
+ <svg
5
+ {...props}
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ viewBox="0 0 24 24"
8
+ fill="currentColor"
9
+ >
10
+ <path
11
+ d="M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z"
12
+ fill="#FF9D0B"
13
+ ></path>
14
+ <path
15
+ d="M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z"
16
+ fill="#FFD21E"
17
+ ></path>
18
+ <path
19
+ d="M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z"
20
+ fill="#FF323D"
21
+ ></path>
22
+ <path
23
+ d="M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z"
24
+ fill="#3A3B45"
25
+ ></path>
26
+ <path
27
+ d="M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z"
28
+ fill="#FF9D0B"
29
+ ></path>
30
+ <path
31
+ d="M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z"
32
+ fill="#FFD21E"
33
+ ></path>
34
+ </svg>
35
+ );
src/components/LandingPage.tsx ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import {
3
+ Loader2,
4
+ Rocket,
5
+ ShieldCheck,
6
+ Brain,
7
+ ArrowUpRight,
8
+ } from "lucide-react";
9
+ import type { LoadingStatus } from "../hooks/LLMContext";
10
+ import HfIcon from "./HfIcon";
11
+
12
+ interface LandingPageProps {
13
+ onStart: () => void;
14
+ status: LoadingStatus;
15
+ isLoading: boolean;
16
+ showChat: boolean;
17
+ }
18
+
19
+ const cards = [
20
+ {
21
+ title: "Step-by-step reasoning",
22
+ eyebrow: "REASONING MODEL",
23
+ body: "LFM2.5-Thinking generates its reasoning process before producing final answers, improving accuracy on complex tasks like math, coding, and logic.",
24
+ Icon: Rocket,
25
+ },
26
+ {
27
+ title: "Private edge inference",
28
+ eyebrow: "LOCAL & PRIVATE",
29
+ body: "WebGPU-accelerated browser inference ensures high performance. No data is sent to a server, and the demo can even run offline after the initial download.",
30
+ Icon: ShieldCheck,
31
+ },
32
+ {
33
+ title: "Scaled reinforcement",
34
+ eyebrow: "TRAINING PIPELINE",
35
+ body: "The 1.2B parameter model benefits from extended pre-training on 28T tokens and large-scale multi-stage reinforcement learning for best-in-class performance.",
36
+ Icon: Brain,
37
+ },
38
+ ] as const;
39
+
40
+ export function LandingPage({ onStart, status, isLoading, showChat }: LandingPageProps) {
41
+ const [introFade, setIntroFade] = useState(true);
42
+
43
+ useEffect(() => {
44
+ const t = setTimeout(() => setIntroFade(false), 50);
45
+ return () => clearTimeout(t);
46
+ }, []);
47
+
48
+ const hideMainContent = isLoading || showChat;
49
+ const readyToStart = status.state === "ready";
50
+
51
+ return (
52
+ <div className="brand-surface relative flex h-screen flex-col overflow-hidden text-black">
53
+ <div className="landing-brand-glow absolute inset-0" />
54
+
55
+ <div
56
+ className={`absolute inset-0 z-50 bg-white transition-opacity duration-1000 pointer-events-none ${
57
+ introFade ? "opacity-100" : "opacity-0"
58
+ }`}
59
+ />
60
+
61
+ <div
62
+ className={`relative z-10 mx-auto flex h-full w-full max-w-7xl flex-col px-6 pb-10 pt-8 sm:px-8 lg:px-14 transition-all duration-700 ${
63
+ hideMainContent
64
+ ? "opacity-0 translate-y-4 pointer-events-none"
65
+ : "opacity-100"
66
+ }`}
67
+ >
68
+ <header className="animate-rise-in flex items-start justify-between">
69
+ <img
70
+ src="/liquid.svg"
71
+ alt="Liquid AI"
72
+ className="h-10 w-auto sm:h-12"
73
+ draggable={false}
74
+ />
75
+ <p className="font-support text-[10px] uppercase tracking-[0.22em] text-[#000000b3] sm:text-xs">
76
+ LFM2.5 WebGPU Demo
77
+ </p>
78
+ </header>
79
+
80
+ <section className="mt-14 flex flex-col items-center text-center">
81
+ <div className="animate-rise-in-delayed space-y-5">
82
+ <p className="font-support text-xs uppercase tracking-[0.2em] text-[#5505afb3]">
83
+ Capable and efficient general-purpose AI systems at every scale
84
+ </p>
85
+ <h1 className="max-w-3xl text-4xl font-bold leading-[1.04] tracking-tight sm:text-6xl lg:text-7xl">
86
+ Capable reasoning.<br />Local inference.<br />WebGPU accelerated.
87
+ </h1>
88
+ <p className="max-w-2xl mx-auto text-base leading-relaxed text-[#000000b3] sm:text-lg">
89
+ Run
90
+ <a
91
+ href="https://huggingface.co/LiquidAI/LFM2.5-1.2B-Thinking-ONNX"
92
+ target="_blank"
93
+ rel="noreferrer"
94
+ className="mx-1 underline decoration-[#5505af4d] underline-offset-4 hover:text-[#5505af] transition-colors"
95
+ >
96
+ LFM2.5-1.2B-Thinking
97
+ </a>
98
+ directly in your browser, powered by
99
+ <HfIcon className="size-7 inline-block ml-1 mb-[1px]" />
100
+ <a
101
+ href="https://github.com/huggingface/transformers.js"
102
+ target="_blank"
103
+ rel="noreferrer"
104
+ className="ml-1 underline decoration-[#5505af4d] underline-offset-4 hover:text-[#5505af] transition-colors"
105
+ >
106
+ Transformers.js
107
+ </a>
108
+ </p>
109
+ </div>
110
+ </section>
111
+
112
+ <section className="mt-10 flex flex-col lg:flex-row gap-4">
113
+ {cards.map(({ eyebrow, title, body, Icon }, idx) => (
114
+ <article
115
+ key={title}
116
+ className="animate-rise-in flex-1 flex items-start gap-4 rounded-2xl border border-[#0000001a] bg-[#ffffffcc] px-4 py-4 backdrop-blur-sm sm:gap-5 sm:px-6 sm:py-5"
117
+ style={{ animationDelay: `${120 + idx * 90}ms` }}
118
+ >
119
+ <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-[#5505af4d] bg-[linear-gradient(135deg,#5505AF_0%,#CD82F0_55%,#FF5F1E_100%)] text-white">
120
+ <Icon className="h-5 w-5" />
121
+ </div>
122
+ <div className="min-w-0 text-left">
123
+ <p className="font-support text-[10px] uppercase tracking-[0.2em] text-[#00000080]">
124
+ {eyebrow}
125
+ </p>
126
+ <h3 className="mt-1 text-xl font-medium leading-tight text-black">
127
+ {title}
128
+ </h3>
129
+ <p className="mt-2 text-sm leading-relaxed text-[#000000b3] sm:text-[15px]">
130
+ {body}
131
+ </p>
132
+ </div>
133
+ </article>
134
+ ))}
135
+ </section>
136
+
137
+ <section className="mt-10 flex flex-col items-center animate-rise-in" style={{ animationDelay: "400ms" }}>
138
+ <button
139
+ onClick={onStart}
140
+ className="inline-flex w-full max-w-sm items-center justify-center gap-2 rounded-xl bg-black px-6 py-3.5 text-base font-semibold text-white transition-transform duration-200 hover:-translate-y-0.5 hover:bg-[#5505af] cursor-pointer"
141
+ >
142
+ {readyToStart
143
+ ? "Start chatting"
144
+ : "Load model & start chatting"}
145
+ <ArrowUpRight className="h-4 w-4" />
146
+ </button>
147
+ {!readyToStart && (
148
+ <p className="mt-3 text-xs text-[#00000080]">
149
+ ~750 MB will be downloaded and cached locally for future sessions.
150
+ </p>
151
+ )}
152
+ </section>
153
+ </div>
154
+
155
+ <div
156
+ className={`brand-surface absolute inset-0 z-20 flex flex-col items-center justify-center transition-opacity duration-700 ${
157
+ isLoading ? "opacity-100" : "opacity-0 pointer-events-none"
158
+ }`}
159
+ >
160
+ <div className={`flex w-full max-w-md flex-col items-center px-6 transition-all duration-700 ${isLoading ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}>
161
+ <img
162
+ src="/liquid.svg"
163
+ alt="Liquid AI"
164
+ className="mb-8 h-9 w-auto"
165
+ draggable={false}
166
+ />
167
+ <Loader2 className="h-10 w-10 animate-spin text-[#5505af]" />
168
+ <p className="mt-4 text-sm tracking-wide text-[#000000b3]">
169
+ {status.state === "loading"
170
+ ? (status.message ?? "Loading model…")
171
+ : status.state === "error"
172
+ ? "Error"
173
+ : "Initializing…"}
174
+ </p>
175
+ <div className="mt-4 h-1.5 w-full rounded-full bg-[#0000001a] overflow-hidden">
176
+ <div
177
+ className="h-full rounded-full bg-[linear-gradient(90deg,#5505AF_0%,#CD82F0_60%,#FF5F1E_100%)] transition-[width] duration-300 ease-out"
178
+ style={{
179
+ width: `${status.state === "ready" ? 100 : status.state === "loading" && status.progress != null ? status.progress : 0}%`,
180
+ }}
181
+ />
182
+ </div>
183
+ {status.state === "error" && (
184
+ <p className="mt-3 text-sm text-red-600">{status.error}</p>
185
+ )}
186
+ </div>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
src/components/LiquidIntro.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Liquid effect adapted from https://www.framer.com/@kevin-levron/
3
+ */
4
+ import { useEffect, useRef, useCallback, useState } from "react";
5
+ import LiquidBackground from "../utils/liquid1.min.js";
6
+
7
+ type LiquidApp = ReturnType<typeof LiquidBackground>;
8
+
9
+ interface LiquidIntroProps {
10
+ onEnter: () => void;
11
+ }
12
+
13
+ export function LiquidIntro({ onEnter }: LiquidIntroProps) {
14
+ const canvasRef = useRef<HTMLCanvasElement>(null);
15
+ const appRef = useRef<LiquidApp | null>(null);
16
+ const [fading, setFading] = useState(false);
17
+ const [ready, setReady] = useState(false);
18
+
19
+ useEffect(() => {
20
+ let disposed = false;
21
+
22
+ async function init() {
23
+ if (disposed || !canvasRef.current) return;
24
+
25
+ const app = LiquidBackground(canvasRef.current);
26
+ appRef.current = app;
27
+
28
+ app.loadImage("/liquid-dark.webp");
29
+ app.liquidPlane.material.metalness = 0.75;
30
+ app.liquidPlane.material.roughness = 0.58;
31
+ app.liquidPlane.uniforms.displacementScale.value = 5;
32
+ app.setRain(false);
33
+
34
+ // Small delay to let the first frame render
35
+ setTimeout(() => {
36
+ if (!disposed) setReady(true);
37
+ }, 300);
38
+ }
39
+
40
+ init();
41
+
42
+ return () => {
43
+ disposed = true;
44
+ if (appRef.current) {
45
+ appRef.current.dispose();
46
+ appRef.current = null;
47
+ }
48
+ };
49
+ }, []);
50
+
51
+ const handleClick = useCallback(() => {
52
+ if (fading) return;
53
+ setFading(true);
54
+ setTimeout(() => {
55
+ if (appRef.current) {
56
+ appRef.current.dispose();
57
+ appRef.current = null;
58
+ }
59
+ onEnter();
60
+ }, 600);
61
+ }, [fading, onEnter]);
62
+
63
+ return (
64
+ <div
65
+ className="absolute inset-0 z-30 cursor-pointer"
66
+ onClick={handleClick}
67
+ >
68
+ <canvas
69
+ ref={canvasRef}
70
+ className={`absolute inset-0 w-full h-full transition-opacity duration-700 ${
71
+ ready ? "opacity-100" : "opacity-0"
72
+ }`}
73
+ />
74
+
75
+ <div
76
+ className={`absolute inset-0 flex flex-col items-center justify-end transition-opacity duration-500 pb-10 ${
77
+ fading ? "opacity-0" : ready ? "opacity-100" : "opacity-0"
78
+ }`}
79
+ >
80
+ <h1 className="text-6xl sm:text-7xl font-bold tracking-tight select-none">
81
+ <span className="text-black">LFM2.5</span>{" "}
82
+ <span className="text-gray-900">WebGPU</span>
83
+ </h1>
84
+
85
+ <p className="mt-6 text-lg text-gray-800 select-none animate-pulse-gentle">
86
+ Click anywhere to start
87
+ </p>
88
+ </div>
89
+
90
+ <div
91
+ className={`absolute bottom-4 right-4 text-right text-gray-500/70 select-none transition-opacity duration-500 flex flex-col space-y-[4px] ${
92
+ fading ? "opacity-0" : ready ? "opacity-100" : "opacity-0"
93
+ }`}
94
+ >
95
+ <a
96
+ href="https://codepen.io/soju22/pen/myVWBGa"
97
+ target="_blank"
98
+ rel="noreferrer"
99
+ className="hover:text-gray-800 transition-colors text-[14px]"
100
+ onClick={(e) => e.stopPropagation()}
101
+ >
102
+ Liquid effect by Kevin Levron
103
+ </a>
104
+ <a
105
+ href="https://creativecommons.org/licenses/by-nc-sa/4.0/"
106
+ target="_blank"
107
+ rel="noreferrer"
108
+ className="hover:text-gray-800 transition-colors text-[12px]"
109
+ onClick={(e) => e.stopPropagation()}
110
+ >
111
+ Licensed under CC BY-NC-SA 4.0
112
+ </a>
113
+ </div>
114
+
115
+ <div
116
+ className={`absolute inset-0 bg-white transition-opacity duration-600 pointer-events-none ${
117
+ fading ? "opacity-100" : "opacity-0"
118
+ }`}
119
+ />
120
+ </div>
121
+ );
122
+ }
src/components/MessageBubble.tsx ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { Streamdown } from "streamdown";
3
+ import { createMathPlugin } from "@streamdown/math";
4
+ import {
5
+ Pencil,
6
+ X,
7
+ Check,
8
+ RotateCcw,
9
+ Copy,
10
+ ClipboardCheck,
11
+ } from "lucide-react";
12
+
13
+ import { useLLM } from "../hooks/useLLM";
14
+ import { ReasoningBlock } from "./ReasoningBlock";
15
+ import type { ChatMessage } from "../hooks/LLMContext";
16
+
17
+ const math = createMathPlugin({singleDollarTextMath: true})
18
+
19
+ interface MessageBubbleProps {
20
+ msg: ChatMessage;
21
+ index: number;
22
+ isStreaming?: boolean;
23
+ thinkingSeconds?: number;
24
+ isGenerating: boolean;
25
+ }
26
+
27
+ // LaTeX commands to auto-wrap with $…$ when found outside math context.
28
+ // `args` is the number of consecutive {…} groups the command consumes.
29
+ const MATH_COMMANDS: { prefix: string; args: number }[] = [
30
+ { prefix: "\\boxed{", args: 1 },
31
+ { prefix: "\\text{", args: 1 },
32
+ { prefix: "\\textbf{", args: 1 },
33
+ { prefix: "\\mathbf{", args: 1 },
34
+ { prefix: "\\mathrm{", args: 1 },
35
+ { prefix: "\\frac{", args: 2 },
36
+ ];
37
+
38
+ /** Advance past a single `{…}` group (including nested braces). */
39
+ function skipBraceGroup(content: string, start: number): number {
40
+ let depth = 1;
41
+ let j = start;
42
+ while (j < content.length && depth > 0) {
43
+ if (content[j] === "{") depth++;
44
+ else if (content[j] === "}") depth--;
45
+ j++;
46
+ }
47
+ return j;
48
+ }
49
+
50
+ function wrapLatexMath(content: string): string {
51
+ let result = "";
52
+ let i = 0;
53
+ // Track math context: null = not in math, "$" = inline, "$$" = display
54
+ let mathContext: null | "$" | "$$" = null;
55
+
56
+ while (i < content.length) {
57
+ const cmd = !mathContext
58
+ ? MATH_COMMANDS.find((c) => content.startsWith(c.prefix, i))
59
+ : undefined;
60
+
61
+ if (cmd) {
62
+ let j = skipBraceGroup(content, i + cmd.prefix.length);
63
+
64
+ for (let a = 1; a < cmd.args; a++) {
65
+ if (content[j] === "{") {
66
+ j = skipBraceGroup(content, j + 1);
67
+ }
68
+ }
69
+
70
+ const expr = content.slice(i, j);
71
+ result += "$" + expr + "$";
72
+ i = j;
73
+ } else if (content[i] === "$") {
74
+ // Check for $$ (display math) vs $ (inline math)
75
+ const isDouble = content[i + 1] === "$";
76
+ const token = isDouble ? "$$" : "$";
77
+
78
+ if (mathContext === token) {
79
+ mathContext = null; // closing delimiter
80
+ } else if (!mathContext) {
81
+ mathContext = token; // opening delimiter
82
+ }
83
+
84
+ result += token;
85
+ i += token.length;
86
+ } else {
87
+ result += content[i];
88
+ i++;
89
+ }
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ function prepareForMathDisplay(content: string): string {
96
+ return wrapLatexMath(
97
+ content
98
+ .replace(/(?<!\\)\\\[/g, "$$$$")
99
+ .replace(/\\\]/g, "$$$$")
100
+ .replace(/(?<!\\)\\\(/g, "$$$$")
101
+ .replace(/\\\)/g, "$$$$"),
102
+ );
103
+ }
104
+
105
+ export function MessageBubble({
106
+ msg,
107
+ index,
108
+ isStreaming,
109
+ thinkingSeconds,
110
+ isGenerating,
111
+ }: MessageBubbleProps) {
112
+ const { editMessage, retryMessage } = useLLM();
113
+ const isUser = msg.role === "user";
114
+ const isThinking = !!isStreaming && !msg.content;
115
+
116
+ const [isEditing, setIsEditing] = useState(false);
117
+ const [editValue, setEditValue] = useState(msg.content);
118
+ const [copied, setCopied] = useState(false);
119
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
120
+
121
+ const handleCopy = useCallback(async () => {
122
+ await navigator.clipboard.writeText(msg.content);
123
+ setCopied(true);
124
+ setTimeout(() => setCopied(false), 2000);
125
+ }, [msg.content]);
126
+
127
+ useEffect(() => {
128
+ if (isEditing && textareaRef.current) {
129
+ textareaRef.current.focus();
130
+ textareaRef.current.style.height = "auto";
131
+ textareaRef.current.style.height =
132
+ textareaRef.current.scrollHeight + "px";
133
+ }
134
+ }, [isEditing]);
135
+
136
+ const handleEdit = useCallback(() => {
137
+ setEditValue(msg.content);
138
+ setIsEditing(true);
139
+ }, [msg.content]);
140
+
141
+ const handleCancel = useCallback(() => {
142
+ setIsEditing(false);
143
+ setEditValue(msg.content);
144
+ }, [msg.content]);
145
+
146
+ const handleSave = useCallback(() => {
147
+ const trimmed = editValue.trim();
148
+ if (!trimmed) return;
149
+ setIsEditing(false);
150
+ editMessage(index, trimmed);
151
+ }, [editValue, editMessage, index]);
152
+
153
+ const handleKeyDown = useCallback(
154
+ (e: React.KeyboardEvent) => {
155
+ if (e.key === "Escape") handleCancel();
156
+ if (e.key === "Enter" && !e.shiftKey) {
157
+ e.preventDefault();
158
+ handleSave();
159
+ }
160
+ },
161
+ [handleCancel, handleSave],
162
+ );
163
+
164
+ if (isEditing) {
165
+ return (
166
+ <div className="flex justify-end">
167
+ <div className="w-full max-w-[80%] flex flex-col gap-2">
168
+ <textarea
169
+ ref={textareaRef}
170
+ value={editValue}
171
+ onChange={(e) => {
172
+ setEditValue(e.target.value);
173
+ e.target.style.height = "auto";
174
+ e.target.style.height = e.target.scrollHeight + "px";
175
+ }}
176
+ onKeyDown={handleKeyDown}
177
+ className="w-full rounded-xl border border-[#0000001f] bg-white px-4 py-3 text-sm text-black placeholder-[#6d6d6d] focus:border-[#5505af] focus:outline-none focus:ring-1 focus:ring-[#5505af] resize-none shadow-sm"
178
+ rows={1}
179
+ />
180
+ <div className="flex justify-end gap-2">
181
+ <button
182
+ onClick={handleCancel}
183
+ className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-[#6d6d6d] hover:text-black border border-[#0000001f] hover:bg-[#f5f5f5] transition-colors cursor-pointer"
184
+ >
185
+ <X className="h-3 w-3" />
186
+ Cancel
187
+ </button>
188
+ <button
189
+ onClick={handleSave}
190
+ disabled={!editValue.trim()}
191
+ className="flex items-center gap-1.5 rounded-lg bg-black px-3 py-1.5 text-xs font-medium text-white hover:bg-[#1f1f1f] disabled:opacity-40 transition-colors cursor-pointer"
192
+ >
193
+ <Check className="h-3 w-3" />
194
+ Update
195
+ </button>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ return (
203
+ <div
204
+ className={`group flex items-start gap-2 ${isUser ? "justify-end" : "justify-start"}`}
205
+ >
206
+ {isUser && !isGenerating && (
207
+ <button
208
+ onClick={handleEdit}
209
+ className="mt-3 opacity-0 group-hover:opacity-100 transition-opacity text-[#6d6d6d] hover:text-black cursor-pointer"
210
+ title="Edit message"
211
+ >
212
+ <Pencil className="h-3.5 w-3.5" />
213
+ </button>
214
+ )}
215
+
216
+ <div
217
+ className={`max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed ${
218
+ isUser
219
+ ? "bg-black text-white rounded-br-md whitespace-pre-wrap"
220
+ : "bg-white text-black rounded-bl-md border border-[#0000001f] shadow-sm"
221
+ }`}
222
+ >
223
+ {!isUser && msg.reasoning && (
224
+ <ReasoningBlock
225
+ reasoning={msg.reasoning}
226
+ isThinking={isThinking}
227
+ thinkingSeconds={thinkingSeconds ?? 0}
228
+ />
229
+ )}
230
+
231
+ {msg.content ? (
232
+ isUser ? (
233
+ msg.content
234
+ ) : (
235
+ <Streamdown
236
+ plugins={{ math }}
237
+ parseIncompleteMarkdown={false}
238
+ isAnimating={isStreaming}
239
+ >
240
+ {prepareForMathDisplay(msg.content)}
241
+ </Streamdown>
242
+ )
243
+ ) : !isUser && !isStreaming ? (
244
+ <p className="italic text-[#6d6d6d]">No response</p>
245
+ ) : null}
246
+ </div>
247
+
248
+ {!isUser && !isStreaming && !isGenerating && (
249
+ <div className="mt-3 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
250
+ {msg.content && (
251
+ <button
252
+ onClick={handleCopy}
253
+ className="rounded-md p-1 text-[#6d6d6d] hover:text-black hover:bg-[#f5f5f5] transition-colors cursor-pointer"
254
+ title="Copy response"
255
+ >
256
+ {copied ? (
257
+ <ClipboardCheck className="h-3.5 w-3.5" />
258
+ ) : (
259
+ <Copy className="h-3.5 w-3.5" />
260
+ )}
261
+ </button>
262
+ )}
263
+ <button
264
+ onClick={() => retryMessage(index)}
265
+ className="rounded-md p-1 text-[#6d6d6d] hover:text-black hover:bg-[#f5f5f5] transition-colors cursor-pointer"
266
+ title="Retry"
267
+ >
268
+ <RotateCcw className="h-3.5 w-3.5" />
269
+ </button>
270
+ </div>
271
+ )}
272
+ </div>
273
+ );
274
+ }
src/components/ReasoningBlock.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { Brain, ChevronDown } from "lucide-react";
3
+
4
+ interface ReasoningBlockProps {
5
+ reasoning: string;
6
+ isThinking: boolean;
7
+ thinkingSeconds: number;
8
+ }
9
+
10
+ export function ReasoningBlock({
11
+ reasoning,
12
+ isThinking,
13
+ thinkingSeconds,
14
+ }: ReasoningBlockProps) {
15
+ const [open, setOpen] = useState(isThinking);
16
+
17
+ useEffect(() => {
18
+ setOpen(isThinking);
19
+ }, [isThinking]);
20
+
21
+ return (
22
+ <div className="mb-3">
23
+ <button
24
+ onClick={() => setOpen((v) => !v)}
25
+ className="flex items-center gap-2 text-xs text-[#6d6d6d] hover:text-black transition-colors cursor-pointer"
26
+ >
27
+ <Brain className="h-3.5 w-3.5" />
28
+ {isThinking ? (
29
+ <span className="thinking-shimmer font-medium">Thinking…</span>
30
+ ) : (
31
+ <span>Thought for {thinkingSeconds}s</span>
32
+ )}
33
+ <ChevronDown
34
+ className={`h-3 w-3 transition-transform duration-200 ${open ? "" : "-rotate-90"}`}
35
+ />
36
+ </button>
37
+ {open && (
38
+ <div className="mt-2 rounded-lg border border-[#0000001f] bg-[#f5f5f5] px-3 py-2 text-xs text-[#6d6d6d] whitespace-pre-wrap">
39
+ {reasoning.trim()}
40
+ </div>
41
+ )}
42
+ </div>
43
+ );
44
+ }
src/components/StatusBar.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Loader2 } from "lucide-react";
2
+ import { useLLM } from "../hooks/useLLM";
3
+
4
+ export function StatusBar() {
5
+ const { status, tps, isGenerating } = useLLM();
6
+
7
+ if (status.state === "loading") {
8
+ return (
9
+ <div className="flex flex-col items-center gap-2 py-12 text-[#6d6d6d]">
10
+ <Loader2 className="h-8 w-8 animate-spin text-[#5505af]" />
11
+ <p className="text-sm">{status.message ?? "Loading model…"}</p>
12
+ {status.progress != null && (
13
+ <div className="w-64 h-2 bg-[#e5e5e5] rounded-full overflow-hidden">
14
+ <div
15
+ className="h-full bg-[#5505af]"
16
+ style={{ width: `${status.progress}%` }}
17
+ />
18
+ </div>
19
+ )}
20
+ </div>
21
+ );
22
+ }
23
+
24
+ if (status.state === "error") {
25
+ return (
26
+ <div className="py-12 text-center text-sm text-red-600">
27
+ Error loading model: {status.error}
28
+ </div>
29
+ );
30
+ }
31
+
32
+ if (isGenerating && tps > 0) {
33
+ return (
34
+ <div className="text-center text-xs text-[#6d6d6d] py-1">
35
+ {tps} tokens/s
36
+ </div>
37
+ );
38
+ }
39
+
40
+ return null;
41
+ }
src/hooks/LLMContext.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext } from "react";
2
+
3
+ let nextMessageId = 0;
4
+
5
+ export function createMessageId(): number {
6
+ return nextMessageId++;
7
+ }
8
+
9
+ export interface ChatMessage {
10
+ id: number;
11
+ role: "user" | "assistant" | "system";
12
+ content: string;
13
+ reasoning?: string;
14
+ }
15
+
16
+ export type LoadingStatus =
17
+ | { state: "idle" }
18
+ | { state: "loading"; progress?: number; message?: string }
19
+ | { state: "ready" }
20
+ | { state: "error"; error: string };
21
+
22
+ export type ReasoningEffort = "low" | "medium" | "high";
23
+
24
+ export interface LLMContextValue {
25
+ status: LoadingStatus;
26
+ messages: ChatMessage[];
27
+ isGenerating: boolean;
28
+ tps: number;
29
+ reasoningEffort: ReasoningEffort;
30
+ setReasoningEffort: (effort: ReasoningEffort) => void;
31
+ loadModel: () => void;
32
+ send: (text: string) => void;
33
+ stop: () => void;
34
+ clearChat: () => void;
35
+ editMessage: (index: number, newContent: string) => void;
36
+ retryMessage: (index: number) => void;
37
+ }
38
+
39
+ export const LLMContext = createContext<LLMContextValue | null>(null);
src/hooks/LLMProvider.tsx ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ useRef,
3
+ useState,
4
+ useCallback,
5
+ type ReactNode,
6
+ } from "react";
7
+ import {
8
+ pipeline,
9
+ TextStreamer,
10
+ InterruptableStoppingCriteria,
11
+ type TextGenerationPipeline,
12
+ } from "@huggingface/transformers";
13
+ import { ThinkStreamParser, type ThinkDelta } from "../utils/think-parser";
14
+ import {
15
+ LLMContext,
16
+ createMessageId,
17
+ type ChatMessage,
18
+ type LoadingStatus,
19
+ type ReasoningEffort,
20
+ } from "./LLMContext";
21
+
22
+ const MODEL_ID = "LiquidAI/LFM2.5-1.2B-Thinking-ONNX";
23
+ const DTYPE = "q4";
24
+
25
+ function applyDeltas(msg: ChatMessage, deltas: ThinkDelta[]): ChatMessage {
26
+ let { content, reasoning = "" } = msg;
27
+ for (const delta of deltas) {
28
+ if (delta.type === "reasoning") reasoning += delta.textDelta;
29
+ else content += delta.textDelta;
30
+ }
31
+ return { ...msg, content, reasoning };
32
+ }
33
+
34
+ export function LLMProvider({ children }: { children: ReactNode }) {
35
+ const generatorRef = useRef<Promise<TextGenerationPipeline> | null>(null);
36
+ const stoppingCriteria = useRef(new InterruptableStoppingCriteria());
37
+
38
+ const [status, setStatus] = useState<LoadingStatus>({ state: "idle" });
39
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
40
+ const messagesRef = useRef<ChatMessage[]>([]);
41
+ const [isGenerating, setIsGenerating] = useState(false);
42
+ const isGeneratingRef = useRef(false);
43
+ const [tps, setTps] = useState(0);
44
+ const [reasoningEffort, setReasoningEffort] =
45
+ useState<ReasoningEffort>("medium");
46
+
47
+ messagesRef.current = messages;
48
+ isGeneratingRef.current = isGenerating;
49
+
50
+ const loadModel = useCallback(() => {
51
+ if (generatorRef.current) return;
52
+
53
+ generatorRef.current = (async () => {
54
+ setStatus({ state: "loading", message: "Downloading model…" });
55
+ try {
56
+ const gen = await pipeline("text-generation", MODEL_ID, {
57
+ dtype: DTYPE,
58
+ device: "webgpu",
59
+ progress_callback: (p: any) => {
60
+ if (p.status !== "progress" || !p.file?.endsWith('.onnx_data')) return;
61
+ setStatus({
62
+ state: "loading",
63
+ progress: p.progress,
64
+ message: `Downloading model… ${Math.round(p.progress)}%`,
65
+ });
66
+ },
67
+ });
68
+ setStatus({ state: "ready" });
69
+ return gen;
70
+ } catch (err) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ setStatus({ state: "error", error: msg });
73
+ generatorRef.current = null;
74
+ throw err;
75
+ }
76
+ })();
77
+ }, []);
78
+
79
+ const runGeneration = useCallback(async (chatHistory: ChatMessage[]) => {
80
+ const generator = await generatorRef.current!;
81
+ setIsGenerating(true);
82
+ setTps(0);
83
+ stoppingCriteria.current.reset();
84
+
85
+ const parser = new ThinkStreamParser();
86
+ let tokenCount = 0;
87
+ let firstTokenTime = 0;
88
+
89
+ const assistantIdx = chatHistory.length;
90
+ setMessages((prev) => [
91
+ ...prev,
92
+ { id: createMessageId(), role: "assistant", content: "", reasoning: "" },
93
+ ]);
94
+
95
+ const streamer = new TextStreamer(generator.tokenizer, {
96
+ skip_prompt: true,
97
+ skip_special_tokens: false,
98
+ callback_function: (output: string) => {
99
+ if (output === "<|im_end|>") return;
100
+ const deltas = parser.push(output);
101
+ if (deltas.length === 0) return;
102
+ setMessages((prev) => {
103
+ const updated = [...prev];
104
+ updated[assistantIdx] = applyDeltas(updated[assistantIdx], deltas);
105
+ return updated;
106
+ });
107
+ },
108
+ token_callback_function: () => {
109
+ tokenCount++;
110
+ if (tokenCount === 1) {
111
+ firstTokenTime = performance.now();
112
+ } else {
113
+ const elapsed = (performance.now() - firstTokenTime) / 1000;
114
+ if (elapsed > 0) {
115
+ setTps(Math.round(((tokenCount - 1) / elapsed) * 10) / 10);
116
+ }
117
+ }
118
+ },
119
+ });
120
+
121
+ const apiMessages = chatHistory.map((m) => ({ role: m.role, content: m.content }));
122
+
123
+ try {
124
+ await generator(apiMessages, {
125
+ max_new_tokens: 8192,
126
+ streamer,
127
+ stopping_criteria: stoppingCriteria.current,
128
+ do_sample: false,
129
+ });
130
+ } catch (err) {
131
+ console.error("Generation error:", err);
132
+ }
133
+
134
+ const remaining = parser.flush();
135
+ if (remaining.length > 0) {
136
+ setMessages((prev) => {
137
+ const updated = [...prev];
138
+ updated[assistantIdx] = applyDeltas(updated[assistantIdx], remaining);
139
+ return updated;
140
+ });
141
+ }
142
+
143
+ setMessages((prev) => {
144
+ const updated = [...prev];
145
+ updated[assistantIdx] = {
146
+ ...updated[assistantIdx],
147
+ content: parser.content.trim() || prev[assistantIdx].content,
148
+ reasoning: parser.reasoning.trim() || prev[assistantIdx].reasoning,
149
+ };
150
+ return updated;
151
+ });
152
+
153
+ setIsGenerating(false);
154
+ }, []);
155
+
156
+ const send = useCallback(
157
+ (text: string) => {
158
+ if (!generatorRef.current || isGeneratingRef.current) return;
159
+
160
+ const userMsg: ChatMessage = {
161
+ id: createMessageId(),
162
+ role: "user",
163
+ content: text,
164
+ };
165
+
166
+ setMessages((prev) => [...prev, userMsg]);
167
+ runGeneration([...messagesRef.current, userMsg]);
168
+ },
169
+ [runGeneration],
170
+ );
171
+
172
+ const stop = useCallback(() => {
173
+ stoppingCriteria.current.interrupt();
174
+ }, []);
175
+
176
+ const clearChat = useCallback(() => {
177
+ if (isGeneratingRef.current) return;
178
+ setMessages([]);
179
+ }, []);
180
+
181
+ const editMessage = useCallback(
182
+ (index: number, newContent: string) => {
183
+ if (isGeneratingRef.current) return;
184
+
185
+ setMessages((prev) => {
186
+ const updated = prev.slice(0, index);
187
+ updated.push({ ...prev[index], content: newContent });
188
+ return updated;
189
+ });
190
+
191
+ const updatedHistory = messagesRef.current.slice(0, index);
192
+ updatedHistory.push({
193
+ ...messagesRef.current[index],
194
+ content: newContent,
195
+ });
196
+
197
+ if (messagesRef.current[index]?.role === "user") {
198
+ setTimeout(() => runGeneration(updatedHistory), 0);
199
+ }
200
+ },
201
+ [runGeneration],
202
+ );
203
+
204
+ const retryMessage = useCallback(
205
+ (index: number) => {
206
+ if (isGeneratingRef.current) return;
207
+
208
+ const history = messagesRef.current.slice(0, index);
209
+ setMessages(history);
210
+ setTimeout(() => runGeneration(history), 0);
211
+ },
212
+ [runGeneration],
213
+ );
214
+
215
+ return (
216
+ <LLMContext.Provider
217
+ value={{
218
+ status,
219
+ messages,
220
+ isGenerating,
221
+ tps,
222
+ reasoningEffort,
223
+ setReasoningEffort,
224
+ loadModel,
225
+ send,
226
+ stop,
227
+ clearChat,
228
+ editMessage,
229
+ retryMessage,
230
+ }}
231
+ >
232
+ {children}
233
+ </LLMContext.Provider>
234
+ );
235
+ }
src/hooks/useLLM.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { useContext } from "react";
2
+ import { LLMContext, type LLMContextValue } from "./LLMContext";
3
+
4
+ export function useLLM(): LLMContextValue {
5
+ const ctx = useContext(LLMContext);
6
+ if (!ctx) throw new Error("useLLM must be used within <LLMProvider>");
7
+ return ctx;
8
+ }
src/index.css ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @source "../node_modules/streamdown/dist/*.js";
4
+
5
+ @font-face {
6
+ font-family: "Sohne";
7
+ src: url("/fonts/Söhne/Söhne-Leicht.otf") format("opentype");
8
+ font-weight: 300;
9
+ font-style: normal;
10
+ font-display: swap;
11
+ }
12
+
13
+ @font-face {
14
+ font-family: "Sohne";
15
+ src: url("/fonts/Söhne/Söhne-Buch.otf") format("opentype");
16
+ font-weight: 400;
17
+ font-style: normal;
18
+ font-display: swap;
19
+ }
20
+
21
+ @font-face {
22
+ font-family: "Sohne";
23
+ src: url("/fonts/Söhne/Söhne-Kräftig.otf") format("opentype");
24
+ font-weight: 700;
25
+ font-style: normal;
26
+ font-display: swap;
27
+ }
28
+
29
+ @font-face {
30
+ font-family: "JetBrains Mono";
31
+ src: url("/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf")
32
+ format("truetype");
33
+ font-weight: 100 800;
34
+ font-style: normal;
35
+ font-display: swap;
36
+ }
37
+
38
+ @font-face {
39
+ font-family: "JetBrains Mono";
40
+ src: url("/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf")
41
+ format("truetype");
42
+ font-weight: 100 800;
43
+ font-style: italic;
44
+ font-display: swap;
45
+ }
46
+
47
+ @layer base {
48
+ html,
49
+ body {
50
+ font-family:
51
+ "Sohne",
52
+ Inter,
53
+ -apple-system,
54
+ BlinkMacSystemFont,
55
+ "Segoe UI",
56
+ sans-serif;
57
+ font-size: 17px;
58
+ height: 100%;
59
+ overflow: hidden;
60
+ background: #ffffff;
61
+ color: #000000;
62
+ }
63
+
64
+ #root {
65
+ height: 100%;
66
+ }
67
+
68
+ /* Sleek scrollbar */
69
+ * {
70
+ scrollbar-width: thin;
71
+ scrollbar-color: transparent transparent;
72
+ }
73
+
74
+ *:hover {
75
+ scrollbar-color: #d4d4d8 transparent;
76
+ }
77
+
78
+ ::-webkit-scrollbar {
79
+ width: 6px;
80
+ height: 6px;
81
+ }
82
+
83
+ ::-webkit-scrollbar-track {
84
+ background: transparent;
85
+ }
86
+
87
+ ::-webkit-scrollbar-thumb {
88
+ background: transparent;
89
+ border-radius: 3px;
90
+ }
91
+
92
+ *:hover::-webkit-scrollbar-thumb {
93
+ background: #d4d4d8;
94
+ }
95
+
96
+ *:hover::-webkit-scrollbar-thumb:hover {
97
+ background: #a1a1aa;
98
+ }
99
+
100
+ :root {
101
+ --brand-white: #ffffff;
102
+ --brand-black: #000000;
103
+ --brand-purple-light: #cd82f0;
104
+ --brand-purple: #5505af;
105
+ --brand-orange: #ff5f1e;
106
+ --brand-white-70: #ffffffb3;
107
+ --brand-white-50: #ffffff80;
108
+ --brand-white-30: #ffffff4d;
109
+ --brand-white-10: #ffffff1a;
110
+ --brand-black-70: #000000b3;
111
+ --brand-black-50: #00000080;
112
+ --brand-black-30: #0000004d;
113
+ --brand-black-10: #0000001a;
114
+ --brand-purple-light-70: #cd82f0b3;
115
+ --brand-purple-light-50: #cd82f080;
116
+ --brand-purple-light-30: #cd82f04d;
117
+ --brand-purple-light-10: #cd82f01a;
118
+ --brand-purple-70: #5505afb3;
119
+ --brand-purple-50: #5505af80;
120
+ --brand-purple-30: #5505af4d;
121
+ --brand-purple-10: #5505af1a;
122
+ --brand-orange-70: #ff5f1eb3;
123
+ --brand-orange-50: #ff5f1e80;
124
+ --brand-orange-30: #ff5f1e4d;
125
+ --brand-orange-10: #ff5f1e1a;
126
+ --background: var(--brand-white);
127
+ --foreground: var(--brand-black);
128
+ --card: var(--brand-white);
129
+ --card-foreground: var(--brand-black);
130
+ --popover: var(--brand-white);
131
+ --popover-foreground: var(--brand-black);
132
+ --primary: var(--brand-black);
133
+ --primary-foreground: var(--brand-white);
134
+ --secondary: #f8f8fa;
135
+ --secondary-foreground: var(--brand-black);
136
+ --muted: #f8f8fa;
137
+ --muted-foreground: var(--brand-black-70);
138
+ --accent: var(--brand-purple-10);
139
+ --accent-foreground: var(--brand-black);
140
+ --destructive: #dc2626;
141
+ --destructive-foreground: var(--brand-white);
142
+ --border: var(--brand-black-10);
143
+ --input: var(--brand-black-10);
144
+ --ring: var(--brand-purple);
145
+ --radius: 0.5rem;
146
+ }
147
+ }
148
+
149
+ .brand-surface {
150
+ background:
151
+ radial-gradient(
152
+ ellipse 70% 50% at 86% 12%,
153
+ var(--brand-purple-light-30),
154
+ transparent 70%
155
+ ),
156
+ radial-gradient(
157
+ ellipse 70% 50% at 12% 86%,
158
+ var(--brand-orange-30),
159
+ transparent 72%
160
+ ),
161
+ radial-gradient(
162
+ ellipse 65% 45% at 55% 52%,
163
+ var(--brand-purple-10),
164
+ transparent 72%
165
+ ),
166
+ var(--brand-white);
167
+ }
168
+
169
+ /* Landing page accent motion */
170
+ .landing-brand-glow {
171
+ background:
172
+ radial-gradient(
173
+ ellipse 52% 38% at 12% 25%,
174
+ var(--brand-purple-light-30),
175
+ transparent
176
+ ),
177
+ radial-gradient(
178
+ ellipse 48% 38% at 88% 68%,
179
+ var(--brand-orange-30),
180
+ transparent
181
+ );
182
+ animation: brand-shift 10s ease-in-out infinite;
183
+ }
184
+
185
+ @keyframes brand-shift {
186
+ 0%, 100% {
187
+ opacity: 0.5;
188
+ transform: translate3d(0, 0, 0);
189
+ }
190
+ 50% {
191
+ opacity: 1;
192
+ transform: translate3d(0, -6px, 0);
193
+ }
194
+ }
195
+
196
+ /* Gentle pulse for "click to continue" text */
197
+ @keyframes pulse-gentle {
198
+ 0%, 100% {
199
+ opacity: 0.6;
200
+ }
201
+ 50% {
202
+ opacity: 1;
203
+ }
204
+ }
205
+
206
+ .animate-pulse-gentle {
207
+ animation: pulse-gentle 2.5s ease-in-out infinite;
208
+ }
209
+
210
+ @keyframes rise-in {
211
+ from {
212
+ opacity: 0;
213
+ transform: translate3d(0, 32px, 0);
214
+ }
215
+ to {
216
+ opacity: 1;
217
+ transform: translate3d(0, 0, 0);
218
+ }
219
+ }
220
+
221
+ .animate-rise-in {
222
+ animation: rise-in 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
223
+ }
224
+
225
+ .animate-rise-in-delayed {
226
+ animation: rise-in 1s cubic-bezier(0.22, 1, 0.36, 1) both;
227
+ animation-delay: 120ms;
228
+ }
229
+
230
+ .font-support {
231
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
232
+ }
233
+
234
+ /* Fade-in for chat transition */
235
+ @keyframes fade-in {
236
+ from {
237
+ opacity: 0;
238
+ }
239
+ to {
240
+ opacity: 1;
241
+ }
242
+ }
243
+
244
+ .animate-fade-in {
245
+ animation: fade-in 0.5s ease-out both;
246
+ }
247
+
248
+ /* Thinking shimmer animation */
249
+ @keyframes thinking-shimmer {
250
+ 0% {
251
+ background-position: 200% 0;
252
+ }
253
+ 100% {
254
+ background-position: -200% 0;
255
+ }
256
+ }
257
+
258
+ .thinking-shimmer {
259
+ background: linear-gradient(
260
+ 105deg,
261
+ #6d6d6d 0%,
262
+ #6d6d6d 40%,
263
+ #5505af 50%,
264
+ #6d6d6d 60%,
265
+ #6d6d6d 100%
266
+ );
267
+ background-size: 200% 100%;
268
+ background-clip: text;
269
+ -webkit-background-clip: text;
270
+ -webkit-text-fill-color: transparent;
271
+ animation: thinking-shimmer 2s ease-in-out infinite;
272
+ }
src/main.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import "./index.css";
4
+ import App from "./App.tsx";
5
+ import { LLMProvider } from "./hooks/LLMProvider";
6
+
7
+ createRoot(document.getElementById("root")!).render(
8
+ <StrictMode>
9
+ <LLMProvider>
10
+ <App />
11
+ </LLMProvider>
12
+ </StrictMode>,
13
+ );
src/utils/liquid1.min.d.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface LiquidApp {
2
+ liquidPlane: {
3
+ material: {
4
+ metalness: number;
5
+ roughness: number;
6
+ };
7
+ uniforms: {
8
+ displacementScale: { value: number };
9
+ };
10
+ };
11
+ loadImage(url: string): void;
12
+ setRain(enabled: boolean): void;
13
+ dispose(): void;
14
+ }
15
+
16
+ declare function LiquidBackground(canvas: HTMLCanvasElement): LiquidApp;
17
+ export default LiquidBackground;
src/utils/liquid1.min.js ADDED
The diff for this file is too large to render. See raw diff
 
src/utils/think-parser.ts ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Incremental streaming parser for <think>...</think> reasoning tags.
3
+ *
4
+ * Tokens are pushed one-by-one as they arrive from the streamer.
5
+ * The parser tracks whether we are currently inside a `<think>` block
6
+ * and emits deltas accordingly.
7
+ */
8
+
9
+ export interface ThinkDelta {
10
+ type: "reasoning" | "content";
11
+ textDelta: string;
12
+ }
13
+
14
+ export class ThinkStreamParser {
15
+ /** Accumulated reasoning text (inside <think>…</think>). */
16
+ reasoning = "";
17
+ /** Accumulated content text (outside think tags). */
18
+ content = "";
19
+
20
+ /** Whether we are currently inside a <think> block. */
21
+ private _inThink = false;
22
+ /** Buffer for detecting partial opening/closing tags at chunk boundaries. */
23
+ private _buf = "";
24
+
25
+ private static readonly OPEN_TAG = "<think>";
26
+ private static readonly CLOSE_TAG = "</think>";
27
+
28
+ reset(): void {
29
+ this.reasoning = "";
30
+ this.content = "";
31
+ this._inThink = false;
32
+ this._buf = "";
33
+ }
34
+
35
+ /**
36
+ * Push a chunk of text (one or more tokens) and return an array of deltas.
37
+ * Most calls will return a single delta; the array handles the rare case
38
+ * where a chunk contains a full tag transition.
39
+ */
40
+ push(text: string): ThinkDelta[] {
41
+ const deltas: ThinkDelta[] = [];
42
+ this._buf += text;
43
+
44
+ while (this._buf.length > 0) {
45
+ if (this._inThink) {
46
+ const closeIdx = this._buf.indexOf(ThinkStreamParser.CLOSE_TAG);
47
+ if (closeIdx !== -1) {
48
+ const before = this._buf.slice(0, closeIdx);
49
+ if (before) {
50
+ this.reasoning += before;
51
+ deltas.push({ type: "reasoning", textDelta: before });
52
+ }
53
+ this._buf = this._buf.slice(
54
+ closeIdx + ThinkStreamParser.CLOSE_TAG.length,
55
+ );
56
+ this._inThink = false;
57
+ continue;
58
+ }
59
+
60
+ // No close tag yet — hold back any tail that could be a partial tag.
61
+ const safeLen = this._safeFlushLength(
62
+ this._buf,
63
+ ThinkStreamParser.CLOSE_TAG,
64
+ );
65
+ if (safeLen > 0) {
66
+ const chunk = this._buf.slice(0, safeLen);
67
+ this.reasoning += chunk;
68
+ deltas.push({ type: "reasoning", textDelta: chunk });
69
+ this._buf = this._buf.slice(safeLen);
70
+ }
71
+ break;
72
+ } else {
73
+ const openIdx = this._buf.indexOf(ThinkStreamParser.OPEN_TAG);
74
+ if (openIdx !== -1) {
75
+ const before = this._buf.slice(0, openIdx);
76
+ if (before) {
77
+ this.content += before;
78
+ deltas.push({ type: "content", textDelta: before });
79
+ }
80
+ this._buf = this._buf.slice(
81
+ openIdx + ThinkStreamParser.OPEN_TAG.length,
82
+ );
83
+ this._inThink = true;
84
+ continue;
85
+ }
86
+
87
+ // No open tag yet — hold back any tail that could be a partial tag.
88
+ const safeLen = this._safeFlushLength(
89
+ this._buf,
90
+ ThinkStreamParser.OPEN_TAG,
91
+ );
92
+ if (safeLen > 0) {
93
+ const chunk = this._buf.slice(0, safeLen);
94
+ this.content += chunk;
95
+ deltas.push({ type: "content", textDelta: chunk });
96
+ this._buf = this._buf.slice(safeLen);
97
+ }
98
+ break;
99
+ }
100
+ }
101
+
102
+ return deltas;
103
+ }
104
+
105
+ /**
106
+ * Flush any remaining buffered text. Call this when generation is complete
107
+ * to ensure no text is left in the partial-tag buffer.
108
+ */
109
+ flush(): ThinkDelta[] {
110
+ if (!this._buf) return [];
111
+ const deltas: ThinkDelta[] = [];
112
+ if (this._inThink) {
113
+ this.reasoning += this._buf;
114
+ deltas.push({ type: "reasoning", textDelta: this._buf });
115
+ } else {
116
+ this.content += this._buf;
117
+ deltas.push({ type: "content", textDelta: this._buf });
118
+ }
119
+ this._buf = "";
120
+ return deltas;
121
+ }
122
+
123
+ /**
124
+ * How many characters from the start of `buf` can be safely emitted
125
+ * without risking cutting a partial `tag` at the end.
126
+ */
127
+ private _safeFlushLength(buf: string, tag: string): number {
128
+ // Check if the tail of buf could be the start of the tag
129
+ for (let overlap = Math.min(buf.length, tag.length - 1); overlap > 0; overlap--) {
130
+ if (buf.endsWith(tag.slice(0, overlap))) {
131
+ return buf.length - overlap;
132
+ }
133
+ }
134
+ return buf.length;
135
+ }
136
+ }
style.css DELETED
@@ -1,28 +0,0 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
vite.config.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [tailwindcss(), react()],
8
+ });