Spaces:
Running
Running
Initial Checkin
#1
by
shreyask
- opened
This view is limited to 50 files because it contains too many changes.
See the raw diff here.
- .gitattributes +2 -0
- .gitignore +21 -20
- README.md +9 -75
- eslint.config.js +23 -0
- index.html +13 -0
- package-lock.json +0 -0
- package.json +29 -31
- public/favicon.ico +0 -0
- public/index.html +0 -43
- public/logo192.png +0 -0
- public/logo512.png +0 -0
- public/manifest.json +0 -25
- public/robots.txt +0 -3
- src/App.css +0 -38
- src/App.js +0 -25
- src/App.test.js +0 -8
- src/App.tsx +928 -0
- src/components/ExamplePrompts.tsx +40 -0
- src/components/LoadingScreen.tsx +550 -0
- src/components/MCPServerManager.tsx +535 -0
- src/components/OAuthCallback.tsx +140 -0
- src/components/ResultBlock.tsx +28 -0
- src/components/ToolCallIndicator.tsx +98 -0
- src/components/ToolItem.tsx +144 -0
- src/components/ToolResultRenderer.tsx +44 -0
- src/components/icons/HfLogo.tsx +35 -0
- src/components/icons/LiquidAILogo.tsx +12 -0
- src/components/icons/MCPLogo.tsx +35 -0
- src/config/constants.ts +35 -0
- src/constants/db.ts +3 -0
- src/constants/examples.ts +39 -0
- src/constants/models.ts +5 -0
- src/constants/systemPrompt.ts +11 -0
- src/hooks/useLLM.ts +234 -0
- src/hooks/useMCP.ts +232 -0
- src/index.css +1 -13
- src/index.js +0 -17
- src/logo.svg +0 -1
- src/main.tsx +24 -0
- src/reportWebVitals.js +0 -13
- src/services/mcpClient.ts +384 -0
- src/services/oauth.ts +239 -0
- src/setupTests.js +0 -5
- src/tools/get_location.js +84 -0
- src/tools/get_time.js +51 -0
- src/tools/index.ts +19 -0
- src/tools/math_eval.js +54 -0
- src/tools/mcp_servers.ts +18 -0
- src/tools/open_webpage.js +49 -0
- src/tools/random_number.js +51 -0
.gitattributes
CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
36 |
+
public/thumbnail.png filter=lfs diff=lfs merge=lfs -text
|
37 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
CHANGED
@@ -1,23 +1,24 @@
|
|
1 |
-
#
|
2 |
-
|
3 |
-
|
4 |
-
/node_modules
|
5 |
-
/.pnp
|
6 |
-
.pnp.js
|
7 |
-
|
8 |
-
# testing
|
9 |
-
/coverage
|
10 |
-
|
11 |
-
# production
|
12 |
-
/build
|
13 |
-
|
14 |
-
# misc
|
15 |
-
.DS_Store
|
16 |
-
.env.local
|
17 |
-
.env.development.local
|
18 |
-
.env.test.local
|
19 |
-
.env.production.local
|
20 |
-
|
21 |
npm-debug.log*
|
22 |
yarn-debug.log*
|
23 |
yarn-error.log*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
# Editor directories and files
|
16 |
+
.vscode/*
|
17 |
+
!.vscode/extensions.json
|
18 |
+
.idea
|
19 |
+
.DS_Store
|
20 |
+
*.suo
|
21 |
+
*.ntvs*
|
22 |
+
*.njsproj
|
23 |
+
*.sln
|
24 |
+
*.sw?
|
README.md
CHANGED
@@ -1,81 +1,15 @@
|
|
1 |
---
|
2 |
title: LFM2 MCP
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: static
|
7 |
-
pinned:
|
|
|
|
|
8 |
app_build_command: npm run build
|
9 |
-
app_file:
|
|
|
10 |
---
|
11 |
|
12 |
-
|
13 |
-
|
14 |
-
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
15 |
-
|
16 |
-
## Available Scripts
|
17 |
-
|
18 |
-
In the project directory, you can run:
|
19 |
-
|
20 |
-
### `npm start`
|
21 |
-
|
22 |
-
Runs the app in the development mode.\
|
23 |
-
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
24 |
-
|
25 |
-
The page will reload when you make changes.\
|
26 |
-
You may also see any lint errors in the console.
|
27 |
-
|
28 |
-
### `npm test`
|
29 |
-
|
30 |
-
Launches the test runner in the interactive watch mode.\
|
31 |
-
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
32 |
-
|
33 |
-
### `npm run build`
|
34 |
-
|
35 |
-
Builds the app for production to the `build` folder.\
|
36 |
-
It correctly bundles React in production mode and optimizes the build for the best performance.
|
37 |
-
|
38 |
-
The build is minified and the filenames include the hashes.\
|
39 |
-
Your app is ready to be deployed!
|
40 |
-
|
41 |
-
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
42 |
-
|
43 |
-
### `npm run eject`
|
44 |
-
|
45 |
-
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
46 |
-
|
47 |
-
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
48 |
-
|
49 |
-
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
50 |
-
|
51 |
-
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
52 |
-
|
53 |
-
## Learn More
|
54 |
-
|
55 |
-
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
56 |
-
|
57 |
-
To learn React, check out the [React documentation](https://reactjs.org/).
|
58 |
-
|
59 |
-
### Code Splitting
|
60 |
-
|
61 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
62 |
-
|
63 |
-
### Analyzing the Bundle Size
|
64 |
-
|
65 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
66 |
-
|
67 |
-
### Making a Progressive Web App
|
68 |
-
|
69 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
70 |
-
|
71 |
-
### Advanced Configuration
|
72 |
-
|
73 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
74 |
-
|
75 |
-
### Deployment
|
76 |
-
|
77 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
78 |
-
|
79 |
-
### `npm run build` fails to minify
|
80 |
-
|
81 |
-
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
|
|
1 |
---
|
2 |
title: LFM2 MCP
|
3 |
+
emoji: 🌐
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: yellow
|
6 |
sdk: static
|
7 |
+
pinned: true
|
8 |
+
license: apache-2.0
|
9 |
+
short_description: In-browser tool calling with MCP, powered by Transformers.js
|
10 |
app_build_command: npm run build
|
11 |
+
app_file: dist/index.html
|
12 |
+
thumbnail: https://huggingface.co/spaces/LiquidAI/LFM2-MCP/resolve/main/public/thumbnail.png
|
13 |
---
|
14 |
|
15 |
+
Check out the configuration reference at <https://huggingface.co/docs/hub/spaces-config-reference>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
eslint.config.js
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import js from "@eslint/js";
|
2 |
+
import globals from "globals";
|
3 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
4 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
5 |
+
import tseslint from "typescript-eslint";
|
6 |
+
import { globalIgnores } from "eslint/config";
|
7 |
+
|
8 |
+
export default tseslint.config([
|
9 |
+
globalIgnores(["dist"]),
|
10 |
+
{
|
11 |
+
files: ["**/*.{ts,tsx}"],
|
12 |
+
extends: [
|
13 |
+
js.configs.recommended,
|
14 |
+
tseslint.configs.recommended,
|
15 |
+
reactHooks.configs["recommended-latest"],
|
16 |
+
reactRefresh.configs.vite,
|
17 |
+
],
|
18 |
+
languageOptions: {
|
19 |
+
ecmaVersion: 2020,
|
20 |
+
globals: globals.browser,
|
21 |
+
},
|
22 |
+
},
|
23 |
+
]);
|
index.html
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" type="image/svg+xml" href="/liquidai-logo.svg" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>LFM2 MCP - In-Browser Tool Calling</title>
|
8 |
+
</head>
|
9 |
+
<body>
|
10 |
+
<div id="root"></div>
|
11 |
+
<script type="module" src="/src/main.tsx"></script>
|
12 |
+
</body>
|
13 |
+
</html>
|
package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
package.json
CHANGED
@@ -1,39 +1,37 @@
|
|
1 |
{
|
2 |
-
"name": "
|
3 |
-
"version": "0.1.0",
|
4 |
"private": true,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
"dependencies": {
|
6 |
-
"@
|
7 |
-
"@
|
8 |
-
"@
|
9 |
-
"@
|
|
|
|
|
10 |
"react": "^19.1.0",
|
11 |
"react-dom": "^19.1.0",
|
12 |
-
"react-
|
13 |
-
"
|
14 |
-
},
|
15 |
-
"scripts": {
|
16 |
-
"start": "react-scripts start",
|
17 |
-
"build": "react-scripts build",
|
18 |
-
"test": "react-scripts test",
|
19 |
-
"eject": "react-scripts eject"
|
20 |
-
},
|
21 |
-
"eslintConfig": {
|
22 |
-
"extends": [
|
23 |
-
"react-app",
|
24 |
-
"react-app/jest"
|
25 |
-
]
|
26 |
},
|
27 |
-
"
|
28 |
-
"
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
"
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
38 |
}
|
39 |
}
|
|
|
1 |
{
|
2 |
+
"name": "lfm-tool-calling",
|
|
|
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": "^3.7.1",
|
14 |
+
"@modelcontextprotocol/sdk": "^1.17.3",
|
15 |
+
"@monaco-editor/react": "^4.7.0",
|
16 |
+
"@tailwindcss/vite": "^4.1.11",
|
17 |
+
"idb": "^8.0.3",
|
18 |
+
"lucide-react": "^0.535.0",
|
19 |
"react": "^19.1.0",
|
20 |
"react-dom": "^19.1.0",
|
21 |
+
"react-router-dom": "^7.8.0",
|
22 |
+
"tailwindcss": "^4.1.11"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
},
|
24 |
+
"devDependencies": {
|
25 |
+
"@eslint/js": "^9.30.1",
|
26 |
+
"@types/react": "^19.1.8",
|
27 |
+
"@types/react-dom": "^19.1.6",
|
28 |
+
"@vitejs/plugin-react": "^4.6.0",
|
29 |
+
"eslint": "^9.30.1",
|
30 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
31 |
+
"eslint-plugin-react-refresh": "^0.4.20",
|
32 |
+
"globals": "^16.3.0",
|
33 |
+
"typescript": "~5.8.3",
|
34 |
+
"typescript-eslint": "^8.35.1",
|
35 |
+
"vite": "^7.0.4"
|
36 |
}
|
37 |
}
|
public/favicon.ico
DELETED
Binary file (3.87 kB)
|
|
public/index.html
DELETED
@@ -1,43 +0,0 @@
|
|
1 |
-
<!DOCTYPE html>
|
2 |
-
<html lang="en">
|
3 |
-
<head>
|
4 |
-
<meta charset="utf-8" />
|
5 |
-
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
7 |
-
<meta name="theme-color" content="#000000" />
|
8 |
-
<meta
|
9 |
-
name="description"
|
10 |
-
content="Web site created using create-react-app"
|
11 |
-
/>
|
12 |
-
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
13 |
-
<!--
|
14 |
-
manifest.json provides metadata used when your web app is installed on a
|
15 |
-
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
16 |
-
-->
|
17 |
-
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
18 |
-
<!--
|
19 |
-
Notice the use of %PUBLIC_URL% in the tags above.
|
20 |
-
It will be replaced with the URL of the `public` folder during the build.
|
21 |
-
Only files inside the `public` folder can be referenced from the HTML.
|
22 |
-
|
23 |
-
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
24 |
-
work correctly both with client-side routing and a non-root public URL.
|
25 |
-
Learn how to configure a non-root public URL by running `npm run build`.
|
26 |
-
-->
|
27 |
-
<title>React App</title>
|
28 |
-
</head>
|
29 |
-
<body>
|
30 |
-
<noscript>You need to enable JavaScript to run this app.</noscript>
|
31 |
-
<div id="root"></div>
|
32 |
-
<!--
|
33 |
-
This HTML file is a template.
|
34 |
-
If you open it directly in the browser, you will see an empty page.
|
35 |
-
|
36 |
-
You can add webfonts, meta tags, or analytics to this file.
|
37 |
-
The build step will place the bundled scripts into the <body> tag.
|
38 |
-
|
39 |
-
To begin the development, run `npm start` or `yarn start`.
|
40 |
-
To create a production bundle, use `npm run build` or `yarn build`.
|
41 |
-
-->
|
42 |
-
</body>
|
43 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public/logo192.png
DELETED
Binary file (5.35 kB)
|
|
public/logo512.png
DELETED
Binary file (9.66 kB)
|
|
public/manifest.json
DELETED
@@ -1,25 +0,0 @@
|
|
1 |
-
{
|
2 |
-
"short_name": "React App",
|
3 |
-
"name": "Create React App Sample",
|
4 |
-
"icons": [
|
5 |
-
{
|
6 |
-
"src": "favicon.ico",
|
7 |
-
"sizes": "64x64 32x32 24x24 16x16",
|
8 |
-
"type": "image/x-icon"
|
9 |
-
},
|
10 |
-
{
|
11 |
-
"src": "logo192.png",
|
12 |
-
"type": "image/png",
|
13 |
-
"sizes": "192x192"
|
14 |
-
},
|
15 |
-
{
|
16 |
-
"src": "logo512.png",
|
17 |
-
"type": "image/png",
|
18 |
-
"sizes": "512x512"
|
19 |
-
}
|
20 |
-
],
|
21 |
-
"start_url": ".",
|
22 |
-
"display": "standalone",
|
23 |
-
"theme_color": "#000000",
|
24 |
-
"background_color": "#ffffff"
|
25 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public/robots.txt
DELETED
@@ -1,3 +0,0 @@
|
|
1 |
-
# https://www.robotstxt.org/robotstxt.html
|
2 |
-
User-agent: *
|
3 |
-
Disallow:
|
|
|
|
|
|
|
|
src/App.css
DELETED
@@ -1,38 +0,0 @@
|
|
1 |
-
.App {
|
2 |
-
text-align: center;
|
3 |
-
}
|
4 |
-
|
5 |
-
.App-logo {
|
6 |
-
height: 40vmin;
|
7 |
-
pointer-events: none;
|
8 |
-
}
|
9 |
-
|
10 |
-
@media (prefers-reduced-motion: no-preference) {
|
11 |
-
.App-logo {
|
12 |
-
animation: App-logo-spin infinite 20s linear;
|
13 |
-
}
|
14 |
-
}
|
15 |
-
|
16 |
-
.App-header {
|
17 |
-
background-color: #282c34;
|
18 |
-
min-height: 100vh;
|
19 |
-
display: flex;
|
20 |
-
flex-direction: column;
|
21 |
-
align-items: center;
|
22 |
-
justify-content: center;
|
23 |
-
font-size: calc(10px + 2vmin);
|
24 |
-
color: white;
|
25 |
-
}
|
26 |
-
|
27 |
-
.App-link {
|
28 |
-
color: #61dafb;
|
29 |
-
}
|
30 |
-
|
31 |
-
@keyframes App-logo-spin {
|
32 |
-
from {
|
33 |
-
transform: rotate(0deg);
|
34 |
-
}
|
35 |
-
to {
|
36 |
-
transform: rotate(360deg);
|
37 |
-
}
|
38 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.js
DELETED
@@ -1,25 +0,0 @@
|
|
1 |
-
import logo from './logo.svg';
|
2 |
-
import './App.css';
|
3 |
-
|
4 |
-
function App() {
|
5 |
-
return (
|
6 |
-
<div className="App">
|
7 |
-
<header className="App-header">
|
8 |
-
<img src={logo} className="App-logo" alt="logo" />
|
9 |
-
<p>
|
10 |
-
Edit <code>src/App.js</code> and save to reload.
|
11 |
-
</p>
|
12 |
-
<a
|
13 |
-
className="App-link"
|
14 |
-
href="https://reactjs.org"
|
15 |
-
target="_blank"
|
16 |
-
rel="noopener noreferrer"
|
17 |
-
>
|
18 |
-
Learn React
|
19 |
-
</a>
|
20 |
-
</header>
|
21 |
-
</div>
|
22 |
-
);
|
23 |
-
}
|
24 |
-
|
25 |
-
export default App;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.test.js
DELETED
@@ -1,8 +0,0 @@
|
|
1 |
-
import { render, screen } from '@testing-library/react';
|
2 |
-
import App from './App';
|
3 |
-
|
4 |
-
test('renders learn react link', () => {
|
5 |
-
render(<App />);
|
6 |
-
const linkElement = screen.getByText(/learn react/i);
|
7 |
-
expect(linkElement).toBeInTheDocument();
|
8 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/App.tsx
ADDED
@@ -0,0 +1,928 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, {
|
2 |
+
useState,
|
3 |
+
useEffect,
|
4 |
+
useCallback,
|
5 |
+
useRef,
|
6 |
+
useMemo,
|
7 |
+
} from "react";
|
8 |
+
import { openDB, type IDBPDatabase } from "idb";
|
9 |
+
import {
|
10 |
+
Play,
|
11 |
+
Plus,
|
12 |
+
Zap,
|
13 |
+
RotateCcw,
|
14 |
+
Settings,
|
15 |
+
X,
|
16 |
+
PanelRightClose,
|
17 |
+
PanelRightOpen,
|
18 |
+
} from "lucide-react";
|
19 |
+
import { useLLM } from "./hooks/useLLM";
|
20 |
+
import { useMCP } from "./hooks/useMCP";
|
21 |
+
|
22 |
+
import type { Tool } from "./components/ToolItem";
|
23 |
+
|
24 |
+
import {
|
25 |
+
parsePythonicCalls,
|
26 |
+
extractPythonicCalls,
|
27 |
+
extractFunctionAndRenderer,
|
28 |
+
generateSchemaFromCode,
|
29 |
+
extractToolCallContent,
|
30 |
+
mapArgsToNamedParams,
|
31 |
+
getErrorMessage,
|
32 |
+
isMobileOrTablet,
|
33 |
+
} from "./utils";
|
34 |
+
|
35 |
+
import { DEFAULT_SYSTEM_PROMPT } from "./constants/systemPrompt";
|
36 |
+
import { DB_NAME, STORE_NAME, SETTINGS_STORE_NAME } from "./constants/db";
|
37 |
+
|
38 |
+
import { DEFAULT_TOOLS, TEMPLATE } from "./tools";
|
39 |
+
import ToolResultRenderer from "./components/ToolResultRenderer";
|
40 |
+
import ToolCallIndicator from "./components/ToolCallIndicator";
|
41 |
+
import ToolItem from "./components/ToolItem";
|
42 |
+
import ResultBlock from "./components/ResultBlock";
|
43 |
+
import ExamplePrompts from "./components/ExamplePrompts";
|
44 |
+
import { MCPServerManager } from "./components/MCPServerManager";
|
45 |
+
|
46 |
+
import { LoadingScreen } from "./components/LoadingScreen";
|
47 |
+
|
48 |
+
interface RenderInfo {
|
49 |
+
call: string;
|
50 |
+
result?: unknown;
|
51 |
+
renderer?: string;
|
52 |
+
input?: Record<string, unknown>;
|
53 |
+
error?: string;
|
54 |
+
}
|
55 |
+
|
56 |
+
interface BaseMessage {
|
57 |
+
role: "system" | "user" | "assistant";
|
58 |
+
content: string;
|
59 |
+
}
|
60 |
+
interface ToolMessage {
|
61 |
+
role: "tool";
|
62 |
+
content: string;
|
63 |
+
renderInfo: RenderInfo[]; // Rich data for the UI
|
64 |
+
}
|
65 |
+
type Message = BaseMessage | ToolMessage;
|
66 |
+
|
67 |
+
async function getDB(): Promise<IDBPDatabase> {
|
68 |
+
return openDB(DB_NAME, 1, {
|
69 |
+
upgrade(db) {
|
70 |
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
71 |
+
db.createObjectStore(STORE_NAME, {
|
72 |
+
keyPath: "id",
|
73 |
+
autoIncrement: true,
|
74 |
+
});
|
75 |
+
}
|
76 |
+
if (!db.objectStoreNames.contains(SETTINGS_STORE_NAME)) {
|
77 |
+
db.createObjectStore(SETTINGS_STORE_NAME, { keyPath: "key" });
|
78 |
+
}
|
79 |
+
},
|
80 |
+
});
|
81 |
+
}
|
82 |
+
|
83 |
+
const App: React.FC = () => {
|
84 |
+
const [systemPrompt, setSystemPrompt] = useState<string>(
|
85 |
+
DEFAULT_SYSTEM_PROMPT
|
86 |
+
);
|
87 |
+
const [isSystemPromptModalOpen, setIsSystemPromptModalOpen] =
|
88 |
+
useState<boolean>(false);
|
89 |
+
const [tempSystemPrompt, setTempSystemPrompt] = useState<string>("");
|
90 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
91 |
+
const [tools, setTools] = useState<Tool[]>([]);
|
92 |
+
const [input, setInput] = useState<string>("");
|
93 |
+
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
94 |
+
const isMobile = useMemo(isMobileOrTablet, []);
|
95 |
+
const [selectedModelId, setSelectedModelId] = useState<string>(
|
96 |
+
isMobile ? "350M" : "1.2B"
|
97 |
+
);
|
98 |
+
const [isModelDropdownOpen, setIsModelDropdownOpen] =
|
99 |
+
useState<boolean>(false);
|
100 |
+
const [isMCPManagerOpen, setIsMCPManagerOpen] = useState<boolean>(false);
|
101 |
+
const [isToolsPanelVisible, setIsToolsPanelVisible] = useState<boolean>(true);
|
102 |
+
const chatContainerRef = useRef<HTMLDivElement>(null);
|
103 |
+
const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
|
104 |
+
const toolsContainerRef = useRef<HTMLDivElement>(null);
|
105 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
106 |
+
const {
|
107 |
+
isLoading,
|
108 |
+
isReady,
|
109 |
+
error,
|
110 |
+
progress,
|
111 |
+
loadModel,
|
112 |
+
generateResponse,
|
113 |
+
clearPastKeyValues,
|
114 |
+
} = useLLM(selectedModelId);
|
115 |
+
|
116 |
+
// MCP integration
|
117 |
+
const {
|
118 |
+
getMCPToolsAsOriginalTools,
|
119 |
+
callMCPTool,
|
120 |
+
connectAll: connectAllMCPServers,
|
121 |
+
} = useMCP();
|
122 |
+
|
123 |
+
const loadTools = useCallback(async (): Promise<void> => {
|
124 |
+
const db = await getDB();
|
125 |
+
const allTools: Tool[] = await db.getAll(STORE_NAME);
|
126 |
+
if (allTools.length === 0) {
|
127 |
+
const defaultTools: Tool[] = Object.entries(DEFAULT_TOOLS).map(
|
128 |
+
([name, code], id) => ({
|
129 |
+
id,
|
130 |
+
name,
|
131 |
+
code,
|
132 |
+
enabled: true,
|
133 |
+
isCollapsed: false,
|
134 |
+
})
|
135 |
+
);
|
136 |
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
137 |
+
await Promise.all(defaultTools.map((tool) => tx.store.put(tool)));
|
138 |
+
await tx.done;
|
139 |
+
setTools(defaultTools);
|
140 |
+
} else {
|
141 |
+
setTools(allTools.map((t) => ({ ...t, isCollapsed: false })));
|
142 |
+
}
|
143 |
+
|
144 |
+
// Load MCP tools and merge them
|
145 |
+
const mcpTools = getMCPToolsAsOriginalTools();
|
146 |
+
setTools((prevTools) => [...prevTools, ...mcpTools]);
|
147 |
+
}, [getMCPToolsAsOriginalTools]);
|
148 |
+
|
149 |
+
useEffect(() => {
|
150 |
+
loadTools();
|
151 |
+
// Connect to MCP servers on startup
|
152 |
+
connectAllMCPServers().catch((error) => {
|
153 |
+
console.error("Failed to connect to MCP servers:", error);
|
154 |
+
});
|
155 |
+
}, [loadTools, connectAllMCPServers]);
|
156 |
+
|
157 |
+
useEffect(() => {
|
158 |
+
if (chatContainerRef.current) {
|
159 |
+
chatContainerRef.current.scrollTop =
|
160 |
+
chatContainerRef.current.scrollHeight;
|
161 |
+
}
|
162 |
+
}, [messages]);
|
163 |
+
|
164 |
+
const updateToolInDB = async (tool: Tool): Promise<void> => {
|
165 |
+
const db = await getDB();
|
166 |
+
await db.put(STORE_NAME, tool);
|
167 |
+
};
|
168 |
+
|
169 |
+
const saveToolDebounced = (tool: Tool): void => {
|
170 |
+
if (tool.id !== undefined && debounceTimers.current[tool.id]) {
|
171 |
+
clearTimeout(debounceTimers.current[tool.id]);
|
172 |
+
}
|
173 |
+
if (tool.id !== undefined) {
|
174 |
+
debounceTimers.current[tool.id] = setTimeout(() => {
|
175 |
+
updateToolInDB(tool);
|
176 |
+
}, 300);
|
177 |
+
}
|
178 |
+
};
|
179 |
+
|
180 |
+
const clearChat = useCallback(() => {
|
181 |
+
setMessages([]);
|
182 |
+
clearPastKeyValues();
|
183 |
+
}, [clearPastKeyValues]);
|
184 |
+
|
185 |
+
const addTool = async (): Promise<void> => {
|
186 |
+
const newTool: Omit<Tool, "id"> = {
|
187 |
+
name: "new_tool",
|
188 |
+
code: TEMPLATE,
|
189 |
+
enabled: true,
|
190 |
+
isCollapsed: false,
|
191 |
+
};
|
192 |
+
const db = await getDB();
|
193 |
+
const id = await db.add(STORE_NAME, newTool);
|
194 |
+
setTools((prev) => {
|
195 |
+
const updated = [...prev, { ...newTool, id: id as number }];
|
196 |
+
setTimeout(() => {
|
197 |
+
if (toolsContainerRef.current) {
|
198 |
+
toolsContainerRef.current.scrollTop =
|
199 |
+
toolsContainerRef.current.scrollHeight;
|
200 |
+
}
|
201 |
+
}, 0);
|
202 |
+
return updated;
|
203 |
+
});
|
204 |
+
clearChat();
|
205 |
+
};
|
206 |
+
|
207 |
+
const deleteTool = async (id: number): Promise<void> => {
|
208 |
+
if (debounceTimers.current[id]) {
|
209 |
+
clearTimeout(debounceTimers.current[id]);
|
210 |
+
}
|
211 |
+
const db = await getDB();
|
212 |
+
await db.delete(STORE_NAME, id);
|
213 |
+
setTools(tools.filter((tool) => tool.id !== id));
|
214 |
+
clearChat();
|
215 |
+
};
|
216 |
+
|
217 |
+
const toggleToolEnabled = (id: number): void => {
|
218 |
+
let changedTool: Tool | undefined;
|
219 |
+
const newTools = tools.map((tool) => {
|
220 |
+
if (tool.id === id) {
|
221 |
+
changedTool = { ...tool, enabled: !tool.enabled };
|
222 |
+
return changedTool;
|
223 |
+
}
|
224 |
+
return tool;
|
225 |
+
});
|
226 |
+
setTools(newTools);
|
227 |
+
if (changedTool) saveToolDebounced(changedTool);
|
228 |
+
};
|
229 |
+
|
230 |
+
const toggleToolCollapsed = (id: number): void => {
|
231 |
+
setTools(
|
232 |
+
tools.map((tool) =>
|
233 |
+
tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool
|
234 |
+
)
|
235 |
+
);
|
236 |
+
};
|
237 |
+
|
238 |
+
const expandTool = (id: number): void => {
|
239 |
+
setTools(
|
240 |
+
tools.map((tool) =>
|
241 |
+
tool.id === id ? { ...tool, isCollapsed: false } : tool
|
242 |
+
)
|
243 |
+
);
|
244 |
+
};
|
245 |
+
|
246 |
+
const handleToolCodeChange = (id: number, newCode: string): void => {
|
247 |
+
let changedTool: Tool | undefined;
|
248 |
+
const newTools = tools.map((tool) => {
|
249 |
+
if (tool.id === id) {
|
250 |
+
const { functionCode } = extractFunctionAndRenderer(newCode);
|
251 |
+
const schema = generateSchemaFromCode(functionCode);
|
252 |
+
changedTool = { ...tool, code: newCode, name: schema.name };
|
253 |
+
return changedTool;
|
254 |
+
}
|
255 |
+
return tool;
|
256 |
+
});
|
257 |
+
setTools(newTools);
|
258 |
+
if (changedTool) saveToolDebounced(changedTool);
|
259 |
+
};
|
260 |
+
|
261 |
+
const executeToolCall = async (callString: string): Promise<string> => {
|
262 |
+
const parsedCall = parsePythonicCalls(callString);
|
263 |
+
if (!parsedCall) throw new Error(`Invalid tool call format: ${callString}`);
|
264 |
+
|
265 |
+
const { name, positionalArgs, keywordArgs } = parsedCall;
|
266 |
+
const toolToUse = tools.find((t) => t.name === name && t.enabled);
|
267 |
+
if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`);
|
268 |
+
|
269 |
+
// Check if this is an MCP tool
|
270 |
+
const isMCPTool = toolToUse.code?.includes("mcpServerId:");
|
271 |
+
if (isMCPTool) {
|
272 |
+
// Extract MCP server ID and tool name from the code
|
273 |
+
const mcpServerMatch = toolToUse.code?.match(/mcpServerId: "([^"]+)"/);
|
274 |
+
const mcpToolMatch = toolToUse.code?.match(/toolName: "([^"]+)"/);
|
275 |
+
|
276 |
+
if (mcpServerMatch && mcpToolMatch) {
|
277 |
+
const serverId = mcpServerMatch[1];
|
278 |
+
const toolName = mcpToolMatch[1];
|
279 |
+
|
280 |
+
// Convert positional and keyword args to a single args object
|
281 |
+
const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
|
282 |
+
const schema = generateSchemaFromCode(functionCode);
|
283 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
284 |
+
|
285 |
+
const args: Record<string, unknown> = {};
|
286 |
+
|
287 |
+
// Map positional args
|
288 |
+
for (
|
289 |
+
let i = 0;
|
290 |
+
i < Math.min(positionalArgs.length, paramNames.length);
|
291 |
+
i++
|
292 |
+
) {
|
293 |
+
args[paramNames[i]] = positionalArgs[i];
|
294 |
+
}
|
295 |
+
|
296 |
+
// Map keyword args
|
297 |
+
Object.entries(keywordArgs).forEach(([key, value]) => {
|
298 |
+
args[key] = value;
|
299 |
+
});
|
300 |
+
|
301 |
+
// Call MCP tool
|
302 |
+
const result = await callMCPTool(serverId, toolName, args);
|
303 |
+
return JSON.stringify(result);
|
304 |
+
}
|
305 |
+
}
|
306 |
+
|
307 |
+
// Handle local tools as before
|
308 |
+
const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
|
309 |
+
const schema = generateSchemaFromCode(functionCode);
|
310 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
311 |
+
|
312 |
+
const finalArgs: unknown[] = [];
|
313 |
+
const requiredParams = schema.parameters.required || [];
|
314 |
+
|
315 |
+
for (let i = 0; i < paramNames.length; ++i) {
|
316 |
+
const paramName = paramNames[i];
|
317 |
+
if (i < positionalArgs.length) {
|
318 |
+
finalArgs.push(positionalArgs[i]);
|
319 |
+
} else if (Object.prototype.hasOwnProperty.call(keywordArgs, paramName)) {
|
320 |
+
finalArgs.push(keywordArgs[paramName]);
|
321 |
+
} else if (
|
322 |
+
Object.prototype.hasOwnProperty.call(
|
323 |
+
schema.parameters.properties[paramName],
|
324 |
+
"default"
|
325 |
+
)
|
326 |
+
) {
|
327 |
+
finalArgs.push(schema.parameters.properties[paramName].default);
|
328 |
+
} else if (!requiredParams.includes(paramName)) {
|
329 |
+
finalArgs.push(undefined);
|
330 |
+
} else {
|
331 |
+
throw new Error(`Missing required argument: ${paramName}`);
|
332 |
+
}
|
333 |
+
}
|
334 |
+
|
335 |
+
const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/);
|
336 |
+
if (!bodyMatch) {
|
337 |
+
throw new Error(
|
338 |
+
"Could not parse function body. Ensure it's a standard `function` declaration."
|
339 |
+
);
|
340 |
+
}
|
341 |
+
const body = bodyMatch[1];
|
342 |
+
const AsyncFunction = Object.getPrototypeOf(
|
343 |
+
async function () {}
|
344 |
+
).constructor;
|
345 |
+
const func = new AsyncFunction(...paramNames, body);
|
346 |
+
const result = await func(...finalArgs);
|
347 |
+
return JSON.stringify(result);
|
348 |
+
};
|
349 |
+
|
350 |
+
const executeToolCalls = async (
|
351 |
+
toolCallContent: string
|
352 |
+
): Promise<RenderInfo[]> => {
|
353 |
+
const toolCalls = extractPythonicCalls(toolCallContent);
|
354 |
+
if (toolCalls.length === 0)
|
355 |
+
return [{ call: "", error: "No valid tool calls found." }];
|
356 |
+
|
357 |
+
const results: RenderInfo[] = [];
|
358 |
+
for (const call of toolCalls) {
|
359 |
+
try {
|
360 |
+
const result = await executeToolCall(call);
|
361 |
+
const parsedCall = parsePythonicCalls(call);
|
362 |
+
const toolUsed = parsedCall
|
363 |
+
? tools.find((t) => t.name === parsedCall.name && t.enabled)
|
364 |
+
: null;
|
365 |
+
const { rendererCode } = toolUsed
|
366 |
+
? extractFunctionAndRenderer(toolUsed.code)
|
367 |
+
: { rendererCode: undefined };
|
368 |
+
|
369 |
+
let parsedResult;
|
370 |
+
try {
|
371 |
+
parsedResult = JSON.parse(result);
|
372 |
+
} catch {
|
373 |
+
parsedResult = result;
|
374 |
+
}
|
375 |
+
|
376 |
+
let namedParams: Record<string, unknown> = Object.create(null);
|
377 |
+
if (parsedCall && toolUsed) {
|
378 |
+
const schema = generateSchemaFromCode(
|
379 |
+
extractFunctionAndRenderer(toolUsed.code).functionCode
|
380 |
+
);
|
381 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
382 |
+
namedParams = mapArgsToNamedParams(
|
383 |
+
paramNames,
|
384 |
+
parsedCall.positionalArgs,
|
385 |
+
parsedCall.keywordArgs
|
386 |
+
);
|
387 |
+
}
|
388 |
+
|
389 |
+
results.push({
|
390 |
+
call,
|
391 |
+
result: parsedResult,
|
392 |
+
renderer: rendererCode,
|
393 |
+
input: namedParams,
|
394 |
+
});
|
395 |
+
} catch (error) {
|
396 |
+
const errorMessage = getErrorMessage(error);
|
397 |
+
results.push({ call, error: errorMessage });
|
398 |
+
}
|
399 |
+
}
|
400 |
+
return results;
|
401 |
+
};
|
402 |
+
|
403 |
+
const handleSendMessage = async (): Promise<void> => {
|
404 |
+
if (!input.trim() || !isReady) return;
|
405 |
+
|
406 |
+
const userMessage: Message = { role: "user", content: input };
|
407 |
+
const currentMessages: Message[] = [...messages, userMessage];
|
408 |
+
setMessages(currentMessages);
|
409 |
+
setInput("");
|
410 |
+
setIsGenerating(true);
|
411 |
+
|
412 |
+
try {
|
413 |
+
const toolSchemas = tools
|
414 |
+
.filter((tool) => tool.enabled)
|
415 |
+
.map((tool) => generateSchemaFromCode(tool.code));
|
416 |
+
|
417 |
+
while (true) {
|
418 |
+
const messagesForGeneration = [
|
419 |
+
{ role: "system" as const, content: systemPrompt },
|
420 |
+
...currentMessages,
|
421 |
+
];
|
422 |
+
|
423 |
+
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
424 |
+
|
425 |
+
let accumulatedContent = "";
|
426 |
+
const response = await generateResponse(
|
427 |
+
messagesForGeneration,
|
428 |
+
toolSchemas,
|
429 |
+
(token: string) => {
|
430 |
+
accumulatedContent += token;
|
431 |
+
setMessages((current) => {
|
432 |
+
const updated = [...current];
|
433 |
+
updated[updated.length - 1] = {
|
434 |
+
role: "assistant",
|
435 |
+
content: accumulatedContent,
|
436 |
+
};
|
437 |
+
return updated;
|
438 |
+
});
|
439 |
+
}
|
440 |
+
);
|
441 |
+
|
442 |
+
currentMessages.push({ role: "assistant", content: response });
|
443 |
+
const toolCallContent = extractToolCallContent(response);
|
444 |
+
|
445 |
+
if (toolCallContent) {
|
446 |
+
const toolResults = await executeToolCalls(toolCallContent);
|
447 |
+
|
448 |
+
const toolMessage: ToolMessage = {
|
449 |
+
role: "tool",
|
450 |
+
content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
|
451 |
+
renderInfo: toolResults,
|
452 |
+
};
|
453 |
+
currentMessages.push(toolMessage);
|
454 |
+
setMessages([...currentMessages]);
|
455 |
+
continue;
|
456 |
+
} else {
|
457 |
+
setMessages(currentMessages);
|
458 |
+
break;
|
459 |
+
}
|
460 |
+
}
|
461 |
+
} catch (error) {
|
462 |
+
const errorMessage = getErrorMessage(error);
|
463 |
+
setMessages([
|
464 |
+
...currentMessages,
|
465 |
+
{
|
466 |
+
role: "assistant",
|
467 |
+
content: `Error generating response: ${errorMessage}`,
|
468 |
+
},
|
469 |
+
]);
|
470 |
+
} finally {
|
471 |
+
setIsGenerating(false);
|
472 |
+
setTimeout(() => inputRef.current?.focus(), 0);
|
473 |
+
}
|
474 |
+
};
|
475 |
+
|
476 |
+
const loadSystemPrompt = useCallback(async (): Promise<void> => {
|
477 |
+
try {
|
478 |
+
const db = await getDB();
|
479 |
+
const stored = await db.get(SETTINGS_STORE_NAME, "systemPrompt");
|
480 |
+
if (stored && stored.value) setSystemPrompt(stored.value);
|
481 |
+
} catch (error) {
|
482 |
+
console.error("Failed to load system prompt:", error);
|
483 |
+
}
|
484 |
+
}, []);
|
485 |
+
|
486 |
+
const saveSystemPrompt = useCallback(
|
487 |
+
async (prompt: string): Promise<void> => {
|
488 |
+
try {
|
489 |
+
const db = await getDB();
|
490 |
+
await db.put(SETTINGS_STORE_NAME, {
|
491 |
+
key: "systemPrompt",
|
492 |
+
value: prompt,
|
493 |
+
});
|
494 |
+
} catch (error) {
|
495 |
+
console.error("Failed to save system prompt:", error);
|
496 |
+
}
|
497 |
+
},
|
498 |
+
[]
|
499 |
+
);
|
500 |
+
|
501 |
+
const loadSelectedModel = useCallback(async (): Promise<void> => {
|
502 |
+
try {
|
503 |
+
await loadModel();
|
504 |
+
} catch (error) {
|
505 |
+
console.error("Failed to load model:", error);
|
506 |
+
}
|
507 |
+
}, [loadModel]);
|
508 |
+
|
509 |
+
const loadSelectedModelId = useCallback(async (): Promise<void> => {
|
510 |
+
try {
|
511 |
+
const db = await getDB();
|
512 |
+
const stored = await db.get(SETTINGS_STORE_NAME, "selectedModelId");
|
513 |
+
if (stored && stored.value) {
|
514 |
+
setSelectedModelId(stored.value);
|
515 |
+
}
|
516 |
+
} catch (error) {
|
517 |
+
console.error("Failed to load selected model ID:", error);
|
518 |
+
}
|
519 |
+
}, []);
|
520 |
+
|
521 |
+
useEffect(() => {
|
522 |
+
loadSystemPrompt();
|
523 |
+
}, [loadSystemPrompt]);
|
524 |
+
|
525 |
+
const handleOpenSystemPromptModal = (): void => {
|
526 |
+
setTempSystemPrompt(systemPrompt);
|
527 |
+
setIsSystemPromptModalOpen(true);
|
528 |
+
};
|
529 |
+
|
530 |
+
const handleSaveSystemPrompt = (): void => {
|
531 |
+
setSystemPrompt(tempSystemPrompt);
|
532 |
+
saveSystemPrompt(tempSystemPrompt);
|
533 |
+
setIsSystemPromptModalOpen(false);
|
534 |
+
};
|
535 |
+
|
536 |
+
const handleCancelSystemPrompt = (): void => {
|
537 |
+
setTempSystemPrompt("");
|
538 |
+
setIsSystemPromptModalOpen(false);
|
539 |
+
};
|
540 |
+
|
541 |
+
const handleResetSystemPrompt = (): void => {
|
542 |
+
setTempSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
543 |
+
};
|
544 |
+
|
545 |
+
const saveSelectedModel = useCallback(
|
546 |
+
async (modelId: string): Promise<void> => {
|
547 |
+
try {
|
548 |
+
const db = await getDB();
|
549 |
+
await db.put(SETTINGS_STORE_NAME, {
|
550 |
+
key: "selectedModelId",
|
551 |
+
value: modelId,
|
552 |
+
});
|
553 |
+
} catch (error) {
|
554 |
+
console.error("Failed to save selected model ID:", error);
|
555 |
+
}
|
556 |
+
},
|
557 |
+
[]
|
558 |
+
);
|
559 |
+
|
560 |
+
useEffect(() => {
|
561 |
+
loadSystemPrompt();
|
562 |
+
loadSelectedModelId();
|
563 |
+
}, [loadSystemPrompt, loadSelectedModelId]);
|
564 |
+
|
565 |
+
const handleModelSelect = async (modelId: string) => {
|
566 |
+
setSelectedModelId(modelId);
|
567 |
+
setIsModelDropdownOpen(false);
|
568 |
+
await saveSelectedModel(modelId);
|
569 |
+
};
|
570 |
+
|
571 |
+
const handleExampleClick = async (messageText: string): Promise<void> => {
|
572 |
+
if (!isReady || isGenerating) return;
|
573 |
+
setInput(messageText);
|
574 |
+
|
575 |
+
const userMessage: Message = { role: "user", content: messageText };
|
576 |
+
const currentMessages: Message[] = [...messages, userMessage];
|
577 |
+
setMessages(currentMessages);
|
578 |
+
setInput("");
|
579 |
+
setIsGenerating(true);
|
580 |
+
|
581 |
+
try {
|
582 |
+
const toolSchemas = tools
|
583 |
+
.filter((tool) => tool.enabled)
|
584 |
+
.map((tool) => generateSchemaFromCode(tool.code));
|
585 |
+
|
586 |
+
while (true) {
|
587 |
+
const messagesForGeneration = [
|
588 |
+
{ role: "system" as const, content: systemPrompt },
|
589 |
+
...currentMessages,
|
590 |
+
];
|
591 |
+
|
592 |
+
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
593 |
+
|
594 |
+
let accumulatedContent = "";
|
595 |
+
const response = await generateResponse(
|
596 |
+
messagesForGeneration,
|
597 |
+
toolSchemas,
|
598 |
+
(token: string) => {
|
599 |
+
accumulatedContent += token;
|
600 |
+
setMessages((current) => {
|
601 |
+
const updated = [...current];
|
602 |
+
updated[updated.length - 1] = {
|
603 |
+
role: "assistant",
|
604 |
+
content: accumulatedContent,
|
605 |
+
};
|
606 |
+
return updated;
|
607 |
+
});
|
608 |
+
}
|
609 |
+
);
|
610 |
+
|
611 |
+
currentMessages.push({ role: "assistant", content: response });
|
612 |
+
const toolCallContent = extractToolCallContent(response);
|
613 |
+
|
614 |
+
if (toolCallContent) {
|
615 |
+
const toolResults = await executeToolCalls(toolCallContent);
|
616 |
+
|
617 |
+
const toolMessage: ToolMessage = {
|
618 |
+
role: "tool",
|
619 |
+
content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
|
620 |
+
renderInfo: toolResults,
|
621 |
+
};
|
622 |
+
currentMessages.push(toolMessage);
|
623 |
+
setMessages([...currentMessages]);
|
624 |
+
continue;
|
625 |
+
} else {
|
626 |
+
setMessages(currentMessages);
|
627 |
+
break;
|
628 |
+
}
|
629 |
+
}
|
630 |
+
} catch (error) {
|
631 |
+
const errorMessage = getErrorMessage(error);
|
632 |
+
setMessages([
|
633 |
+
...currentMessages,
|
634 |
+
{
|
635 |
+
role: "assistant",
|
636 |
+
content: `Error generating response: ${errorMessage}`,
|
637 |
+
},
|
638 |
+
]);
|
639 |
+
} finally {
|
640 |
+
setIsGenerating(false);
|
641 |
+
setTimeout(() => inputRef.current?.focus(), 0);
|
642 |
+
}
|
643 |
+
};
|
644 |
+
|
645 |
+
return (
|
646 |
+
<div className="font-sans bg-gray-900">
|
647 |
+
{!isReady ? (
|
648 |
+
<LoadingScreen
|
649 |
+
isLoading={isLoading}
|
650 |
+
progress={progress}
|
651 |
+
error={error}
|
652 |
+
loadSelectedModel={loadSelectedModel}
|
653 |
+
selectedModelId={selectedModelId}
|
654 |
+
isModelDropdownOpen={isModelDropdownOpen}
|
655 |
+
setIsModelDropdownOpen={setIsModelDropdownOpen}
|
656 |
+
handleModelSelect={handleModelSelect}
|
657 |
+
/>
|
658 |
+
) : (
|
659 |
+
<div className="flex h-screen text-white">
|
660 |
+
<div
|
661 |
+
className={`flex flex-col p-4 transition-all duration-300 ${
|
662 |
+
isToolsPanelVisible ? "w-1/2" : "w-full"
|
663 |
+
}`}
|
664 |
+
>
|
665 |
+
<div className="flex items-center justify-between mb-4">
|
666 |
+
<div className="flex items-center gap-3">
|
667 |
+
<h1 className="text-3xl font-bold text-gray-200">LFM2 MCP</h1>
|
668 |
+
</div>
|
669 |
+
<div className="flex items-center gap-3">
|
670 |
+
<div className="flex items-center text-green-400">
|
671 |
+
<Zap size={16} className="mr-2" />
|
672 |
+
Ready
|
673 |
+
</div>
|
674 |
+
<button
|
675 |
+
disabled={isGenerating}
|
676 |
+
onClick={clearChat}
|
677 |
+
className={`h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors text-sm ${
|
678 |
+
isGenerating
|
679 |
+
? "bg-gray-600 cursor-not-allowed opacity-50"
|
680 |
+
: "bg-gray-600 hover:bg-gray-700"
|
681 |
+
}`}
|
682 |
+
title="Clear chat"
|
683 |
+
>
|
684 |
+
<RotateCcw size={14} className="mr-2" /> Clear
|
685 |
+
</button>
|
686 |
+
<button
|
687 |
+
onClick={handleOpenSystemPromptModal}
|
688 |
+
className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-gray-600 hover:bg-gray-700 text-sm"
|
689 |
+
title="Edit system prompt"
|
690 |
+
>
|
691 |
+
<Settings size={16} />
|
692 |
+
</button>
|
693 |
+
<button
|
694 |
+
onClick={() => setIsMCPManagerOpen(true)}
|
695 |
+
className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-blue-600 hover:bg-blue-700 text-sm"
|
696 |
+
title="Manage MCP Servers"
|
697 |
+
>
|
698 |
+
🌐
|
699 |
+
</button>
|
700 |
+
<button
|
701 |
+
onClick={() => setIsToolsPanelVisible(!isToolsPanelVisible)}
|
702 |
+
className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-gray-600 hover:bg-gray-700 text-sm"
|
703 |
+
title={
|
704 |
+
isToolsPanelVisible
|
705 |
+
? "Hide Tools Panel"
|
706 |
+
: "Show Tools Panel"
|
707 |
+
}
|
708 |
+
>
|
709 |
+
{isToolsPanelVisible ? (
|
710 |
+
<PanelRightClose size={16} />
|
711 |
+
) : (
|
712 |
+
<PanelRightOpen size={16} />
|
713 |
+
)}
|
714 |
+
</button>
|
715 |
+
</div>
|
716 |
+
</div>
|
717 |
+
|
718 |
+
<div
|
719 |
+
ref={chatContainerRef}
|
720 |
+
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto mb-4 space-y-4"
|
721 |
+
>
|
722 |
+
{messages.length === 0 && isReady ? (
|
723 |
+
<ExamplePrompts onExampleClick={handleExampleClick} />
|
724 |
+
) : (
|
725 |
+
messages.map((msg, index) => {
|
726 |
+
const key = `${msg.role}-${index}`;
|
727 |
+
|
728 |
+
if (msg.role === "user") {
|
729 |
+
return (
|
730 |
+
<div key={key} className="flex justify-end">
|
731 |
+
<div className="p-3 rounded-lg max-w-md bg-indigo-600">
|
732 |
+
<p className="text-sm whitespace-pre-wrap">
|
733 |
+
{msg.content}
|
734 |
+
</p>
|
735 |
+
</div>
|
736 |
+
</div>
|
737 |
+
);
|
738 |
+
} else if (msg.role === "assistant") {
|
739 |
+
const isToolCall = msg.content.includes(
|
740 |
+
"<|tool_call_start|>"
|
741 |
+
);
|
742 |
+
|
743 |
+
if (isToolCall) {
|
744 |
+
const nextMessage = messages[index + 1];
|
745 |
+
const isCompleted = nextMessage?.role === "tool";
|
746 |
+
const hasError =
|
747 |
+
isCompleted &&
|
748 |
+
(nextMessage as ToolMessage).renderInfo.some(
|
749 |
+
(info) => !!info.error
|
750 |
+
);
|
751 |
+
|
752 |
+
return (
|
753 |
+
<div key={key} className="flex justify-start">
|
754 |
+
<div className="p-3 rounded-lg bg-gray-700">
|
755 |
+
<ToolCallIndicator
|
756 |
+
content={msg.content}
|
757 |
+
isRunning={!isCompleted}
|
758 |
+
hasError={hasError}
|
759 |
+
/>
|
760 |
+
</div>
|
761 |
+
</div>
|
762 |
+
);
|
763 |
+
}
|
764 |
+
|
765 |
+
return (
|
766 |
+
<div key={key} className="flex justify-start">
|
767 |
+
<div className="p-3 rounded-lg max-w-md bg-gray-700">
|
768 |
+
<p className="text-sm whitespace-pre-wrap">
|
769 |
+
{msg.content}
|
770 |
+
</p>
|
771 |
+
</div>
|
772 |
+
</div>
|
773 |
+
);
|
774 |
+
} else if (msg.role === "tool") {
|
775 |
+
const visibleToolResults = msg.renderInfo.filter(
|
776 |
+
(info) =>
|
777 |
+
info.error || (info.result != null && info.renderer)
|
778 |
+
);
|
779 |
+
|
780 |
+
if (visibleToolResults.length === 0) return null;
|
781 |
+
|
782 |
+
return (
|
783 |
+
<div key={key} className="flex justify-start">
|
784 |
+
<div className="p-3 rounded-lg bg-gray-700 max-w-lg">
|
785 |
+
<div className="space-y-3">
|
786 |
+
{visibleToolResults.map((info, idx) => (
|
787 |
+
<div className="flex flex-col gap-2" key={idx}>
|
788 |
+
<div className="text-xs text-gray-400 font-mono">
|
789 |
+
{info.call}
|
790 |
+
</div>
|
791 |
+
{info.error ? (
|
792 |
+
<ResultBlock error={info.error} />
|
793 |
+
) : (
|
794 |
+
<ToolResultRenderer
|
795 |
+
result={info.result}
|
796 |
+
rendererCode={info.renderer}
|
797 |
+
input={info.input}
|
798 |
+
/>
|
799 |
+
)}
|
800 |
+
</div>
|
801 |
+
))}
|
802 |
+
</div>
|
803 |
+
</div>
|
804 |
+
</div>
|
805 |
+
);
|
806 |
+
}
|
807 |
+
return null;
|
808 |
+
})
|
809 |
+
)}
|
810 |
+
</div>
|
811 |
+
|
812 |
+
<div className="flex">
|
813 |
+
<input
|
814 |
+
ref={inputRef}
|
815 |
+
type="text"
|
816 |
+
value={input}
|
817 |
+
onChange={(e) => setInput(e.target.value)}
|
818 |
+
onKeyDown={(e) =>
|
819 |
+
e.key === "Enter" &&
|
820 |
+
!isGenerating &&
|
821 |
+
isReady &&
|
822 |
+
handleSendMessage()
|
823 |
+
}
|
824 |
+
disabled={isGenerating || !isReady}
|
825 |
+
className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
826 |
+
placeholder={
|
827 |
+
isReady
|
828 |
+
? "Type your message here..."
|
829 |
+
: "Load model first to enable chat"
|
830 |
+
}
|
831 |
+
/>
|
832 |
+
<button
|
833 |
+
onClick={handleSendMessage}
|
834 |
+
disabled={isGenerating || !isReady}
|
835 |
+
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold p-3 rounded-r-lg transition-colors"
|
836 |
+
>
|
837 |
+
<Play size={20} />
|
838 |
+
</button>
|
839 |
+
</div>
|
840 |
+
</div>
|
841 |
+
|
842 |
+
{isToolsPanelVisible && (
|
843 |
+
<div className="w-1/2 flex flex-col p-4 border-l border-gray-700 transition-all duration-300">
|
844 |
+
<div className="flex justify-between items-center mb-4">
|
845 |
+
<h2 className="text-2xl font-bold text-teal-400">Tools</h2>
|
846 |
+
<button
|
847 |
+
onClick={addTool}
|
848 |
+
className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
|
849 |
+
>
|
850 |
+
<Plus size={16} className="mr-2" /> Add Tool
|
851 |
+
</button>
|
852 |
+
</div>
|
853 |
+
<div
|
854 |
+
ref={toolsContainerRef}
|
855 |
+
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3"
|
856 |
+
>
|
857 |
+
{tools.map((tool) => (
|
858 |
+
<ToolItem
|
859 |
+
key={tool.id}
|
860 |
+
tool={tool}
|
861 |
+
onToggleEnabled={() => toggleToolEnabled(tool.id)}
|
862 |
+
onToggleCollapsed={() => toggleToolCollapsed(tool.id)}
|
863 |
+
onExpand={() => expandTool(tool.id)}
|
864 |
+
onDelete={() => deleteTool(tool.id)}
|
865 |
+
onCodeChange={(newCode) =>
|
866 |
+
handleToolCodeChange(tool.id, newCode)
|
867 |
+
}
|
868 |
+
/>
|
869 |
+
))}
|
870 |
+
</div>
|
871 |
+
</div>
|
872 |
+
)}
|
873 |
+
</div>
|
874 |
+
)}
|
875 |
+
|
876 |
+
{isSystemPromptModalOpen && (
|
877 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
878 |
+
<div className="bg-gray-800 rounded-lg p-6 w-3/4 max-w-4xl max-h-3/4 flex flex-col text-gray-100">
|
879 |
+
<div className="flex justify-between items-center mb-4">
|
880 |
+
<h2 className="text-xl font-bold text-indigo-400">
|
881 |
+
Edit System Prompt
|
882 |
+
</h2>
|
883 |
+
<button
|
884 |
+
onClick={handleCancelSystemPrompt}
|
885 |
+
className="text-gray-400 hover:text-white"
|
886 |
+
>
|
887 |
+
<X size={20} />
|
888 |
+
</button>
|
889 |
+
</div>
|
890 |
+
<div className="flex-grow mb-4">
|
891 |
+
<textarea
|
892 |
+
value={tempSystemPrompt}
|
893 |
+
onChange={(e) => setTempSystemPrompt(e.target.value)}
|
894 |
+
className="w-full h-full bg-gray-700 text-white p-4 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
895 |
+
placeholder="Enter your system prompt here..."
|
896 |
+
style={{ minHeight: "300px" }}
|
897 |
+
/>
|
898 |
+
</div>
|
899 |
+
<div className="flex justify-between">
|
900 |
+
<button
|
901 |
+
onClick={handleResetSystemPrompt}
|
902 |
+
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors"
|
903 |
+
>
|
904 |
+
Reset
|
905 |
+
</button>
|
906 |
+
<div className="flex gap-3">
|
907 |
+
<button
|
908 |
+
onClick={handleSaveSystemPrompt}
|
909 |
+
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
910 |
+
>
|
911 |
+
Save
|
912 |
+
</button>
|
913 |
+
</div>
|
914 |
+
</div>
|
915 |
+
</div>
|
916 |
+
</div>
|
917 |
+
)}
|
918 |
+
|
919 |
+
{/* MCP Server Manager Modal */}
|
920 |
+
<MCPServerManager
|
921 |
+
isOpen={isMCPManagerOpen}
|
922 |
+
onClose={() => setIsMCPManagerOpen(false)}
|
923 |
+
/>
|
924 |
+
</div>
|
925 |
+
);
|
926 |
+
};
|
927 |
+
|
928 |
+
export default App;
|
src/components/ExamplePrompts.tsx
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
import { DEFAULT_EXAMPLES, type Example } from "../constants/examples";
|
3 |
+
|
4 |
+
interface ExamplePromptsProps {
|
5 |
+
examples?: Example[];
|
6 |
+
onExampleClick: (messageText: string) => void;
|
7 |
+
}
|
8 |
+
|
9 |
+
const ExamplePrompts: React.FC<ExamplePromptsProps> = ({
|
10 |
+
examples,
|
11 |
+
onExampleClick,
|
12 |
+
}) => (
|
13 |
+
<div className="flex flex-col items-center justify-center h-full space-y-6">
|
14 |
+
<div className="text-center mb-6">
|
15 |
+
<h2 className="text-2xl font-semibold text-gray-300 mb-1">
|
16 |
+
Try an example
|
17 |
+
</h2>
|
18 |
+
<p className="text-sm text-gray-500">Click one to get started</p>
|
19 |
+
</div>
|
20 |
+
|
21 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl w-full px-4">
|
22 |
+
{(examples || DEFAULT_EXAMPLES).map((example, index) => (
|
23 |
+
<button
|
24 |
+
key={index}
|
25 |
+
onClick={() => onExampleClick(example.messageText)}
|
26 |
+
className="flex items-center gap-3 p-4 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-left group cursor-pointer"
|
27 |
+
>
|
28 |
+
<span className="text-xl flex-shrink-0 group-hover:scale-110 transition-transform">
|
29 |
+
{example.icon}
|
30 |
+
</span>
|
31 |
+
<span className="text-sm text-gray-200 group-hover:text-white transition-colors">
|
32 |
+
{example.displayText}
|
33 |
+
</span>
|
34 |
+
</button>
|
35 |
+
))}
|
36 |
+
</div>
|
37 |
+
</div>
|
38 |
+
);
|
39 |
+
|
40 |
+
export default ExamplePrompts;
|
src/components/LoadingScreen.tsx
ADDED
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { ChevronDown } from "lucide-react";
|
2 |
+
import { MODEL_OPTIONS } from "../constants/models";
|
3 |
+
import LiquidAILogo from "./icons/LiquidAILogo";
|
4 |
+
import HfLogo from "./icons/HfLogo";
|
5 |
+
import MCPLogo from "./icons/MCPLogo";
|
6 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
7 |
+
import ReactDOM from "react-dom";
|
8 |
+
|
9 |
+
type Dot = {
|
10 |
+
x: number;
|
11 |
+
y: number;
|
12 |
+
radius: number;
|
13 |
+
speed: number;
|
14 |
+
opacity: number;
|
15 |
+
blur: number;
|
16 |
+
pulse: number;
|
17 |
+
pulseSpeed: number;
|
18 |
+
};
|
19 |
+
export const LoadingScreen = ({
|
20 |
+
isLoading,
|
21 |
+
progress,
|
22 |
+
error,
|
23 |
+
loadSelectedModel,
|
24 |
+
selectedModelId,
|
25 |
+
isModelDropdownOpen,
|
26 |
+
setIsModelDropdownOpen,
|
27 |
+
handleModelSelect,
|
28 |
+
}: {
|
29 |
+
isLoading: boolean;
|
30 |
+
progress: number;
|
31 |
+
error: string | null;
|
32 |
+
loadSelectedModel: () => void;
|
33 |
+
selectedModelId: string;
|
34 |
+
isModelDropdownOpen: boolean;
|
35 |
+
setIsModelDropdownOpen: (isOpen: boolean) => void;
|
36 |
+
handleModelSelect: (modelId: string) => void;
|
37 |
+
}) => {
|
38 |
+
const model = useMemo(
|
39 |
+
() => MODEL_OPTIONS.find((opt) => opt.id === selectedModelId),
|
40 |
+
[selectedModelId]
|
41 |
+
);
|
42 |
+
|
43 |
+
// Refs
|
44 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
45 |
+
const dropdownBtnRef = useRef<HTMLButtonElement>(null);
|
46 |
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
47 |
+
const wrapperRef = useRef<HTMLDivElement>(null); // NEW: anchor for centering
|
48 |
+
|
49 |
+
// For keyboard navigation
|
50 |
+
const [activeIndex, setActiveIndex] = useState(
|
51 |
+
Math.max(
|
52 |
+
0,
|
53 |
+
MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)
|
54 |
+
)
|
55 |
+
);
|
56 |
+
|
57 |
+
// Background Animation Effect (crisper dots + reduced motion)
|
58 |
+
useEffect(() => {
|
59 |
+
const canvas = canvasRef.current;
|
60 |
+
if (!canvas) return;
|
61 |
+
|
62 |
+
const ctx = canvas.getContext("2d");
|
63 |
+
if (!ctx) return;
|
64 |
+
|
65 |
+
const prefersReduced =
|
66 |
+
typeof window !== "undefined" &&
|
67 |
+
window.matchMedia &&
|
68 |
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
69 |
+
|
70 |
+
let animationFrameId: number;
|
71 |
+
let dots: Dot[] = [];
|
72 |
+
|
73 |
+
const setup = () => {
|
74 |
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
75 |
+
const { innerWidth, innerHeight } = window;
|
76 |
+
canvas.width = Math.floor(innerWidth * dpr);
|
77 |
+
canvas.height = Math.floor(innerHeight * dpr);
|
78 |
+
canvas.style.width = `${innerWidth}px`;
|
79 |
+
canvas.style.height = `${innerHeight}px`;
|
80 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
81 |
+
|
82 |
+
dots = [];
|
83 |
+
const numDots = Math.floor((innerWidth * innerHeight) / 12000);
|
84 |
+
for (let i = 0; i < numDots; ++i) {
|
85 |
+
dots.push({
|
86 |
+
x: Math.random() * innerWidth,
|
87 |
+
y: Math.random() * innerHeight,
|
88 |
+
radius: Math.random() * 2 + 0.3,
|
89 |
+
speed: prefersReduced ? 0 : Math.random() * 0.3 + 0.05,
|
90 |
+
opacity: Math.random() * 0.4 + 0.1,
|
91 |
+
blur: Math.random() > 0.8 ? Math.random() * 1.5 + 0.5 : 0,
|
92 |
+
pulse: Math.random() * Math.PI * 2,
|
93 |
+
pulseSpeed: prefersReduced ? 0 : Math.random() * 0.02 + 0.01,
|
94 |
+
});
|
95 |
+
}
|
96 |
+
};
|
97 |
+
|
98 |
+
const draw = () => {
|
99 |
+
if (!ctx) return;
|
100 |
+
const width = canvas.clientWidth;
|
101 |
+
const height = canvas.clientHeight;
|
102 |
+
ctx.clearRect(0, 0, width, height);
|
103 |
+
|
104 |
+
dots.forEach((dot) => {
|
105 |
+
dot.y += dot.speed;
|
106 |
+
dot.pulse += dot.pulseSpeed;
|
107 |
+
|
108 |
+
if (dot.y > height + dot.radius) {
|
109 |
+
dot.y = -dot.radius;
|
110 |
+
dot.x = Math.random() * width;
|
111 |
+
}
|
112 |
+
|
113 |
+
const pulseFactor = 1 + Math.sin(dot.pulse) * 0.2;
|
114 |
+
const currentRadius = dot.radius * pulseFactor;
|
115 |
+
const currentOpacity = dot.opacity * (0.8 + Math.sin(dot.pulse) * 0.2);
|
116 |
+
|
117 |
+
ctx.beginPath();
|
118 |
+
ctx.arc(dot.x, dot.y, currentRadius, 0, Math.PI * 2);
|
119 |
+
ctx.fillStyle = `rgba(255, 255, 255, ${currentOpacity})`;
|
120 |
+
if (dot.blur > 0) ctx.filter = `blur(${dot.blur}px)`;
|
121 |
+
ctx.fill();
|
122 |
+
ctx.filter = "none";
|
123 |
+
});
|
124 |
+
|
125 |
+
animationFrameId = requestAnimationFrame(draw);
|
126 |
+
};
|
127 |
+
|
128 |
+
const handleResize = () => {
|
129 |
+
cancelAnimationFrame(animationFrameId);
|
130 |
+
setup();
|
131 |
+
draw();
|
132 |
+
};
|
133 |
+
|
134 |
+
setup();
|
135 |
+
draw();
|
136 |
+
window.addEventListener("resize", handleResize);
|
137 |
+
|
138 |
+
return () => {
|
139 |
+
window.removeEventListener("resize", handleResize);
|
140 |
+
cancelAnimationFrame(animationFrameId);
|
141 |
+
};
|
142 |
+
}, []);
|
143 |
+
|
144 |
+
// Close dropdown on Escape / click outside
|
145 |
+
useEffect(() => {
|
146 |
+
if (!isModelDropdownOpen) return;
|
147 |
+
|
148 |
+
const onKey = (e: KeyboardEvent) => {
|
149 |
+
if (e.key === "Escape") setIsModelDropdownOpen(false);
|
150 |
+
if (e.key === "ArrowDown") {
|
151 |
+
e.preventDefault();
|
152 |
+
setActiveIndex((i) => Math.min(MODEL_OPTIONS.length - 1, i + 1));
|
153 |
+
}
|
154 |
+
if (e.key === "ArrowUp") {
|
155 |
+
e.preventDefault();
|
156 |
+
setActiveIndex((i) => Math.max(0, i - 1));
|
157 |
+
}
|
158 |
+
if (e.key === "Enter") {
|
159 |
+
e.preventDefault();
|
160 |
+
const opt = MODEL_OPTIONS[activeIndex];
|
161 |
+
if (opt) {
|
162 |
+
handleModelSelect(opt.id);
|
163 |
+
setIsModelDropdownOpen(false);
|
164 |
+
dropdownBtnRef.current?.focus();
|
165 |
+
}
|
166 |
+
}
|
167 |
+
};
|
168 |
+
|
169 |
+
const onClick = (e: MouseEvent) => {
|
170 |
+
const target = e.target as Node;
|
171 |
+
if (
|
172 |
+
dropdownRef.current &&
|
173 |
+
!dropdownRef.current.contains(target) &&
|
174 |
+
!dropdownBtnRef.current?.contains(target)
|
175 |
+
) {
|
176 |
+
setIsModelDropdownOpen(false);
|
177 |
+
}
|
178 |
+
};
|
179 |
+
|
180 |
+
document.addEventListener("keydown", onKey);
|
181 |
+
document.addEventListener("mousedown", onClick);
|
182 |
+
return () => {
|
183 |
+
document.removeEventListener("keydown", onKey);
|
184 |
+
document.removeEventListener("mousedown", onClick);
|
185 |
+
};
|
186 |
+
}, [
|
187 |
+
isModelDropdownOpen,
|
188 |
+
activeIndex,
|
189 |
+
setIsModelDropdownOpen,
|
190 |
+
handleModelSelect,
|
191 |
+
]);
|
192 |
+
|
193 |
+
// Recompute portal position on open + resize
|
194 |
+
const [, forceRerender] = useState(0);
|
195 |
+
useEffect(() => {
|
196 |
+
const onResize = () => forceRerender((x) => x + 1);
|
197 |
+
window.addEventListener("resize", onResize);
|
198 |
+
return () => window.removeEventListener("resize", onResize);
|
199 |
+
}, []);
|
200 |
+
|
201 |
+
// Compute portal style based on the whole button group (center + clamp + optional drop-up)
|
202 |
+
const portalStyle = useMemo(() => {
|
203 |
+
if (typeof window === "undefined") return {};
|
204 |
+
const anchor = wrapperRef.current || dropdownBtnRef.current;
|
205 |
+
if (!anchor) return {};
|
206 |
+
|
207 |
+
const rect = anchor.getBoundingClientRect();
|
208 |
+
|
209 |
+
const margin = 8;
|
210 |
+
const minWidth = 320;
|
211 |
+
const dropdownWidth = Math.max(rect.width, minWidth);
|
212 |
+
|
213 |
+
// Center
|
214 |
+
let left = Math.round(rect.left + rect.width / 2 - dropdownWidth / 2);
|
215 |
+
// Clamp to viewport
|
216 |
+
left = Math.min(
|
217 |
+
Math.max(margin, left),
|
218 |
+
window.innerWidth - dropdownWidth - margin
|
219 |
+
);
|
220 |
+
|
221 |
+
// Flip up if not enough space below
|
222 |
+
const spaceBelow = window.innerHeight - rect.bottom;
|
223 |
+
const spaceAbove = rect.top;
|
224 |
+
const estimatedItemH = 56; // rough item height
|
225 |
+
const estimatedPad = 16;
|
226 |
+
const estimatedHeight =
|
227 |
+
estimatedItemH * Math.min(MODEL_OPTIONS.length, 6) + estimatedPad;
|
228 |
+
const dropUp = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
|
229 |
+
|
230 |
+
const top = dropUp ? rect.top - estimatedHeight - 8 : rect.bottom + 8;
|
231 |
+
|
232 |
+
return {
|
233 |
+
position: "fixed" as const,
|
234 |
+
left: `${left}px`,
|
235 |
+
top: `${top}px`,
|
236 |
+
width: `${dropdownWidth}px`,
|
237 |
+
zIndex: 100,
|
238 |
+
};
|
239 |
+
}, []);
|
240 |
+
|
241 |
+
return (
|
242 |
+
<div className="relative flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 text-white p-6 overflow-hidden">
|
243 |
+
{/* Background Canvas */}
|
244 |
+
<canvas
|
245 |
+
ref={canvasRef}
|
246 |
+
className="absolute top-0 left-0 w-full h-full z-0"
|
247 |
+
/>
|
248 |
+
|
249 |
+
{/* Vignette Overlay */}
|
250 |
+
<div className="absolute top-0 left-0 w-full h-full z-10 bg-[radial-gradient(ellipse_at_center,_rgba(15,23,42,0.1)_0%,_rgba(15,23,42,0.4)_40%,_rgba(15,23,42,0.9)_100%)]" />
|
251 |
+
|
252 |
+
{/* Grid Overlay */}
|
253 |
+
<div className="absolute inset-0 z-5 opacity-[0.02] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]" />
|
254 |
+
|
255 |
+
{/* Main Content */}
|
256 |
+
<div className="relative z-20 max-w-4xl w-full flex flex-col items-center">
|
257 |
+
{/* Logos */}
|
258 |
+
<div className="flex items-center justify-center mb-8 gap-5">
|
259 |
+
<a
|
260 |
+
href="https://www.liquid.ai/"
|
261 |
+
target="_blank"
|
262 |
+
rel="noopener noreferrer"
|
263 |
+
title="Liquid AI"
|
264 |
+
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"
|
265 |
+
>
|
266 |
+
<LiquidAILogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />
|
267 |
+
</a>
|
268 |
+
<span className="text-gray-500 text-3xl font-extralight">×</span>
|
269 |
+
<a
|
270 |
+
href="https://huggingface.co/docs/transformers.js"
|
271 |
+
target="_blank"
|
272 |
+
rel="noopener noreferrer"
|
273 |
+
title="Transformers.js"
|
274 |
+
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"
|
275 |
+
>
|
276 |
+
<HfLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />
|
277 |
+
</a>
|
278 |
+
<span className="text-gray-500 text-3xl font-extralight">×</span>
|
279 |
+
<a
|
280 |
+
href="https://modelcontextprotocol.io/"
|
281 |
+
target="_blank"
|
282 |
+
rel="noopener noreferrer"
|
283 |
+
title="Model Context Protocol"
|
284 |
+
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"
|
285 |
+
>
|
286 |
+
<MCPLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />
|
287 |
+
</a>
|
288 |
+
</div>
|
289 |
+
|
290 |
+
{/* Hero */}
|
291 |
+
<div className="text-center mb-8 space-y-4">
|
292 |
+
<h1 className="text-5xl sm:text-6xl md:text-7xl font-black bg-gradient-to-r from-white via-gray-100 to-gray-300 bg-clip-text text-transparent tracking-tight leading-none">
|
293 |
+
LFM2 MCP
|
294 |
+
</h1>
|
295 |
+
<p className="text-lg sm:text-xl md:text-2xl text-gray-300 font-light leading-relaxed">
|
296 |
+
Run next-gen hybrid models in your browser with tools powered by the{" "}
|
297 |
+
<a
|
298 |
+
href="https://modelcontextprotocol.io/"
|
299 |
+
target="_blank"
|
300 |
+
rel="noopener noreferrer"
|
301 |
+
>
|
302 |
+
<span className="text-indigo-400 font-medium">
|
303 |
+
Model Context Protocol (MCP)
|
304 |
+
</span>{" "}
|
305 |
+
enabling secure, real-time connections to remote servers.
|
306 |
+
</a>
|
307 |
+
</p>
|
308 |
+
<div className="w-24 h-1 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full mx-auto" />
|
309 |
+
</div>
|
310 |
+
|
311 |
+
{/* Description Cards */}
|
312 |
+
<div className="grid md:grid-cols-2 gap-6 text-gray-400 mb-10">
|
313 |
+
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">
|
314 |
+
<h3 className="text-white font-semibold mb-3 flex items-center">
|
315 |
+
<div className="w-2 h-2 bg-indigo-500 rounded-full mr-3" />
|
316 |
+
Model Context Protocol
|
317 |
+
</h3>
|
318 |
+
<p className="text-sm leading-relaxed">
|
319 |
+
Connect seamlessly to remote{" "}
|
320 |
+
<a
|
321 |
+
href="https://modelcontextprotocol.io/"
|
322 |
+
target="_blank"
|
323 |
+
rel="noopener noreferrer"
|
324 |
+
className="text-indigo-400 hover:underline"
|
325 |
+
>
|
326 |
+
MCP servers
|
327 |
+
</a>{" "}
|
328 |
+
using streaming or SSE protocols with support for no-auth, basic
|
329 |
+
auth, and OAuth.
|
330 |
+
</p>
|
331 |
+
</div>
|
332 |
+
|
333 |
+
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">
|
334 |
+
<h3 className="text-white font-semibold mb-3 flex items-center">
|
335 |
+
<div className="w-2 h-2 bg-purple-500 rounded-full mr-3" />
|
336 |
+
Edge AI Technology
|
337 |
+
</h3>
|
338 |
+
<p className="text-sm leading-relaxed">
|
339 |
+
Powered by{" "}
|
340 |
+
<a
|
341 |
+
href="https://www.liquid.ai/"
|
342 |
+
target="_blank"
|
343 |
+
rel="noopener noreferrer"
|
344 |
+
className="text-indigo-400 hover:underline"
|
345 |
+
>
|
346 |
+
Liquid AI’s
|
347 |
+
</a>{" "}
|
348 |
+
LFM2 hybrid models, optimized for on-device deployment and edge AI
|
349 |
+
scenarios.
|
350 |
+
</p>
|
351 |
+
</div>
|
352 |
+
</div>
|
353 |
+
|
354 |
+
<p className="text-gray-400 text-base sm:text-lg mb-10">
|
355 |
+
Everything runs entirely in your browser with{" "}
|
356 |
+
<a
|
357 |
+
href="https://huggingface.co/docs/transformers.js"
|
358 |
+
target="_blank"
|
359 |
+
rel="noopener noreferrer"
|
360 |
+
className="text-indigo-400 hover:underline font-medium"
|
361 |
+
>
|
362 |
+
Transformers.js
|
363 |
+
</a>{" "}
|
364 |
+
and ONNX Runtime Web.
|
365 |
+
</p>
|
366 |
+
|
367 |
+
{/* Action */}
|
368 |
+
<div className="text-center space-y-6">
|
369 |
+
<p className="text-gray-400 text-base sm:text-lg font-medium">
|
370 |
+
Select a model to load locally, and connect to a remote MCP server
|
371 |
+
to get started.
|
372 |
+
</p>
|
373 |
+
|
374 |
+
<div className="relative">
|
375 |
+
<div
|
376 |
+
ref={wrapperRef} // anchor for dropdown centering
|
377 |
+
className="flex rounded-2xl shadow-2xl overflow-hidden"
|
378 |
+
>
|
379 |
+
<button
|
380 |
+
onClick={isLoading ? undefined : loadSelectedModel}
|
381 |
+
disabled={isLoading}
|
382 |
+
className={`flex items-center justify-center font-bold transition-all text-lg flex-1 ${
|
383 |
+
isLoading
|
384 |
+
? "bg-gray-700 text-gray-400 cursor-not-allowed"
|
385 |
+
: "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-lg hover:shadow-xl transform hover:scale-[1.01] active:scale-[0.99]"
|
386 |
+
}`}
|
387 |
+
aria-live="polite"
|
388 |
+
aria-busy={isLoading}
|
389 |
+
aria-label={
|
390 |
+
isLoading
|
391 |
+
? `Loading ${model?.label ?? "model"} ${progress}%`
|
392 |
+
: `Load ${model?.label ?? "model"}`
|
393 |
+
}
|
394 |
+
>
|
395 |
+
<div className="px-8 py-4">
|
396 |
+
{isLoading ? (
|
397 |
+
<div className="flex items-center">
|
398 |
+
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
399 |
+
<span className="ml-3 font-semibold">
|
400 |
+
Loading... {progress}%
|
401 |
+
</span>
|
402 |
+
</div>
|
403 |
+
) : (
|
404 |
+
<span className="font-semibold">Load {model?.label}</span>
|
405 |
+
)}
|
406 |
+
</div>
|
407 |
+
</button>
|
408 |
+
|
409 |
+
<button
|
410 |
+
ref={dropdownBtnRef}
|
411 |
+
onClick={(e) => {
|
412 |
+
if (!isLoading) {
|
413 |
+
e.stopPropagation();
|
414 |
+
setIsModelDropdownOpen(!isModelDropdownOpen);
|
415 |
+
setActiveIndex(
|
416 |
+
Math.max(
|
417 |
+
0,
|
418 |
+
MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)
|
419 |
+
)
|
420 |
+
);
|
421 |
+
}
|
422 |
+
}}
|
423 |
+
onKeyDown={(e) => {
|
424 |
+
if (isLoading) return;
|
425 |
+
if (
|
426 |
+
e.key === " " ||
|
427 |
+
e.key === "Enter" ||
|
428 |
+
e.key === "ArrowDown"
|
429 |
+
) {
|
430 |
+
e.preventDefault();
|
431 |
+
if (!isModelDropdownOpen) setIsModelDropdownOpen(true);
|
432 |
+
}
|
433 |
+
}}
|
434 |
+
aria-haspopup="menu"
|
435 |
+
aria-expanded={isModelDropdownOpen}
|
436 |
+
aria-controls="model-dropdown"
|
437 |
+
aria-label="Select model"
|
438 |
+
className={`px-4 py-4 border-l border-white/20 transition-all ${
|
439 |
+
isLoading
|
440 |
+
? "bg-gray-700 cursor-not-allowed"
|
441 |
+
: "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 hover:shadow-lg transform hover:scale-[1.01] active:scale-[0.99]"
|
442 |
+
}`}
|
443 |
+
disabled={isLoading}
|
444 |
+
>
|
445 |
+
<ChevronDown
|
446 |
+
size={20}
|
447 |
+
className={`transition-transform duration-200 ${
|
448 |
+
isModelDropdownOpen ? "rotate-180" : ""
|
449 |
+
}`}
|
450 |
+
/>
|
451 |
+
</button>
|
452 |
+
</div>
|
453 |
+
|
454 |
+
{/* Dropdown (Portal) */}
|
455 |
+
{isModelDropdownOpen &&
|
456 |
+
typeof document !== "undefined" &&
|
457 |
+
ReactDOM.createPortal(
|
458 |
+
<div
|
459 |
+
id="model-dropdown"
|
460 |
+
ref={dropdownRef}
|
461 |
+
style={portalStyle}
|
462 |
+
role="menu"
|
463 |
+
aria-label="Model options"
|
464 |
+
className="bg-gray-800/95 border border-gray-600/50 rounded-2xl shadow-2xl overflow-hidden animate-in slide-in-from-top-2 duration-200 dropdown-z30"
|
465 |
+
>
|
466 |
+
{MODEL_OPTIONS.map((option, index) => {
|
467 |
+
const selected = selectedModelId === option.id;
|
468 |
+
const isActive = activeIndex === index;
|
469 |
+
return (
|
470 |
+
<button
|
471 |
+
key={option.id}
|
472 |
+
role="menuitem"
|
473 |
+
aria-checked={selected}
|
474 |
+
onMouseEnter={() => setActiveIndex(index)}
|
475 |
+
onClick={() => {
|
476 |
+
handleModelSelect(option.id);
|
477 |
+
setIsModelDropdownOpen(false);
|
478 |
+
dropdownBtnRef.current?.focus();
|
479 |
+
}}
|
480 |
+
className={`w-full px-6 py-4 text-left transition-all duration-200 relative group outline-none ${
|
481 |
+
selected
|
482 |
+
? "bg-gradient-to-r from-indigo-600/50 to-purple-600/50 text-white border-l-4 border-indigo-400"
|
483 |
+
: "text-gray-200 hover:bg-white/10 hover:text-white"
|
484 |
+
} ${index === 0 ? "rounded-t-2xl" : ""} ${
|
485 |
+
index === MODEL_OPTIONS.length - 1
|
486 |
+
? "rounded-b-2xl"
|
487 |
+
: ""
|
488 |
+
} ${isActive && !selected ? "bg-white/5" : ""}`}
|
489 |
+
>
|
490 |
+
<div className="flex items-center justify-between">
|
491 |
+
<div>
|
492 |
+
<div className="font-semibold text-lg">
|
493 |
+
{option.label}
|
494 |
+
</div>
|
495 |
+
<div className="text-sm text-gray-400 mt-1">
|
496 |
+
{option.size}
|
497 |
+
</div>
|
498 |
+
</div>
|
499 |
+
{selected && (
|
500 |
+
<div className="w-2 h-2 bg-indigo-400 rounded-full" />
|
501 |
+
)}
|
502 |
+
</div>
|
503 |
+
{!selected && (
|
504 |
+
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl" />
|
505 |
+
)}
|
506 |
+
</button>
|
507 |
+
);
|
508 |
+
})}
|
509 |
+
</div>,
|
510 |
+
document.body
|
511 |
+
)}
|
512 |
+
</div>
|
513 |
+
</div>
|
514 |
+
|
515 |
+
{/* Error */}
|
516 |
+
{error && (
|
517 |
+
<div
|
518 |
+
role="alert"
|
519 |
+
className="bg-red-900/30 backdrop-blur-sm border border-red-500/50 rounded-2xl p-6 mt-8 max-w-md text-center"
|
520 |
+
>
|
521 |
+
<p className="text-red-200 mb-4 font-medium">Error: {error}</p>
|
522 |
+
<button
|
523 |
+
onClick={loadSelectedModel}
|
524 |
+
className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 px-6 py-3 rounded-xl font-semibold transition-all transform hover:scale-105 active:scale-95 shadow-lg"
|
525 |
+
>
|
526 |
+
Try Again
|
527 |
+
</button>
|
528 |
+
</div>
|
529 |
+
)}
|
530 |
+
</div>
|
531 |
+
|
532 |
+
{/* Click-away fallback for touch devices */}
|
533 |
+
{isModelDropdownOpen && (
|
534 |
+
<div
|
535 |
+
className="fixed inset-0 z-40 bg-black/20"
|
536 |
+
onClick={(e) => {
|
537 |
+
const target = e.target as Node;
|
538 |
+
if (
|
539 |
+
dropdownRef.current &&
|
540 |
+
!dropdownRef.current.contains(target) &&
|
541 |
+
!dropdownBtnRef.current?.contains(target)
|
542 |
+
) {
|
543 |
+
setIsModelDropdownOpen(false);
|
544 |
+
}
|
545 |
+
}}
|
546 |
+
/>
|
547 |
+
)}
|
548 |
+
</div>
|
549 |
+
);
|
550 |
+
};
|
src/components/MCPServerManager.tsx
ADDED
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useState } from "react";
|
2 |
+
import { discoverOAuthEndpoints, startOAuthFlow } from "../services/oauth";
|
3 |
+
import { Plus, Server, Wifi, WifiOff, Trash2, TestTube } from "lucide-react";
|
4 |
+
import { useMCP } from "../hooks/useMCP";
|
5 |
+
import type { MCPServerConfig } from "../types/mcp";
|
6 |
+
import { STORAGE_KEYS, DEFAULTS } from "../config/constants";
|
7 |
+
|
8 |
+
interface MCPServerManagerProps {
|
9 |
+
isOpen: boolean;
|
10 |
+
onClose: () => void;
|
11 |
+
}
|
12 |
+
|
13 |
+
export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
|
14 |
+
isOpen,
|
15 |
+
onClose,
|
16 |
+
}) => {
|
17 |
+
const {
|
18 |
+
mcpState,
|
19 |
+
addServer,
|
20 |
+
removeServer,
|
21 |
+
connectToServer,
|
22 |
+
disconnectFromServer,
|
23 |
+
testConnection,
|
24 |
+
} = useMCP();
|
25 |
+
const [showAddForm, setShowAddForm] = useState(false);
|
26 |
+
const [testingConnection, setTestingConnection] = useState<string | null>(
|
27 |
+
null
|
28 |
+
);
|
29 |
+
const [notification, setNotification] = useState<{
|
30 |
+
message: string;
|
31 |
+
type: "success" | "error";
|
32 |
+
} | null>(null);
|
33 |
+
|
34 |
+
const [newServer, setNewServer] = useState<Omit<MCPServerConfig, "id">>({
|
35 |
+
name: "",
|
36 |
+
url: "",
|
37 |
+
enabled: true,
|
38 |
+
transport: "streamable-http",
|
39 |
+
auth: {
|
40 |
+
type: "bearer",
|
41 |
+
},
|
42 |
+
});
|
43 |
+
|
44 |
+
if (!isOpen) return null;
|
45 |
+
|
46 |
+
const handleAddServer = async () => {
|
47 |
+
if (!newServer.name || !newServer.url) return;
|
48 |
+
|
49 |
+
const serverConfig: MCPServerConfig = {
|
50 |
+
...newServer,
|
51 |
+
id: `server_${Date.now()}`,
|
52 |
+
};
|
53 |
+
|
54 |
+
// Persist name and transport for OAuth flow
|
55 |
+
localStorage.setItem(STORAGE_KEYS.MCP_SERVER_NAME, newServer.name);
|
56 |
+
localStorage.setItem(
|
57 |
+
STORAGE_KEYS.MCP_SERVER_TRANSPORT,
|
58 |
+
newServer.transport
|
59 |
+
);
|
60 |
+
|
61 |
+
try {
|
62 |
+
await addServer(serverConfig);
|
63 |
+
setNewServer({
|
64 |
+
name: "",
|
65 |
+
url: "",
|
66 |
+
enabled: true,
|
67 |
+
transport: "streamable-http",
|
68 |
+
auth: {
|
69 |
+
type: "bearer",
|
70 |
+
},
|
71 |
+
});
|
72 |
+
setShowAddForm(false);
|
73 |
+
} catch (error) {
|
74 |
+
setNotification({
|
75 |
+
message: `Failed to add server: ${
|
76 |
+
error instanceof Error ? error.message : "Unknown error"
|
77 |
+
}`,
|
78 |
+
type: "error",
|
79 |
+
});
|
80 |
+
setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
|
81 |
+
}
|
82 |
+
};
|
83 |
+
|
84 |
+
const handleTestConnection = async (config: MCPServerConfig) => {
|
85 |
+
setTestingConnection(config.id);
|
86 |
+
try {
|
87 |
+
const success = await testConnection(config);
|
88 |
+
if (success) {
|
89 |
+
setNotification({
|
90 |
+
message: "Connection test successful!",
|
91 |
+
type: "success",
|
92 |
+
});
|
93 |
+
} else {
|
94 |
+
setNotification({
|
95 |
+
message: "Connection test failed. Please check your configuration.",
|
96 |
+
type: "error",
|
97 |
+
});
|
98 |
+
}
|
99 |
+
} catch (error) {
|
100 |
+
setNotification({
|
101 |
+
message: `Connection test failed: ${error}`,
|
102 |
+
type: "error",
|
103 |
+
});
|
104 |
+
} finally {
|
105 |
+
setTestingConnection(null);
|
106 |
+
// Auto-hide notification after 3 seconds
|
107 |
+
setTimeout(() => setNotification(null), DEFAULTS.NOTIFICATION_TIMEOUT);
|
108 |
+
}
|
109 |
+
};
|
110 |
+
|
111 |
+
const handleToggleConnection = async (
|
112 |
+
serverId: string,
|
113 |
+
isConnected: boolean
|
114 |
+
) => {
|
115 |
+
try {
|
116 |
+
if (isConnected) {
|
117 |
+
await disconnectFromServer(serverId);
|
118 |
+
} else {
|
119 |
+
await connectToServer(serverId);
|
120 |
+
}
|
121 |
+
} catch (error) {
|
122 |
+
setNotification({
|
123 |
+
message: `Failed to toggle connection: ${
|
124 |
+
error instanceof Error ? error.message : "Unknown error"
|
125 |
+
}`,
|
126 |
+
type: "error",
|
127 |
+
});
|
128 |
+
setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
|
129 |
+
}
|
130 |
+
};
|
131 |
+
|
132 |
+
return (
|
133 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
134 |
+
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl max-h-[80vh] overflow-y-auto">
|
135 |
+
<div className="flex justify-between items-center mb-6">
|
136 |
+
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
137 |
+
<Server className="text-blue-400" />
|
138 |
+
MCP Server Manager
|
139 |
+
</h2>
|
140 |
+
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
141 |
+
✕
|
142 |
+
</button>
|
143 |
+
</div>
|
144 |
+
|
145 |
+
{/* Add Server Button */}
|
146 |
+
<div className="mb-6">
|
147 |
+
<button
|
148 |
+
onClick={() => setShowAddForm(!showAddForm)}
|
149 |
+
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
150 |
+
>
|
151 |
+
<Plus size={16} />
|
152 |
+
Add MCP Server
|
153 |
+
</button>
|
154 |
+
</div>
|
155 |
+
|
156 |
+
{/* Add Server Form */}
|
157 |
+
{showAddForm && (
|
158 |
+
<div className="bg-gray-700 rounded-lg p-4 mb-6">
|
159 |
+
<h3 className="text-lg font-semibold text-white mb-4">
|
160 |
+
Add New MCP Server
|
161 |
+
</h3>
|
162 |
+
<div className="space-y-4">
|
163 |
+
<div>
|
164 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
165 |
+
Server Name
|
166 |
+
</label>
|
167 |
+
<input
|
168 |
+
type="text"
|
169 |
+
value={newServer.name}
|
170 |
+
onChange={(e) =>
|
171 |
+
setNewServer({ ...newServer, name: e.target.value })
|
172 |
+
}
|
173 |
+
className="w-full bg-gray-600 text-white rounded px-3 py-2"
|
174 |
+
placeholder="My MCP Server"
|
175 |
+
/>
|
176 |
+
</div>
|
177 |
+
|
178 |
+
<div>
|
179 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
180 |
+
Server URL
|
181 |
+
</label>
|
182 |
+
<input
|
183 |
+
type="url"
|
184 |
+
value={newServer.url}
|
185 |
+
onChange={(e) =>
|
186 |
+
setNewServer({ ...newServer, url: e.target.value })
|
187 |
+
}
|
188 |
+
className="w-full bg-gray-600 text-white rounded px-3 py-2"
|
189 |
+
placeholder="http://localhost:3000/mcp"
|
190 |
+
/>
|
191 |
+
</div>
|
192 |
+
|
193 |
+
<div>
|
194 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
195 |
+
Transport
|
196 |
+
</label>
|
197 |
+
<select
|
198 |
+
value={newServer.transport}
|
199 |
+
onChange={(e) =>
|
200 |
+
setNewServer({
|
201 |
+
...newServer,
|
202 |
+
transport: e.target.value as MCPServerConfig["transport"],
|
203 |
+
})
|
204 |
+
}
|
205 |
+
className="w-full bg-gray-600 text-white rounded px-3 py-2"
|
206 |
+
>
|
207 |
+
<option value="streamable-http">Streamable HTTP</option>
|
208 |
+
<option value="sse">Server-Sent Events</option>
|
209 |
+
</select>
|
210 |
+
</div>
|
211 |
+
|
212 |
+
<div>
|
213 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
214 |
+
Authentication
|
215 |
+
</label>
|
216 |
+
<select
|
217 |
+
value={newServer.auth?.type || "none"}
|
218 |
+
onChange={(e) => {
|
219 |
+
const authType = e.target.value;
|
220 |
+
if (authType === "none") {
|
221 |
+
setNewServer({ ...newServer, auth: undefined });
|
222 |
+
} else {
|
223 |
+
setNewServer({
|
224 |
+
...newServer,
|
225 |
+
auth: {
|
226 |
+
type: authType as "bearer" | "basic" | "oauth",
|
227 |
+
...(authType === "bearer" ? { token: "" } : {}),
|
228 |
+
...(authType === "basic"
|
229 |
+
? { username: "", password: "" }
|
230 |
+
: {}),
|
231 |
+
...(authType === "oauth" ? { token: "" } : {}),
|
232 |
+
},
|
233 |
+
});
|
234 |
+
}
|
235 |
+
}}
|
236 |
+
className="w-full bg-gray-600 text-white rounded px-3 py-2"
|
237 |
+
>
|
238 |
+
<option value="none">No Authentication</option>
|
239 |
+
<option value="bearer">Bearer Token</option>
|
240 |
+
<option value="basic">Basic Auth</option>
|
241 |
+
<option value="oauth">OAuth Token</option>
|
242 |
+
</select>
|
243 |
+
</div>
|
244 |
+
|
245 |
+
{/* Auth-specific fields */}
|
246 |
+
{newServer.auth?.type === "bearer" && (
|
247 |
+
<div>
|
248 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
249 |
+
Bearer Token
|
250 |
+
</label>
|
251 |
+
<input
|
252 |
+
type="password"
|
253 |
+
value={newServer.auth.token || ""}
|
254 |
+
onChange={(e) =>
|
255 |
+
setNewServer({
|
256 |
+
...newServer,
|
257 |
+
auth: { ...newServer.auth!, token: e.target.value },
|
258 |
+
})
|
259 |
+
}
|
260 |
+
className="w-full bg-gray-600 text-white rounded px-3 py-2"
|
261 |
+
placeholder="your-bearer-token"
|
262 |
+
/>
|
263 |
+
</div>
|
264 |
+
)}
|
265 |
+
|
266 |
+
{newServer.auth?.type === "basic" && (
|
267 |
+
<>
|
268 |
+
<div>
|
269 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
270 |
+
Username
|
271 |
+
</label>
|
272 |
+
<input
|
273 |
+
type="text"
|
274 |
+
value={newServer.auth.username || ""}
|
275 |
+
onChange={(e) =>
|
276 |
+
setNewServer({
|
277 |
+
...newServer,
|
278 |
+
auth: {
|
279 |
+
...newServer.auth!,
|
280 |
+
username: e.target.value,
|
281 |
+
},
|
282 |
+
})
|
283 |
+
}
|
284 |
+
className="w-full bg-gray-600 text-white rounded px-3 py-2"
|
285 |
+
placeholder="username"
|
286 |
+
/>
|
287 |
+
</div>
|
288 |
+
<div>
|
289 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
290 |
+
Password
|
291 |
+
</label>
|
292 |
+
<input
|
293 |
+
type="password"
|
294 |
+
value={newServer.auth.password || ""}
|
295 |
+
onChange={(e) =>
|
296 |
+
setNewServer({
|
297 |
+
...newServer,
|
298 |
+
auth: {
|
299 |
+
...newServer.auth!,
|
300 |
+
password: e.target.value,
|
301 |
+
},
|
302 |
+
})
|
303 |
+
}
|
304 |
+
className="w-full bg-gray-600 text-white rounded px-3 py-2"
|
305 |
+
placeholder="password"
|
306 |
+
/>
|
307 |
+
</div>
|
308 |
+
</>
|
309 |
+
)}
|
310 |
+
|
311 |
+
{newServer.auth?.type === "oauth" && (
|
312 |
+
<div>
|
313 |
+
<label className="block text-sm font-medium text-gray-300 mb-1">
|
314 |
+
OAuth Authorization
|
315 |
+
</label>
|
316 |
+
<button
|
317 |
+
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded mb-2"
|
318 |
+
type="button"
|
319 |
+
onClick={async () => {
|
320 |
+
try {
|
321 |
+
// Persist name and transport for OAuthCallback
|
322 |
+
localStorage.setItem(
|
323 |
+
STORAGE_KEYS.MCP_SERVER_NAME,
|
324 |
+
newServer.name
|
325 |
+
);
|
326 |
+
localStorage.setItem(
|
327 |
+
STORAGE_KEYS.MCP_SERVER_TRANSPORT,
|
328 |
+
newServer.transport
|
329 |
+
);
|
330 |
+
const endpoints = await discoverOAuthEndpoints(
|
331 |
+
newServer.url
|
332 |
+
);
|
333 |
+
|
334 |
+
if (!endpoints.clientId || !endpoints.redirectUri) {
|
335 |
+
throw new Error(
|
336 |
+
"Missing required OAuth configuration (clientId or redirectUri)"
|
337 |
+
);
|
338 |
+
}
|
339 |
+
|
340 |
+
startOAuthFlow({
|
341 |
+
authorizationEndpoint:
|
342 |
+
endpoints.authorizationEndpoint,
|
343 |
+
clientId: endpoints.clientId as string,
|
344 |
+
redirectUri: endpoints.redirectUri as string,
|
345 |
+
scopes: (endpoints.scopes || []) as string[],
|
346 |
+
});
|
347 |
+
} catch (err) {
|
348 |
+
setNotification({
|
349 |
+
message:
|
350 |
+
"OAuth discovery failed: " +
|
351 |
+
(err instanceof Error ? err.message : String(err)),
|
352 |
+
type: "error",
|
353 |
+
});
|
354 |
+
setTimeout(
|
355 |
+
() => setNotification(null),
|
356 |
+
DEFAULTS.OAUTH_ERROR_TIMEOUT
|
357 |
+
);
|
358 |
+
}
|
359 |
+
}}
|
360 |
+
>
|
361 |
+
Connect with OAuth
|
362 |
+
</button>
|
363 |
+
<p className="text-xs text-gray-400">
|
364 |
+
You will be redirected to authorize this app with the MCP
|
365 |
+
server.
|
366 |
+
</p>
|
367 |
+
</div>
|
368 |
+
)}
|
369 |
+
|
370 |
+
<div className="flex items-center gap-2">
|
371 |
+
<input
|
372 |
+
type="checkbox"
|
373 |
+
id="enabled"
|
374 |
+
checked={newServer.enabled}
|
375 |
+
onChange={(e) =>
|
376 |
+
setNewServer({ ...newServer, enabled: e.target.checked })
|
377 |
+
}
|
378 |
+
className="rounded"
|
379 |
+
/>
|
380 |
+
<label htmlFor="enabled" className="text-sm text-gray-300">
|
381 |
+
Auto-connect when added
|
382 |
+
</label>
|
383 |
+
</div>
|
384 |
+
|
385 |
+
<div className="flex gap-2">
|
386 |
+
<button
|
387 |
+
onClick={handleAddServer}
|
388 |
+
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded"
|
389 |
+
>
|
390 |
+
Add Server
|
391 |
+
</button>
|
392 |
+
<button
|
393 |
+
onClick={() => setShowAddForm(false)}
|
394 |
+
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
|
395 |
+
>
|
396 |
+
Cancel
|
397 |
+
</button>
|
398 |
+
</div>
|
399 |
+
</div>
|
400 |
+
</div>
|
401 |
+
)}
|
402 |
+
|
403 |
+
{/* Server List */}
|
404 |
+
<div className="space-y-4">
|
405 |
+
<h3 className="text-lg font-semibold text-white">
|
406 |
+
Configured Servers
|
407 |
+
</h3>
|
408 |
+
|
409 |
+
{Object.values(mcpState.servers).length === 0 ? (
|
410 |
+
<div className="text-gray-400 text-center py-8">
|
411 |
+
No MCP servers configured. Add one to get started!
|
412 |
+
</div>
|
413 |
+
) : (
|
414 |
+
Object.values(mcpState.servers).map((connection) => (
|
415 |
+
<div
|
416 |
+
key={connection.config.id}
|
417 |
+
className="bg-gray-700 rounded-lg p-4"
|
418 |
+
>
|
419 |
+
<div className="flex items-center justify-between">
|
420 |
+
<div className="flex items-center gap-3">
|
421 |
+
<div
|
422 |
+
className={`w-3 h-3 rounded-full ${
|
423 |
+
connection.isConnected ? "bg-green-400" : "bg-red-400"
|
424 |
+
}`}
|
425 |
+
/>
|
426 |
+
<div>
|
427 |
+
<h4 className="text-white font-medium">
|
428 |
+
{connection.config.name}
|
429 |
+
</h4>
|
430 |
+
<p className="text-gray-400 text-sm">
|
431 |
+
{connection.config.url}
|
432 |
+
</p>
|
433 |
+
<p className="text-gray-500 text-xs">
|
434 |
+
Transport: {connection.config.transport}
|
435 |
+
{connection.config.auth &&
|
436 |
+
` • Auth: ${connection.config.auth.type}`}
|
437 |
+
{connection.isConnected &&
|
438 |
+
` • ${connection.tools.length} tools available`}
|
439 |
+
</p>
|
440 |
+
</div>
|
441 |
+
</div>
|
442 |
+
|
443 |
+
<div className="flex items-center gap-2">
|
444 |
+
{/* Test Connection */}
|
445 |
+
<button
|
446 |
+
onClick={() => handleTestConnection(connection.config)}
|
447 |
+
disabled={testingConnection === connection.config.id}
|
448 |
+
className="p-2 text-yellow-400 hover:text-yellow-300 disabled:opacity-50"
|
449 |
+
title="Test Connection"
|
450 |
+
>
|
451 |
+
<TestTube size={16} />
|
452 |
+
</button>
|
453 |
+
|
454 |
+
{/* Connect/Disconnect */}
|
455 |
+
<button
|
456 |
+
onClick={() =>
|
457 |
+
handleToggleConnection(
|
458 |
+
connection.config.id,
|
459 |
+
connection.isConnected
|
460 |
+
)
|
461 |
+
}
|
462 |
+
className={`p-2 ${
|
463 |
+
connection.isConnected
|
464 |
+
? "text-green-400 hover:text-green-300"
|
465 |
+
: "text-gray-400 hover:text-gray-300"
|
466 |
+
}`}
|
467 |
+
title={connection.isConnected ? "Disconnect" : "Connect"}
|
468 |
+
>
|
469 |
+
{connection.isConnected ? (
|
470 |
+
<Wifi size={16} />
|
471 |
+
) : (
|
472 |
+
<WifiOff size={16} />
|
473 |
+
)}
|
474 |
+
</button>
|
475 |
+
|
476 |
+
{/* Remove Server */}
|
477 |
+
<button
|
478 |
+
onClick={() => removeServer(connection.config.id)}
|
479 |
+
className="p-2 text-red-400 hover:text-red-300"
|
480 |
+
title="Remove Server"
|
481 |
+
>
|
482 |
+
<Trash2 size={16} />
|
483 |
+
</button>
|
484 |
+
</div>
|
485 |
+
</div>
|
486 |
+
|
487 |
+
{connection.lastError && (
|
488 |
+
<div className="mt-2 text-red-400 text-sm">
|
489 |
+
Error: {connection.lastError}
|
490 |
+
</div>
|
491 |
+
)}
|
492 |
+
|
493 |
+
{connection.isConnected && connection.tools.length > 0 && (
|
494 |
+
<div className="mt-3">
|
495 |
+
<details className="text-sm">
|
496 |
+
<summary className="text-gray-300 cursor-pointer">
|
497 |
+
Available Tools ({connection.tools.length})
|
498 |
+
</summary>
|
499 |
+
<div className="mt-2 space-y-1">
|
500 |
+
{connection.tools.map((tool) => (
|
501 |
+
<div key={tool.name} className="text-gray-400 pl-4">
|
502 |
+
• {tool.name} -{" "}
|
503 |
+
{tool.description || "No description"}
|
504 |
+
</div>
|
505 |
+
))}
|
506 |
+
</div>
|
507 |
+
</details>
|
508 |
+
</div>
|
509 |
+
)}
|
510 |
+
</div>
|
511 |
+
))
|
512 |
+
)}
|
513 |
+
</div>
|
514 |
+
|
515 |
+
{mcpState.error && (
|
516 |
+
<div className="mt-4 p-4 bg-red-900 border border-red-700 rounded-lg text-red-200">
|
517 |
+
<strong>Error:</strong> {mcpState.error}
|
518 |
+
</div>
|
519 |
+
)}
|
520 |
+
|
521 |
+
{notification && (
|
522 |
+
<div
|
523 |
+
className={`mt-4 p-4 border rounded-lg ${
|
524 |
+
notification.type === "success"
|
525 |
+
? "bg-green-900 border-green-700 text-green-200"
|
526 |
+
: "bg-red-900 border-red-700 text-red-200"
|
527 |
+
}`}
|
528 |
+
>
|
529 |
+
{notification.message}
|
530 |
+
</div>
|
531 |
+
)}
|
532 |
+
</div>
|
533 |
+
</div>
|
534 |
+
);
|
535 |
+
};
|
src/components/OAuthCallback.tsx
ADDED
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect, useState } from "react";
|
2 |
+
import { useNavigate } from "react-router-dom";
|
3 |
+
import { exchangeCodeForToken } from "../services/oauth";
|
4 |
+
import { secureStorage } from "../utils/storage";
|
5 |
+
import type { MCPServerConfig } from "../types/mcp";
|
6 |
+
import { STORAGE_KEYS, DEFAULTS } from "../config/constants";
|
7 |
+
|
8 |
+
interface OAuthTokens {
|
9 |
+
access_token: string;
|
10 |
+
refresh_token?: string;
|
11 |
+
expires_in?: number;
|
12 |
+
token_type?: string;
|
13 |
+
[key: string]: string | number | undefined;
|
14 |
+
}
|
15 |
+
|
16 |
+
interface OAuthCallbackProps {
|
17 |
+
serverUrl: string;
|
18 |
+
onSuccess?: (tokens: OAuthTokens) => void;
|
19 |
+
onError?: (error: Error) => void;
|
20 |
+
}
|
21 |
+
|
22 |
+
const OAuthCallback: React.FC<OAuthCallbackProps> = ({
|
23 |
+
serverUrl,
|
24 |
+
onSuccess,
|
25 |
+
onError,
|
26 |
+
}) => {
|
27 |
+
const [status, setStatus] = useState<string>("Authorizing...");
|
28 |
+
const navigate = useNavigate(); // Add this hook
|
29 |
+
|
30 |
+
useEffect(() => {
|
31 |
+
// Parse parameters from URL search params (OAuth providers send code in query string)
|
32 |
+
const parseHashParams = () => {
|
33 |
+
return new URLSearchParams(window.location.search);
|
34 |
+
};
|
35 |
+
|
36 |
+
const params = parseHashParams();
|
37 |
+
const code = params.get("code");
|
38 |
+
const state = params.get("state");
|
39 |
+
const error = params.get("error");
|
40 |
+
|
41 |
+
// Verify state parameter for CSRF protection
|
42 |
+
const savedState = localStorage.getItem('oauth_state');
|
43 |
+
if (state !== savedState) {
|
44 |
+
setStatus("Invalid state parameter. Possible CSRF attack.");
|
45 |
+
if (onError) onError(new Error("Invalid state parameter"));
|
46 |
+
return;
|
47 |
+
}
|
48 |
+
|
49 |
+
// Check for OAuth errors
|
50 |
+
if (error) {
|
51 |
+
const errorDescription = params.get("error_description") || error;
|
52 |
+
setStatus(`OAuth error: ${errorDescription}`);
|
53 |
+
if (onError) onError(new Error(errorDescription));
|
54 |
+
return;
|
55 |
+
}
|
56 |
+
|
57 |
+
// Always persist MCP server URL for robustness
|
58 |
+
localStorage.setItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL, serverUrl);
|
59 |
+
|
60 |
+
if (code) {
|
61 |
+
exchangeCodeForToken({
|
62 |
+
serverUrl,
|
63 |
+
code,
|
64 |
+
redirectUri: window.location.origin + "/#" + DEFAULTS.OAUTH_REDIRECT_PATH, // Add hash
|
65 |
+
})
|
66 |
+
.then(async (tokens) => {
|
67 |
+
await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
|
68 |
+
|
69 |
+
// Add MCP server to MCPClientService for UI
|
70 |
+
const mcpServerUrl = localStorage.getItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
|
71 |
+
if (mcpServerUrl) {
|
72 |
+
const serverName =
|
73 |
+
localStorage.getItem(STORAGE_KEYS.MCP_SERVER_NAME) || mcpServerUrl;
|
74 |
+
const serverTransport =
|
75 |
+
(localStorage.getItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT) as MCPServerConfig['transport']) || DEFAULTS.MCP_TRANSPORT;
|
76 |
+
|
77 |
+
const serverConfig = {
|
78 |
+
id: `server_${Date.now()}`,
|
79 |
+
name: serverName,
|
80 |
+
url: mcpServerUrl,
|
81 |
+
enabled: true,
|
82 |
+
transport: serverTransport,
|
83 |
+
auth: {
|
84 |
+
type: "bearer" as const,
|
85 |
+
token: tokens.access_token,
|
86 |
+
},
|
87 |
+
};
|
88 |
+
|
89 |
+
let servers: MCPServerConfig[] = [];
|
90 |
+
try {
|
91 |
+
const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
|
92 |
+
if (stored) servers = JSON.parse(stored);
|
93 |
+
} catch {}
|
94 |
+
|
95 |
+
const exists = servers.some((s: MCPServerConfig) => s.url === mcpServerUrl);
|
96 |
+
if (!exists) {
|
97 |
+
servers.push(serverConfig);
|
98 |
+
localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(servers));
|
99 |
+
}
|
100 |
+
|
101 |
+
// Clear temp values
|
102 |
+
localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_NAME);
|
103 |
+
localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT);
|
104 |
+
localStorage.removeItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
|
105 |
+
}
|
106 |
+
|
107 |
+
// Clear OAuth state
|
108 |
+
localStorage.removeItem('oauth_state');
|
109 |
+
|
110 |
+
setStatus("Authorization successful! Redirecting...");
|
111 |
+
if (onSuccess) onSuccess(tokens);
|
112 |
+
|
113 |
+
// Use React Router navigation instead of window.location.replace
|
114 |
+
setTimeout(() => {
|
115 |
+
navigate("/", { replace: true });
|
116 |
+
}, 1000);
|
117 |
+
})
|
118 |
+
.catch((err) => {
|
119 |
+
setStatus("OAuth token exchange failed: " + err.message);
|
120 |
+
if (onError) onError(err);
|
121 |
+
// Clear OAuth state on error
|
122 |
+
localStorage.removeItem('oauth_state');
|
123 |
+
});
|
124 |
+
} else {
|
125 |
+
setStatus("Missing authorization code in callback URL.");
|
126 |
+
if (onError) onError(new Error("Missing authorization code"));
|
127 |
+
}
|
128 |
+
}, [serverUrl, onSuccess, onError, navigate]);
|
129 |
+
|
130 |
+
return (
|
131 |
+
<div className="flex items-center justify-center min-h-screen">
|
132 |
+
<div className="text-center">
|
133 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
134 |
+
<p>{status}</p>
|
135 |
+
</div>
|
136 |
+
</div>
|
137 |
+
);
|
138 |
+
};
|
139 |
+
|
140 |
+
export default OAuthCallback;
|
src/components/ResultBlock.tsx
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
|
3 |
+
interface ResultBlockProps {
|
4 |
+
error?: string;
|
5 |
+
result?: unknown;
|
6 |
+
}
|
7 |
+
|
8 |
+
const ResultBlock: React.FC<ResultBlockProps> = ({
|
9 |
+
error,
|
10 |
+
result,
|
11 |
+
}) => (
|
12 |
+
<div
|
13 |
+
className={
|
14 |
+
error
|
15 |
+
? "bg-red-900 border border-red-600 rounded p-3"
|
16 |
+
: "bg-gray-700 border border-gray-600 rounded p-3"
|
17 |
+
}
|
18 |
+
>
|
19 |
+
{error ? <p className="text-red-300 text-sm">Error: {error}</p> : null}
|
20 |
+
<pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto mt-2">
|
21 |
+
{result !== undefined && result !== null
|
22 |
+
? (typeof result === "object" ? JSON.stringify(result, null, 2) : String(result))
|
23 |
+
: "No result"}
|
24 |
+
</pre>
|
25 |
+
</div>
|
26 |
+
);
|
27 |
+
|
28 |
+
export default ResultBlock;
|
src/components/ToolCallIndicator.tsx
ADDED
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
import { extractToolCallContent } from "../utils";
|
3 |
+
|
4 |
+
const ToolCallIndicator: React.FC<{
|
5 |
+
content: string;
|
6 |
+
isRunning: boolean;
|
7 |
+
hasError: boolean;
|
8 |
+
}> = ({ content, isRunning, hasError }) => (
|
9 |
+
<div
|
10 |
+
className={`transition-all duration-500 ease-in-out rounded-lg p-4 ${
|
11 |
+
isRunning
|
12 |
+
? "bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/50"
|
13 |
+
: hasError
|
14 |
+
? "bg-gradient-to-r from-red-900/30 to-rose-900/30 border border-red-600/50"
|
15 |
+
: "bg-gradient-to-r from-green-900/30 to-emerald-900/30 border border-green-600/50"
|
16 |
+
}`}
|
17 |
+
>
|
18 |
+
<div className="flex items-start space-x-3">
|
19 |
+
<div className="flex-shrink-0">
|
20 |
+
<div className="relative w-6 h-6">
|
21 |
+
{/* Spinner for running */}
|
22 |
+
<div
|
23 |
+
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-500 ${
|
24 |
+
isRunning ? "opacity-100" : "opacity-0 pointer-events-none"
|
25 |
+
}`}
|
26 |
+
>
|
27 |
+
<div className="w-6 h-6 bg-green-400/0 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin"></div>
|
28 |
+
</div>
|
29 |
+
|
30 |
+
{/* Cross for error */}
|
31 |
+
<div
|
32 |
+
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-500 ${
|
33 |
+
hasError ? "opacity-100" : "opacity-0 pointer-events-none"
|
34 |
+
}`}
|
35 |
+
>
|
36 |
+
<div className="w-6 h-6 bg-red-400/100 rounded-full flex items-center justify-center transition-colors duration-500 ease-in-out">
|
37 |
+
<span className="text-xs text-gray-900 font-bold">✗</span>
|
38 |
+
</div>
|
39 |
+
</div>
|
40 |
+
|
41 |
+
{/* Tick for success */}
|
42 |
+
<div
|
43 |
+
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-500 ${
|
44 |
+
!isRunning && !hasError
|
45 |
+
? "opacity-100"
|
46 |
+
: "opacity-0 pointer-events-none"
|
47 |
+
}`}
|
48 |
+
>
|
49 |
+
<div className="w-6 h-6 bg-green-400/100 rounded-full flex items-center justify-center transition-colors duration-500 ease-in-out">
|
50 |
+
<span className="text-xs text-gray-900 font-bold">✓</span>
|
51 |
+
</div>
|
52 |
+
</div>
|
53 |
+
</div>
|
54 |
+
</div>
|
55 |
+
<div className="flex-grow min-w-0">
|
56 |
+
<div className="flex items-center space-x-2 mb-2">
|
57 |
+
<span
|
58 |
+
className={`font-semibold text-sm transition-colors duration-500 ease-in-out ${
|
59 |
+
isRunning
|
60 |
+
? "text-yellow-400"
|
61 |
+
: hasError
|
62 |
+
? "text-red-400"
|
63 |
+
: "text-green-400"
|
64 |
+
}`}
|
65 |
+
>
|
66 |
+
🔧 Tool Call
|
67 |
+
</span>
|
68 |
+
{isRunning && (
|
69 |
+
<span className="text-yellow-300 text-xs animate-pulse">
|
70 |
+
Running...
|
71 |
+
</span>
|
72 |
+
)}
|
73 |
+
</div>
|
74 |
+
<div className="bg-gray-800/50 rounded p-2 mb-2">
|
75 |
+
<code className="text-xs text-gray-300 font-mono break-all">
|
76 |
+
{extractToolCallContent(content) ?? "..."}
|
77 |
+
</code>
|
78 |
+
</div>
|
79 |
+
<p
|
80 |
+
className={`text-xs transition-colors duration-500 ease-in-out ${
|
81 |
+
isRunning
|
82 |
+
? "text-yellow-200"
|
83 |
+
: hasError
|
84 |
+
? "text-red-200"
|
85 |
+
: "text-green-200"
|
86 |
+
}`}
|
87 |
+
>
|
88 |
+
{isRunning
|
89 |
+
? "Executing tool call..."
|
90 |
+
: hasError
|
91 |
+
? "Tool call failed"
|
92 |
+
: "Tool call completed"}
|
93 |
+
</p>
|
94 |
+
</div>
|
95 |
+
</div>
|
96 |
+
</div>
|
97 |
+
);
|
98 |
+
export default ToolCallIndicator;
|
src/components/ToolItem.tsx
ADDED
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import Editor from "@monaco-editor/react";
|
2 |
+
import { ChevronUp, ChevronDown, Trash2, Power } from "lucide-react";
|
3 |
+
import { useMemo } from "react";
|
4 |
+
|
5 |
+
import { extractFunctionAndRenderer, generateSchemaFromCode } from "../utils";
|
6 |
+
|
7 |
+
export interface Tool {
|
8 |
+
id: number;
|
9 |
+
name: string;
|
10 |
+
code: string;
|
11 |
+
enabled: boolean;
|
12 |
+
isCollapsed?: boolean;
|
13 |
+
renderer?: string;
|
14 |
+
}
|
15 |
+
|
16 |
+
interface ToolItemProps {
|
17 |
+
tool: Tool;
|
18 |
+
onToggleEnabled: () => void;
|
19 |
+
onToggleCollapsed: () => void;
|
20 |
+
onExpand: () => void;
|
21 |
+
onDelete: () => void;
|
22 |
+
onCodeChange: (newCode: string) => void;
|
23 |
+
}
|
24 |
+
|
25 |
+
const ToolItem: React.FC<ToolItemProps> = ({
|
26 |
+
tool,
|
27 |
+
onToggleEnabled,
|
28 |
+
onToggleCollapsed,
|
29 |
+
onDelete,
|
30 |
+
onCodeChange,
|
31 |
+
}) => {
|
32 |
+
const { functionCode } = extractFunctionAndRenderer(tool.code);
|
33 |
+
const schema = useMemo(
|
34 |
+
() => generateSchemaFromCode(functionCode),
|
35 |
+
[functionCode],
|
36 |
+
);
|
37 |
+
|
38 |
+
return (
|
39 |
+
<div
|
40 |
+
className={`bg-gray-700 rounded-lg p-4 transition-all ${!tool.enabled ? "opacity-50 grayscale" : ""}`}
|
41 |
+
>
|
42 |
+
<div
|
43 |
+
className="flex justify-between items-center cursor-pointer"
|
44 |
+
onClick={onToggleCollapsed}
|
45 |
+
>
|
46 |
+
<div>
|
47 |
+
<h3 className="text-lg font-bold text-teal-300 font-mono">
|
48 |
+
{schema.name}
|
49 |
+
</h3>
|
50 |
+
<div className="text-xs text-gray-300 mt-1">{schema.description}</div>
|
51 |
+
</div>
|
52 |
+
<div className="flex items-center space-x-3">
|
53 |
+
<button
|
54 |
+
onClick={(e) => {
|
55 |
+
e.stopPropagation();
|
56 |
+
onToggleEnabled();
|
57 |
+
}}
|
58 |
+
className={`p-1 rounded-full ${tool.enabled ? "text-green-400 hover:bg-green-900" : "text-red-400 hover:bg-red-900"}`}
|
59 |
+
>
|
60 |
+
<Power size={18} />
|
61 |
+
</button>
|
62 |
+
<button
|
63 |
+
onClick={(e) => {
|
64 |
+
e.stopPropagation();
|
65 |
+
onDelete();
|
66 |
+
}}
|
67 |
+
className="p-2 text-gray-400 hover:text-red-500 hover:bg-gray-600 rounded-lg"
|
68 |
+
>
|
69 |
+
<Trash2 size={18} />
|
70 |
+
</button>
|
71 |
+
<button
|
72 |
+
onClick={(e) => {
|
73 |
+
e.stopPropagation();
|
74 |
+
onToggleCollapsed();
|
75 |
+
}}
|
76 |
+
className="p-2 text-gray-400 hover:text-white"
|
77 |
+
>
|
78 |
+
{tool.isCollapsed ? (
|
79 |
+
<ChevronDown size={20} />
|
80 |
+
) : (
|
81 |
+
<ChevronUp size={20} />
|
82 |
+
)}
|
83 |
+
</button>
|
84 |
+
</div>
|
85 |
+
</div>
|
86 |
+
{!tool.isCollapsed && (
|
87 |
+
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
88 |
+
<div className="md:col-span-2">
|
89 |
+
<label className="text-sm font-bold text-gray-400">
|
90 |
+
Implementation & Renderer
|
91 |
+
</label>
|
92 |
+
<div
|
93 |
+
className="mt-1 rounded-md overflow-visible border border-gray-600"
|
94 |
+
style={{ overflow: "visible" }}
|
95 |
+
>
|
96 |
+
<Editor
|
97 |
+
height="300px"
|
98 |
+
language="javascript"
|
99 |
+
theme="vs-dark"
|
100 |
+
value={tool.code}
|
101 |
+
onChange={(value) => onCodeChange(value || "")}
|
102 |
+
options={{
|
103 |
+
minimap: { enabled: false },
|
104 |
+
scrollbar: { verticalScrollbarSize: 10 },
|
105 |
+
fontSize: 14,
|
106 |
+
lineDecorationsWidth: 0,
|
107 |
+
lineNumbersMinChars: 3,
|
108 |
+
scrollBeyondLastLine: false,
|
109 |
+
}}
|
110 |
+
/>
|
111 |
+
</div>
|
112 |
+
</div>
|
113 |
+
<div className="flex flex-col">
|
114 |
+
<label className="text-sm font-bold text-gray-400">
|
115 |
+
Generated Schema
|
116 |
+
</label>
|
117 |
+
<div className="mt-1 rounded-md flex-grow overflow-visible border border-gray-600">
|
118 |
+
<Editor
|
119 |
+
height="300px"
|
120 |
+
language="json"
|
121 |
+
theme="vs-dark"
|
122 |
+
value={JSON.stringify(schema, null, 2)}
|
123 |
+
options={{
|
124 |
+
readOnly: true,
|
125 |
+
minimap: { enabled: false },
|
126 |
+
scrollbar: { verticalScrollbarSize: 10 },
|
127 |
+
lineNumbers: "off",
|
128 |
+
glyphMargin: false,
|
129 |
+
folding: false,
|
130 |
+
lineDecorationsWidth: 0,
|
131 |
+
lineNumbersMinChars: 0,
|
132 |
+
scrollBeyondLastLine: false,
|
133 |
+
fontSize: 12,
|
134 |
+
}}
|
135 |
+
/>
|
136 |
+
</div>
|
137 |
+
</div>
|
138 |
+
</div>
|
139 |
+
)}
|
140 |
+
</div>
|
141 |
+
);
|
142 |
+
};
|
143 |
+
|
144 |
+
export default ToolItem;
|
src/components/ToolResultRenderer.tsx
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import ResultBlock from "./ResultBlock";
|
3 |
+
|
4 |
+
interface ToolResultRendererProps {
|
5 |
+
result: unknown;
|
6 |
+
rendererCode?: string;
|
7 |
+
input?: unknown;
|
8 |
+
}
|
9 |
+
|
10 |
+
const ToolResultRenderer: React.FC<ToolResultRendererProps> = ({ result, rendererCode, input }) => {
|
11 |
+
if (!rendererCode) {
|
12 |
+
return <ResultBlock result={result} />;
|
13 |
+
}
|
14 |
+
|
15 |
+
try {
|
16 |
+
const exportMatch = rendererCode.match(/export\s+default\s+(.*)/s);
|
17 |
+
if (!exportMatch) {
|
18 |
+
throw new Error("Invalid renderer format - no export default found");
|
19 |
+
}
|
20 |
+
|
21 |
+
const componentCode = exportMatch[1].trim();
|
22 |
+
const componentFunction = new Function(
|
23 |
+
"React",
|
24 |
+
"input",
|
25 |
+
"output",
|
26 |
+
`
|
27 |
+
const { createElement: h, Fragment } = React;
|
28 |
+
const JSXComponent = ${componentCode};
|
29 |
+
return JSXComponent(input, output);
|
30 |
+
`,
|
31 |
+
);
|
32 |
+
|
33 |
+
const element = componentFunction(React, input || {}, result);
|
34 |
+
return element;
|
35 |
+
} catch (error) {
|
36 |
+
return (
|
37 |
+
<ResultBlock
|
38 |
+
error={error instanceof Error ? error.message : "Unknown error"}
|
39 |
+
result={result}
|
40 |
+
/>
|
41 |
+
);
|
42 |
+
}
|
43 |
+
};
|
44 |
+
export default ToolResultRenderer;
|
src/components/icons/HfLogo.tsx
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
|
3 |
+
export default (props: React.SVGProps<SVGSVGElement>) => (
|
4 |
+
<svg
|
5 |
+
{...props}
|
6 |
+
xmlns="http://www.w3.org/2000/svg"
|
7 |
+
viewBox="0 0 24 24"
|
8 |
+
fill="currentColor"
|
9 |
+
>
|
10 |
+
<path
|
11 |
+
d="M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z"
|
12 |
+
fill="#FF9D0B"
|
13 |
+
></path>
|
14 |
+
<path
|
15 |
+
d="M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z"
|
16 |
+
fill="#FFD21E"
|
17 |
+
></path>
|
18 |
+
<path
|
19 |
+
d="M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z"
|
20 |
+
fill="#FF323D"
|
21 |
+
></path>
|
22 |
+
<path
|
23 |
+
d="M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z"
|
24 |
+
fill="#3A3B45"
|
25 |
+
></path>
|
26 |
+
<path
|
27 |
+
d="M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z"
|
28 |
+
fill="#FF9D0B"
|
29 |
+
></path>
|
30 |
+
<path
|
31 |
+
d="M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z"
|
32 |
+
fill="#FFD21E"
|
33 |
+
></path>
|
34 |
+
</svg>
|
35 |
+
);
|
src/components/icons/LiquidAILogo.tsx
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
|
3 |
+
export default (props: React.SVGProps<SVGSVGElement>) => (
|
4 |
+
<svg
|
5 |
+
{...props}
|
6 |
+
xmlns="http://www.w3.org/2000/svg"
|
7 |
+
viewBox="0 0 24 24"
|
8 |
+
fill="currentColor"
|
9 |
+
>
|
10 |
+
<path d="M12.028 8.546l-.008.005 3.03 5.25a3.94 3.94 0 01.643 2.162c0 .754-.212 1.46-.58 2.062l6.173-1.991L11.63 0 9.304 3.872l2.724 4.674zM6.837 24l4.85-4.053h-.013c-2.219 0-4.017-1.784-4.017-3.984 0-.794.235-1.534.64-2.156l2.865-4.976-2.381-4.087L2 16.034 6.83 24h.007zM13.737 19.382h-.001L8.222 24h8.182l4.148-6.769-6.815 2.151z"></path>
|
11 |
+
</svg>
|
12 |
+
);
|
src/components/icons/MCPLogo.tsx
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
|
3 |
+
const MCPLogo = ({
|
4 |
+
className = "",
|
5 |
+
...props
|
6 |
+
}: React.SVGProps<SVGSVGElement>) => (
|
7 |
+
<svg
|
8 |
+
viewBox="0 0 180 180"
|
9 |
+
fill="none"
|
10 |
+
xmlns="http://www.w3.org/2000/svg"
|
11 |
+
className={className}
|
12 |
+
{...props}
|
13 |
+
>
|
14 |
+
<path
|
15 |
+
d="M23.5996 85.2532L86.2021 22.6507C94.8457 14.0071 108.86 14.0071 117.503 22.6507C126.147 31.2942 126.147 45.3083 117.503 53.9519L70.2254 101.23"
|
16 |
+
stroke="currentColor"
|
17 |
+
strokeWidth="11.0667"
|
18 |
+
strokeLinecap="round"
|
19 |
+
/>
|
20 |
+
<path
|
21 |
+
d="M70.8789 100.578L117.504 53.952C126.148 45.3083 140.163 45.3083 148.806 53.952L149.132 54.278C157.776 62.9216 157.776 76.9357 149.132 85.5792L92.5139 142.198C89.6327 145.079 89.6327 149.75 92.5139 152.631L104.14 164.257"
|
22 |
+
stroke="currentColor"
|
23 |
+
strokeWidth="11.0667"
|
24 |
+
strokeLinecap="round"
|
25 |
+
/>
|
26 |
+
<path
|
27 |
+
d="M101.853 38.3013L55.553 84.6011C46.9094 93.2447 46.9094 107.258 55.553 115.902C64.1966 124.546 78.2106 124.546 86.8543 115.902L133.154 69.6025"
|
28 |
+
stroke="currentColor"
|
29 |
+
strokeWidth="11.0667"
|
30 |
+
strokeLinecap="round"
|
31 |
+
/>
|
32 |
+
</svg>
|
33 |
+
);
|
34 |
+
|
35 |
+
export default MCPLogo;
|
src/config/constants.ts
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Application configuration constants
|
3 |
+
*/
|
4 |
+
|
5 |
+
// MCP Client Configuration
|
6 |
+
export const MCP_CLIENT_CONFIG = {
|
7 |
+
NAME: "LFM2-WebGPU",
|
8 |
+
VERSION: "1.0.0",
|
9 |
+
TEST_CLIENT_NAME: "LFM2-WebGPU-Test",
|
10 |
+
} as const;
|
11 |
+
|
12 |
+
// Storage Keys
|
13 |
+
export const STORAGE_KEYS = {
|
14 |
+
MCP_SERVERS: "mcp-servers",
|
15 |
+
OAUTH_CLIENT_ID: "oauth_client_id",
|
16 |
+
OAUTH_CLIENT_SECRET: "oauth_client_secret",
|
17 |
+
OAUTH_AUTHORIZATION_ENDPOINT: "oauth_authorization_endpoint",
|
18 |
+
OAUTH_TOKEN_ENDPOINT: "oauth_token_endpoint",
|
19 |
+
OAUTH_REDIRECT_URI: "oauth_redirect_uri",
|
20 |
+
OAUTH_RESOURCE: "oauth_resource",
|
21 |
+
OAUTH_ACCESS_TOKEN: "oauth_access_token",
|
22 |
+
OAUTH_CODE_VERIFIER: "oauth_code_verifier",
|
23 |
+
OAUTH_MCP_SERVER_URL: "oauth_mcp_server_url",
|
24 |
+
OAUTH_AUTHORIZATION_SERVER_METADATA: "oauth_authorization_server_metadata",
|
25 |
+
MCP_SERVER_NAME: "mcp_server_name",
|
26 |
+
MCP_SERVER_TRANSPORT: "mcp_server_transport",
|
27 |
+
} as const;
|
28 |
+
|
29 |
+
// Default Values
|
30 |
+
export const DEFAULTS = {
|
31 |
+
MCP_TRANSPORT: "streamable-http" as const,
|
32 |
+
OAUTH_REDIRECT_PATH: "/oauth/callback",
|
33 |
+
NOTIFICATION_TIMEOUT: 3000,
|
34 |
+
OAUTH_ERROR_TIMEOUT: 5000,
|
35 |
+
} as const;
|
src/constants/db.ts
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
export const DB_NAME = "tool-caller-db";
|
2 |
+
export const STORE_NAME = "tools";
|
3 |
+
export const SETTINGS_STORE_NAME = "settings";
|
src/constants/examples.ts
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface Example {
|
2 |
+
icon: string;
|
3 |
+
displayText: string;
|
4 |
+
messageText: string;
|
5 |
+
}
|
6 |
+
|
7 |
+
export const DEFAULT_EXAMPLES: Example[] = [
|
8 |
+
{
|
9 |
+
icon: "🌍",
|
10 |
+
displayText: "Where am I and what time is it?",
|
11 |
+
messageText: "Where am I and what time is it?",
|
12 |
+
},
|
13 |
+
{
|
14 |
+
icon: "👋",
|
15 |
+
displayText: "Say hello",
|
16 |
+
messageText: "Say hello",
|
17 |
+
},
|
18 |
+
{
|
19 |
+
icon: "🔢",
|
20 |
+
displayText: "Solve a math problem",
|
21 |
+
messageText: "What is 123 plus 15% of 200 all divided by 7?",
|
22 |
+
},
|
23 |
+
{
|
24 |
+
icon: "😴",
|
25 |
+
displayText: "Sleep for 3 seconds",
|
26 |
+
messageText: "Sleep for 3 seconds",
|
27 |
+
},
|
28 |
+
{
|
29 |
+
icon: "🎲",
|
30 |
+
displayText: "Generate a random number",
|
31 |
+
messageText: "Generate a random number between 1 and 100.",
|
32 |
+
},
|
33 |
+
{
|
34 |
+
icon: "📹",
|
35 |
+
displayText: "Play a video",
|
36 |
+
messageText:
|
37 |
+
'Open the following webpage: "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1".',
|
38 |
+
},
|
39 |
+
];
|
src/constants/models.ts
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const MODEL_OPTIONS = [
|
2 |
+
{ id: "350M", label: "LFM2-350M", size: "350M parameters (312 MB)" },
|
3 |
+
{ id: "700M", label: "LFM2-700M", size: "700M parameters (579 MB)" },
|
4 |
+
{ id: "1.2B", label: "LFM2-1.2B", size: "1.2B parameters (868 MB)" },
|
5 |
+
];
|
src/constants/systemPrompt.ts
ADDED
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export const DEFAULT_SYSTEM_PROMPT = [
|
2 |
+
"You are an AI assistant with access to a set of tools.",
|
3 |
+
"When a user asks a question, determine if a tool should be called to help answer.",
|
4 |
+
"If a tool is needed, respond with a tool call using the following format: ",
|
5 |
+
"<|tool_call_start|>[tool_function_call_1, tool_function_call_2, ...]<|tool_call_end|>.",
|
6 |
+
'Each tool function call should use Python-like syntax, e.g., speak("Hello"), random_number(min=1, max=10).',
|
7 |
+
"If no tool is needed, you should answer the user directly without calling any tools.",
|
8 |
+
"Always use the most relevant tool(s) for the user's request.",
|
9 |
+
"If a tool returns an error, explain the error to the user.",
|
10 |
+
"Be concise and helpful.",
|
11 |
+
].join(" ");
|
src/hooks/useLLM.ts
ADDED
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useRef, useCallback } from "react";
|
2 |
+
import {
|
3 |
+
AutoModelForCausalLM,
|
4 |
+
AutoTokenizer,
|
5 |
+
TextStreamer,
|
6 |
+
} from "@huggingface/transformers";
|
7 |
+
|
8 |
+
interface LLMState {
|
9 |
+
isLoading: boolean;
|
10 |
+
isReady: boolean;
|
11 |
+
error: string | null;
|
12 |
+
progress: number;
|
13 |
+
}
|
14 |
+
|
15 |
+
interface LLMInstance {
|
16 |
+
model: any;
|
17 |
+
tokenizer: any;
|
18 |
+
}
|
19 |
+
|
20 |
+
let moduleCache: {
|
21 |
+
[modelId: string]: {
|
22 |
+
instance: LLMInstance | null;
|
23 |
+
loadingPromise: Promise<LLMInstance> | null;
|
24 |
+
};
|
25 |
+
} = {};
|
26 |
+
|
27 |
+
export const useLLM = (modelId?: string) => {
|
28 |
+
const [state, setState] = useState<LLMState>({
|
29 |
+
isLoading: false,
|
30 |
+
isReady: false,
|
31 |
+
error: null,
|
32 |
+
progress: 0,
|
33 |
+
});
|
34 |
+
|
35 |
+
const instanceRef = useRef<LLMInstance | null>(null);
|
36 |
+
const loadingPromiseRef = useRef<Promise<LLMInstance> | null>(null);
|
37 |
+
|
38 |
+
const abortControllerRef = useRef<AbortController | null>(null);
|
39 |
+
const pastKeyValuesRef = useRef<any>(null);
|
40 |
+
|
41 |
+
const loadModel = useCallback(async () => {
|
42 |
+
if (!modelId) {
|
43 |
+
throw new Error("Model ID is required");
|
44 |
+
}
|
45 |
+
|
46 |
+
const MODEL_ID = `onnx-community/LFM2-${modelId}-ONNX`;
|
47 |
+
|
48 |
+
if (!moduleCache[modelId]) {
|
49 |
+
moduleCache[modelId] = {
|
50 |
+
instance: null,
|
51 |
+
loadingPromise: null,
|
52 |
+
};
|
53 |
+
}
|
54 |
+
|
55 |
+
const cache = moduleCache[modelId];
|
56 |
+
|
57 |
+
const existingInstance = instanceRef.current || cache.instance;
|
58 |
+
if (existingInstance) {
|
59 |
+
instanceRef.current = existingInstance;
|
60 |
+
cache.instance = existingInstance;
|
61 |
+
setState((prev) => ({ ...prev, isReady: true, isLoading: false }));
|
62 |
+
return existingInstance;
|
63 |
+
}
|
64 |
+
|
65 |
+
const existingPromise = loadingPromiseRef.current || cache.loadingPromise;
|
66 |
+
if (existingPromise) {
|
67 |
+
try {
|
68 |
+
const instance = await existingPromise;
|
69 |
+
instanceRef.current = instance;
|
70 |
+
cache.instance = instance;
|
71 |
+
setState((prev) => ({ ...prev, isReady: true, isLoading: false }));
|
72 |
+
return instance;
|
73 |
+
} catch (error) {
|
74 |
+
setState((prev) => ({
|
75 |
+
...prev,
|
76 |
+
isLoading: false,
|
77 |
+
error:
|
78 |
+
error instanceof Error ? error.message : "Failed to load model",
|
79 |
+
}));
|
80 |
+
throw error;
|
81 |
+
}
|
82 |
+
}
|
83 |
+
|
84 |
+
setState((prev) => ({
|
85 |
+
...prev,
|
86 |
+
isLoading: true,
|
87 |
+
error: null,
|
88 |
+
progress: 0,
|
89 |
+
}));
|
90 |
+
|
91 |
+
abortControllerRef.current = new AbortController();
|
92 |
+
|
93 |
+
const loadingPromise = (async () => {
|
94 |
+
try {
|
95 |
+
const progressCallback = (progress: any) => {
|
96 |
+
// Only update progress for weights
|
97 |
+
if (
|
98 |
+
progress.status === "progress" &&
|
99 |
+
progress.file.endsWith(".onnx_data")
|
100 |
+
) {
|
101 |
+
const percentage = Math.round(
|
102 |
+
(progress.loaded / progress.total) * 100,
|
103 |
+
);
|
104 |
+
setState((prev) => ({ ...prev, progress: percentage }));
|
105 |
+
}
|
106 |
+
};
|
107 |
+
|
108 |
+
const tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID, {
|
109 |
+
progress_callback: progressCallback,
|
110 |
+
});
|
111 |
+
|
112 |
+
const model = await AutoModelForCausalLM.from_pretrained(MODEL_ID, {
|
113 |
+
dtype: "q4f16",
|
114 |
+
device: "webgpu",
|
115 |
+
progress_callback: progressCallback,
|
116 |
+
});
|
117 |
+
|
118 |
+
const instance = { model, tokenizer };
|
119 |
+
instanceRef.current = instance;
|
120 |
+
cache.instance = instance;
|
121 |
+
loadingPromiseRef.current = null;
|
122 |
+
cache.loadingPromise = null;
|
123 |
+
|
124 |
+
setState((prev) => ({
|
125 |
+
...prev,
|
126 |
+
isLoading: false,
|
127 |
+
isReady: true,
|
128 |
+
progress: 100,
|
129 |
+
}));
|
130 |
+
return instance;
|
131 |
+
} catch (error) {
|
132 |
+
loadingPromiseRef.current = null;
|
133 |
+
cache.loadingPromise = null;
|
134 |
+
setState((prev) => ({
|
135 |
+
...prev,
|
136 |
+
isLoading: false,
|
137 |
+
error:
|
138 |
+
error instanceof Error ? error.message : "Failed to load model",
|
139 |
+
}));
|
140 |
+
throw error;
|
141 |
+
}
|
142 |
+
})();
|
143 |
+
|
144 |
+
loadingPromiseRef.current = loadingPromise;
|
145 |
+
cache.loadingPromise = loadingPromise;
|
146 |
+
return loadingPromise;
|
147 |
+
}, [modelId]);
|
148 |
+
|
149 |
+
const generateResponse = useCallback(
|
150 |
+
async (
|
151 |
+
messages: Array<{ role: string; content: string }>,
|
152 |
+
tools: Array<any>,
|
153 |
+
onToken?: (token: string) => void,
|
154 |
+
): Promise<string> => {
|
155 |
+
const instance = instanceRef.current;
|
156 |
+
if (!instance) {
|
157 |
+
throw new Error("Model not loaded. Call loadModel() first.");
|
158 |
+
}
|
159 |
+
|
160 |
+
const { model, tokenizer } = instance;
|
161 |
+
|
162 |
+
// Apply chat template with tools
|
163 |
+
const input = tokenizer.apply_chat_template(messages, {
|
164 |
+
tools,
|
165 |
+
add_generation_prompt: true,
|
166 |
+
return_dict: true,
|
167 |
+
});
|
168 |
+
|
169 |
+
const streamer = onToken
|
170 |
+
? new TextStreamer(tokenizer, {
|
171 |
+
skip_prompt: true,
|
172 |
+
skip_special_tokens: false,
|
173 |
+
callback_function: (token: string) => {
|
174 |
+
onToken(token);
|
175 |
+
},
|
176 |
+
})
|
177 |
+
: undefined;
|
178 |
+
|
179 |
+
// Generate the response
|
180 |
+
const { sequences, past_key_values } = await model.generate({
|
181 |
+
...input,
|
182 |
+
past_key_values: pastKeyValuesRef.current,
|
183 |
+
max_new_tokens: 512,
|
184 |
+
do_sample: false,
|
185 |
+
streamer,
|
186 |
+
return_dict_in_generate: true,
|
187 |
+
});
|
188 |
+
pastKeyValuesRef.current = past_key_values;
|
189 |
+
|
190 |
+
// Decode the generated text with special tokens preserved (except final <|im_end|>) for tool call detection
|
191 |
+
const response = tokenizer
|
192 |
+
.batch_decode(sequences.slice(null, [input.input_ids.dims[1], null]), {
|
193 |
+
skip_special_tokens: false,
|
194 |
+
})[0]
|
195 |
+
.replace(/<\|im_end\|>$/, "");
|
196 |
+
|
197 |
+
return response;
|
198 |
+
},
|
199 |
+
[],
|
200 |
+
);
|
201 |
+
|
202 |
+
const clearPastKeyValues = useCallback(() => {
|
203 |
+
pastKeyValuesRef.current = null;
|
204 |
+
}, []);
|
205 |
+
|
206 |
+
const cleanup = useCallback(() => {
|
207 |
+
if (abortControllerRef.current) {
|
208 |
+
abortControllerRef.current.abort();
|
209 |
+
}
|
210 |
+
}, []);
|
211 |
+
|
212 |
+
useEffect(() => {
|
213 |
+
return cleanup;
|
214 |
+
}, [cleanup]);
|
215 |
+
|
216 |
+
useEffect(() => {
|
217 |
+
if (modelId && moduleCache[modelId]) {
|
218 |
+
const existingInstance =
|
219 |
+
instanceRef.current || moduleCache[modelId].instance;
|
220 |
+
if (existingInstance) {
|
221 |
+
instanceRef.current = existingInstance;
|
222 |
+
setState((prev) => ({ ...prev, isReady: true }));
|
223 |
+
}
|
224 |
+
}
|
225 |
+
}, [modelId]);
|
226 |
+
|
227 |
+
return {
|
228 |
+
...state,
|
229 |
+
loadModel,
|
230 |
+
generateResponse,
|
231 |
+
clearPastKeyValues,
|
232 |
+
cleanup,
|
233 |
+
};
|
234 |
+
};
|
src/hooks/useMCP.ts
ADDED
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useCallback } from 'react';
|
2 |
+
import { MCPClientService } from '../services/mcpClient';
|
3 |
+
import type { MCPServerConfig, MCPClientState, ExtendedTool } from '../types/mcp';
|
4 |
+
import type { Tool as OriginalTool } from '../components/ToolItem';
|
5 |
+
|
6 |
+
// Singleton instance
|
7 |
+
let mcpClientInstance: MCPClientService | null = null;
|
8 |
+
|
9 |
+
const getMCPClient = (): MCPClientService => {
|
10 |
+
if (!mcpClientInstance) {
|
11 |
+
mcpClientInstance = new MCPClientService();
|
12 |
+
}
|
13 |
+
return mcpClientInstance;
|
14 |
+
};
|
15 |
+
|
16 |
+
export const useMCP = () => {
|
17 |
+
const [mcpState, setMCPState] = useState<MCPClientState>({
|
18 |
+
servers: {},
|
19 |
+
isLoading: false,
|
20 |
+
error: undefined
|
21 |
+
});
|
22 |
+
|
23 |
+
const mcpClient = getMCPClient();
|
24 |
+
|
25 |
+
// Subscribe to MCP state changes
|
26 |
+
useEffect(() => {
|
27 |
+
const handleStateChange = (state: MCPClientState) => {
|
28 |
+
setMCPState(state);
|
29 |
+
};
|
30 |
+
|
31 |
+
mcpClient.addStateListener(handleStateChange);
|
32 |
+
|
33 |
+
// Get initial state
|
34 |
+
setMCPState(mcpClient.getState());
|
35 |
+
|
36 |
+
return () => {
|
37 |
+
mcpClient.removeStateListener(handleStateChange);
|
38 |
+
};
|
39 |
+
}, [mcpClient]);
|
40 |
+
|
41 |
+
// Add a new MCP server
|
42 |
+
const addServer = useCallback(async (config: MCPServerConfig): Promise<void> => {
|
43 |
+
return mcpClient.addServer(config);
|
44 |
+
}, [mcpClient]);
|
45 |
+
|
46 |
+
// Remove an MCP server
|
47 |
+
const removeServer = useCallback(async (serverId: string): Promise<void> => {
|
48 |
+
return mcpClient.removeServer(serverId);
|
49 |
+
}, [mcpClient]);
|
50 |
+
|
51 |
+
// Connect to a server
|
52 |
+
const connectToServer = useCallback(async (serverId: string): Promise<void> => {
|
53 |
+
return mcpClient.connectToServer(serverId);
|
54 |
+
}, [mcpClient]);
|
55 |
+
|
56 |
+
// Disconnect from a server
|
57 |
+
const disconnectFromServer = useCallback(async (serverId: string): Promise<void> => {
|
58 |
+
return mcpClient.disconnectFromServer(serverId);
|
59 |
+
}, [mcpClient]);
|
60 |
+
|
61 |
+
// Test connection to a server
|
62 |
+
const testConnection = useCallback(async (config: MCPServerConfig): Promise<boolean> => {
|
63 |
+
return mcpClient.testConnection(config);
|
64 |
+
}, [mcpClient]);
|
65 |
+
|
66 |
+
// Call a tool on an MCP server
|
67 |
+
const callMCPTool = useCallback(async (serverId: string, toolName: string, args: Record<string, unknown>) => {
|
68 |
+
return mcpClient.callTool(serverId, toolName, args);
|
69 |
+
}, [mcpClient]);
|
70 |
+
|
71 |
+
// Get all available MCP tools
|
72 |
+
const getMCPTools = useCallback((): ExtendedTool[] => {
|
73 |
+
const mcpTools: ExtendedTool[] = [];
|
74 |
+
|
75 |
+
Object.entries(mcpState.servers).forEach(([serverId, connection]) => {
|
76 |
+
if (connection.isConnected && connection.config.enabled) {
|
77 |
+
connection.tools.forEach((mcpTool) => {
|
78 |
+
mcpTools.push({
|
79 |
+
id: `${serverId}:${mcpTool.name}`,
|
80 |
+
name: mcpTool.name,
|
81 |
+
enabled: true,
|
82 |
+
isCollapsed: false,
|
83 |
+
mcpServerId: serverId,
|
84 |
+
mcpTool: mcpTool,
|
85 |
+
isRemote: true
|
86 |
+
});
|
87 |
+
});
|
88 |
+
}
|
89 |
+
});
|
90 |
+
|
91 |
+
return mcpTools;
|
92 |
+
}, [mcpState.servers]);
|
93 |
+
|
94 |
+
// Convert MCP tools to the format expected by the existing tool system
|
95 |
+
const getMCPToolsAsOriginalTools = useCallback((): OriginalTool[] => {
|
96 |
+
const mcpTools: OriginalTool[] = [];
|
97 |
+
let globalId = Date.now(); // Use timestamp to force tool refresh
|
98 |
+
|
99 |
+
Object.entries(mcpState.servers).forEach(([serverId, connection]) => {
|
100 |
+
if (connection.isConnected && connection.config.enabled) {
|
101 |
+
connection.tools.forEach((mcpTool) => {
|
102 |
+
// Convert tool name to valid JavaScript identifier
|
103 |
+
const jsToolName = mcpTool.name.replace(/[-\s]/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
104 |
+
|
105 |
+
// Create a JavaScript function that calls the MCP tool
|
106 |
+
const safeDescription = (mcpTool.description || `MCP tool from ${connection.config.name}`).replace(/[`${}\\]/g, '');
|
107 |
+
const serverName = connection.config.name;
|
108 |
+
const safeParams = Object.entries(mcpTool.inputSchema.properties || {}).map(([name, prop]) => {
|
109 |
+
const p = prop as { type?: string; description?: string };
|
110 |
+
const safeType = (p.type || 'any').replace(/[`${}\\]/g, '');
|
111 |
+
const safeDesc = (p.description || '').replace(/[`${}\\]/g, '');
|
112 |
+
return `@param {${safeType}} ${name} - ${safeDesc}`;
|
113 |
+
}).join('\n * ');
|
114 |
+
|
115 |
+
const code = `/**
|
116 |
+
* ${safeDescription}
|
117 |
+
* ${safeParams}
|
118 |
+
* @returns {Promise<any>} Tool execution result
|
119 |
+
*/
|
120 |
+
export async function ${jsToolName}(${Object.keys(mcpTool.inputSchema.properties || {}).join(', ')}) {
|
121 |
+
// This is an MCP tool - execution is handled by the MCP client
|
122 |
+
return { mcpServerId: "${serverId}", toolName: ${JSON.stringify(mcpTool.name)}, arguments: arguments };
|
123 |
+
}
|
124 |
+
|
125 |
+
export default (input, output) =>
|
126 |
+
React.createElement(
|
127 |
+
"div",
|
128 |
+
{ className: "bg-blue-50 border border-blue-200 rounded-lg p-4" },
|
129 |
+
React.createElement(
|
130 |
+
"div",
|
131 |
+
{ className: "flex items-center mb-2" },
|
132 |
+
React.createElement(
|
133 |
+
"div",
|
134 |
+
{
|
135 |
+
className:
|
136 |
+
"w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3",
|
137 |
+
},
|
138 |
+
"🌐",
|
139 |
+
),
|
140 |
+
React.createElement(
|
141 |
+
"h3",
|
142 |
+
{ className: "text-blue-900 font-semibold" },
|
143 |
+
"${mcpTool.name} (MCP)"
|
144 |
+
),
|
145 |
+
),
|
146 |
+
React.createElement(
|
147 |
+
"div",
|
148 |
+
{ className: "text-sm space-y-1" },
|
149 |
+
React.createElement(
|
150 |
+
"p",
|
151 |
+
{ className: "text-blue-700 font-medium" },
|
152 |
+
"Server: " + ${JSON.stringify(serverName)}
|
153 |
+
),
|
154 |
+
React.createElement(
|
155 |
+
"p",
|
156 |
+
{ className: "text-blue-700 font-medium" },
|
157 |
+
"Input: " + JSON.stringify(input)
|
158 |
+
),
|
159 |
+
React.createElement(
|
160 |
+
"div",
|
161 |
+
{ className: "mt-3" },
|
162 |
+
React.createElement(
|
163 |
+
"h4",
|
164 |
+
{ className: "text-blue-800 font-medium mb-2" },
|
165 |
+
"Result:"
|
166 |
+
),
|
167 |
+
React.createElement(
|
168 |
+
"pre",
|
169 |
+
{
|
170 |
+
className: "text-gray-800 text-xs bg-gray-50 p-3 rounded border overflow-x-auto max-w-full",
|
171 |
+
style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }
|
172 |
+
},
|
173 |
+
(() => {
|
174 |
+
// Try to parse and format JSON content from text fields
|
175 |
+
if (output && output.content && Array.isArray(output.content)) {
|
176 |
+
const textContent = output.content.find(item => item.type === 'text' && item.text);
|
177 |
+
if (textContent && textContent.text) {
|
178 |
+
try {
|
179 |
+
const parsed = JSON.parse(textContent.text);
|
180 |
+
return JSON.stringify(parsed, null, 2);
|
181 |
+
} catch {
|
182 |
+
// If not JSON, return the original text
|
183 |
+
return textContent.text;
|
184 |
+
}
|
185 |
+
}
|
186 |
+
}
|
187 |
+
// Fallback to original output
|
188 |
+
return JSON.stringify(output, null, 2);
|
189 |
+
})()
|
190 |
+
)
|
191 |
+
),
|
192 |
+
),
|
193 |
+
);`;
|
194 |
+
|
195 |
+
mcpTools.push({
|
196 |
+
id: globalId++,
|
197 |
+
name: jsToolName, // Use JavaScript-safe name for function calls
|
198 |
+
code: code,
|
199 |
+
enabled: true,
|
200 |
+
isCollapsed: false
|
201 |
+
});
|
202 |
+
});
|
203 |
+
}
|
204 |
+
});
|
205 |
+
|
206 |
+
return mcpTools;
|
207 |
+
}, [mcpState.servers]);
|
208 |
+
|
209 |
+
// Connect to all enabled servers
|
210 |
+
const connectAll = useCallback(async (): Promise<void> => {
|
211 |
+
return mcpClient.connectAll();
|
212 |
+
}, [mcpClient]);
|
213 |
+
|
214 |
+
// Disconnect from all servers
|
215 |
+
const disconnectAll = useCallback(async (): Promise<void> => {
|
216 |
+
return mcpClient.disconnectAll();
|
217 |
+
}, [mcpClient]);
|
218 |
+
|
219 |
+
return {
|
220 |
+
mcpState,
|
221 |
+
addServer,
|
222 |
+
removeServer,
|
223 |
+
connectToServer,
|
224 |
+
disconnectFromServer,
|
225 |
+
testConnection,
|
226 |
+
callMCPTool,
|
227 |
+
getMCPTools,
|
228 |
+
getMCPToolsAsOriginalTools,
|
229 |
+
connectAll,
|
230 |
+
disconnectAll
|
231 |
+
};
|
232 |
+
};
|
src/index.css
CHANGED
@@ -1,13 +1 @@
|
|
1 |
-
|
2 |
-
margin: 0;
|
3 |
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
4 |
-
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
5 |
-
sans-serif;
|
6 |
-
-webkit-font-smoothing: antialiased;
|
7 |
-
-moz-osx-font-smoothing: grayscale;
|
8 |
-
}
|
9 |
-
|
10 |
-
code {
|
11 |
-
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
12 |
-
monospace;
|
13 |
-
}
|
|
|
1 |
+
@import "tailwindcss";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/index.js
DELETED
@@ -1,17 +0,0 @@
|
|
1 |
-
import React from 'react';
|
2 |
-
import ReactDOM from 'react-dom/client';
|
3 |
-
import './index.css';
|
4 |
-
import App from './App';
|
5 |
-
import reportWebVitals from './reportWebVitals';
|
6 |
-
|
7 |
-
const root = ReactDOM.createRoot(document.getElementById('root'));
|
8 |
-
root.render(
|
9 |
-
<React.StrictMode>
|
10 |
-
<App />
|
11 |
-
</React.StrictMode>
|
12 |
-
);
|
13 |
-
|
14 |
-
// If you want to start measuring performance in your app, pass a function
|
15 |
-
// to log results (for example: reportWebVitals(console.log))
|
16 |
-
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
17 |
-
reportWebVitals();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/logo.svg
DELETED
src/main.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { StrictMode } from "react";
|
2 |
+
import { createRoot } from "react-dom/client";
|
3 |
+
import { HashRouter, Routes, Route } from "react-router-dom";
|
4 |
+
import "./index.css";
|
5 |
+
import App from "./App.tsx";
|
6 |
+
import OAuthCallback from "./components/OAuthCallback";
|
7 |
+
|
8 |
+
createRoot(document.getElementById("root")!).render(
|
9 |
+
<StrictMode>
|
10 |
+
<HashRouter>
|
11 |
+
<Routes>
|
12 |
+
<Route
|
13 |
+
path="/oauth/callback"
|
14 |
+
element={
|
15 |
+
<OAuthCallback
|
16 |
+
serverUrl={localStorage.getItem("oauth_mcp_server_url") || ""}
|
17 |
+
/>
|
18 |
+
}
|
19 |
+
/>
|
20 |
+
<Route path="/*" element={<App />} />
|
21 |
+
</Routes>
|
22 |
+
</HashRouter>
|
23 |
+
</StrictMode>
|
24 |
+
);
|
src/reportWebVitals.js
DELETED
@@ -1,13 +0,0 @@
|
|
1 |
-
const reportWebVitals = onPerfEntry => {
|
2 |
-
if (onPerfEntry && onPerfEntry instanceof Function) {
|
3 |
-
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
4 |
-
getCLS(onPerfEntry);
|
5 |
-
getFID(onPerfEntry);
|
6 |
-
getFCP(onPerfEntry);
|
7 |
-
getLCP(onPerfEntry);
|
8 |
-
getTTFB(onPerfEntry);
|
9 |
-
});
|
10 |
-
}
|
11 |
-
};
|
12 |
-
|
13 |
-
export default reportWebVitals;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/services/mcpClient.ts
ADDED
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
2 |
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
3 |
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
4 |
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
5 |
+
import { MCP_SERVERS } from "../tools/mcp_servers";
|
6 |
+
import type {
|
7 |
+
MCPServerConfig,
|
8 |
+
MCPServerConnection,
|
9 |
+
MCPClientState,
|
10 |
+
MCPToolResult,
|
11 |
+
} from "../types/mcp.js";
|
12 |
+
import { MCP_CLIENT_CONFIG, STORAGE_KEYS } from "../config/constants";
|
13 |
+
|
14 |
+
export class MCPClientService {
|
15 |
+
private clients: Map<string, Client> = new Map();
|
16 |
+
private connections: Map<string, MCPServerConnection> = new Map();
|
17 |
+
private listeners: Array<(state: MCPClientState) => void> = [];
|
18 |
+
|
19 |
+
constructor() {
|
20 |
+
// Load saved server configurations from localStorage
|
21 |
+
this.loadServerConfigs();
|
22 |
+
|
23 |
+
// If no servers are present, load initial list from MCP_SERVERS (imported)
|
24 |
+
if (this.connections.size === 0) {
|
25 |
+
MCP_SERVERS.forEach((config) => {
|
26 |
+
this.addServer(config);
|
27 |
+
});
|
28 |
+
}
|
29 |
+
}
|
30 |
+
|
31 |
+
// Add state change listener
|
32 |
+
addStateListener(listener: (state: MCPClientState) => void) {
|
33 |
+
this.listeners.push(listener);
|
34 |
+
}
|
35 |
+
|
36 |
+
// Remove state change listener
|
37 |
+
removeStateListener(listener: (state: MCPClientState) => void) {
|
38 |
+
const index = this.listeners.indexOf(listener);
|
39 |
+
if (index > -1) {
|
40 |
+
this.listeners.splice(index, 1);
|
41 |
+
}
|
42 |
+
}
|
43 |
+
|
44 |
+
// Notify all listeners of state changes
|
45 |
+
private notifyStateChange() {
|
46 |
+
const state = this.getState();
|
47 |
+
this.listeners.forEach((listener) => listener(state));
|
48 |
+
}
|
49 |
+
|
50 |
+
// Get current MCP client state
|
51 |
+
getState(): MCPClientState {
|
52 |
+
const servers: Record<string, MCPServerConnection> = {};
|
53 |
+
for (const [id, connection] of this.connections) {
|
54 |
+
servers[id] = connection;
|
55 |
+
}
|
56 |
+
|
57 |
+
return {
|
58 |
+
servers,
|
59 |
+
isLoading: false,
|
60 |
+
error: undefined,
|
61 |
+
};
|
62 |
+
}
|
63 |
+
|
64 |
+
// Load server configurations from localStorage
|
65 |
+
private loadServerConfigs() {
|
66 |
+
try {
|
67 |
+
const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
|
68 |
+
if (stored) {
|
69 |
+
const configs: MCPServerConfig[] = JSON.parse(stored);
|
70 |
+
configs.forEach((config) => {
|
71 |
+
const connection: MCPServerConnection = {
|
72 |
+
config,
|
73 |
+
isConnected: false,
|
74 |
+
tools: [],
|
75 |
+
lastError: undefined,
|
76 |
+
lastConnected: undefined,
|
77 |
+
};
|
78 |
+
this.connections.set(config.id, connection);
|
79 |
+
});
|
80 |
+
}
|
81 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
82 |
+
} catch (error) {
|
83 |
+
// Silently handle missing or corrupted config
|
84 |
+
}
|
85 |
+
}
|
86 |
+
|
87 |
+
// Save server configurations to localStorage
|
88 |
+
private saveServerConfigs() {
|
89 |
+
try {
|
90 |
+
const configs = Array.from(this.connections.values()).map(
|
91 |
+
(conn) => conn.config
|
92 |
+
);
|
93 |
+
localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(configs));
|
94 |
+
} catch (error) {
|
95 |
+
// Handle storage errors gracefully
|
96 |
+
throw new Error(
|
97 |
+
`Failed to save server configuration: ${
|
98 |
+
error instanceof Error ? error.message : "Unknown error"
|
99 |
+
}`
|
100 |
+
);
|
101 |
+
}
|
102 |
+
}
|
103 |
+
|
104 |
+
// Add a new MCP server
|
105 |
+
async addServer(config: MCPServerConfig): Promise<void> {
|
106 |
+
const connection: MCPServerConnection = {
|
107 |
+
config,
|
108 |
+
isConnected: false,
|
109 |
+
tools: [],
|
110 |
+
lastError: undefined,
|
111 |
+
lastConnected: undefined,
|
112 |
+
};
|
113 |
+
|
114 |
+
this.connections.set(config.id, connection);
|
115 |
+
this.saveServerConfigs();
|
116 |
+
this.notifyStateChange();
|
117 |
+
|
118 |
+
// Auto-connect if enabled
|
119 |
+
if (config.enabled) {
|
120 |
+
await this.connectToServer(config.id);
|
121 |
+
}
|
122 |
+
}
|
123 |
+
|
124 |
+
// Remove an MCP server
|
125 |
+
async removeServer(serverId: string): Promise<void> {
|
126 |
+
// Disconnect first if connected
|
127 |
+
await this.disconnectFromServer(serverId);
|
128 |
+
|
129 |
+
// Remove from our maps
|
130 |
+
this.connections.delete(serverId);
|
131 |
+
this.clients.delete(serverId);
|
132 |
+
|
133 |
+
this.saveServerConfigs();
|
134 |
+
this.notifyStateChange();
|
135 |
+
}
|
136 |
+
|
137 |
+
// Connect to an MCP server
|
138 |
+
async connectToServer(serverId: string): Promise<void> {
|
139 |
+
const connection = this.connections.get(serverId);
|
140 |
+
if (!connection) {
|
141 |
+
throw new Error(`Server ${serverId} not found`);
|
142 |
+
}
|
143 |
+
|
144 |
+
if (connection.isConnected) {
|
145 |
+
return; // Already connected
|
146 |
+
}
|
147 |
+
|
148 |
+
try {
|
149 |
+
// Create client
|
150 |
+
const client = new Client(
|
151 |
+
{
|
152 |
+
name: MCP_CLIENT_CONFIG.NAME,
|
153 |
+
version: MCP_CLIENT_CONFIG.VERSION,
|
154 |
+
},
|
155 |
+
{
|
156 |
+
capabilities: {
|
157 |
+
tools: {},
|
158 |
+
},
|
159 |
+
}
|
160 |
+
);
|
161 |
+
|
162 |
+
// Create transport based on config
|
163 |
+
let transport;
|
164 |
+
const url = new URL(connection.config.url);
|
165 |
+
|
166 |
+
// Prepare headers for authentication
|
167 |
+
const headers: Record<string, string> = {};
|
168 |
+
if (connection.config.auth) {
|
169 |
+
switch (connection.config.auth.type) {
|
170 |
+
case "bearer":
|
171 |
+
if (connection.config.auth.token) {
|
172 |
+
headers[
|
173 |
+
"Authorization"
|
174 |
+
] = `Bearer ${connection.config.auth.token}`;
|
175 |
+
}
|
176 |
+
break;
|
177 |
+
case "basic":
|
178 |
+
if (
|
179 |
+
connection.config.auth.username &&
|
180 |
+
connection.config.auth.password
|
181 |
+
) {
|
182 |
+
const credentials = btoa(
|
183 |
+
`${connection.config.auth.username}:${connection.config.auth.password}`
|
184 |
+
);
|
185 |
+
headers["Authorization"] = `Basic ${credentials}`;
|
186 |
+
}
|
187 |
+
break;
|
188 |
+
case "oauth":
|
189 |
+
if (connection.config.auth.token) {
|
190 |
+
headers[
|
191 |
+
"Authorization"
|
192 |
+
] = `Bearer ${connection.config.auth.token}`;
|
193 |
+
}
|
194 |
+
break;
|
195 |
+
}
|
196 |
+
}
|
197 |
+
|
198 |
+
switch (connection.config.transport) {
|
199 |
+
case "streamable-http":
|
200 |
+
transport = new StreamableHTTPClientTransport(url, {
|
201 |
+
requestInit:
|
202 |
+
Object.keys(headers).length > 0 ? { headers } : undefined,
|
203 |
+
});
|
204 |
+
break;
|
205 |
+
|
206 |
+
case "sse":
|
207 |
+
transport = new SSEClientTransport(url, {
|
208 |
+
requestInit:
|
209 |
+
Object.keys(headers).length > 0 ? { headers } : undefined,
|
210 |
+
});
|
211 |
+
break;
|
212 |
+
|
213 |
+
default:
|
214 |
+
throw new Error(
|
215 |
+
`Unsupported transport: ${connection.config.transport}`
|
216 |
+
);
|
217 |
+
}
|
218 |
+
|
219 |
+
// Set up error handling
|
220 |
+
client.onerror = (error) => {
|
221 |
+
connection.lastError = error.message;
|
222 |
+
connection.isConnected = false;
|
223 |
+
this.notifyStateChange();
|
224 |
+
};
|
225 |
+
|
226 |
+
// Connect to the server
|
227 |
+
await client.connect(transport);
|
228 |
+
|
229 |
+
// List available tools
|
230 |
+
const toolsResult = await client.listTools();
|
231 |
+
|
232 |
+
// Update connection state
|
233 |
+
connection.isConnected = true;
|
234 |
+
connection.tools = toolsResult.tools;
|
235 |
+
connection.lastError = undefined;
|
236 |
+
connection.lastConnected = new Date();
|
237 |
+
|
238 |
+
// Store client reference
|
239 |
+
this.clients.set(serverId, client);
|
240 |
+
|
241 |
+
this.notifyStateChange();
|
242 |
+
} catch (error) {
|
243 |
+
connection.isConnected = false;
|
244 |
+
connection.lastError =
|
245 |
+
error instanceof Error ? error.message : "Connection failed";
|
246 |
+
this.notifyStateChange();
|
247 |
+
throw error;
|
248 |
+
}
|
249 |
+
}
|
250 |
+
|
251 |
+
// Disconnect from an MCP server
|
252 |
+
async disconnectFromServer(serverId: string): Promise<void> {
|
253 |
+
const client = this.clients.get(serverId);
|
254 |
+
const connection = this.connections.get(serverId);
|
255 |
+
|
256 |
+
if (client) {
|
257 |
+
try {
|
258 |
+
await client.close();
|
259 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
260 |
+
} catch (error) {
|
261 |
+
// Handle disconnect error silently
|
262 |
+
}
|
263 |
+
this.clients.delete(serverId);
|
264 |
+
}
|
265 |
+
|
266 |
+
if (connection) {
|
267 |
+
connection.isConnected = false;
|
268 |
+
connection.tools = [];
|
269 |
+
this.notifyStateChange();
|
270 |
+
}
|
271 |
+
}
|
272 |
+
|
273 |
+
// Get all tools from all connected servers
|
274 |
+
getAllTools(): Tool[] {
|
275 |
+
const allTools: Tool[] = [];
|
276 |
+
|
277 |
+
for (const connection of this.connections.values()) {
|
278 |
+
if (connection.isConnected && connection.config.enabled) {
|
279 |
+
allTools.push(...connection.tools);
|
280 |
+
}
|
281 |
+
}
|
282 |
+
|
283 |
+
return allTools;
|
284 |
+
}
|
285 |
+
|
286 |
+
// Call a tool on an MCP server
|
287 |
+
async callTool(
|
288 |
+
serverId: string,
|
289 |
+
toolName: string,
|
290 |
+
args: Record<string, unknown>
|
291 |
+
): Promise<MCPToolResult> {
|
292 |
+
const client = this.clients.get(serverId);
|
293 |
+
const connection = this.connections.get(serverId);
|
294 |
+
|
295 |
+
if (!client || !connection?.isConnected) {
|
296 |
+
throw new Error(`Not connected to server ${serverId}`);
|
297 |
+
}
|
298 |
+
|
299 |
+
try {
|
300 |
+
const result = await client.callTool({
|
301 |
+
name: toolName,
|
302 |
+
arguments: args,
|
303 |
+
});
|
304 |
+
|
305 |
+
return {
|
306 |
+
content: Array.isArray(result.content) ? result.content : [],
|
307 |
+
isError: Boolean(result.isError),
|
308 |
+
};
|
309 |
+
} catch (error) {
|
310 |
+
throw new Error(
|
311 |
+
`Tool execution failed (${toolName}): ${
|
312 |
+
error instanceof Error ? error.message : "Unknown error"
|
313 |
+
}`
|
314 |
+
);
|
315 |
+
}
|
316 |
+
}
|
317 |
+
|
318 |
+
// Test connection to a server without saving it
|
319 |
+
async testConnection(config: MCPServerConfig): Promise<boolean> {
|
320 |
+
try {
|
321 |
+
const client = new Client(
|
322 |
+
{
|
323 |
+
name: MCP_CLIENT_CONFIG.TEST_CLIENT_NAME,
|
324 |
+
version: MCP_CLIENT_CONFIG.VERSION,
|
325 |
+
},
|
326 |
+
{
|
327 |
+
capabilities: {
|
328 |
+
tools: {},
|
329 |
+
},
|
330 |
+
}
|
331 |
+
);
|
332 |
+
|
333 |
+
let transport;
|
334 |
+
const url = new URL(config.url);
|
335 |
+
|
336 |
+
switch (config.transport) {
|
337 |
+
case "streamable-http":
|
338 |
+
transport = new StreamableHTTPClientTransport(url);
|
339 |
+
break;
|
340 |
+
|
341 |
+
case "sse":
|
342 |
+
transport = new SSEClientTransport(url);
|
343 |
+
break;
|
344 |
+
|
345 |
+
default:
|
346 |
+
throw new Error(`Unsupported transport: ${config.transport}`);
|
347 |
+
}
|
348 |
+
|
349 |
+
await client.connect(transport);
|
350 |
+
await client.close();
|
351 |
+
return true;
|
352 |
+
} catch (error) {
|
353 |
+
throw new Error(
|
354 |
+
`Connection test failed: ${
|
355 |
+
error instanceof Error ? error.message : "Unknown error"
|
356 |
+
}`
|
357 |
+
);
|
358 |
+
}
|
359 |
+
}
|
360 |
+
|
361 |
+
// Connect to all enabled servers
|
362 |
+
async connectAll(): Promise<void> {
|
363 |
+
const promises = Array.from(this.connections.entries())
|
364 |
+
.filter(
|
365 |
+
([, connection]) => connection.config.enabled && !connection.isConnected
|
366 |
+
)
|
367 |
+
.map(([serverId]) =>
|
368 |
+
this.connectToServer(serverId).catch(() => {
|
369 |
+
// Handle auto-connection error silently
|
370 |
+
})
|
371 |
+
);
|
372 |
+
|
373 |
+
await Promise.all(promises);
|
374 |
+
}
|
375 |
+
|
376 |
+
// Disconnect from all servers
|
377 |
+
async disconnectAll(): Promise<void> {
|
378 |
+
const promises = Array.from(this.connections.keys()).map((serverId) =>
|
379 |
+
this.disconnectFromServer(serverId)
|
380 |
+
);
|
381 |
+
|
382 |
+
await Promise.all(promises);
|
383 |
+
}
|
384 |
+
}
|
src/services/oauth.ts
ADDED
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
discoverOAuthProtectedResourceMetadata,
|
3 |
+
discoverAuthorizationServerMetadata,
|
4 |
+
startAuthorization,
|
5 |
+
exchangeAuthorization,
|
6 |
+
registerClient,
|
7 |
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
8 |
+
import { secureStorage } from "../utils/storage";
|
9 |
+
import { MCP_CLIENT_CONFIG, STORAGE_KEYS, DEFAULTS } from "../config/constants";
|
10 |
+
// Utility to fetch .well-known/modelcontextprotocol for OAuth endpoints
|
11 |
+
export async function discoverOAuthEndpoints(serverUrl: string) {
|
12 |
+
// ...existing code...
|
13 |
+
let resourceMetadata, authMetadata, authorizationServerUrl;
|
14 |
+
try {
|
15 |
+
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
|
16 |
+
if (resourceMetadata?.authorization_servers?.length) {
|
17 |
+
authorizationServerUrl = resourceMetadata.authorization_servers[0];
|
18 |
+
}
|
19 |
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
20 |
+
} catch (e) {
|
21 |
+
// Fallback to direct metadata discovery if protected resource fails
|
22 |
+
authMetadata = await discoverAuthorizationServerMetadata(serverUrl);
|
23 |
+
authorizationServerUrl = authMetadata?.issuer || serverUrl;
|
24 |
+
}
|
25 |
+
|
26 |
+
if (!authorizationServerUrl) {
|
27 |
+
throw new Error("No authorization server found for this MCP server");
|
28 |
+
}
|
29 |
+
|
30 |
+
// Discover authorization server metadata if not already done
|
31 |
+
if (!authMetadata) {
|
32 |
+
authMetadata = await discoverAuthorizationServerMetadata(
|
33 |
+
authorizationServerUrl
|
34 |
+
);
|
35 |
+
}
|
36 |
+
|
37 |
+
if (
|
38 |
+
!authMetadata ||
|
39 |
+
!authMetadata.authorization_endpoint ||
|
40 |
+
!authMetadata.token_endpoint
|
41 |
+
) {
|
42 |
+
throw new Error("Missing OAuth endpoints in authorization server metadata");
|
43 |
+
}
|
44 |
+
|
45 |
+
// If client_id is missing, register client dynamically
|
46 |
+
if (!authMetadata.client_id && authMetadata.registration_endpoint) {
|
47 |
+
// Determine token endpoint auth method
|
48 |
+
let tokenEndpointAuthMethod = "none";
|
49 |
+
if (
|
50 |
+
authMetadata.token_endpoint_auth_methods_supported?.includes(
|
51 |
+
"client_secret_post"
|
52 |
+
)
|
53 |
+
) {
|
54 |
+
tokenEndpointAuthMethod = "client_secret_post";
|
55 |
+
} else if (
|
56 |
+
authMetadata.token_endpoint_auth_methods_supported?.includes(
|
57 |
+
"client_secret_basic"
|
58 |
+
)
|
59 |
+
) {
|
60 |
+
tokenEndpointAuthMethod = "client_secret_basic";
|
61 |
+
}
|
62 |
+
const clientMetadata = {
|
63 |
+
redirect_uris: [
|
64 |
+
String(
|
65 |
+
authMetadata.redirect_uri ||
|
66 |
+
window.location.origin + "/#/oauth/callback"
|
67 |
+
),
|
68 |
+
],
|
69 |
+
client_name: MCP_CLIENT_CONFIG.NAME,
|
70 |
+
grant_types: ["authorization_code"],
|
71 |
+
response_types: ["code"],
|
72 |
+
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
73 |
+
};
|
74 |
+
const clientInfo = await registerClient(authorizationServerUrl, {
|
75 |
+
metadata: authMetadata,
|
76 |
+
clientMetadata,
|
77 |
+
});
|
78 |
+
authMetadata.client_id = clientInfo.client_id;
|
79 |
+
if (clientInfo.client_secret) {
|
80 |
+
authMetadata.client_secret = clientInfo.client_secret;
|
81 |
+
}
|
82 |
+
// Persist client credentials for later use
|
83 |
+
localStorage.setItem(STORAGE_KEYS.OAUTH_CLIENT_ID, clientInfo.client_id);
|
84 |
+
if (clientInfo.client_secret) {
|
85 |
+
await secureStorage.setItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET, clientInfo.client_secret);
|
86 |
+
}
|
87 |
+
}
|
88 |
+
if (!authMetadata.client_id) {
|
89 |
+
throw new Error(
|
90 |
+
"Missing client_id and registration not supported by authorization server"
|
91 |
+
);
|
92 |
+
}
|
93 |
+
|
94 |
+
// Step 3: Validate resource
|
95 |
+
const resource = resourceMetadata?.resource
|
96 |
+
? new URL(resourceMetadata.resource)
|
97 |
+
: undefined;
|
98 |
+
|
99 |
+
// Persist endpoints, metadata, and MCP server URL for callback use
|
100 |
+
localStorage.setItem(
|
101 |
+
STORAGE_KEYS.OAUTH_AUTHORIZATION_ENDPOINT,
|
102 |
+
authMetadata.authorization_endpoint
|
103 |
+
);
|
104 |
+
localStorage.setItem(STORAGE_KEYS.OAUTH_TOKEN_ENDPOINT, authMetadata.token_endpoint);
|
105 |
+
localStorage.setItem(
|
106 |
+
STORAGE_KEYS.OAUTH_REDIRECT_URI,
|
107 |
+
(authMetadata.redirect_uri ||window.location.origin + "/#" + DEFAULTS.OAUTH_REDIRECT_PATH).toString()
|
108 |
+
);
|
109 |
+
localStorage.setItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL, serverUrl);
|
110 |
+
localStorage.setItem(
|
111 |
+
STORAGE_KEYS.OAUTH_AUTHORIZATION_SERVER_METADATA,
|
112 |
+
JSON.stringify(authMetadata)
|
113 |
+
);
|
114 |
+
if (resource) {
|
115 |
+
localStorage.setItem(STORAGE_KEYS.OAUTH_RESOURCE, resource.toString());
|
116 |
+
}
|
117 |
+
return {
|
118 |
+
authorizationEndpoint: authMetadata.authorization_endpoint,
|
119 |
+
tokenEndpoint: authMetadata.token_endpoint,
|
120 |
+
clientId: authMetadata.client_id,
|
121 |
+
clientSecret: authMetadata.client_secret,
|
122 |
+
scopes: authMetadata.scopes || [],
|
123 |
+
redirectUri:
|
124 |
+
authMetadata.redirect_uri || window.location.origin + "/#/oauth/callback",
|
125 |
+
resource,
|
126 |
+
};
|
127 |
+
}
|
128 |
+
|
129 |
+
// Start OAuth flow: redirect user to authorization endpoint
|
130 |
+
export async function startOAuthFlow({
|
131 |
+
authorizationEndpoint,
|
132 |
+
clientId,
|
133 |
+
redirectUri,
|
134 |
+
scopes,
|
135 |
+
resource,
|
136 |
+
}: {
|
137 |
+
authorizationEndpoint: string;
|
138 |
+
clientId: string;
|
139 |
+
redirectUri: string;
|
140 |
+
scopes?: string[];
|
141 |
+
resource?: URL;
|
142 |
+
}) {
|
143 |
+
// Use Proof Key for Code Exchange (PKCE) and SDK to build the authorization URL
|
144 |
+
// Use persisted client_id if available
|
145 |
+
const persistedClientId = localStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_ID) || clientId;
|
146 |
+
const clientInformation = { client_id: persistedClientId };
|
147 |
+
// Retrieve metadata from localStorage if available
|
148 |
+
let metadata;
|
149 |
+
try {
|
150 |
+
const stored = localStorage.getItem(STORAGE_KEYS.OAUTH_AUTHORIZATION_SERVER_METADATA);
|
151 |
+
if (stored) metadata = JSON.parse(stored);
|
152 |
+
} catch {
|
153 |
+
console.warn("Failed to parse stored OAuth metadata, using defaults");
|
154 |
+
}
|
155 |
+
// Always pass resource from localStorage if not provided
|
156 |
+
let resourceParam = resource;
|
157 |
+
if (!resourceParam) {
|
158 |
+
const resourceStr = localStorage.getItem(STORAGE_KEYS.OAUTH_RESOURCE);
|
159 |
+
if (resourceStr) resourceParam = new URL(resourceStr);
|
160 |
+
}
|
161 |
+
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
162 |
+
authorizationEndpoint,
|
163 |
+
{
|
164 |
+
metadata,
|
165 |
+
clientInformation,
|
166 |
+
redirectUrl: redirectUri,
|
167 |
+
scope: scopes?.join(" ") || undefined,
|
168 |
+
resource: resourceParam,
|
169 |
+
}
|
170 |
+
);
|
171 |
+
// Save codeVerifier in localStorage for later token exchange
|
172 |
+
localStorage.setItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER, codeVerifier);
|
173 |
+
window.location.href = authorizationUrl.toString();
|
174 |
+
}
|
175 |
+
|
176 |
+
// Exchange code for token using MCP SDK
|
177 |
+
export async function exchangeCodeForToken({
|
178 |
+
code,
|
179 |
+
redirectUri,
|
180 |
+
}: {
|
181 |
+
serverUrl?: string;
|
182 |
+
code: string;
|
183 |
+
redirectUri: string;
|
184 |
+
}) {
|
185 |
+
// Use only persisted credentials and endpoints for token exchange
|
186 |
+
const tokenEndpoint = localStorage.getItem(STORAGE_KEYS.OAUTH_TOKEN_ENDPOINT);
|
187 |
+
const redirectUriPersisted = localStorage.getItem(STORAGE_KEYS.OAUTH_REDIRECT_URI);
|
188 |
+
const resourceStr = localStorage.getItem(STORAGE_KEYS.OAUTH_RESOURCE);
|
189 |
+
const persistedClientId = localStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_ID);
|
190 |
+
const persistedClientSecret = await secureStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET);
|
191 |
+
const codeVerifier = localStorage.getItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER);
|
192 |
+
if (!persistedClientId || !tokenEndpoint || !codeVerifier)
|
193 |
+
throw new Error(
|
194 |
+
"Missing OAuth client credentials or endpoints for token exchange"
|
195 |
+
);
|
196 |
+
const clientInformation: { client_id: string; client_secret?: string } = { client_id: persistedClientId };
|
197 |
+
if (persistedClientSecret) {
|
198 |
+
clientInformation.client_secret = persistedClientSecret;
|
199 |
+
}
|
200 |
+
// Retrieve metadata from localStorage if available
|
201 |
+
let metadata;
|
202 |
+
try {
|
203 |
+
const stored = localStorage.getItem(STORAGE_KEYS.OAUTH_AUTHORIZATION_SERVER_METADATA);
|
204 |
+
if (stored) metadata = JSON.parse(stored);
|
205 |
+
} catch {
|
206 |
+
console.warn("Failed to parse stored OAuth metadata, using defaults");
|
207 |
+
}
|
208 |
+
// Use SDK to exchange code for tokens
|
209 |
+
const tokens = await exchangeAuthorization(tokenEndpoint, {
|
210 |
+
metadata,
|
211 |
+
clientInformation,
|
212 |
+
authorizationCode: code,
|
213 |
+
codeVerifier,
|
214 |
+
redirectUri: redirectUriPersisted || redirectUri,
|
215 |
+
resource: resourceStr ? new URL(resourceStr) : undefined,
|
216 |
+
});
|
217 |
+
// Persist access token in localStorage and sync to mcp-servers
|
218 |
+
if (tokens && tokens.access_token) {
|
219 |
+
await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
|
220 |
+
try {
|
221 |
+
const serversStr = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
|
222 |
+
if (serversStr) {
|
223 |
+
const servers = JSON.parse(serversStr);
|
224 |
+
for (const server of servers) {
|
225 |
+
if (
|
226 |
+
server.auth &&
|
227 |
+
(server.auth.type === "bearer" || server.auth.type === "oauth")
|
228 |
+
) {
|
229 |
+
server.auth.token = tokens.access_token;
|
230 |
+
}
|
231 |
+
}
|
232 |
+
localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(servers));
|
233 |
+
}
|
234 |
+
} catch (err) {
|
235 |
+
console.warn("Failed to sync token to mcp-servers:", err);
|
236 |
+
}
|
237 |
+
}
|
238 |
+
return tokens;
|
239 |
+
}
|
src/setupTests.js
DELETED
@@ -1,5 +0,0 @@
|
|
1 |
-
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
2 |
-
// allows you to do things like:
|
3 |
-
// expect(element).toHaveTextContent(/react/i)
|
4 |
-
// learn more: https://github.com/testing-library/jest-dom
|
5 |
-
import '@testing-library/jest-dom';
|
|
|
|
|
|
|
|
|
|
|
|
src/tools/get_location.js
ADDED
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Get the user's current location using the browser's geolocation API.
|
3 |
+
* @returns {Promise<{ latitude: number, longitude: number }>} The current position { latitude, longitude }.
|
4 |
+
*/
|
5 |
+
export async function get_location() {
|
6 |
+
return new Promise((resolve, reject) => {
|
7 |
+
if (!navigator.geolocation) {
|
8 |
+
reject("Geolocation not supported.");
|
9 |
+
return;
|
10 |
+
}
|
11 |
+
navigator.geolocation.getCurrentPosition(
|
12 |
+
(pos) =>
|
13 |
+
resolve({
|
14 |
+
latitude: pos.coords.latitude,
|
15 |
+
longitude: pos.coords.longitude,
|
16 |
+
}),
|
17 |
+
(err) => reject(err.message || "Geolocation error"),
|
18 |
+
);
|
19 |
+
});
|
20 |
+
}
|
21 |
+
|
22 |
+
export default (input, output) =>
|
23 |
+
React.createElement(
|
24 |
+
"div",
|
25 |
+
{ className: "bg-green-50 border border-green-200 rounded-lg p-4" },
|
26 |
+
React.createElement(
|
27 |
+
"div",
|
28 |
+
{ className: "flex items-center mb-2" },
|
29 |
+
React.createElement(
|
30 |
+
"div",
|
31 |
+
{
|
32 |
+
className:
|
33 |
+
"w-8 h-8 bg-green-100 rounded-full flex items-center justify-center mr-3",
|
34 |
+
},
|
35 |
+
"📍",
|
36 |
+
),
|
37 |
+
React.createElement(
|
38 |
+
"h3",
|
39 |
+
{ className: "text-green-900 font-semibold" },
|
40 |
+
"Location",
|
41 |
+
),
|
42 |
+
),
|
43 |
+
output?.latitude && output?.longitude
|
44 |
+
? React.createElement(
|
45 |
+
"div",
|
46 |
+
{ className: "space-y-1 text-sm" },
|
47 |
+
React.createElement(
|
48 |
+
"p",
|
49 |
+
{ className: "text-green-700" },
|
50 |
+
React.createElement(
|
51 |
+
"span",
|
52 |
+
{ className: "font-medium" },
|
53 |
+
"Latitude: ",
|
54 |
+
),
|
55 |
+
output.latitude.toFixed(6),
|
56 |
+
),
|
57 |
+
React.createElement(
|
58 |
+
"p",
|
59 |
+
{ className: "text-green-700" },
|
60 |
+
React.createElement(
|
61 |
+
"span",
|
62 |
+
{ className: "font-medium" },
|
63 |
+
"Longitude: ",
|
64 |
+
),
|
65 |
+
output.longitude.toFixed(6),
|
66 |
+
),
|
67 |
+
React.createElement(
|
68 |
+
"a",
|
69 |
+
{
|
70 |
+
href: `https://maps.google.com?q=${output.latitude},${output.longitude}`,
|
71 |
+
target: "_blank",
|
72 |
+
rel: "noopener noreferrer",
|
73 |
+
className:
|
74 |
+
"inline-block mt-2 text-green-600 hover:text-green-800 underline text-xs",
|
75 |
+
},
|
76 |
+
"View on Google Maps",
|
77 |
+
),
|
78 |
+
)
|
79 |
+
: React.createElement(
|
80 |
+
"p",
|
81 |
+
{ className: "text-green-700 text-sm" },
|
82 |
+
JSON.stringify(output),
|
83 |
+
),
|
84 |
+
);
|
src/tools/get_time.js
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Get the current date and time.
|
3 |
+
* @returns {{ iso: string, local: string }} The current date and time as ISO and local time strings.
|
4 |
+
*/
|
5 |
+
export function get_time() {
|
6 |
+
const now = new Date();
|
7 |
+
return {
|
8 |
+
iso: now.toISOString(),
|
9 |
+
local: now.toLocaleString(undefined, {
|
10 |
+
dateStyle: "full",
|
11 |
+
timeStyle: "long",
|
12 |
+
}),
|
13 |
+
};
|
14 |
+
}
|
15 |
+
|
16 |
+
export default (input, output) =>
|
17 |
+
React.createElement(
|
18 |
+
"div",
|
19 |
+
{ className: "bg-amber-50 border border-amber-200 rounded-lg p-4" },
|
20 |
+
React.createElement(
|
21 |
+
"div",
|
22 |
+
{ className: "flex items-center mb-2" },
|
23 |
+
React.createElement(
|
24 |
+
"div",
|
25 |
+
{
|
26 |
+
className:
|
27 |
+
"w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center mr-3",
|
28 |
+
},
|
29 |
+
"🕐",
|
30 |
+
),
|
31 |
+
React.createElement(
|
32 |
+
"h3",
|
33 |
+
{ className: "text-amber-900 font-semibold" },
|
34 |
+
"Current Time",
|
35 |
+
),
|
36 |
+
),
|
37 |
+
React.createElement(
|
38 |
+
"div",
|
39 |
+
{ className: "text-sm space-y-1" },
|
40 |
+
React.createElement(
|
41 |
+
"p",
|
42 |
+
{ className: "text-amber-700 font-mono" },
|
43 |
+
output.local,
|
44 |
+
),
|
45 |
+
React.createElement(
|
46 |
+
"p",
|
47 |
+
{ className: "text-amber-600 text-xs" },
|
48 |
+
new Date(output.iso).toLocaleString(),
|
49 |
+
),
|
50 |
+
),
|
51 |
+
);
|
src/tools/index.ts
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import SPEAK_TOOL from "./speak.js?raw";
|
2 |
+
import GET_LOCATION_TOOL from "./get_location.js?raw";
|
3 |
+
import SLEEP_TOOL from "./sleep.js?raw";
|
4 |
+
import GET_TIME_TOOL from "./get_time.js?raw";
|
5 |
+
import RANDOM_NUMBER_TOOL from "./random_number.js?raw";
|
6 |
+
import MATH_EVAL_TOOL from "./math_eval.js?raw";
|
7 |
+
import TEMPLATE_TOOL from "./template.js?raw";
|
8 |
+
import OPEN_WEBPAGE_TOOL from "./open_webpage.js?raw";
|
9 |
+
|
10 |
+
export const DEFAULT_TOOLS = {
|
11 |
+
speak: SPEAK_TOOL,
|
12 |
+
get_location: GET_LOCATION_TOOL,
|
13 |
+
sleep: SLEEP_TOOL,
|
14 |
+
get_time: GET_TIME_TOOL,
|
15 |
+
random_number: RANDOM_NUMBER_TOOL,
|
16 |
+
math_eval: MATH_EVAL_TOOL,
|
17 |
+
open_webpage: OPEN_WEBPAGE_TOOL,
|
18 |
+
};
|
19 |
+
export const TEMPLATE = TEMPLATE_TOOL;
|
src/tools/math_eval.js
ADDED
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Evaluate a math expression.
|
3 |
+
* @param {string} expression - The math expression (e.g., "2 + 2 * (3 - 1)").
|
4 |
+
* @returns {number} The result of the expression.
|
5 |
+
*/
|
6 |
+
export function math_eval(expression) {
|
7 |
+
// Only allow numbers, spaces, and math symbols: + - * / % ( ) .
|
8 |
+
if (!/^[\d\s+\-*/%.()]+$/.test(expression)) {
|
9 |
+
throw new Error("Invalid characters in expression.");
|
10 |
+
}
|
11 |
+
return Function('"use strict";return (' + expression + ")")();
|
12 |
+
}
|
13 |
+
|
14 |
+
export default (input, output) =>
|
15 |
+
React.createElement(
|
16 |
+
"div",
|
17 |
+
{ className: "bg-emerald-50 border border-emerald-200 rounded-lg p-4" },
|
18 |
+
React.createElement(
|
19 |
+
"div",
|
20 |
+
{ className: "flex items-center mb-2" },
|
21 |
+
React.createElement(
|
22 |
+
"div",
|
23 |
+
{
|
24 |
+
className:
|
25 |
+
"w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center mr-3",
|
26 |
+
},
|
27 |
+
"🧮",
|
28 |
+
),
|
29 |
+
React.createElement(
|
30 |
+
"h3",
|
31 |
+
{ className: "text-emerald-900 font-semibold" },
|
32 |
+
"Math Evaluation",
|
33 |
+
),
|
34 |
+
),
|
35 |
+
React.createElement(
|
36 |
+
"div",
|
37 |
+
{ className: "text-center" },
|
38 |
+
React.createElement(
|
39 |
+
"div",
|
40 |
+
{ className: "text-lg font-mono text-emerald-700 mb-1" },
|
41 |
+
input.expression || "Unknown expression",
|
42 |
+
),
|
43 |
+
React.createElement(
|
44 |
+
"div",
|
45 |
+
{ className: "text-2xl font-bold text-emerald-600 mb-1" },
|
46 |
+
`= ${output}`,
|
47 |
+
),
|
48 |
+
React.createElement(
|
49 |
+
"p",
|
50 |
+
{ className: "text-emerald-500 text-xs" },
|
51 |
+
"Calculation result",
|
52 |
+
),
|
53 |
+
),
|
54 |
+
);
|
src/tools/mcp_servers.ts
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { MCPServerConfig } from "../types/mcp";
|
2 |
+
|
3 |
+
export const MCP_SERVERS: MCPServerConfig[] = [
|
4 |
+
{
|
5 |
+
id: "hf-transformers-demo-gitmcp",
|
6 |
+
name: "HuggingFace Transformers.js Documentation",
|
7 |
+
url: "https://gitmcp.io/huggingface/transformers.js",
|
8 |
+
enabled: true,
|
9 |
+
transport: "streamable-http",
|
10 |
+
},
|
11 |
+
{
|
12 |
+
id: "mcp-servers-docs",
|
13 |
+
name: "MCP Documentation",
|
14 |
+
url: "https://gitmcp.io/modelcontextprotocol/modelcontextprotocol",
|
15 |
+
enabled: true,
|
16 |
+
transport: "streamable-http",
|
17 |
+
},
|
18 |
+
];
|
src/tools/open_webpage.js
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Open a webpage
|
3 |
+
* @param {string} src - The URL of the webpage.
|
4 |
+
* @returns {string} The validated URL.
|
5 |
+
*/
|
6 |
+
export function open_webpage(src) {
|
7 |
+
try {
|
8 |
+
const urlObj = new URL(src);
|
9 |
+
if (!["http:", "https:"].includes(urlObj.protocol)) {
|
10 |
+
throw new Error("Only HTTP and HTTPS URLs are allowed.");
|
11 |
+
}
|
12 |
+
return urlObj.href;
|
13 |
+
} catch (error) {
|
14 |
+
throw new Error("Invalid URL provided.");
|
15 |
+
}
|
16 |
+
}
|
17 |
+
|
18 |
+
export default (input, output) => {
|
19 |
+
return React.createElement(
|
20 |
+
"div",
|
21 |
+
{ className: "bg-blue-50 border border-blue-200 rounded-lg p-4" },
|
22 |
+
React.createElement(
|
23 |
+
"div",
|
24 |
+
{ className: "flex items-center mb-2" },
|
25 |
+
React.createElement(
|
26 |
+
"div",
|
27 |
+
{
|
28 |
+
className:
|
29 |
+
"w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3",
|
30 |
+
},
|
31 |
+
"🌐",
|
32 |
+
),
|
33 |
+
React.createElement(
|
34 |
+
"h3",
|
35 |
+
{ className: "text-blue-900 font-semibold" },
|
36 |
+
"Web Page",
|
37 |
+
),
|
38 |
+
),
|
39 |
+
React.createElement("iframe", {
|
40 |
+
src: output,
|
41 |
+
className: "w-full border border-blue-300 rounded",
|
42 |
+
width: 480,
|
43 |
+
height: 360,
|
44 |
+
title: "Embedded content",
|
45 |
+
allow: "autoplay",
|
46 |
+
frameBorder: "0",
|
47 |
+
}),
|
48 |
+
);
|
49 |
+
};
|
src/tools/random_number.js
ADDED
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Generate a random integer between min and max (inclusive).
|
3 |
+
* @param {number} min - Minimum value (inclusive).
|
4 |
+
* @param {number} max - Maximum value (inclusive).
|
5 |
+
* @returns {number} A random integer.
|
6 |
+
*/
|
7 |
+
export function random_number(min, max) {
|
8 |
+
min = Math.ceil(Number(min));
|
9 |
+
max = Math.floor(Number(max));
|
10 |
+
if (isNaN(min) || isNaN(max) || min > max) {
|
11 |
+
throw new Error("Invalid min or max value.");
|
12 |
+
}
|
13 |
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
14 |
+
}
|
15 |
+
|
16 |
+
export default (input, output) =>
|
17 |
+
React.createElement(
|
18 |
+
"div",
|
19 |
+
{ className: "bg-indigo-50 border border-indigo-200 rounded-lg p-4" },
|
20 |
+
React.createElement(
|
21 |
+
"div",
|
22 |
+
{ className: "flex items-center mb-2" },
|
23 |
+
React.createElement(
|
24 |
+
"div",
|
25 |
+
{
|
26 |
+
className:
|
27 |
+
"w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center mr-3",
|
28 |
+
},
|
29 |
+
"🎲",
|
30 |
+
),
|
31 |
+
React.createElement(
|
32 |
+
"h3",
|
33 |
+
{ className: "text-indigo-900 font-semibold" },
|
34 |
+
"Random Number",
|
35 |
+
),
|
36 |
+
),
|
37 |
+
React.createElement(
|
38 |
+
"div",
|
39 |
+
{ className: "text-center" },
|
40 |
+
React.createElement(
|
41 |
+
"div",
|
42 |
+
{ className: "text-3xl font-bold text-indigo-600 mb-1" },
|
43 |
+
output,
|
44 |
+
),
|
45 |
+
React.createElement(
|
46 |
+
"p",
|
47 |
+
{ className: "text-indigo-500 text-xs" },
|
48 |
+
`Range: ${input.min || "?"} - ${input.max || "?"}`,
|
49 |
+
),
|
50 |
+
),
|
51 |
+
);
|