Commit
·
9b72f0d
1
Parent(s):
a4778fe
init
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .DS_Store +0 -0
- .env +3 -0
- .gitignore +27 -0
- .idea/.gitignore +8 -0
- .idea/codeStyles/Project.xml +62 -0
- .idea/codeStyles/codeStyleConfig.xml +5 -0
- .idea/inspectionProfiles/Project_Default.xml +6 -0
- .idea/modules.xml +8 -0
- .idea/php.xml +19 -0
- .idea/prettier.xml +6 -0
- .idea/transformers.js-text-generation.iml +8 -0
- .idea/vcs.xml +7 -0
- .prettierrc +14 -0
- README.md +2 -0
- index.html +17 -17
- package-lock.json +0 -0
- package.json +47 -0
- postcss.config.js +5 -0
- public/hf.svg +8 -0
- src/App.tsx +103 -0
- src/assets/react.svg +1 -0
- src/chat/Chat.tsx +134 -0
- src/chat/ChatForm.tsx +62 -0
- src/index.css +38 -0
- src/main.tsx +10 -0
- src/textGeneration/TextGeneration.ts +131 -0
- src/textGeneration/types.ts +141 -0
- src/textGeneration/worker/textGenerationWorker.ts +202 -0
- src/theme/button/Button.tsx +184 -0
- src/theme/form/FormError.tsx +16 -0
- src/theme/form/InputCheckbox.tsx +81 -0
- src/theme/form/InputCheckboxList.tsx +99 -0
- src/theme/form/InputSelect.tsx +121 -0
- src/theme/form/InputSlider.tsx +101 -0
- src/theme/form/InputText.tsx +89 -0
- src/theme/form/InputTextarea.tsx +73 -0
- src/theme/form/LabelTooltip.tsx +18 -0
- src/theme/index.ts +21 -0
- src/theme/misc/Card.tsx +21 -0
- src/theme/misc/Loader.tsx +9 -0
- src/theme/misc/Message.tsx +99 -0
- src/theme/misc/MessageContent.tsx +102 -0
- src/theme/misc/Modal.tsx +202 -0
- src/theme/misc/Slider.tsx +23 -0
- src/theme/misc/Tooltip.tsx +86 -0
- src/utils/calculateDownloadProgress.ts +76 -0
- src/utils/classnames.ts +13 -0
- src/utils/context/chatSettings/ChatSettingsContext.ts +21 -0
- src/utils/context/chatSettings/ChatSettingsContextProvider.tsx +89 -0
- 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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 |
+
}
|