nico-martin HF Staff commited on
Commit
9b72f0d
·
1 Parent(s): a4778fe
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .DS_Store +0 -0
  2. .env +3 -0
  3. .gitignore +27 -0
  4. .idea/.gitignore +8 -0
  5. .idea/codeStyles/Project.xml +62 -0
  6. .idea/codeStyles/codeStyleConfig.xml +5 -0
  7. .idea/inspectionProfiles/Project_Default.xml +6 -0
  8. .idea/modules.xml +8 -0
  9. .idea/php.xml +19 -0
  10. .idea/prettier.xml +6 -0
  11. .idea/transformers.js-text-generation.iml +8 -0
  12. .idea/vcs.xml +7 -0
  13. .prettierrc +14 -0
  14. README.md +2 -0
  15. index.html +17 -17
  16. package-lock.json +0 -0
  17. package.json +47 -0
  18. postcss.config.js +5 -0
  19. public/hf.svg +8 -0
  20. src/App.tsx +103 -0
  21. src/assets/react.svg +1 -0
  22. src/chat/Chat.tsx +134 -0
  23. src/chat/ChatForm.tsx +62 -0
  24. src/index.css +38 -0
  25. src/main.tsx +10 -0
  26. src/textGeneration/TextGeneration.ts +131 -0
  27. src/textGeneration/types.ts +141 -0
  28. src/textGeneration/worker/textGenerationWorker.ts +202 -0
  29. src/theme/button/Button.tsx +184 -0
  30. src/theme/form/FormError.tsx +16 -0
  31. src/theme/form/InputCheckbox.tsx +81 -0
  32. src/theme/form/InputCheckboxList.tsx +99 -0
  33. src/theme/form/InputSelect.tsx +121 -0
  34. src/theme/form/InputSlider.tsx +101 -0
  35. src/theme/form/InputText.tsx +89 -0
  36. src/theme/form/InputTextarea.tsx +73 -0
  37. src/theme/form/LabelTooltip.tsx +18 -0
  38. src/theme/index.ts +21 -0
  39. src/theme/misc/Card.tsx +21 -0
  40. src/theme/misc/Loader.tsx +9 -0
  41. src/theme/misc/Message.tsx +99 -0
  42. src/theme/misc/MessageContent.tsx +102 -0
  43. src/theme/misc/Modal.tsx +202 -0
  44. src/theme/misc/Slider.tsx +23 -0
  45. src/theme/misc/Tooltip.tsx +86 -0
  46. src/utils/calculateDownloadProgress.ts +76 -0
  47. src/utils/classnames.ts +13 -0
  48. src/utils/context/chatSettings/ChatSettingsContext.ts +21 -0
  49. src/utils/context/chatSettings/ChatSettingsContextProvider.tsx +89 -0
  50. src/utils/context/chatSettings/ChatSettingsModal.tsx +53 -0
.DS_Store ADDED
Binary file (6.15 kB). View file
 
