Upload 30 files
Browse files- .gitattributes +5 -0
- eslint.config.js +23 -0
- index.html +14 -17
- package.json +39 -0
- public/fonts/JetBrains/JetBrainsMono-Italic-VariableFont_wght.ttf +3 -0
- public/fonts/JetBrains/JetBrainsMono-VariableFont_wght.ttf +3 -0
- public/fonts/Söhne/Söhne-Buch.otf +3 -0
- public/fonts/Söhne/Söhne-Kräftig.otf +3 -0
- public/fonts/Söhne/Söhne-Leicht.otf +3 -0
- public/liquid-dark.webp +0 -0
- public/liquid.svg +11 -0
- src/App.tsx +69 -0
- src/components/ChatApp.tsx +272 -0
- src/components/HfIcon.tsx +35 -0
- src/components/LandingPage.tsx +190 -0
- src/components/LiquidIntro.tsx +122 -0
- src/components/MessageBubble.tsx +274 -0
- src/components/ReasoningBlock.tsx +44 -0
- src/components/StatusBar.tsx +41 -0
- src/hooks/LLMContext.ts +39 -0
- src/hooks/LLMProvider.tsx +235 -0
- src/hooks/useLLM.ts +8 -0
- src/index.css +272 -0
- src/main.tsx +13 -0
- src/utils/liquid1.min.d.ts +17 -0
- src/utils/liquid1.min.js +0 -0
- src/utils/think-parser.ts +136 -0
- tsconfig.app.json +28 -0
- tsconfig.json +7 -0
- tsconfig.node.json +26 -0
- vite.config.ts +8 -0
.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
|
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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 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 |
+
}
|
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 |
+
});
|