.env ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ PORT=1291
2
+ SSL_KEY=/Users/nico/Documents/Dev/_ssh/localhost-key.pem
3
+ SSL_CRT=/Users/nico/Documents/Dev/_ssh/localhost.pem
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # AI tooling
16
+ .claude
17
+
18
+ # Editor directories and files
19
+ .vscode/*
20
+ !.vscode/extensions.json
21
+ .idea
22
+ .DS_Store
23
+ *.suo
24
+ *.ntvs*
25
+ *.njsproj
26
+ *.sln
27
+ *.sw?
.idea/.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
.idea/codeStyles/Project.xml ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <component name="ProjectCodeStyleConfiguration">
2
+ <code_scheme name="Project" version="173">
3
+ <HTMLCodeStyleSettings>
4
+ <option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
5
+ </HTMLCodeStyleSettings>
6
+ <JSCodeStyleSettings version="0">
7
+ <option name="FORCE_SEMICOLON_STYLE" value="true" />
8
+ <option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
9
+ <option name="FORCE_QUOTE_STYlE" value="true" />
10
+ <option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
11
+ <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
12
+ <option name="SPACES_WITHIN_IMPORTS" value="true" />
13
+ </JSCodeStyleSettings>
14
+ <TypeScriptCodeStyleSettings version="0">
15
+ <option name="FORCE_SEMICOLON_STYLE" value="true" />
16
+ <option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
17
+ <option name="FORCE_QUOTE_STYlE" value="true" />
18
+ <option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
19
+ <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
20
+ <option name="SPACES_WITHIN_IMPORTS" value="true" />
21
+ </TypeScriptCodeStyleSettings>
22
+ <VueCodeStyleSettings>
23
+ <option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
24
+ <option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
25
+ </VueCodeStyleSettings>
26
+ <codeStyleSettings language="HTML">
27
+ <option name="SOFT_MARGINS" value="80" />
28
+ <indentOptions>
29
+ <option name="INDENT_SIZE" value="2" />
30
+ <option name="CONTINUATION_INDENT_SIZE" value="2" />
31
+ <option name="TAB_SIZE" value="2" />
32
+ </indentOptions>
33
+ </codeStyleSettings>
34
+ <codeStyleSettings language="JavaScript">
35
+ <option name="SOFT_MARGINS" value="80" />
36
+ <indentOptions>
37
+ <option name="INDENT_SIZE" value="2" />
38
+ <option name="CONTINUATION_INDENT_SIZE" value="2" />
39
+ <option name="TAB_SIZE" value="2" />
40
+ </indentOptions>
41
+ </codeStyleSettings>
42
+ <codeStyleSettings language="PHP">
43
+ <indentOptions>
44
+ <option name="USE_TAB_CHARACTER" value="true" />
45
+ </indentOptions>
46
+ </codeStyleSettings>
47
+ <codeStyleSettings language="TypeScript">
48
+ <option name="SOFT_MARGINS" value="80" />
49
+ <indentOptions>
50
+ <option name="INDENT_SIZE" value="2" />
51
+ <option name="CONTINUATION_INDENT_SIZE" value="2" />
52
+ <option name="TAB_SIZE" value="2" />
53
+ </indentOptions>
54
+ </codeStyleSettings>
55
+ <codeStyleSettings language="Vue">
56
+ <option name="SOFT_MARGINS" value="80" />
57
+ <indentOptions>
58
+ <option name="CONTINUATION_INDENT_SIZE" value="2" />
59
+ </indentOptions>
60
+ </codeStyleSettings>
61
+ </code_scheme>
62
+ </component>
.idea/codeStyles/codeStyleConfig.xml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <component name="ProjectCodeStyleConfiguration">
2
+ <state>
3
+ <option name="USE_PER_PROJECT_SETTINGS" value="true" />
4
+ </state>
5
+ </component>
.idea/inspectionProfiles/Project_Default.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
5
+ </profile>
6
+ </component>
.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/transformers.js-text-generation.iml" filepath="$PROJECT_DIR$/.idea/transformers.js-text-generation.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
.idea/php.xml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="MessDetectorOptionsConfiguration">
4
+ <option name="transferred" value="true" />
5
+ </component>
6
+ <component name="PHPCSFixerOptionsConfiguration">
7
+ <option name="transferred" value="true" />
8
+ </component>
9
+ <component name="PHPCodeSnifferOptionsConfiguration">
10
+ <option name="highlightLevel" value="WARNING" />
11
+ <option name="transferred" value="true" />
12
+ </component>
13
+ <component name="PhpStanOptionsConfiguration">
14
+ <option name="transferred" value="true" />
15
+ </component>
16
+ <component name="PsalmOptionsConfiguration">
17
+ <option name="transferred" value="true" />
18
+ </component>
19
+ </project>
.idea/prettier.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="PrettierConfiguration">
4
+ <option name="myConfigurationMode" value="AUTOMATIC" />
5
+ </component>
6
+ </project>
.idea/transformers.js-text-generation.iml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="WEB_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$" />
5
+ <orderEntry type="inheritedJdk" />
6
+ <orderEntry type="sourceFolder" forTests="false" />
7
+ </component>
8
+ </module>
.idea/vcs.xml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
6
+ </component>
7
+ </project>
.prettierrc ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "plugins": [
3
+ "@trivago/prettier-plugin-sort-imports",
4
+ "prettier-plugin-tailwindcss"
5
+ ],
6
+ "singleQuote": false,
7
+ "trailingComma": "es5",
8
+ "importOrder": [
9
+ "<THIRD_PARTY_MODULES>",
10
+ "^[./]"
11
+ ],
12
+ "importOrderSeparation": true,
13
+ "importOrderSortSpecifiers": true
14
+ }
README.md CHANGED
@@ -5,6 +5,8 @@ colorFrom: blue
5
  colorTo: pink
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
5
  colorTo: pink
6
  sdk: static
7
  pinned: false
8
+ app_build_command: npm run build
9
+ app_file: dist/index.html
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
index.html CHANGED
@@ -1,19 +1,19 @@
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
+ <link rel="icon" type="image/svg+xml" href="/hf.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Source+Sans+3:wght@400;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <title>Transformers.js LLMs</title>
14
+ </head>
15
+ <body>
16
+ <div id="root"></div>
17
+ <script type="module" src="/src/main.tsx"></script>
18
+ </body>
19
  </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "transformersjs-minimal",
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": "../transformers.js",
14
+ "@tailwindcss/typography": "^0.5.19",
15
+ "lucide-react": "^0.554.0",
16
+ "react": "^19.1.1",
17
+ "react-dom": "^19.1.1",
18
+ "react-hook-form": "^7.66.1",
19
+ "showdown": "^2.1.0"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.36.0",
23
+ "@tailwindcss/vite": "^4.1.13",
24
+ "@trivago/prettier-plugin-sort-imports": "^5.2.2",
25
+ "@types/node": "^24.6.1",
26
+ "@types/react": "^19.1.13",
27
+ "@types/react-dom": "^19.1.9",
28
+ "@types/showdown": "^2.0.6",
29
+ "@vitejs/plugin-react": "^5.0.3",
30
+ "autoprefixer": "^10.4.21",
31
+ "dotenv": "^17.2.3",
32
+ "eslint": "^9.36.0",
33
+ "eslint-plugin-react-hooks": "^5.2.0",
34
+ "eslint-plugin-react-refresh": "^0.4.20",
35
+ "fs": "^0.0.1-security",
36
+ "globals": "^16.4.0",
37
+ "postcss": "^8.5.6",
38
+ "prettier": "^3.6.2",
39
+ "prettier-plugin-tailwindcss": "^0.6.14",
40
+ "tailwindcss": "^4.1.13",
41
+ "typescript": "~5.8.3",
42
+ "typescript-eslint": "^8.44.0",
43
+ "vite": "^7.1.7",
44
+ "vite-plugin-html-config": "^2.0.2",
45
+ "vite-tsconfig-paths": "^5.1.4"
46
+ }
47
+ }
postcss.config.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ autoprefixer: {},
4
+ },
5
+ }
public/hf.svg ADDED
src/App.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Message } from "@huggingface/transformers";
2
+ import ChatSettingsContextProvider from "@utils/context/chatSettings/ChatSettingsContextProvider.tsx";
3
+ import { type FormEvent, useMemo, useState } from "react";
4
+
5
+ import Chat from "./chat/Chat.tsx";
6
+ import TextGeneration from "./textGeneration/TextGeneration.ts";
7
+ import ThemeContextProvider from "./utils/context/theme/ThemeContextProvider.tsx";
8
+
9
+ export default function App() {
10
+ return (
11
+ <ThemeContextProvider>
12
+ <ChatSettingsContextProvider>
13
+ <Chat className="mx-auto h-screen max-w-4xl" />
14
+ </ChatSettingsContextProvider>
15
+ </ThemeContextProvider>
16
+ );
17
+ }
18
+
19
+ /*export default function App() {
20
+ const [initialized, setInitialized] = useState<boolean>(false);
21
+ const [input, setInput] = useState<string>("");
22
+ const [messages, setMessages] = useState<Array<Message>>([
23
+ {
24
+ role: "system",
25
+ content: "You are a helpful assistant",
26
+ },
27
+ ]);
28
+ const [isLoading, setIsLoading] = useState(false);
29
+
30
+ const textGeneration = useMemo(() => new TextGeneration(), []);
31
+
32
+ const init = async () => {
33
+ await textGeneration.initializeModel(console.log);
34
+ setInitialized(true);
35
+ };
36
+
37
+ const handleSubmit = async (e: FormEvent) => {
38
+ e.preventDefault();
39
+ if (!input.trim()) return;
40
+
41
+ setIsLoading(true);
42
+ const requestMessages = messages;
43
+ requestMessages.push({
44
+ role: "user",
45
+ content: input,
46
+ });
47
+
48
+ try {
49
+ const resp = await textGeneration.generateText(
50
+ requestMessages,
51
+ [],
52
+ console.log
53
+ );
54
+ console.log(resp);
55
+ requestMessages.push({
56
+ role: "assistant",
57
+ content: resp.response,
58
+ });
59
+ setMessages(requestMessages);
60
+ setInput("");
61
+ } catch (error) {
62
+ console.error("Error:", error);
63
+ //setOutput("Error generating text. Check console for details.");
64
+ } finally {
65
+ setIsLoading(false);
66
+ }
67
+ };
68
+
69
+ return (
70
+ <div className="mx-auto max-w-3xl">
71
+ <h1 className="mb-6 text-3xl font-bold text-gray-900">Text Generation</h1>
72
+ {initialized ? (
73
+ <form onSubmit={handleSubmit} className="space-y-4">
74
+ <textarea
75
+ value={input}
76
+ onChange={(e) => setInput(e.target.value)}
77
+ placeholder="Enter your prompt here..."
78
+ rows={5}
79
+ className="w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-100"
80
+ disabled={isLoading}
81
+ />
82
+ <button
83
+ type="submit"
84
+ disabled={isLoading}
85
+ className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
86
+ >
87
+ {isLoading ? "Generating..." : "Answer"}
88
+ </button>
89
+ </form>
90
+ ) : (
91
+ <button
92
+ type="button"
93
+ onClick={init}
94
+ disabled={isLoading}
95
+ className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
96
+ >
97
+ initialize
98
+ </button>
99
+ )}
100
+ </div>
101
+ );
102
+ }
103
+ */
src/assets/react.svg ADDED
src/chat/Chat.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Message } from "@huggingface/transformers";
2
+ import { Button, Card, Slider } from "@theme";
3
+ import { useChatSettings } from "@utils/context/chatSettings/useChatSettings.ts";
4
+ import { formatBytes } from "@utils/format.ts";
5
+ import { MODELS } from "@utils/models.ts";
6
+ import { TOOLS } from "@utils/tools.ts";
7
+ import { Settings } from "lucide-react";
8
+ import { useEffect, useMemo, useRef, useState } from "react";
9
+
10
+ import TextGeneration from "../textGeneration/TextGeneration.ts";
11
+ import cn from "../utils/classnames.ts";
12
+ import ChatForm from "./ChatForm.tsx";
13
+
14
+ enum State {
15
+ IDLE,
16
+ INITIALIZING,
17
+ READY,
18
+ GENERATING,
19
+ }
20
+
21
+ export default function Chat({ className = "" }: { className?: string }) {
22
+ const { openSettingsModal, settings, downloadedModels } = useChatSettings();
23
+ const initializedModelKey = useRef<string>(null);
24
+ const [downloadProgress, setDownloadProgress] = useState<number>(0);
25
+ const [state, setState] = useState<State>(State.IDLE);
26
+ const [messages, setMessages] = useState<Array<Message>>([]);
27
+ const settingsKey = useRef<string>(null);
28
+
29
+ const generator = useMemo(() => new TextGeneration(), []);
30
+
31
+ useEffect(() => {
32
+ const newSettingsKey = JSON.stringify(settings);
33
+ if (!settings || settingsKey.current === newSettingsKey) return;
34
+ settingsKey.current = newSettingsKey;
35
+ setMessages([{ role: "system", content: settings.systemPrompt }]);
36
+ }, [settings]);
37
+
38
+ if (!settings) return;
39
+
40
+ const modelDownloaded = downloadedModels.includes(settings.modelKey);
41
+
42
+ const initializeModel = async () => {
43
+ setState(State.INITIALIZING);
44
+ setDownloadProgress(0);
45
+ await generator.initializeModel(settings.modelKey, (percentage) =>
46
+ setDownloadProgress(percentage)
47
+ );
48
+ initializedModelKey.current = settings.modelKey;
49
+ setState(State.READY);
50
+ };
51
+
52
+ const generate = async (prompt: string) => {
53
+ if (initializedModelKey.current !== settings.modelKey) {
54
+ await initializeModel();
55
+ }
56
+ setState(State.GENERATING);
57
+ const conversation = [...messages, { role: "user", content: prompt }];
58
+
59
+ const { response, modelUsage } = await generator.generateText(
60
+ settings.modelKey,
61
+ conversation,
62
+ [],
63
+ settings.temperature,
64
+ settings.enableThinking,
65
+ console.log
66
+ );
67
+ console.log(response, modelUsage);
68
+ setMessages([...conversation, { role: "assistant", content: response }]);
69
+ };
70
+
71
+ const model = MODELS[settings.modelKey];
72
+ const ready: boolean = state === State.READY || modelDownloaded;
73
+
74
+ return (
75
+ <div className={cn(className, "flex flex-col gap-4 px-4 py-8")}>
76
+ {state === State.IDLE && !modelDownloaded ? (
77
+ <div className="flex h-full items-center justify-center">
78
+ <Card className="flex max-w-[500px] flex-col gap-4">
79
+ <p>
80
+ You are about to load <b>{model.title}</b>.<br />
81
+ Once downloaded, the model ({formatBytes(model.size)}) will be
82
+ cached and reused when you revisit the page.
83
+ </p>
84
+ <p>
85
+ Everything runs directly in your browser using 🤗 Transformers.js
86
+ and ONNX Runtime Web, meaning your conversations aren't sent to a
87
+ server. You can even disconnect from the internet after the model
88
+ has loaded!
89
+ </p>
90
+ <Button onClick={initializeModel}>
91
+ Download Model ({formatBytes(model.size)})
92
+ </Button>
93
+ </Card>
94
+ </div>
95
+ ) : state === State.INITIALIZING ? (
96
+ <div className="flex h-full items-center justify-center">
97
+ <div className="flex h-full w-full max-w-[400px] flex-col items-center justify-center gap-2">
98
+ <p className="flex w-full items-center justify-between">
99
+ <span>
100
+ {modelDownloaded
101
+ ? "initializing the model..."
102
+ : "downloading the model..."}
103
+ </span>
104
+ <span>{downloadProgress.toFixed(2)}%</span>
105
+ </p>
106
+ <Slider width={Math.round(downloadProgress)} />
107
+ </div>
108
+ </div>
109
+ ) : (
110
+ <div>
111
+ {messages.map((message, index) => (
112
+ <p>{message.content}</p>
113
+ ))}
114
+ </div>
115
+ )}
116
+ <Card className="mt-auto">
117
+ <ChatForm disabled={!ready} onSubmit={(prompt) => generate(prompt)} />
118
+ </Card>
119
+ <div className="flex justify-between dark:border-gray-700">
120
+ <Button
121
+ iconLeft={<Settings />}
122
+ size="xs"
123
+ variant="ghost"
124
+ color="mono"
125
+ onClick={openSettingsModal}
126
+ >
127
+ {settings?.modelKey ? MODELS[settings.modelKey].title : ""} |{" "}
128
+ {settings?.tools.length}/{TOOLS.length} tool
129
+ {settings?.tools.length !== 1 ? "s" : ""} active
130
+ </Button>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
src/chat/ChatForm.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { InputText, InputTextarea } from "@theme";
2
+ import { Controller, useForm } from "react-hook-form";
3
+
4
+ import cn from "../utils/classnames.ts";
5
+
6
+ interface FormParams {
7
+ input: string;
8
+ }
9
+
10
+ export default function ChatForm({
11
+ className = "",
12
+ onSubmit,
13
+ disabled,
14
+ }: {
15
+ className?: string;
16
+ onSubmit: (prompt: string) => void;
17
+ disabled: boolean;
18
+ }) {
19
+ const {
20
+ control,
21
+ formState: { errors },
22
+ handleSubmit,
23
+ reset,
24
+ watch,
25
+ setValue,
26
+ } = useForm<FormParams>({
27
+ defaultValues: {
28
+ input: "",
29
+ },
30
+ });
31
+
32
+ const userPrompt = watch("input");
33
+
34
+ return (
35
+ <div className={cn(className)}>
36
+ <form
37
+ className="-mx-2"
38
+ onSubmit={handleSubmit((data) => onSubmit(data.input))}
39
+ >
40
+ <Controller
41
+ name="input"
42
+ control={control}
43
+ rules={{ required: "Message is required" }}
44
+ render={({ field }) => (
45
+ <input
46
+ id="text-input"
47
+ placeholder="Type your message..."
48
+ //error={errors.input?.message as string}
49
+ disabled={disabled}
50
+ value={field.value}
51
+ onChange={field.onChange}
52
+ className={cn(
53
+ "w-full rounded-md border-0 p-2 text-sm transition-colors focus:outline-none"
54
+ /*'focus:ring-1 focus:ring-yellow-500 focus:ring-offset-1'*/
55
+ )}
56
+ />
57
+ )}
58
+ />
59
+ </form>
60
+ </div>
61
+ );
62
+ }
src/index.css ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @plugin "@tailwindcss/typography";
3
+
4
+ @variant dark (&:where(.dark, .dark *));
5
+
6
+ @theme {
7
+ --font-sans: "Source Sans 3", ui-sans-serif, system-ui, sans-serif;
8
+ --font-mono: "IBM Plex Mono", ui-monospace, monospace;
9
+
10
+ --animate-shiny-text: shiny-text 8s infinite;
11
+ }
12
+
13
+ @keyframes shiny-text {
14
+ 0%,
15
+ 90%,
16
+ 100% {
17
+ background-position: calc(-100% - var(--shiny-width)) 0;
18
+ }
19
+ 30%,
20
+ 60% {
21
+ background-position: calc(100% + var(--shiny-width)) 0;
22
+ }
23
+ }
24
+
25
+ body {
26
+ background-color: white;
27
+ color: black;
28
+ --shiny-width: 50px;
29
+ }
30
+
31
+ .dark body {
32
+ background-color: #0b0f19;
33
+ color: white;
34
+ }
35
+
36
+ #root {
37
+ min-height: 100vh;
38
+ }
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
src/textGeneration/TextGeneration.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Message } from "@huggingface/transformers";
2
+
3
+ import type { ChatTemplateTool } from "../utils/webMcp.ts";
4
+ import {
5
+ type ModelUsage,
6
+ type Request,
7
+ RequestType,
8
+ type Response,
9
+ ResponseType,
10
+ } from "./types.ts";
11
+
12
+ export default class TextGeneration {
13
+ private worker: Worker;
14
+ private requestId: number = 0;
15
+
16
+ constructor() {
17
+ this.worker = new Worker(
18
+ new URL("./worker/textGenerationWorker.ts", import.meta.url),
19
+ {
20
+ type: "module",
21
+ }
22
+ );
23
+ }
24
+
25
+ private postWorkerMessage = (request: Request) =>
26
+ this.worker.postMessage(request);
27
+ private addWorkerEventListener = (
28
+ listener: (ev: MessageEvent<Response>) => void
29
+ ) => this.worker.addEventListener("message", listener);
30
+ private removeWorkerEventListener = (
31
+ listener: (ev: MessageEvent<Response>) => void
32
+ ) => this.worker.removeEventListener("message", listener);
33
+
34
+ public async initializeModel(
35
+ modelKey: string,
36
+ onDownload: (percentage: number) => void
37
+ ) {
38
+ return new Promise<number>((resolve, reject) => {
39
+ const requestId = this.requestId++;
40
+
41
+ const listener = ({ data }: MessageEvent<Response>) => {
42
+ if (data.requestId !== requestId) return;
43
+ if (data.type === ResponseType.ERROR) {
44
+ this.removeWorkerEventListener(listener);
45
+ reject(data.message);
46
+ }
47
+ if (data.type !== ResponseType.INITIALIZE_MODEL) return;
48
+ if (data.done) {
49
+ this.removeWorkerEventListener(listener);
50
+ resolve(data.progress);
51
+ }
52
+ onDownload(data.progress);
53
+ };
54
+
55
+ this.addWorkerEventListener(listener);
56
+ this.postWorkerMessage({
57
+ type: RequestType.INITIALIZE_MODEL,
58
+ modelKey,
59
+ requestId,
60
+ });
61
+ });
62
+ }
63
+
64
+ public async abort() {
65
+ return new Promise<void>((resolve, reject) => {
66
+ const requestId = this.requestId++;
67
+
68
+ const listener = ({ data }: MessageEvent<Response>) => {
69
+ if (data.requestId !== requestId) return;
70
+ if (data.type === ResponseType.ERROR) {
71
+ this.removeWorkerEventListener(listener);
72
+ reject(data.message);
73
+ }
74
+ if (data.type === ResponseType.GENERATE_TEXT_ABORTED) {
75
+ this.removeWorkerEventListener(listener);
76
+ resolve();
77
+ }
78
+ };
79
+
80
+ this.addWorkerEventListener(listener);
81
+ this.postWorkerMessage({
82
+ type: RequestType.GENERATE_MESSAGE_ABORT,
83
+ requestId,
84
+ });
85
+ });
86
+ }
87
+
88
+ public async generateText(
89
+ modelKey: string,
90
+ messages: Array<Message>,
91
+ tools: Array<ChatTemplateTool> = [],
92
+ temperature: number,
93
+ enableThinking: boolean,
94
+ onChunkUpdate: (chunk: string) => void = () => {}
95
+ ): Promise<{ response: string; modelUsage: ModelUsage }> {
96
+ return new Promise<{ response: string; modelUsage: ModelUsage }>(
97
+ (resolve, reject) => {
98
+ const requestId = this.requestId++;
99
+
100
+ const listener = ({ data }: MessageEvent<Response>) => {
101
+ if (data.requestId !== requestId) return;
102
+ if (data.type === ResponseType.ERROR) {
103
+ this.removeWorkerEventListener(listener);
104
+ reject(data.message);
105
+ }
106
+ if (data.type === ResponseType.GENERATE_TEXT_DONE) {
107
+ this.removeWorkerEventListener(listener);
108
+ resolve({
109
+ response: data.response,
110
+ modelUsage: data.modelUsage,
111
+ });
112
+ }
113
+ if (data.type === ResponseType.GENERATE_TEXT_CHUNK) {
114
+ onChunkUpdate(data.chunk);
115
+ }
116
+ };
117
+
118
+ this.addWorkerEventListener(listener);
119
+ this.postWorkerMessage({
120
+ type: RequestType.GENERATE_MESSAGE,
121
+ modelKey,
122
+ messages,
123
+ tools,
124
+ requestId,
125
+ temperature,
126
+ enableThinking,
127
+ });
128
+ }
129
+ );
130
+ }
131
+ }
src/textGeneration/types.ts ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Message } from "@huggingface/transformers";
2
+
3
+ import type { MODELS } from "../utils/models.ts";
4
+ import type { ChatTemplateTool } from "../utils/webMcp.ts";
5
+
6
+ export enum RequestType {
7
+ INITIALIZE_MODEL,
8
+ GENERATE_MESSAGE,
9
+ GENERATE_MESSAGE_ABORT,
10
+ }
11
+
12
+ interface RequestInitializeModel {
13
+ modelKey: keyof typeof MODELS;
14
+ type: RequestType.INITIALIZE_MODEL;
15
+ requestId: number;
16
+ }
17
+
18
+ interface RequestGenerateMessage {
19
+ modelKey: keyof typeof MODELS;
20
+ type: RequestType.GENERATE_MESSAGE;
21
+ messages: Array<Message>;
22
+ tools?: Array<ChatTemplateTool>;
23
+ temperature: number;
24
+ enableThinking: boolean;
25
+ requestId: number;
26
+ }
27
+
28
+ interface RequestAbortGeneration {
29
+ type: RequestType.GENERATE_MESSAGE_ABORT;
30
+ requestId: number;
31
+ }
32
+
33
+ export type Request =
34
+ | RequestInitializeModel
35
+ | RequestGenerateMessage
36
+ | RequestAbortGeneration;
37
+
38
+ export enum ResponseType {
39
+ GENERATE_TEXT_CHUNK,
40
+ GENERATE_TEXT_DONE,
41
+ GENERATE_TEXT_ABORTED,
42
+ INITIALIZE_MODEL,
43
+ ERROR,
44
+ }
45
+
46
+ interface ResponseError {
47
+ type: ResponseType.ERROR;
48
+ message: string;
49
+ requestId: number;
50
+ }
51
+
52
+ interface ResponseGenerateTextChunk {
53
+ type: ResponseType.GENERATE_TEXT_CHUNK;
54
+ chunk: string;
55
+ requestId: number;
56
+ }
57
+
58
+ interface ResponseGenerateTextDone {
59
+ type: ResponseType.GENERATE_TEXT_DONE;
60
+ response: string;
61
+ modelUsage: ModelUsage;
62
+ requestId: number;
63
+ }
64
+
65
+ interface ResponseGenerateTextAborted {
66
+ type: ResponseType.GENERATE_TEXT_ABORTED;
67
+ requestId: number;
68
+ }
69
+
70
+ interface ResponseInitializeModel {
71
+ type: ResponseType.INITIALIZE_MODEL;
72
+ progress: number;
73
+ done: boolean;
74
+ requestId: number;
75
+ }
76
+
77
+ export interface ModelUsage {
78
+ inputDurationMs: number;
79
+ outputTokens: number;
80
+ outputDurationMs: number;
81
+ outputTps: number;
82
+ doneMs: number;
83
+ modelKey: keyof typeof MODELS;
84
+ model: string;
85
+ }
86
+
87
+ interface ResponseGenerateTextAborted {
88
+ type: ResponseType.GENERATE_TEXT_ABORTED;
89
+ requestId: number;
90
+ }
91
+
92
+ export type Response =
93
+ | ResponseGenerateTextChunk
94
+ | ResponseGenerateTextDone
95
+ | ResponseGenerateTextAborted
96
+ | ResponseInitializeModel
97
+ | ResponseError;
98
+
99
+ /*
100
+ interface ResponseGenerateTextDone {}
101
+
102
+ export enum ResponseStatus {
103
+ SUCCESS,
104
+ ERROR,
105
+ STARTED,
106
+ }
107
+
108
+ export enum BackgroundTasks {
109
+ EXTRACT_FEATURES,
110
+ INITIALIZE_MODELS,
111
+ AGENT_GENERATE_TEXT,
112
+ AGENT_GET_MESSAGES,
113
+ AGENT_CLEAR,
114
+ }
115
+
116
+ export enum BackgroundMessages {
117
+ DOWNLOAD_PROGRESS,
118
+ MESSAGES_UPDATE,
119
+ }
120
+
121
+ export type Dtype = "fp32" | "fp16" | "q4" | "q4f16";
122
+
123
+ export interface ChatMessageUser {
124
+ role: "user";
125
+ content: string;
126
+ }
127
+
128
+ export interface ChatMessageTool {
129
+ name: string;
130
+ functionSignature: string;
131
+ id: string;
132
+ result: string;
133
+ }
134
+
135
+ export interface ChatMessageAssistant {
136
+ role: "assistant";
137
+ content: string;
138
+ tools: Array<ChatMessageTool>;
139
+ }
140
+
141
+ export type ChatMessage = ChatMessageUser | ChatMessageAssistant;*/
src/textGeneration/worker/textGenerationWorker.ts ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AutoModelForCausalLM,
3
+ AutoTokenizer,
4
+ InterruptableStoppingCriteria,
5
+ PreTrainedModel,
6
+ PreTrainedTokenizer,
7
+ Tensor,
8
+ TextStreamer,
9
+ } from "@huggingface/transformers";
10
+ import { calculateDownloadProgress } from "@utils/calculateDownloadProgress.ts";
11
+ import { MODELS } from "@utils/models.ts";
12
+
13
+ import {
14
+ type Request,
15
+ RequestType,
16
+ type Response,
17
+ ResponseType,
18
+ } from "../types.ts";
19
+
20
+ interface Pipeline {
21
+ tokenizer: PreTrainedTokenizer;
22
+ model: PreTrainedModel;
23
+ }
24
+
25
+ let pipeline: Pipeline | null = null;
26
+ let initializedModelKey: keyof typeof MODELS | null = null;
27
+ let cache: { pastKeyValues: any | null; key: string } = {
28
+ pastKeyValues: null,
29
+ key: "",
30
+ };
31
+ let stoppingCriteria: any | null = null;
32
+ let abortController: AbortController = new AbortController();
33
+
34
+ const getTextGenerationPipeline = async (
35
+ modelKey: keyof typeof MODELS,
36
+ onDownloadProgress: (percentage: number) => void = () => {}
37
+ ): Promise<Pipeline> => {
38
+ if (pipeline && modelKey === initializedModelKey) return pipeline;
39
+ if (pipeline) {
40
+ await pipeline.model.dispose();
41
+ }
42
+
43
+ const MODEL = MODELS[modelKey];
44
+ const MODEL_FILES = new Map();
45
+ for (const [key, value] of Object.entries(MODEL.files)) {
46
+ MODEL_FILES.set(key, { loaded: 0, total: value });
47
+ }
48
+
49
+ try {
50
+ const tokenizer = await AutoTokenizer.from_pretrained(MODEL.modelId);
51
+ const model = await AutoModelForCausalLM.from_pretrained(MODEL.modelId, {
52
+ dtype: MODEL.dtype,
53
+ device: MODEL.device,
54
+ progress_callback: calculateDownloadProgress(
55
+ ({ percentage }) => onDownloadProgress(percentage),
56
+ MODEL_FILES
57
+ ),
58
+ });
59
+ pipeline = { tokenizer, model };
60
+ initializedModelKey = modelKey;
61
+ return pipeline;
62
+ } catch (error) {
63
+ console.error("Failed to initialize feature extraction pipeline:", error);
64
+ throw error;
65
+ }
66
+ };
67
+
68
+ const postMessage = (message: Response) => self.postMessage(message);
69
+
70
+ self.onmessage = async ({ data }: MessageEvent<Request>) => {
71
+ if (data.type === RequestType.INITIALIZE_MODEL) {
72
+ let lastPercentage = 0;
73
+ await getTextGenerationPipeline(data.modelKey, (percentage) => {
74
+ if (lastPercentage === percentage) return;
75
+ lastPercentage = percentage;
76
+ postMessage({
77
+ type: ResponseType.INITIALIZE_MODEL,
78
+ progress: percentage,
79
+ done: false,
80
+ requestId: data.requestId,
81
+ });
82
+ });
83
+ postMessage({
84
+ type: ResponseType.INITIALIZE_MODEL,
85
+ progress: 100,
86
+ done: true,
87
+ requestId: data.requestId,
88
+ });
89
+ }
90
+
91
+ if (data.type === RequestType.GENERATE_MESSAGE_ABORT) {
92
+ abortController.abort();
93
+ stoppingCriteria.interrupt();
94
+ postMessage({
95
+ type: ResponseType.GENERATE_TEXT_ABORTED,
96
+ requestId: data.requestId,
97
+ });
98
+ }
99
+
100
+ if (data.type === RequestType.GENERATE_MESSAGE) {
101
+ const MODEL = MODELS[data.modelKey];
102
+
103
+ const { messages, tools, requestId } = data;
104
+ console.log(messages, tools);
105
+ const { tokenizer, model } = await getTextGenerationPipeline(data.modelKey);
106
+ if (!stoppingCriteria) {
107
+ stoppingCriteria = new InterruptableStoppingCriteria();
108
+ }
109
+
110
+ const input = tokenizer.apply_chat_template(messages, {
111
+ tools,
112
+ add_generation_prompt: true,
113
+ return_dict: true,
114
+ }) as {
115
+ input_ids: Tensor;
116
+ attention_mask: number[] | number[][] | Tensor;
117
+ };
118
+
119
+ const started = performance.now();
120
+ let firstTokenTime: DOMHighResTimeStamp | null = null;
121
+ let numTokens = 0;
122
+ let tps: number = 0;
123
+
124
+ const tokenCallbackFunction = () => {
125
+ firstTokenTime ??= performance.now();
126
+ if (numTokens++ > 0) {
127
+ tps = (numTokens / (performance.now() - firstTokenTime)) * 1000;
128
+ }
129
+ };
130
+
131
+ const callbackFunction = (chunk: string) => {
132
+ if (abortController.signal?.aborted) {
133
+ throw new DOMException("Request cancelled", "AbortError");
134
+ }
135
+ postMessage({
136
+ type: ResponseType.GENERATE_TEXT_CHUNK,
137
+ chunk,
138
+ requestId,
139
+ });
140
+ };
141
+
142
+ const streamer = new TextStreamer(tokenizer, {
143
+ skip_prompt: true,
144
+ skip_special_tokens: true,
145
+ token_callback_function: tokenCallbackFunction,
146
+ callback_function: callbackFunction,
147
+ });
148
+
149
+ const cacheKey = MODEL.modelId + JSON.stringify(messages.slice(0, -1));
150
+ const useCache = cacheKey === cache.key;
151
+ console.log("useCache", useCache);
152
+
153
+ const { sequences, past_key_values } = (await model.generate({
154
+ ...input,
155
+ max_new_tokens: 1024,
156
+ past_key_values: useCache ? cache.pastKeyValues : null,
157
+ return_dict_in_generate: true,
158
+ streamer,
159
+ })) as { sequences: Tensor; past_key_values: any };
160
+ const ended = performance.now();
161
+
162
+ const lengthOfInput = input.input_ids.dims[1];
163
+ const response = tokenizer.batch_decode(
164
+ /**
165
+ * First argument (null): Don't slice dimension 0 (the batch dimension) - keep all batches
166
+ * Second argument ([lengthOfInput, Number.MAX_SAFE_INTEGER]): For dimension 1 (the sequence/token dimension), slice from index lengthOfInput to the end
167
+ */
168
+ sequences.slice(null, [lengthOfInput, Number.MAX_SAFE_INTEGER]),
169
+ {
170
+ skip_special_tokens: true, // removes the <|end_of_text|>
171
+ }
172
+ )[0];
173
+
174
+ cache = {
175
+ pastKeyValues: past_key_values,
176
+ key:
177
+ MODEL.modelId +
178
+ JSON.stringify([
179
+ ...messages,
180
+ {
181
+ role: "assistant",
182
+ content: response,
183
+ },
184
+ ]),
185
+ };
186
+
187
+ postMessage({
188
+ type: ResponseType.GENERATE_TEXT_DONE,
189
+ response,
190
+ modelUsage: {
191
+ inputDurationMs: (firstTokenTime || 0) - started,
192
+ outputTokens: numTokens,
193
+ outputDurationMs: performance.now() - ended,
194
+ outputTps: tps,
195
+ doneMs: ended - started,
196
+ modelKey: MODEL.modelId,
197
+ model: MODEL.title,
198
+ },
199
+ requestId,
200
+ });
201
+ }
202
+ };
src/theme/button/Button.tsx ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ReactNode, cloneElement, isValidElement } from "react";
2
+
3
+ import cn from "../../utils/classnames.ts";
4
+
5
+ type ButtonColor = "primary" | "secondary" | "mono" | "danger";
6
+ type ButtonVariant = "solid" | "outline" | "ghost";
7
+ type ButtonSize = "xs" | "sm" | "md" | "lg";
8
+
9
+ interface BaseButtonProps {
10
+ children?: ReactNode;
11
+ className?: string;
12
+ color?: ButtonColor;
13
+ variant?: ButtonVariant;
14
+ size?: ButtonSize;
15
+ iconLeft?: ReactNode;
16
+ iconRight?: ReactNode;
17
+ disabled?: boolean;
18
+ loading?: boolean;
19
+ shiny?: boolean;
20
+ }
21
+
22
+ interface ButtonAsButton extends BaseButtonProps {
23
+ onClick?: () => void;
24
+ type?: "button" | "submit" | "reset";
25
+ }
26
+
27
+ interface ButtonAsLink extends BaseButtonProps {
28
+ href: string;
29
+ onClick?: never;
30
+ to?: never;
31
+ target?: string;
32
+ rel?: string;
33
+ }
34
+
35
+ export type ButtonProps = ButtonAsButton | ButtonAsLink;
36
+
37
+ const sizeClasses: Record<ButtonSize, string> = {
38
+ xs: "px-2 py-1.5 text-xs",
39
+ sm: "px-3 py-1.5 text-sm",
40
+ md: "px-4 py-2 text-base",
41
+ lg: "px-6 py-3 text-lg",
42
+ };
43
+
44
+ const iconOnlySizeClasses: Record<ButtonSize, string> = {
45
+ xs: "p-1.5",
46
+ sm: "p-1.5",
47
+ md: "p-2",
48
+ lg: "p-3",
49
+ };
50
+
51
+ const iconSizeClasses: Record<ButtonSize, string> = {
52
+ xs: "h-3 w-3",
53
+ sm: "h-4 w-4",
54
+ md: "h-5 w-5",
55
+ lg: "h-6 w-6",
56
+ };
57
+
58
+ const colorVariantClasses: Record<
59
+ ButtonColor,
60
+ Record<ButtonVariant, string>
61
+ > = {
62
+ primary: {
63
+ solid:
64
+ "bg-yellow-500 text-gray-900 hover:bg-yellow-600 dark:bg-yellow-400 dark:text-gray-900 dark:hover:bg-yellow-500",
65
+ outline:
66
+ "border-1 border-yellow-500 text-yellow-600 hover:bg-yellow-50 dark:border-yellow-400 dark:text-yellow-400 dark:hover:bg-yellow-950",
67
+ ghost:
68
+ "text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-950",
69
+ },
70
+ secondary: {
71
+ solid:
72
+ "bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600",
73
+ outline:
74
+ "border-1 border-blue-600 text-blue-600 hover:bg-blue-50 dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-950",
75
+ ghost:
76
+ "text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950",
77
+ },
78
+ mono: {
79
+ solid:
80
+ "bg-gray-900 text-white hover:bg-gray-800 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100",
81
+ outline:
82
+ "border-1 border-gray-900 text-gray-900 hover:bg-gray-50 dark:border-white dark:text-white dark:hover:bg-white/10",
83
+ ghost:
84
+ "text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10",
85
+ },
86
+ danger: {
87
+ solid:
88
+ "bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600",
89
+ outline:
90
+ "border-1 border-red-600 text-red-600 hover:bg-red-50 dark:border-red-400 dark:text-red-400 dark:hover:bg-red-950",
91
+ ghost:
92
+ "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950",
93
+ },
94
+ };
95
+
96
+ export default function Button({
97
+ children,
98
+ className = "",
99
+ color = "primary",
100
+ variant = "solid",
101
+ size = "md",
102
+ iconLeft,
103
+ iconRight,
104
+ disabled = false,
105
+ loading = false,
106
+ shiny = false,
107
+ ...props
108
+ }: ButtonProps) {
109
+ const isIconOnly = !children && (iconLeft || iconRight || loading);
110
+
111
+ const baseClasses = cn(
112
+ "inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
113
+ isIconOnly ? iconOnlySizeClasses[size] : sizeClasses[size],
114
+ colorVariantClasses[color][variant],
115
+ {
116
+ "cursor-not-allowed opacity-50": disabled || loading,
117
+ },
118
+ className
119
+ );
120
+
121
+ const renderIcon = (icon: ReactNode) => {
122
+ if (isValidElement(icon)) {
123
+ return cloneElement(icon as any, {
124
+ className: cn((icon.props as any)?.className, iconSizeClasses[size]),
125
+ });
126
+ }
127
+ return icon;
128
+ };
129
+
130
+ const content = (
131
+ <>
132
+ {loading && (
133
+ <svg
134
+ className="h-4 w-4 animate-spin"
135
+ xmlns="http://www.w3.org/2000/svg"
136
+ fill="none"
137
+ viewBox="0 0 24 24"
138
+ >
139
+ <circle
140
+ className="opacity-25"
141
+ cx="12"
142
+ cy="12"
143
+ r="10"
144
+ stroke="currentColor"
145
+ strokeWidth="4"
146
+ />
147
+ <path
148
+ className="opacity-75"
149
+ fill="currentColor"
150
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
151
+ />
152
+ </svg>
153
+ )}
154
+ {!loading && iconLeft && renderIcon(iconLeft)}
155
+ {children && <span>{children}</span>}
156
+ {!loading && iconRight && renderIcon(iconRight)}
157
+ </>
158
+ );
159
+
160
+ if ("href" in props && props.href) {
161
+ return (
162
+ <a
163
+ href={props.href}
164
+ className={baseClasses}
165
+ target={props.target}
166
+ rel={props.rel}
167
+ aria-disabled={disabled || loading}
168
+ >
169
+ {content}
170
+ </a>
171
+ );
172
+ }
173
+
174
+ return (
175
+ <button
176
+ type={(props as ButtonAsButton).type || "button"}
177
+ onClick={(props as ButtonAsButton).onClick}
178
+ disabled={disabled || loading}
179
+ className={baseClasses}
180
+ >
181
+ {content}
182
+ </button>
183
+ );
184
+ }
src/theme/form/FormError.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames.ts";
2
+ import type { ReactNode } from "react";
3
+
4
+ export default function FormError({
5
+ className = "",
6
+ children,
7
+ }: {
8
+ className?: string;
9
+ children: ReactNode;
10
+ }) {
11
+ return (
12
+ <span className={cn(className, 'dark:text-red-400" text-sm text-red-600')}>
13
+ {children}
14
+ </span>
15
+ );
16
+ }
src/theme/form/InputCheckbox.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames.ts";
2
+ import { type ChangeEvent, type ReactNode, forwardRef } from "react";
3
+
4
+ import LabelTooltip from "./LabelTooltip.tsx";
5
+
6
+ interface InputCheckboxProps {
7
+ label: string;
8
+ description?: string;
9
+ error?: string;
10
+ required?: boolean;
11
+ className?: string;
12
+ id?: string;
13
+ tooltip?: string | ReactNode;
14
+ more?: ReactNode;
15
+ moreTitle?: string;
16
+ checked?: boolean;
17
+ onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
18
+ }
19
+
20
+ const InputCheckbox = forwardRef<HTMLInputElement, InputCheckboxProps>(
21
+ (
22
+ {
23
+ label,
24
+ description,
25
+ error,
26
+ required,
27
+ className = "",
28
+ id,
29
+ tooltip = "",
30
+ more = null,
31
+ moreTitle = null,
32
+ ...props
33
+ },
34
+ ref
35
+ ) => {
36
+ const checkboxId =
37
+ id || `checkbox-${Math.random().toString(36).substr(2, 9)}`;
38
+
39
+ return (
40
+ <label htmlFor={id} className={cn("flex flex-col gap-2", className)}>
41
+ <div className="relative text-sm font-medium text-gray-900 dark:text-gray-100">
42
+ {label}
43
+ {required && (
44
+ <span className="ml-1 text-blue-500 dark:text-blue-400">*</span>
45
+ )}
46
+ {tooltip !== "" && <LabelTooltip text={<>{tooltip}</>} />}
47
+ </div>
48
+ <div className="mt-2 flex items-center gap-2">
49
+ <input
50
+ ref={ref}
51
+ type="checkbox"
52
+ id={checkboxId}
53
+ className={cn(
54
+ "h-4 w-4 cursor-pointer rounded border transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none",
55
+ "bg-white dark:bg-gray-800",
56
+ "focus:ring-offset-white dark:focus:ring-offset-gray-900",
57
+ error
58
+ ? "border-red-300 text-red-600 focus:border-red-500 focus:ring-red-500 dark:border-red-700 dark:text-red-500 dark:focus:border-red-600 dark:focus:ring-red-600"
59
+ : "border-gray-300 text-yellow-600 focus:border-yellow-500 focus:ring-yellow-500 dark:border-gray-600 dark:text-yellow-500 dark:focus:border-yellow-400 dark:focus:ring-yellow-400"
60
+ )}
61
+ {...props}
62
+ />
63
+ {description && (
64
+ <p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
65
+ {description}
66
+ </p>
67
+ )}
68
+ </div>
69
+ {error && (
70
+ <span className="text-sm text-red-600 dark:text-red-400">
71
+ {error}
72
+ </span>
73
+ )}
74
+ </label>
75
+ );
76
+ }
77
+ );
78
+
79
+ InputCheckbox.displayName = "InputCheckbox";
80
+
81
+ export default InputCheckbox;
src/theme/form/InputCheckboxList.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames.ts";
2
+ import { type ReactNode } from "react";
3
+
4
+ import { FormError } from "../index.ts";
5
+ import LabelTooltip from "./LabelTooltip.tsx";
6
+
7
+ interface CheckboxOption {
8
+ name: string;
9
+ label: string;
10
+ description?: string;
11
+ }
12
+
13
+ interface InputCheckboxListProps {
14
+ label: string;
15
+ options: CheckboxOption[];
16
+ value: string[];
17
+ onChange: (value: string[]) => void;
18
+ error?: string;
19
+ required?: boolean;
20
+ className?: string;
21
+ id?: string;
22
+ tooltip?: string | ReactNode;
23
+ }
24
+
25
+ export default function InputCheckboxList({
26
+ label,
27
+ options,
28
+ value,
29
+ onChange,
30
+ error,
31
+ required,
32
+ className = "",
33
+ id,
34
+ tooltip = "",
35
+ }: InputCheckboxListProps) {
36
+ const handleCheckboxChange = (optionName: string, checked: boolean) => {
37
+ if (checked) {
38
+ onChange([...value, optionName]);
39
+ } else {
40
+ onChange(value.filter((name) => name !== optionName));
41
+ }
42
+ };
43
+
44
+ return (
45
+ <div className={cn("flex flex-col gap-2", className)}>
46
+ <div className="relative text-sm font-medium text-gray-900 dark:text-gray-100">
47
+ {label}
48
+ {required && (
49
+ <span className="ml-1 text-blue-500 dark:text-blue-400">*</span>
50
+ )}
51
+ {tooltip !== "" && <LabelTooltip text={<>{tooltip}</>} />}
52
+ </div>
53
+
54
+ <div className="flex flex-col gap-3">
55
+ {options.map((option) => {
56
+ const checkboxId = `${id || "checkbox-list"}-${option.name}`;
57
+ const isChecked = value.includes(option.name);
58
+
59
+ return (
60
+ <label
61
+ key={option.name}
62
+ htmlFor={checkboxId}
63
+ className="flex cursor-pointer items-start gap-3"
64
+ >
65
+ <input
66
+ type="checkbox"
67
+ id={checkboxId}
68
+ checked={isChecked}
69
+ onChange={(e) =>
70
+ handleCheckboxChange(option.name, e.target.checked)
71
+ }
72
+ className={cn(
73
+ "mt-0.5 h-4 w-4 cursor-pointer rounded border transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none",
74
+ "bg-white dark:bg-gray-800",
75
+ "focus:ring-offset-white dark:focus:ring-offset-gray-900",
76
+ error
77
+ ? "border-red-300 text-red-600 focus:border-red-500 focus:ring-red-500 dark:border-red-700 dark:text-red-500 dark:focus:border-red-600 dark:focus:ring-red-600"
78
+ : "border-gray-300 text-yellow-600 focus:border-yellow-500 focus:ring-yellow-500 dark:border-gray-600 dark:text-yellow-500 dark:focus:border-yellow-400 dark:focus:ring-yellow-400"
79
+ )}
80
+ />
81
+ <div className="flex flex-col gap-1">
82
+ <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
83
+ {option.label}
84
+ </span>
85
+ {option.description && (
86
+ <span className="text-xs text-gray-600 dark:text-gray-400">
87
+ {option.description}
88
+ </span>
89
+ )}
90
+ </div>
91
+ </label>
92
+ );
93
+ })}
94
+ </div>
95
+
96
+ {error && <FormError>{error}</FormError>}
97
+ </div>
98
+ );
99
+ }
src/theme/form/InputSelect.tsx ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ReactNode, type SelectHTMLAttributes, forwardRef } from "react";
2
+
3
+ import cn from "../../utils/classnames.ts";
4
+ import { FormError } from "../index.ts";
5
+ import LabelTooltip from "./LabelTooltip.tsx";
6
+
7
+ interface Option {
8
+ value: string;
9
+ label: string;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ interface OptGroup {
14
+ label: string;
15
+ options: Option[];
16
+ }
17
+
18
+ type SelectOptions = Option[] | OptGroup[];
19
+
20
+ interface InputSelectProps
21
+ extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "className"> {
22
+ label: string;
23
+ placeholder?: string;
24
+ error?: string;
25
+ required?: boolean;
26
+ className?: string;
27
+ id?: string;
28
+ options: SelectOptions;
29
+ tooltip?: string | ReactNode;
30
+ more?: ReactNode;
31
+ moreTitle?: string;
32
+ }
33
+
34
+ const InputSelect = forwardRef<HTMLSelectElement, InputSelectProps>(
35
+ (
36
+ {
37
+ label,
38
+ placeholder,
39
+ error,
40
+ required,
41
+ className = "",
42
+ id,
43
+ options,
44
+ tooltip = "",
45
+ more = null,
46
+ moreTitle = null,
47
+ ...props
48
+ },
49
+ ref
50
+ ) => {
51
+ return (
52
+ <div className={cn("flex flex-col gap-2", className)}>
53
+ <label
54
+ htmlFor={id}
55
+ className="relative text-sm font-medium text-gray-900 dark:text-gray-100"
56
+ >
57
+ {label}
58
+ {required && (
59
+ <span className="ml-1 text-blue-500 dark:text-blue-400">*</span>
60
+ )}
61
+ {tooltip !== "" && (
62
+ <LabelTooltip position="right" text={<>{tooltip}</>} />
63
+ )}
64
+ </label>
65
+ <select
66
+ ref={ref}
67
+ id={id}
68
+ className={cn(
69
+ "w-full rounded-md border px-3 py-2 text-sm transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none",
70
+ "bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100",
71
+ "focus:ring-offset-white dark:focus:ring-offset-gray-900",
72
+ error
73
+ ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700 dark:focus:border-red-600 dark:focus:ring-red-600"
74
+ : "border-gray-300 focus:border-yellow-500 focus:ring-yellow-500 dark:border-gray-600 dark:focus:border-yellow-400 dark:focus:ring-yellow-400"
75
+ )}
76
+ {...props}
77
+ >
78
+ {placeholder && (
79
+ <option value="" disabled>
80
+ {placeholder}
81
+ </option>
82
+ )}
83
+ {options.map((item, index) => {
84
+ // Check if this is an OptGroup by checking for 'options' property
85
+ if ("options" in item) {
86
+ return (
87
+ <optgroup key={`optgroup-${index}`} label={item.label}>
88
+ {item.options.map((option) => (
89
+ <option
90
+ key={option.value}
91
+ value={option.value}
92
+ disabled={option.disabled}
93
+ >
94
+ {option.label}
95
+ </option>
96
+ ))}
97
+ </optgroup>
98
+ );
99
+ } else {
100
+ // This is a regular Option
101
+ return (
102
+ <option
103
+ key={item.value}
104
+ value={item.value}
105
+ disabled={item.disabled}
106
+ >
107
+ {item.label}
108
+ </option>
109
+ );
110
+ }
111
+ })}
112
+ </select>
113
+ {error && <FormError>{error}</FormError>}
114
+ </div>
115
+ );
116
+ }
117
+ );
118
+
119
+ InputSelect.displayName = "InputSelect";
120
+
121
+ export default InputSelect;
src/theme/form/InputSlider.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames";
2
+ import { type ChangeEvent, type ReactNode, forwardRef } from "react";
3
+
4
+ import { FormError } from "../index.ts";
5
+ import LabelTooltip from "./LabelTooltip.tsx";
6
+
7
+ interface InputSliderProps {
8
+ label: string;
9
+ error?: string;
10
+ required?: boolean;
11
+ className?: string;
12
+ id?: string;
13
+ disabled?: boolean;
14
+ value?: number;
15
+ onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
16
+ hideLabel?: boolean;
17
+ tooltip?: string | ReactNode;
18
+ min?: number;
19
+ max?: number;
20
+ step?: number;
21
+ showValue?: boolean;
22
+ }
23
+
24
+ const InputSlider = forwardRef<HTMLInputElement, InputSliderProps>(
25
+ (
26
+ {
27
+ label,
28
+ error,
29
+ required,
30
+ className = "",
31
+ id,
32
+ hideLabel = false,
33
+ tooltip = "",
34
+ min = 0,
35
+ max = 100,
36
+ step = 1,
37
+ showValue = true,
38
+ value = min,
39
+ ...props
40
+ },
41
+ ref
42
+ ) => {
43
+ return (
44
+ <div className={cn("flex flex-col gap-2", className)}>
45
+ <div className="flex items-center justify-between">
46
+ <label
47
+ htmlFor={id}
48
+ className={cn(
49
+ "relative text-sm font-medium text-gray-900 dark:text-gray-100",
50
+ {
51
+ "clip-[rect(0,0,0,0)] sr-only absolute m-[-1px] h-px w-px overflow-hidden border-0 p-0 whitespace-nowrap":
52
+ hideLabel,
53
+ }
54
+ )}
55
+ >
56
+ {label}
57
+ {required && (
58
+ <span className="ml-1 text-blue-500 dark:text-blue-400">*</span>
59
+ )}
60
+ {tooltip !== "" && <LabelTooltip text={<>{tooltip}</>} />}
61
+ </label>
62
+ {showValue && (
63
+ <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
64
+ {value}
65
+ </span>
66
+ )}
67
+ </div>
68
+ <input
69
+ ref={ref}
70
+ type="range"
71
+ id={id}
72
+ min={min}
73
+ max={max}
74
+ step={step}
75
+ value={value}
76
+ className={cn(
77
+ "w-full h-2 rounded-lg appearance-none cursor-pointer",
78
+ "bg-gray-200 dark:bg-gray-700",
79
+ "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-yellow-500 dark:[&::-webkit-slider-thumb]:bg-yellow-400",
80
+ "[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-all",
81
+ "[&::-webkit-slider-thumb]:hover:bg-yellow-600 dark:[&::-webkit-slider-thumb]:hover:bg-yellow-500",
82
+ "[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-yellow-500 dark:[&::-moz-range-thumb]:bg-yellow-400",
83
+ "[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:transition-all",
84
+ "[&::-moz-range-thumb]:hover:bg-yellow-600 dark:[&::-moz-range-thumb]:hover:bg-yellow-500",
85
+ "focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 dark:focus:ring-yellow-400",
86
+ "focus:ring-offset-white dark:focus:ring-offset-gray-900",
87
+ error &&
88
+ "ring-2 ring-red-500 dark:ring-red-600",
89
+ props.disabled && "opacity-50 cursor-not-allowed"
90
+ )}
91
+ {...props}
92
+ />
93
+ {error && <FormError>{error}</FormError>}
94
+ </div>
95
+ );
96
+ }
97
+ );
98
+
99
+ InputSlider.displayName = "InputSlider";
100
+
101
+ export default InputSlider;
src/theme/form/InputText.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames";
2
+ import { type ChangeEvent, type ReactNode, forwardRef } from "react";
3
+
4
+ import { FormError } from "../index.ts";
5
+ import LabelTooltip from "./LabelTooltip.tsx";
6
+
7
+ interface InputTextProps {
8
+ label: string;
9
+ placeholder?: string;
10
+ error?: string;
11
+ required?: boolean;
12
+ className?: string;
13
+ id?: string;
14
+ disabled?: boolean;
15
+ value?: string | number;
16
+ onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
17
+ hideLabel?: boolean;
18
+ tooltip?: string | ReactNode;
19
+ type?: "text" | "number" | "email" | "password" | "url" | "tel";
20
+ min?: number;
21
+ max?: number;
22
+ step?: number;
23
+ }
24
+
25
+ const InputText = forwardRef<HTMLInputElement, InputTextProps>(
26
+ (
27
+ {
28
+ label,
29
+ placeholder,
30
+ error,
31
+ required,
32
+ className = "",
33
+ id,
34
+ hideLabel = false,
35
+ type = "text",
36
+ min,
37
+ max,
38
+ step,
39
+ tooltip = "",
40
+ ...props
41
+ },
42
+ ref
43
+ ) => {
44
+ return (
45
+ <div className={cn("flex flex-col gap-2", className)}>
46
+ <label
47
+ htmlFor={id}
48
+ className={cn(
49
+ "relative text-sm font-medium text-gray-900 dark:text-gray-100",
50
+ {
51
+ "clip-[rect(0,0,0,0)] sr-only absolute m-[-1px] h-px w-px overflow-hidden border-0 p-0 whitespace-nowrap":
52
+ hideLabel,
53
+ }
54
+ )}
55
+ >
56
+ {label}
57
+ {required && (
58
+ <span className="ml-1 text-blue-500 dark:text-blue-400">*</span>
59
+ )}
60
+ {tooltip !== "" && <LabelTooltip text={<>{tooltip}</>} />}
61
+ </label>
62
+ <input
63
+ ref={ref}
64
+ type={type}
65
+ id={id}
66
+ placeholder={placeholder}
67
+ min={min}
68
+ max={max}
69
+ step={step}
70
+ className={cn(
71
+ "w-full rounded-md border px-3 py-2 text-sm transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none",
72
+ "bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100",
73
+ "focus:ring-offset-white dark:focus:ring-offset-gray-900",
74
+ error
75
+ ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700 dark:focus:border-red-600 dark:focus:ring-red-600"
76
+ : "border-gray-300 focus:border-yellow-500 focus:ring-yellow-500 dark:border-gray-600 dark:focus:border-yellow-400 dark:focus:ring-yellow-400",
77
+ "placeholder:text-gray-400 dark:placeholder:text-gray-500"
78
+ )}
79
+ {...props}
80
+ />
81
+ {error && <FormError>{error}</FormError>}
82
+ </div>
83
+ );
84
+ }
85
+ );
86
+
87
+ InputText.displayName = "InputText";
88
+
89
+ export default InputText;
src/theme/form/InputTextarea.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames";
2
+ import { type ChangeEvent, forwardRef } from "react";
3
+
4
+ interface InputTextareaProps {
5
+ label: string;
6
+ placeholder?: string;
7
+ error?: string;
8
+ required?: boolean;
9
+ className?: string;
10
+ id?: string;
11
+ rows?: number;
12
+ value?: string;
13
+ onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
14
+ hideLabel?: boolean;
15
+ }
16
+
17
+ const InputTextarea = forwardRef<HTMLTextAreaElement, InputTextareaProps>(
18
+ (
19
+ {
20
+ label,
21
+ placeholder,
22
+ error,
23
+ required,
24
+ className = "",
25
+ id,
26
+ rows = 4,
27
+ hideLabel = false,
28
+ ...props
29
+ },
30
+ ref
31
+ ) => {
32
+ return (
33
+ <div className={cn("flex flex-col gap-2", className)}>
34
+ {!hideLabel && (
35
+ <label
36
+ htmlFor={id}
37
+ className="text-sm font-medium text-gray-900 dark:text-gray-100"
38
+ >
39
+ {label}
40
+ {required && (
41
+ <span className="ml-1 text-blue-500 dark:text-blue-400">*</span>
42
+ )}
43
+ </label>
44
+ )}
45
+ <textarea
46
+ ref={ref}
47
+ id={id}
48
+ rows={rows}
49
+ placeholder={placeholder}
50
+ className={cn(
51
+ "resize-vertical w-full rounded-md border px-3 py-2 text-sm transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none",
52
+ "bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100",
53
+ "focus:ring-offset-white dark:focus:ring-offset-gray-900",
54
+ error
55
+ ? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700 dark:focus:border-red-600 dark:focus:ring-red-600"
56
+ : "border-gray-300 focus:border-yellow-500 focus:ring-yellow-500 dark:border-gray-600 dark:focus:border-yellow-400 dark:focus:ring-yellow-400",
57
+ "placeholder:text-gray-400 dark:placeholder:text-gray-500"
58
+ )}
59
+ {...props}
60
+ />
61
+ {error && (
62
+ <span className="text-sm text-red-600 dark:text-red-400">
63
+ {error}
64
+ </span>
65
+ )}
66
+ </div>
67
+ );
68
+ }
69
+ );
70
+
71
+ InputTextarea.displayName = "InputTextarea";
72
+
73
+ export default InputTextarea;
src/theme/form/LabelTooltip.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Tooltip, type TooltipProps } from "@theme";
2
+ import { CircleQuestionMark } from "lucide-react";
3
+
4
+ interface LabelTooltipProps extends Omit<TooltipProps, "children"> {}
5
+
6
+ export default function LabelTooltip({ ...tooltip }: LabelTooltipProps) {
7
+ return (
8
+ <span className="absolute top-1/2 ml-1 -translate-y-1/2">
9
+ <Tooltip
10
+ {...tooltip}
11
+ text={<>{tooltip.text}</>}
12
+ className="block text-gray-500"
13
+ >
14
+ <CircleQuestionMark className="block w-4" />
15
+ </Tooltip>
16
+ </span>
17
+ );
18
+ }
src/theme/index.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* button */
2
+ export { default as Button } from "./button/Button";
3
+
4
+ /* form */
5
+ export { default as InputText } from "./form/InputText";
6
+ export { default as InputTextarea } from "./form/InputTextarea";
7
+ export { default as InputSelect } from "./form/InputSelect";
8
+ export { default as InputCheckbox } from "./form/InputCheckbox";
9
+ export { default as InputCheckboxList } from "./form/InputCheckboxList";
10
+ export { default as InputSlider } from "./form/InputSlider";
11
+ export { default as LabelTooltip } from "./form/LabelTooltip";
12
+ export { default as FormError } from "./form/FormError";
13
+
14
+ /* misc */
15
+ export { default as Modal } from "./misc/Modal";
16
+ export { default as Tooltip, type TooltipProps } from "./misc/Tooltip";
17
+ export { default as Message } from "./misc/Message";
18
+ export { default as MessageContent } from "./misc/MessageContent";
19
+ export { default as Slider } from "./misc/Slider";
20
+ export { default as Card } from "./misc/Card";
21
+ export { default as Loader } from "./misc/Loader";
src/theme/misc/Card.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames.ts";
2
+ import type { ReactNode } from "react";
3
+
4
+ export default function Card({
5
+ className = "",
6
+ children,
7
+ }: {
8
+ className?: string;
9
+ children: ReactNode;
10
+ }) {
11
+ return (
12
+ <div
13
+ className={cn(
14
+ className,
15
+ "rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
16
+ )}
17
+ >
18
+ {children}
19
+ </div>
20
+ );
21
+ }
src/theme/misc/Loader.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames.ts";
2
+
3
+ export default function Loader({ className = "" }: { className?: string }) {
4
+ return (
5
+ <div className={cn(className, "flex items-center justify-center py-12")}>
6
+ <div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-yellow-500 dark:border-gray-600 dark:border-t-yellow-400" />
7
+ </div>
8
+ );
9
+ }
src/theme/misc/Message.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames";
2
+ import { AlertTriangle, CheckCircle, Info, X, XCircle } from "lucide-react";
3
+ import type { ReactNode } from "react";
4
+
5
+ interface MessageProps {
6
+ type: "success" | "error" | "warning" | "info";
7
+ title: string;
8
+ message?: string;
9
+ children?: ReactNode;
10
+ onClose?: () => void;
11
+ className?: string;
12
+ }
13
+
14
+ const messageStyles = {
15
+ success: {
16
+ container:
17
+ "border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/20",
18
+ icon: "text-green-600 dark:text-green-500",
19
+ title: "text-green-800 dark:text-green-400",
20
+ text: "text-green-700 dark:text-green-300",
21
+ closeButton:
22
+ "text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-200",
23
+ },
24
+ error: {
25
+ container:
26
+ "border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20",
27
+ icon: "text-red-600 dark:text-red-500",
28
+ title: "text-red-800 dark:text-red-400",
29
+ text: "text-red-700 dark:text-red-300",
30
+ closeButton:
31
+ "text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-200",
32
+ },
33
+ warning: {
34
+ container:
35
+ "border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-900/20",
36
+ icon: "text-yellow-600 dark:text-yellow-500",
37
+ title: "text-yellow-800 dark:text-yellow-400",
38
+ text: "text-yellow-700 dark:text-yellow-300",
39
+ closeButton:
40
+ "text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-200",
41
+ },
42
+ info: {
43
+ container:
44
+ "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20",
45
+ icon: "text-blue-600 dark:text-blue-500",
46
+ title: "text-blue-800 dark:text-blue-400",
47
+ text: "text-blue-700 dark:text-blue-300",
48
+ closeButton:
49
+ "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200",
50
+ },
51
+ };
52
+
53
+ const icons = {
54
+ success: CheckCircle,
55
+ error: XCircle,
56
+ warning: AlertTriangle,
57
+ info: Info,
58
+ };
59
+
60
+ export default function Message({
61
+ type,
62
+ title,
63
+ message,
64
+ children,
65
+ onClose,
66
+ className = "",
67
+ }: MessageProps) {
68
+ const styles = messageStyles[type];
69
+ const Icon = icons[type];
70
+
71
+ return (
72
+ <div className={cn("rounded-lg border p-4", styles.container, className)}>
73
+ <div className="flex items-start gap-3">
74
+ <Icon className={cn("mt-0.5 h-5 w-5 flex-shrink-0", styles.icon)} />
75
+ <div className="flex-1">
76
+ <h3 className={cn("font-semibold", styles.title)}>{title}</h3>
77
+ {message && (
78
+ <p className={cn("mt-1 text-sm", styles.text)}>{message}</p>
79
+ )}
80
+ {children && (
81
+ <div className={cn("mt-2 text-sm", styles.text)}>{children}</div>
82
+ )}
83
+ </div>
84
+ {onClose && (
85
+ <button
86
+ onClick={onClose}
87
+ className={cn(
88
+ "flex-shrink-0 rounded p-1 transition-colors",
89
+ styles.closeButton
90
+ )}
91
+ aria-label="Close message"
92
+ >
93
+ <X className="h-4 w-4" />
94
+ </button>
95
+ )}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
src/theme/misc/MessageContent.tsx ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import showdown from "showdown";
3
+
4
+ const converter = new showdown.Converter();
5
+ interface ParsedContent {
6
+ thinkContent: string | null;
7
+ afterContent: string;
8
+ isThinking: boolean;
9
+ }
10
+
11
+ function parseThinkTags(content: string): ParsedContent {
12
+ const openTagIndex = content.indexOf("<think>");
13
+
14
+ if (openTagIndex === -1) {
15
+ return {
16
+ thinkContent: null,
17
+ afterContent: content,
18
+ isThinking: false,
19
+ };
20
+ }
21
+
22
+ const closeTagIndex = content.indexOf("</think>");
23
+
24
+ if (closeTagIndex === -1) {
25
+ return {
26
+ thinkContent: content.slice(openTagIndex + 7),
27
+ afterContent: "",
28
+ isThinking: true,
29
+ };
30
+ }
31
+
32
+ return {
33
+ thinkContent: content.slice(openTagIndex + 7, closeTagIndex),
34
+ afterContent: content.slice(closeTagIndex + 8),
35
+ isThinking: false,
36
+ };
37
+ }
38
+
39
+ export default function MessageContent({ content }: { content: string }) {
40
+ const [showThinking, setShowThinking] = useState(false);
41
+ const [thinkingTime, setThinkingTime] = useState(0);
42
+ const parsed = parseThinkTags(content);
43
+
44
+ useEffect(() => {
45
+ if (parsed.isThinking) {
46
+ const startTime = Date.now();
47
+ const interval = setInterval(() => {
48
+ setThinkingTime((Date.now() - startTime) / 1000);
49
+ }, 100);
50
+ return () => clearInterval(interval);
51
+ }
52
+ }, [parsed.isThinking]);
53
+
54
+ if (!parsed.thinkContent) {
55
+ return (
56
+ <div
57
+ className="prose prose-sm dark:prose-invert prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2 prose-h3:text-base prose-p:my-2 prose-ul:my-2 prose-li:my-0 max-w-none"
58
+ dangerouslySetInnerHTML={{
59
+ __html: converter.makeHtml(content),
60
+ }}
61
+ />
62
+ );
63
+ }
64
+
65
+ return (
66
+ <div className="space-y-2">
67
+ {parsed.isThinking ? (
68
+ <div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
69
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
70
+ <span className="text-xs">
71
+ Thinking for {thinkingTime.toFixed(1)}s...
72
+ </span>
73
+ </div>
74
+ ) : (
75
+ <div>
76
+ <button
77
+ onClick={() => setShowThinking(!showThinking)}
78
+ className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
79
+ >
80
+ {showThinking ? "Hide" : "Show"} thinking
81
+ </button>
82
+ {showThinking && (
83
+ <div
84
+ className="prose dark:prose-invert prose-li:text-xs prose-headings:text-xs prose-p:text-xs prose-headings:font-semibold prose-p:my-2 prose-ul:my-2 prose-li:my-0 prose-hr:my-4 max-w-none"
85
+ dangerouslySetInnerHTML={{
86
+ __html: converter.makeHtml(parsed.thinkContent),
87
+ }}
88
+ />
89
+ )}
90
+ </div>
91
+ )}
92
+ {parsed.afterContent && (
93
+ <div
94
+ className="prose dark:prose-invert prose-li:text-sm prose-headings:text-sm prose-p:text-sm prose-headings:font-semibold prose-p:my-2 prose-ul:my-2 prose-li:my-0 prose-hr:my-4 max-w-none"
95
+ dangerouslySetInnerHTML={{
96
+ __html: converter.makeHtml(parsed.afterContent),
97
+ }}
98
+ />
99
+ )}
100
+ </div>
101
+ );
102
+ }
src/theme/misc/Modal.tsx ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames";
2
+ import { X } from "lucide-react";
3
+ import { type ReactNode, useEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ interface ModalProps {
7
+ isOpen: boolean;
8
+ onClose: () => void;
9
+ title?: string;
10
+ children: ReactNode;
11
+ className?: string;
12
+ size?: "sm" | "md" | "lg" | "xl" | "full";
13
+ showCloseButton?: boolean;
14
+ }
15
+
16
+ const sizeStyles = {
17
+ sm: "max-w-sm",
18
+ md: "max-w-md",
19
+ lg: "max-w-xl",
20
+ xl: "max-w-3xl",
21
+ full: "max-w-full md:mx-4",
22
+ };
23
+
24
+ // Get all focusable elements within a container
25
+ const getFocusableElements = (container: HTMLElement): HTMLElement[] => {
26
+ const focusableSelectors = [
27
+ "a[href]",
28
+ "button:not([disabled])",
29
+ "textarea:not([disabled])",
30
+ "input:not([disabled])",
31
+ "select:not([disabled])",
32
+ '[tabindex]:not([tabindex="-1"])',
33
+ ].join(", ");
34
+
35
+ return Array.from(container.querySelectorAll(focusableSelectors));
36
+ };
37
+
38
+ export default function Modal({
39
+ isOpen,
40
+ onClose,
41
+ title,
42
+ children,
43
+ className = "",
44
+ size = "md",
45
+ showCloseButton = true,
46
+ }: ModalProps) {
47
+ const modalRef = useRef<HTMLDivElement>(null);
48
+ const [isAnimating, setIsAnimating] = useState(false);
49
+ const [shouldRender, setShouldRender] = useState(false);
50
+
51
+ useEffect(() => {
52
+ if (isOpen) {
53
+ setShouldRender(true);
54
+ // Start animation after render
55
+ const timer = setTimeout(() => setIsAnimating(true), 10);
56
+ return () => clearTimeout(timer);
57
+ } else {
58
+ setIsAnimating(false);
59
+ // Remove from DOM after animation completes
60
+ const timer = setTimeout(() => setShouldRender(false), 200);
61
+ return () => clearTimeout(timer);
62
+ }
63
+ }, [isOpen]);
64
+
65
+ useEffect(() => {
66
+ const handleEscape = (e: KeyboardEvent) => {
67
+ if (e.key === "Escape") {
68
+ onClose();
69
+ }
70
+ };
71
+
72
+ if (isOpen) {
73
+ document.addEventListener("keydown", handleEscape);
74
+ document.body.style.overflow = "hidden";
75
+ }
76
+
77
+ return () => {
78
+ document.removeEventListener("keydown", handleEscape);
79
+ document.body.style.overflow = "unset";
80
+ };
81
+ }, [isOpen, onClose]);
82
+
83
+ useEffect(() => {
84
+ if (!isAnimating || !modalRef.current) return;
85
+
86
+ const modal = modalRef.current;
87
+ const focusableElements = getFocusableElements(modal);
88
+
89
+ if (focusableElements.length === 0) {
90
+ modal.focus();
91
+ return;
92
+ }
93
+
94
+ const firstFocusable = focusableElements[0];
95
+ const lastFocusable = focusableElements[focusableElements.length - 1];
96
+
97
+ firstFocusable.focus();
98
+
99
+ const handleTabKey = (e: KeyboardEvent) => {
100
+ if (e.key !== "Tab") return;
101
+
102
+ // Shift + Tab (backwards)
103
+ if (e.shiftKey) {
104
+ if (document.activeElement === firstFocusable) {
105
+ e.preventDefault();
106
+ lastFocusable.focus();
107
+ }
108
+ }
109
+ // Tab (forwards)
110
+ else {
111
+ if (document.activeElement === lastFocusable) {
112
+ e.preventDefault();
113
+ firstFocusable.focus();
114
+ }
115
+ }
116
+ };
117
+
118
+ modal.addEventListener("keydown", handleTabKey);
119
+
120
+ return () => {
121
+ modal.removeEventListener("keydown", handleTabKey);
122
+ };
123
+ }, [isAnimating]);
124
+
125
+ const handleBackdropClick = (e: React.MouseEvent) => {
126
+ if (e.target === e.currentTarget) {
127
+ onClose();
128
+ }
129
+ };
130
+
131
+ if (!shouldRender) return null;
132
+
133
+ const modalContent = (
134
+ <div
135
+ className={cn(
136
+ "fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-200",
137
+ isAnimating ? "opacity-100" : "opacity-0"
138
+ )}
139
+ onClick={handleBackdropClick}
140
+ >
141
+ {/* Backdrop */}
142
+ <div
143
+ className={cn(
144
+ "absolute inset-0 bg-black/50 transition-opacity duration-200",
145
+ isAnimating ? "opacity-100" : "opacity-0"
146
+ )}
147
+ />
148
+
149
+ {/* Modal */}
150
+ <div
151
+ ref={modalRef}
152
+ tabIndex={-1}
153
+ className={cn(
154
+ "relative w-full rounded-lg border border-neutral-200 bg-white shadow-xl transition-all duration-200 dark:border-gray-700",
155
+ "dark:bg-gray-900",
156
+ "flex max-h-[90vh] flex-col",
157
+ sizeStyles[size],
158
+ isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0",
159
+ className
160
+ )}
161
+ role="dialog"
162
+ aria-modal="true"
163
+ aria-labelledby={title ? "modal-title" : undefined}
164
+ >
165
+ {/* Header */}
166
+ {(title || showCloseButton) && (
167
+ <div className="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 p-4 dark:border-gray-700">
168
+ {title && (
169
+ <h2
170
+ id="modal-title"
171
+ className="text-lg font-semibold text-neutral-900 dark:text-gray-100"
172
+ >
173
+ {title}
174
+ </h2>
175
+ )}
176
+ {showCloseButton && (
177
+ <button
178
+ onClick={onClose}
179
+ className="ml-auto cursor-pointer rounded-full p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
180
+ aria-label="Close modal"
181
+ >
182
+ <X size={20} />
183
+ </button>
184
+ )}
185
+ </div>
186
+ )}
187
+
188
+ {/* Content */}
189
+ <div
190
+ className={cn(
191
+ "min-h-0 flex-1 overflow-y-auto",
192
+ title || showCloseButton ? "p-4" : "p-0"
193
+ )}
194
+ >
195
+ {children}
196
+ </div>
197
+ </div>
198
+ </div>
199
+ );
200
+
201
+ return createPortal(modalContent, document.body);
202
+ }
src/theme/misc/Slider.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames.ts";
2
+
3
+ export default function Slider({
4
+ className = "",
5
+ width,
6
+ }: {
7
+ className?: string;
8
+ width: number;
9
+ }) {
10
+ return (
11
+ <div
12
+ className={cn(
13
+ className,
14
+ "h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
15
+ )}
16
+ >
17
+ <div
18
+ className="h-full rounded-full bg-yellow-500 duration-500 dark:bg-yellow-400"
19
+ style={{ width: `${width}%` }}
20
+ />
21
+ </div>
22
+ );
23
+ }
src/theme/misc/Tooltip.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cn from "@utils/classnames.ts";
2
+ import { type ReactNode, useRef, useState } from "react";
3
+
4
+ type TooltipPosition = "top" | "bottom" | "left" | "right";
5
+
6
+ export interface TooltipProps {
7
+ children: ReactNode;
8
+ text: ReactNode;
9
+ position?: TooltipPosition;
10
+ className?: string;
11
+ }
12
+
13
+ export default function Tooltip({
14
+ children,
15
+ text,
16
+ position = "top",
17
+ className = "",
18
+ }: TooltipProps) {
19
+ const [isVisible, setIsVisible] = useState(false);
20
+ const timeoutRef = useRef<number>(null);
21
+
22
+ const positionClasses: Record<TooltipPosition, string> = {
23
+ top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
24
+ bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
25
+ left: "right-full top-1/2 -translate-y-1/2 mr-2",
26
+ right: "left-full top-1/2 -translate-y-1/2 ml-2",
27
+ };
28
+
29
+ const arrowClasses: Record<TooltipPosition, string> = {
30
+ top: "top-full left-1/2 -translate-x-1/2 border-l-transparent border-r-transparent border-b-transparent border-t-gray-900 dark:border-t-gray-100",
31
+ bottom:
32
+ "bottom-full left-1/2 -translate-x-1/2 border-l-transparent border-r-transparent border-t-transparent border-b-gray-900 dark:border-b-gray-100",
33
+ left: "left-full top-1/2 -translate-y-1/2 border-t-transparent border-b-transparent border-r-transparent border-l-gray-900 dark:border-l-gray-100",
34
+ right:
35
+ "right-full top-1/2 -translate-y-1/2 border-t-transparent border-b-transparent border-l-transparent border-r-gray-900 dark:border-r-gray-100",
36
+ };
37
+
38
+ const showTooltip = () => {
39
+ if (timeoutRef.current) {
40
+ clearTimeout(timeoutRef.current);
41
+ timeoutRef.current = null;
42
+ }
43
+ setIsVisible(true);
44
+ };
45
+
46
+ const hideTooltip = () => {
47
+ timeoutRef.current = window.setTimeout(() => {
48
+ setIsVisible(false);
49
+ }, 100);
50
+ };
51
+
52
+ return (
53
+ <div
54
+ className={cn("relative inline-block", className)}
55
+ onMouseEnter={showTooltip}
56
+ onMouseLeave={hideTooltip}
57
+ onFocus={showTooltip}
58
+ onBlur={hideTooltip}
59
+ >
60
+ <button type="button" className="block">
61
+ {children}
62
+ </button>
63
+ {isVisible && (
64
+ <div
65
+ className={cn(
66
+ "absolute z-50",
67
+ positionClasses[position],
68
+ "animate-fadeIn"
69
+ )}
70
+ role="tooltip"
71
+ >
72
+ <div
73
+ className={cn(
74
+ "rounded bg-gray-900 px-3 py-2 text-sm whitespace-nowrap text-white dark:bg-gray-100 dark:text-gray-900"
75
+ )}
76
+ >
77
+ {text}
78
+ </div>
79
+ <div
80
+ className={cn("absolute h-0 w-0 border-4", arrowClasses[position])}
81
+ />
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+ }
src/utils/calculateDownloadProgress.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ProgressInfo } from "@huggingface/transformers";
2
+
3
+ export const calculateDownloadProgress = (
4
+ callback: (data: {
5
+ percentage: number;
6
+ total: number;
7
+ loaded: number;
8
+ files: Record<string, number>;
9
+ }) => void,
10
+ files: Map<string, { loaded: number; total: number }> = new Map()
11
+ ) => {
12
+ return (progressInfo: ProgressInfo) => {
13
+ if (progressInfo.status === "ready") {
14
+ let totalLoaded = 0;
15
+ let totalSize = 0;
16
+ const filesRecord: Record<string, number> = {};
17
+
18
+ for (const [fileName, fileProgress] of files.entries()) {
19
+ totalLoaded += fileProgress.loaded;
20
+ totalSize += fileProgress.total;
21
+ filesRecord[fileName] = fileProgress.total;
22
+ }
23
+
24
+ callback({
25
+ percentage: 100,
26
+ total: totalSize,
27
+ loaded: totalLoaded,
28
+ files: filesRecord,
29
+ });
30
+ return;
31
+ }
32
+ if (progressInfo.status === "progress") {
33
+ files.set(progressInfo.file, {
34
+ loaded: progressInfo.loaded,
35
+ total: progressInfo.total,
36
+ });
37
+
38
+ const hasOnnxFile = Array.from(files.keys()).some((file) =>
39
+ file.endsWith(".onnx")
40
+ );
41
+
42
+ if (!hasOnnxFile) {
43
+ callback({
44
+ percentage: 0,
45
+ total: 0,
46
+ loaded: 0,
47
+ files: {},
48
+ });
49
+ return;
50
+ }
51
+
52
+ let totalLoaded = 0;
53
+ let totalSize = 0;
54
+ const filesRecord: Record<string, number> = {};
55
+
56
+ for (const [fileName, fileProgress] of files.entries()) {
57
+ totalLoaded += fileProgress.loaded;
58
+ totalSize += fileProgress.total;
59
+ filesRecord[fileName] = fileProgress.total;
60
+ }
61
+
62
+ const percentage =
63
+ totalSize > 0 ? round((totalLoaded / totalSize) * 100, 2) : 0;
64
+
65
+ callback({
66
+ percentage,
67
+ total: totalSize,
68
+ loaded: totalLoaded,
69
+ files: filesRecord,
70
+ });
71
+ }
72
+ };
73
+ };
74
+
75
+ const round = (value: number, decimals: number = 0) =>
76
+ Number(Math.round(Number(value + "e" + decimals)) + "e-" + decimals);
src/utils/classnames.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const cn = (...classes: Array<Record<string, boolean> | string>): string =>
2
+ classes
3
+ .map((entry) =>
4
+ typeof entry === "string"
5
+ ? entry
6
+ : Object.entries(entry || {})
7
+ .filter(([, append]) => append)
8
+ .map(([cl]) => cl)
9
+ .join(" "),
10
+ )
11
+ .filter((e) => e !== "")
12
+ .join(" ");
13
+ export default cn;
src/utils/context/chatSettings/ChatSettingsContext.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DEFAULT_SYSTEM_PROMPT, MODELS } from "@utils/models.ts";
2
+ import { createContext } from "react";
3
+
4
+ import type { ChatSettingsContextType } from "./types.ts";
5
+
6
+ const ChatSettingsContext = createContext<ChatSettingsContextType>({
7
+ settings: null,
8
+ setSettings: () => {},
9
+ downloadedModels: [],
10
+ openSettingsModal: () => {},
11
+ });
12
+
13
+ export default ChatSettingsContext;
14
+
15
+ export const DEFAULT_CHAT_SETTINGS = {
16
+ tools: [],
17
+ modelKey: Object.keys(MODELS)[0],
18
+ systemPrompt: DEFAULT_SYSTEM_PROMPT,
19
+ temperature: 0.7,
20
+ enableThinking: true,
21
+ };
src/utils/context/chatSettings/ChatSettingsContextProvider.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ChatSettingsModal from "@utils/context/chatSettings/ChatSettingsModal.tsx";
2
+ import {
3
+ getSettingsFromURL,
4
+ setSettingsToURL,
5
+ } from "@utils/context/chatSettings/routeParams.ts";
6
+ import { isModelCached } from "@utils/isModelCached.ts";
7
+ import { MODELS } from "@utils/models.ts";
8
+ import { type ReactNode, useEffect, useState } from "react";
9
+
10
+ import ChatSettingsContext from "./ChatSettingsContext.ts";
11
+ import type { ChatSettings } from "./types.ts";
12
+
13
+ export default function ChatSettingsContextProvider({
14
+ children,
15
+ }: {
16
+ children: ReactNode;
17
+ }) {
18
+ const [settings, setSettings] = useState<ChatSettings | null>(null);
19
+ const [downloadedModels, setDownloadedModels] = useState<Array<string>>([]);
20
+ const [modalOpen, setModalOpen] = useState<boolean>(false);
21
+
22
+ const hasAllSettings = (settings: ChatSettings | null): boolean =>
23
+ Boolean(settings) &&
24
+ Object.values(settings || {}).every(
25
+ (value) => value !== null && value !== undefined
26
+ );
27
+
28
+ useEffect(() => {
29
+ Promise.all(
30
+ Object.entries(MODELS).map(async ([modelKey, model]) => ({
31
+ modelKey,
32
+ cached: await isModelCached(model),
33
+ }))
34
+ ).then((cachedModels) =>
35
+ setDownloadedModels(
36
+ cachedModels
37
+ .filter(({ cached }) => cached)
38
+ .map(({ modelKey }) => modelKey)
39
+ )
40
+ );
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ if (!settings) return;
45
+ hasAllSettings(settings) && setSettingsToURL(settings);
46
+ }, [settings]);
47
+
48
+ useEffect(() => {
49
+ const settings = getSettingsFromURL();
50
+ console.log(settings);
51
+ if (settings) {
52
+ setSettings({
53
+ tools: settings?.tools,
54
+ modelKey: settings?.modelKey,
55
+ systemPrompt: settings?.systemPrompt,
56
+ temperature: settings?.temperature,
57
+ enableThinking: settings?.enableThinking,
58
+ });
59
+ }
60
+
61
+ if (!hasAllSettings(settings)) {
62
+ setModalOpen(true);
63
+ }
64
+ }, []);
65
+
66
+ return (
67
+ <ChatSettingsContext
68
+ value={{
69
+ settings,
70
+ setSettings,
71
+ downloadedModels,
72
+ openSettingsModal: () => setModalOpen(true),
73
+ }}
74
+ >
75
+ <ChatSettingsModal
76
+ isOpen={modalOpen}
77
+ showCloseButton={hasAllSettings(settings)}
78
+ onClose={() => setModalOpen(false)}
79
+ settings={settings}
80
+ onChange={(settings) => {
81
+ setSettings(settings);
82
+ setModalOpen(false);
83
+ }}
84
+ downloadedModels={downloadedModels}
85
+ />
86
+ {children}
87
+ </ChatSettingsContext>
88
+ );
89
+ }
src/utils/context/chatSettings/ChatSettingsModal.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Modal } from "@theme";
2
+
3
+ import { DEFAULT_CHAT_SETTINGS } from "./ChatSettingsContext.ts";
4
+ import ChatSettingsModalForm from "./ChatSettingsModalForm.tsx";
5
+ import type { ChatSettings } from "./types.ts";
6
+
7
+ export default function ChatSettingsModal({
8
+ isOpen,
9
+ onClose,
10
+ settings,
11
+ onChange,
12
+ downloadedModels,
13
+ showCloseButton,
14
+ }: {
15
+ isOpen: boolean;
16
+ onClose: () => void;
17
+ settings: ChatSettings | null;
18
+ onChange: (settings: ChatSettings) => void;
19
+ downloadedModels: Array<string>;
20
+ showCloseButton: boolean;
21
+ }) {
22
+ const handleSubmit = (data: ChatSettings) => onChange(data);
23
+
24
+ return (
25
+ <Modal
26
+ isOpen={isOpen}
27
+ onClose={onClose}
28
+ showCloseButton={showCloseButton}
29
+ title="Conversation Settings"
30
+ >
31
+ <ChatSettingsModalForm
32
+ key={JSON.stringify(settings)}
33
+ defaultValues={
34
+ settings
35
+ ? {
36
+ tools: settings.tools || DEFAULT_CHAT_SETTINGS.tools,
37
+ modelKey: settings.modelKey || DEFAULT_CHAT_SETTINGS.modelKey,
38
+ systemPrompt:
39
+ settings.systemPrompt || DEFAULT_CHAT_SETTINGS.systemPrompt,
40
+ temperature:
41
+ settings.temperature || DEFAULT_CHAT_SETTINGS.temperature,
42
+ enableThinking:
43
+ settings.enableThinking ||
44
+ DEFAULT_CHAT_SETTINGS.enableThinking,
45
+ }
46
+ : DEFAULT_CHAT_SETTINGS
47
+ }
48
+ onSubmit={handleSubmit}
49
+ downloadedModels={downloadedModels}
50
+ />
51
+ </Modal>
52
+ );
53
+ }