Spaces:
Running
Running
init
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +2 -0
- .gitignore +22 -20
- README.md +4 -4
- eslint.config.js +23 -0
- index.html +12 -0
- package.json +29 -31
- public/favicon.ico +0 -0
- public/index.html +0 -43
- public/liquidai-logo.svg +1 -0
- public/logo192.png +0 -0
- public/logo512.png +0 -0
- public/manifest.json +0 -25
- public/robots.txt +0 -3
- public/thumbnail.png +3 -0
- src/App.css +0 -38
- src/App.js +0 -25
- src/App.test.js +0 -8
- src/App.tsx +925 -0
- src/components/ExamplePrompts.tsx +53 -0
- src/components/LoadingScreen.tsx +538 -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/index.ts +2 -0
- src/tools/mcp_servers.ts +18 -0
- src/tools/template.js +47 -0
- src/types/mcp.ts +58 -0
- src/utils.ts +370 -0
- src/utils/storage.ts +108 -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,25 @@
|
|
| 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?
|
| 25 |
+
package-lock.json
|
README.md
CHANGED
|
@@ -4,16 +4,16 @@ emoji: 🐠
|
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
-
pinned:
|
| 8 |
app_build_command: npm run build
|
| 9 |
app_file: build/index.html
|
| 10 |
license: apache-2.0
|
| 11 |
-
short_description: Use MCP and
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
This project
|
| 17 |
|
| 18 |
## Available Scripts
|
| 19 |
|
|
|
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
+
pinned: true
|
| 8 |
app_build_command: npm run build
|
| 9 |
app_file: build/index.html
|
| 10 |
license: apache-2.0
|
| 11 |
+
short_description: Use MCP and WebGPU-based LLMs with tool calling
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# WebGPU MCP
|
| 15 |
|
| 16 |
+
Run WebGPU-based language models with tool calling capabilities in your browser, powered by the Model Context Protocol (MCP). This project supports any WebGPU-compatible models from [Hugging Face ONNX Community](https://huggingface.co/onnx-community) that support tool calling.
|
| 17 |
|
| 18 |
## Available Scripts
|
| 19 |
|
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,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>WebGPU MCP - In-Browser Tool Calling</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<div id="root"></div>
|
| 10 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 11 |
+
</body>
|
| 12 |
+
</html>
|
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": "mcp-webgpu",
|
|
|
|
| 3 |
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"@huggingface/transformers": "^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/liquidai-logo.svg
ADDED
|
|
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:
|
|
|
|
|
|
|
|
|
|
|
|
public/thumbnail.png
ADDED
|
|
Git LFS Details
|
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,925 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 { 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 |
+
setTools(allTools.map((t) => ({ ...t, isCollapsed: false })));
|
| 127 |
+
|
| 128 |
+
// Load MCP tools and merge them
|
| 129 |
+
const mcpTools = getMCPToolsAsOriginalTools();
|
| 130 |
+
setTools((prevTools) => [...prevTools, ...mcpTools]);
|
| 131 |
+
}, [getMCPToolsAsOriginalTools]);
|
| 132 |
+
|
| 133 |
+
useEffect(() => {
|
| 134 |
+
loadTools();
|
| 135 |
+
// Connect to MCP servers on startup
|
| 136 |
+
connectAllMCPServers().catch((error) => {
|
| 137 |
+
console.error("Failed to connect to MCP servers:", error);
|
| 138 |
+
});
|
| 139 |
+
}, [loadTools, connectAllMCPServers]);
|
| 140 |
+
|
| 141 |
+
useEffect(() => {
|
| 142 |
+
if (chatContainerRef.current) {
|
| 143 |
+
chatContainerRef.current.scrollTop =
|
| 144 |
+
chatContainerRef.current.scrollHeight;
|
| 145 |
+
}
|
| 146 |
+
}, [messages]);
|
| 147 |
+
|
| 148 |
+
const updateToolInDB = async (tool: Tool): Promise<void> => {
|
| 149 |
+
const db = await getDB();
|
| 150 |
+
await db.put(STORE_NAME, tool);
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const saveToolDebounced = (tool: Tool): void => {
|
| 154 |
+
if (tool.id !== undefined && debounceTimers.current[tool.id]) {
|
| 155 |
+
clearTimeout(debounceTimers.current[tool.id]);
|
| 156 |
+
}
|
| 157 |
+
if (tool.id !== undefined) {
|
| 158 |
+
debounceTimers.current[tool.id] = setTimeout(() => {
|
| 159 |
+
updateToolInDB(tool);
|
| 160 |
+
}, 300);
|
| 161 |
+
}
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const clearChat = useCallback(() => {
|
| 165 |
+
setMessages([]);
|
| 166 |
+
clearPastKeyValues();
|
| 167 |
+
}, [clearPastKeyValues]);
|
| 168 |
+
|
| 169 |
+
const addTool = async (): Promise<void> => {
|
| 170 |
+
const newTool: Omit<Tool, "id"> = {
|
| 171 |
+
name: "new_tool",
|
| 172 |
+
code: TEMPLATE,
|
| 173 |
+
enabled: true,
|
| 174 |
+
isCollapsed: false,
|
| 175 |
+
};
|
| 176 |
+
const db = await getDB();
|
| 177 |
+
const id = await db.add(STORE_NAME, newTool);
|
| 178 |
+
setTools((prev) => {
|
| 179 |
+
const updated = [...prev, { ...newTool, id: id as number }];
|
| 180 |
+
setTimeout(() => {
|
| 181 |
+
if (toolsContainerRef.current) {
|
| 182 |
+
toolsContainerRef.current.scrollTop =
|
| 183 |
+
toolsContainerRef.current.scrollHeight;
|
| 184 |
+
}
|
| 185 |
+
}, 0);
|
| 186 |
+
return updated;
|
| 187 |
+
});
|
| 188 |
+
clearChat();
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const deleteTool = async (id: number): Promise<void> => {
|
| 192 |
+
if (debounceTimers.current[id]) {
|
| 193 |
+
clearTimeout(debounceTimers.current[id]);
|
| 194 |
+
}
|
| 195 |
+
const db = await getDB();
|
| 196 |
+
await db.delete(STORE_NAME, id);
|
| 197 |
+
setTools(tools.filter((tool) => tool.id !== id));
|
| 198 |
+
clearChat();
|
| 199 |
+
};
|
| 200 |
+
|
| 201 |
+
const toggleToolEnabled = (id: number): void => {
|
| 202 |
+
let changedTool: Tool | undefined;
|
| 203 |
+
const newTools = tools.map((tool) => {
|
| 204 |
+
if (tool.id === id) {
|
| 205 |
+
changedTool = { ...tool, enabled: !tool.enabled };
|
| 206 |
+
return changedTool;
|
| 207 |
+
}
|
| 208 |
+
return tool;
|
| 209 |
+
});
|
| 210 |
+
setTools(newTools);
|
| 211 |
+
if (changedTool) saveToolDebounced(changedTool);
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const toggleToolCollapsed = (id: number): void => {
|
| 215 |
+
setTools(
|
| 216 |
+
tools.map((tool) =>
|
| 217 |
+
tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool
|
| 218 |
+
)
|
| 219 |
+
);
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
const expandTool = (id: number): void => {
|
| 223 |
+
setTools(
|
| 224 |
+
tools.map((tool) =>
|
| 225 |
+
tool.id === id ? { ...tool, isCollapsed: false } : tool
|
| 226 |
+
)
|
| 227 |
+
);
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
const handleToolCodeChange = (id: number, newCode: string): void => {
|
| 231 |
+
let changedTool: Tool | undefined;
|
| 232 |
+
const newTools = tools.map((tool) => {
|
| 233 |
+
if (tool.id === id) {
|
| 234 |
+
const { functionCode } = extractFunctionAndRenderer(newCode);
|
| 235 |
+
const schema = generateSchemaFromCode(functionCode);
|
| 236 |
+
changedTool = { ...tool, code: newCode, name: schema.name };
|
| 237 |
+
return changedTool;
|
| 238 |
+
}
|
| 239 |
+
return tool;
|
| 240 |
+
});
|
| 241 |
+
setTools(newTools);
|
| 242 |
+
if (changedTool) saveToolDebounced(changedTool);
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
const executeToolCall = async (callString: string): Promise<string> => {
|
| 246 |
+
const parsedCall = parsePythonicCalls(callString);
|
| 247 |
+
if (!parsedCall) throw new Error(`Invalid tool call format: ${callString}`);
|
| 248 |
+
|
| 249 |
+
const { name, positionalArgs, keywordArgs } = parsedCall;
|
| 250 |
+
const toolToUse = tools.find((t) => t.name === name && t.enabled);
|
| 251 |
+
if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`);
|
| 252 |
+
|
| 253 |
+
// Check if this is an MCP tool
|
| 254 |
+
const isMCPTool = toolToUse.code?.includes("mcpServerId:");
|
| 255 |
+
if (isMCPTool) {
|
| 256 |
+
// Extract MCP server ID and tool name from the code
|
| 257 |
+
const mcpServerMatch = toolToUse.code?.match(/mcpServerId: "([^"]+)"/);
|
| 258 |
+
const mcpToolMatch = toolToUse.code?.match(/toolName: "([^"]+)"/);
|
| 259 |
+
|
| 260 |
+
if (mcpServerMatch && mcpToolMatch) {
|
| 261 |
+
const serverId = mcpServerMatch[1];
|
| 262 |
+
const toolName = mcpToolMatch[1];
|
| 263 |
+
|
| 264 |
+
// Convert positional and keyword args to a single args object
|
| 265 |
+
const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
|
| 266 |
+
const schema = generateSchemaFromCode(functionCode);
|
| 267 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
| 268 |
+
|
| 269 |
+
const args: Record<string, unknown> = {};
|
| 270 |
+
|
| 271 |
+
// Map positional args
|
| 272 |
+
for (
|
| 273 |
+
let i = 0;
|
| 274 |
+
i < Math.min(positionalArgs.length, paramNames.length);
|
| 275 |
+
i++
|
| 276 |
+
) {
|
| 277 |
+
args[paramNames[i]] = positionalArgs[i];
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// Map keyword args
|
| 281 |
+
Object.entries(keywordArgs).forEach(([key, value]) => {
|
| 282 |
+
args[key] = value;
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
// Call MCP tool
|
| 286 |
+
const result = await callMCPTool(serverId, toolName, args);
|
| 287 |
+
return JSON.stringify(result);
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// Handle local tools as before
|
| 292 |
+
const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
|
| 293 |
+
const schema = generateSchemaFromCode(functionCode);
|
| 294 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
| 295 |
+
|
| 296 |
+
const finalArgs: unknown[] = [];
|
| 297 |
+
const requiredParams = schema.parameters.required || [];
|
| 298 |
+
|
| 299 |
+
for (let i = 0; i < paramNames.length; ++i) {
|
| 300 |
+
const paramName = paramNames[i];
|
| 301 |
+
if (i < positionalArgs.length) {
|
| 302 |
+
finalArgs.push(positionalArgs[i]);
|
| 303 |
+
} else if (Object.prototype.hasOwnProperty.call(keywordArgs, paramName)) {
|
| 304 |
+
finalArgs.push(keywordArgs[paramName]);
|
| 305 |
+
} else if (
|
| 306 |
+
Object.prototype.hasOwnProperty.call(
|
| 307 |
+
schema.parameters.properties[paramName],
|
| 308 |
+
"default"
|
| 309 |
+
)
|
| 310 |
+
) {
|
| 311 |
+
finalArgs.push(schema.parameters.properties[paramName].default);
|
| 312 |
+
} else if (!requiredParams.includes(paramName)) {
|
| 313 |
+
finalArgs.push(undefined);
|
| 314 |
+
} else {
|
| 315 |
+
throw new Error(`Missing required argument: ${paramName}`);
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/);
|
| 320 |
+
if (!bodyMatch) {
|
| 321 |
+
throw new Error(
|
| 322 |
+
"Could not parse function body. Ensure it's a standard `function` declaration."
|
| 323 |
+
);
|
| 324 |
+
}
|
| 325 |
+
const body = bodyMatch[1];
|
| 326 |
+
const AsyncFunction = Object.getPrototypeOf(
|
| 327 |
+
async function () {}
|
| 328 |
+
).constructor;
|
| 329 |
+
const func = new AsyncFunction(...paramNames, body);
|
| 330 |
+
const result = await func(...finalArgs);
|
| 331 |
+
return JSON.stringify(result);
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
const executeToolCalls = async (
|
| 335 |
+
toolCallContent: string
|
| 336 |
+
): Promise<RenderInfo[]> => {
|
| 337 |
+
const toolCalls = extractPythonicCalls(toolCallContent);
|
| 338 |
+
if (toolCalls.length === 0)
|
| 339 |
+
return [{ call: "", error: "No valid tool calls found." }];
|
| 340 |
+
|
| 341 |
+
const results: RenderInfo[] = [];
|
| 342 |
+
for (const call of toolCalls) {
|
| 343 |
+
try {
|
| 344 |
+
const result = await executeToolCall(call);
|
| 345 |
+
const parsedCall = parsePythonicCalls(call);
|
| 346 |
+
const toolUsed = parsedCall
|
| 347 |
+
? tools.find((t) => t.name === parsedCall.name && t.enabled)
|
| 348 |
+
: null;
|
| 349 |
+
const { rendererCode } = toolUsed
|
| 350 |
+
? extractFunctionAndRenderer(toolUsed.code)
|
| 351 |
+
: { rendererCode: undefined };
|
| 352 |
+
|
| 353 |
+
let parsedResult;
|
| 354 |
+
try {
|
| 355 |
+
parsedResult = JSON.parse(result);
|
| 356 |
+
} catch {
|
| 357 |
+
parsedResult = result;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
let namedParams: Record<string, unknown> = Object.create(null);
|
| 361 |
+
if (parsedCall && toolUsed) {
|
| 362 |
+
const schema = generateSchemaFromCode(
|
| 363 |
+
extractFunctionAndRenderer(toolUsed.code).functionCode
|
| 364 |
+
);
|
| 365 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
| 366 |
+
namedParams = mapArgsToNamedParams(
|
| 367 |
+
paramNames,
|
| 368 |
+
parsedCall.positionalArgs,
|
| 369 |
+
parsedCall.keywordArgs
|
| 370 |
+
);
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
const renderInfo: RenderInfo = {
|
| 374 |
+
call,
|
| 375 |
+
result: parsedResult,
|
| 376 |
+
renderer: rendererCode,
|
| 377 |
+
};
|
| 378 |
+
if (namedParams && Object.keys(namedParams).length > 0) {
|
| 379 |
+
renderInfo.input = namedParams;
|
| 380 |
+
}
|
| 381 |
+
results.push(renderInfo);
|
| 382 |
+
} catch (error) {
|
| 383 |
+
const errorMessage = getErrorMessage(error);
|
| 384 |
+
results.push({ call, error: errorMessage });
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
return results;
|
| 388 |
+
};
|
| 389 |
+
|
| 390 |
+
const handleSendMessage = async (): Promise<void> => {
|
| 391 |
+
if (!input.trim() || !isReady) return;
|
| 392 |
+
|
| 393 |
+
const userMessage: Message = { role: "user", content: input };
|
| 394 |
+
const currentMessages: Message[] = [...messages, userMessage];
|
| 395 |
+
setMessages(currentMessages);
|
| 396 |
+
setInput("");
|
| 397 |
+
setIsGenerating(true);
|
| 398 |
+
|
| 399 |
+
try {
|
| 400 |
+
const toolSchemas = tools
|
| 401 |
+
.filter((tool) => tool.enabled)
|
| 402 |
+
.map((tool) => generateSchemaFromCode(tool.code));
|
| 403 |
+
|
| 404 |
+
while (true) {
|
| 405 |
+
const messagesForGeneration = [
|
| 406 |
+
{ role: "system" as const, content: systemPrompt },
|
| 407 |
+
...currentMessages,
|
| 408 |
+
];
|
| 409 |
+
|
| 410 |
+
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
| 411 |
+
|
| 412 |
+
let accumulatedContent = "";
|
| 413 |
+
const response = await generateResponse(
|
| 414 |
+
messagesForGeneration,
|
| 415 |
+
toolSchemas,
|
| 416 |
+
(token: string) => {
|
| 417 |
+
accumulatedContent += token;
|
| 418 |
+
setMessages((current) => {
|
| 419 |
+
const updated = [...current];
|
| 420 |
+
updated[updated.length - 1] = {
|
| 421 |
+
role: "assistant",
|
| 422 |
+
content: accumulatedContent,
|
| 423 |
+
};
|
| 424 |
+
return updated;
|
| 425 |
+
});
|
| 426 |
+
}
|
| 427 |
+
);
|
| 428 |
+
|
| 429 |
+
currentMessages.push({ role: "assistant", content: response });
|
| 430 |
+
const toolCallContent = extractToolCallContent(response);
|
| 431 |
+
|
| 432 |
+
if (toolCallContent) {
|
| 433 |
+
const toolResults = await executeToolCalls(toolCallContent);
|
| 434 |
+
|
| 435 |
+
const toolMessage: ToolMessage = {
|
| 436 |
+
role: "tool",
|
| 437 |
+
content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
|
| 438 |
+
renderInfo: toolResults,
|
| 439 |
+
};
|
| 440 |
+
currentMessages.push(toolMessage);
|
| 441 |
+
setMessages([...currentMessages]);
|
| 442 |
+
continue;
|
| 443 |
+
} else {
|
| 444 |
+
setMessages(currentMessages);
|
| 445 |
+
break;
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
} catch (error) {
|
| 449 |
+
const errorMessage = getErrorMessage(error);
|
| 450 |
+
setMessages([
|
| 451 |
+
...currentMessages,
|
| 452 |
+
{
|
| 453 |
+
role: "assistant",
|
| 454 |
+
content: `Error generating response: ${errorMessage}`,
|
| 455 |
+
},
|
| 456 |
+
]);
|
| 457 |
+
} finally {
|
| 458 |
+
setIsGenerating(false);
|
| 459 |
+
setTimeout(() => inputRef.current?.focus(), 0);
|
| 460 |
+
}
|
| 461 |
+
};
|
| 462 |
+
|
| 463 |
+
const loadSystemPrompt = useCallback(async (): Promise<void> => {
|
| 464 |
+
try {
|
| 465 |
+
const db = await getDB();
|
| 466 |
+
const stored = await db.get(SETTINGS_STORE_NAME, "systemPrompt");
|
| 467 |
+
if (stored && stored.value) setSystemPrompt(stored.value);
|
| 468 |
+
} catch (error) {
|
| 469 |
+
console.error("Failed to load system prompt:", error);
|
| 470 |
+
}
|
| 471 |
+
}, []);
|
| 472 |
+
|
| 473 |
+
const saveSystemPrompt = useCallback(
|
| 474 |
+
async (prompt: string): Promise<void> => {
|
| 475 |
+
try {
|
| 476 |
+
const db = await getDB();
|
| 477 |
+
await db.put(SETTINGS_STORE_NAME, {
|
| 478 |
+
key: "systemPrompt",
|
| 479 |
+
value: prompt,
|
| 480 |
+
});
|
| 481 |
+
} catch (error) {
|
| 482 |
+
console.error("Failed to save system prompt:", error);
|
| 483 |
+
}
|
| 484 |
+
},
|
| 485 |
+
[]
|
| 486 |
+
);
|
| 487 |
+
|
| 488 |
+
const loadSelectedModel = useCallback(async (): Promise<void> => {
|
| 489 |
+
try {
|
| 490 |
+
await loadModel();
|
| 491 |
+
} catch (error) {
|
| 492 |
+
console.error("Failed to load model:", error);
|
| 493 |
+
}
|
| 494 |
+
}, [loadModel]);
|
| 495 |
+
|
| 496 |
+
const loadSelectedModelId = useCallback(async (): Promise<void> => {
|
| 497 |
+
try {
|
| 498 |
+
const db = await getDB();
|
| 499 |
+
const stored = await db.get(SETTINGS_STORE_NAME, "selectedModelId");
|
| 500 |
+
if (stored && stored.value) {
|
| 501 |
+
setSelectedModelId(stored.value);
|
| 502 |
+
}
|
| 503 |
+
} catch (error) {
|
| 504 |
+
console.error("Failed to load selected model ID:", error);
|
| 505 |
+
}
|
| 506 |
+
}, []);
|
| 507 |
+
|
| 508 |
+
useEffect(() => {
|
| 509 |
+
loadSystemPrompt();
|
| 510 |
+
}, [loadSystemPrompt]);
|
| 511 |
+
|
| 512 |
+
const handleOpenSystemPromptModal = (): void => {
|
| 513 |
+
setTempSystemPrompt(systemPrompt);
|
| 514 |
+
setIsSystemPromptModalOpen(true);
|
| 515 |
+
};
|
| 516 |
+
|
| 517 |
+
const handleSaveSystemPrompt = (): void => {
|
| 518 |
+
setSystemPrompt(tempSystemPrompt);
|
| 519 |
+
saveSystemPrompt(tempSystemPrompt);
|
| 520 |
+
setIsSystemPromptModalOpen(false);
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
const handleCancelSystemPrompt = (): void => {
|
| 524 |
+
setTempSystemPrompt("");
|
| 525 |
+
setIsSystemPromptModalOpen(false);
|
| 526 |
+
};
|
| 527 |
+
|
| 528 |
+
const handleResetSystemPrompt = (): void => {
|
| 529 |
+
setTempSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
| 530 |
+
};
|
| 531 |
+
|
| 532 |
+
const saveSelectedModel = useCallback(
|
| 533 |
+
async (modelId: string): Promise<void> => {
|
| 534 |
+
try {
|
| 535 |
+
const db = await getDB();
|
| 536 |
+
await db.put(SETTINGS_STORE_NAME, {
|
| 537 |
+
key: "selectedModelId",
|
| 538 |
+
value: modelId,
|
| 539 |
+
});
|
| 540 |
+
} catch (error) {
|
| 541 |
+
console.error("Failed to save selected model ID:", error);
|
| 542 |
+
}
|
| 543 |
+
},
|
| 544 |
+
[]
|
| 545 |
+
);
|
| 546 |
+
|
| 547 |
+
useEffect(() => {
|
| 548 |
+
loadSystemPrompt();
|
| 549 |
+
loadSelectedModelId();
|
| 550 |
+
}, [loadSystemPrompt, loadSelectedModelId]);
|
| 551 |
+
|
| 552 |
+
const handleModelSelect = async (modelId: string) => {
|
| 553 |
+
setSelectedModelId(modelId);
|
| 554 |
+
setIsModelDropdownOpen(false);
|
| 555 |
+
await saveSelectedModel(modelId);
|
| 556 |
+
};
|
| 557 |
+
|
| 558 |
+
const handleExampleClick = async (messageText: string): Promise<void> => {
|
| 559 |
+
if (!isReady || isGenerating) return;
|
| 560 |
+
setInput(messageText);
|
| 561 |
+
|
| 562 |
+
const userMessage: Message = { role: "user", content: messageText };
|
| 563 |
+
const currentMessages: Message[] = [...messages, userMessage];
|
| 564 |
+
setMessages(currentMessages);
|
| 565 |
+
setInput("");
|
| 566 |
+
setIsGenerating(true);
|
| 567 |
+
|
| 568 |
+
try {
|
| 569 |
+
const toolSchemas = tools
|
| 570 |
+
.filter((tool) => tool.enabled)
|
| 571 |
+
.map((tool) => generateSchemaFromCode(tool.code));
|
| 572 |
+
|
| 573 |
+
while (true) {
|
| 574 |
+
const messagesForGeneration = [
|
| 575 |
+
{ role: "system" as const, content: systemPrompt },
|
| 576 |
+
...currentMessages,
|
| 577 |
+
];
|
| 578 |
+
|
| 579 |
+
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
| 580 |
+
|
| 581 |
+
let accumulatedContent = "";
|
| 582 |
+
const response = await generateResponse(
|
| 583 |
+
messagesForGeneration,
|
| 584 |
+
toolSchemas,
|
| 585 |
+
(token: string) => {
|
| 586 |
+
accumulatedContent += token;
|
| 587 |
+
setMessages((current) => {
|
| 588 |
+
const updated = [...current];
|
| 589 |
+
updated[updated.length - 1] = {
|
| 590 |
+
role: "assistant",
|
| 591 |
+
content: accumulatedContent,
|
| 592 |
+
};
|
| 593 |
+
return updated;
|
| 594 |
+
});
|
| 595 |
+
}
|
| 596 |
+
);
|
| 597 |
+
|
| 598 |
+
currentMessages.push({ role: "assistant", content: response });
|
| 599 |
+
const toolCallContent = extractToolCallContent(response);
|
| 600 |
+
|
| 601 |
+
if (toolCallContent) {
|
| 602 |
+
const toolResults = await executeToolCalls(toolCallContent);
|
| 603 |
+
|
| 604 |
+
const toolMessage: ToolMessage = {
|
| 605 |
+
role: "tool",
|
| 606 |
+
content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
|
| 607 |
+
renderInfo: toolResults,
|
| 608 |
+
};
|
| 609 |
+
currentMessages.push(toolMessage);
|
| 610 |
+
setMessages([...currentMessages]);
|
| 611 |
+
continue;
|
| 612 |
+
} else {
|
| 613 |
+
setMessages(currentMessages);
|
| 614 |
+
break;
|
| 615 |
+
}
|
| 616 |
+
}
|
| 617 |
+
} catch (error) {
|
| 618 |
+
const errorMessage = getErrorMessage(error);
|
| 619 |
+
setMessages([
|
| 620 |
+
...currentMessages,
|
| 621 |
+
{
|
| 622 |
+
role: "assistant",
|
| 623 |
+
content: `Error generating response: ${errorMessage}`,
|
| 624 |
+
},
|
| 625 |
+
]);
|
| 626 |
+
} finally {
|
| 627 |
+
setIsGenerating(false);
|
| 628 |
+
setTimeout(() => inputRef.current?.focus(), 0);
|
| 629 |
+
}
|
| 630 |
+
};
|
| 631 |
+
|
| 632 |
+
return (
|
| 633 |
+
<div className="font-sans bg-gray-900">
|
| 634 |
+
{!isReady ? (
|
| 635 |
+
<LoadingScreen
|
| 636 |
+
isLoading={isLoading}
|
| 637 |
+
progress={progress}
|
| 638 |
+
error={error}
|
| 639 |
+
loadSelectedModel={loadSelectedModel}
|
| 640 |
+
selectedModelId={selectedModelId}
|
| 641 |
+
isModelDropdownOpen={isModelDropdownOpen}
|
| 642 |
+
setIsModelDropdownOpen={setIsModelDropdownOpen}
|
| 643 |
+
handleModelSelect={handleModelSelect}
|
| 644 |
+
/>
|
| 645 |
+
) : (
|
| 646 |
+
<div className="flex h-screen text-white">
|
| 647 |
+
<div
|
| 648 |
+
className={`flex flex-col p-4 transition-all duration-300 ${
|
| 649 |
+
isToolsPanelVisible ? "w-1/2" : "w-full"
|
| 650 |
+
}`}
|
| 651 |
+
>
|
| 652 |
+
<div className="flex items-center justify-between mb-4">
|
| 653 |
+
<div className="flex items-center gap-3">
|
| 654 |
+
<h1 className="text-3xl font-bold text-gray-200">WebGPU MCP</h1>
|
| 655 |
+
</div>
|
| 656 |
+
<div className="flex items-center gap-3">
|
| 657 |
+
<div className="flex items-center text-green-400">
|
| 658 |
+
<Zap size={16} className="mr-2" />
|
| 659 |
+
Ready
|
| 660 |
+
</div>
|
| 661 |
+
<button
|
| 662 |
+
disabled={isGenerating}
|
| 663 |
+
onClick={clearChat}
|
| 664 |
+
className={`h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors text-sm ${
|
| 665 |
+
isGenerating
|
| 666 |
+
? "bg-gray-600 cursor-not-allowed opacity-50"
|
| 667 |
+
: "bg-gray-600 hover:bg-gray-700"
|
| 668 |
+
}`}
|
| 669 |
+
title="Clear chat"
|
| 670 |
+
>
|
| 671 |
+
<RotateCcw size={14} className="mr-2" /> Clear
|
| 672 |
+
</button>
|
| 673 |
+
<button
|
| 674 |
+
onClick={handleOpenSystemPromptModal}
|
| 675 |
+
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"
|
| 676 |
+
title="Edit system prompt"
|
| 677 |
+
>
|
| 678 |
+
<Settings size={16} />
|
| 679 |
+
</button>
|
| 680 |
+
<button
|
| 681 |
+
onClick={() => setIsMCPManagerOpen(true)}
|
| 682 |
+
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"
|
| 683 |
+
title="Manage MCP Servers"
|
| 684 |
+
>
|
| 685 |
+
🌐
|
| 686 |
+
</button>
|
| 687 |
+
<button
|
| 688 |
+
onClick={() => setIsToolsPanelVisible(!isToolsPanelVisible)}
|
| 689 |
+
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"
|
| 690 |
+
title={
|
| 691 |
+
isToolsPanelVisible
|
| 692 |
+
? "Hide Tools Panel"
|
| 693 |
+
: "Show Tools Panel"
|
| 694 |
+
}
|
| 695 |
+
>
|
| 696 |
+
{isToolsPanelVisible ? (
|
| 697 |
+
<PanelRightClose size={16} />
|
| 698 |
+
) : (
|
| 699 |
+
<PanelRightOpen size={16} />
|
| 700 |
+
)}
|
| 701 |
+
</button>
|
| 702 |
+
</div>
|
| 703 |
+
</div>
|
| 704 |
+
|
| 705 |
+
<div
|
| 706 |
+
ref={chatContainerRef}
|
| 707 |
+
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto mb-4 space-y-4"
|
| 708 |
+
>
|
| 709 |
+
{messages.length === 0 && isReady ? (
|
| 710 |
+
<ExamplePrompts
|
| 711 |
+
examples={tools
|
| 712 |
+
.filter((tool) => tool.enabled)
|
| 713 |
+
.map((tool) => ({
|
| 714 |
+
icon: "🛠️",
|
| 715 |
+
displayText: tool.name,
|
| 716 |
+
messageText: `${tool.name}()`,
|
| 717 |
+
}))
|
| 718 |
+
.filter((ex) => ex.displayText)}
|
| 719 |
+
onExampleClick={handleExampleClick}
|
| 720 |
+
/>
|
| 721 |
+
) : (
|
| 722 |
+
messages.map((msg, index) => {
|
| 723 |
+
const key = `${msg.role}-${index}`;
|
| 724 |
+
|
| 725 |
+
if (msg.role === "user") {
|
| 726 |
+
return (
|
| 727 |
+
<div key={key} className="flex justify-end">
|
| 728 |
+
<div className="p-3 rounded-lg max-w-md bg-indigo-600">
|
| 729 |
+
<p className="text-sm whitespace-pre-wrap">
|
| 730 |
+
{msg.content}
|
| 731 |
+
</p>
|
| 732 |
+
</div>
|
| 733 |
+
</div>
|
| 734 |
+
);
|
| 735 |
+
} else if (msg.role === "assistant") {
|
| 736 |
+
const isToolCall = msg.content.includes(
|
| 737 |
+
"<|tool_call_start|>"
|
| 738 |
+
);
|
| 739 |
+
|
| 740 |
+
if (isToolCall) {
|
| 741 |
+
const nextMessage = messages[index + 1];
|
| 742 |
+
const isCompleted = nextMessage?.role === "tool";
|
| 743 |
+
const hasError =
|
| 744 |
+
isCompleted &&
|
| 745 |
+
(nextMessage as ToolMessage).renderInfo.some(
|
| 746 |
+
(info) => !!info.error
|
| 747 |
+
);
|
| 748 |
+
|
| 749 |
+
return (
|
| 750 |
+
<div key={key} className="flex justify-start">
|
| 751 |
+
<div className="p-3 rounded-lg bg-gray-700">
|
| 752 |
+
<ToolCallIndicator
|
| 753 |
+
content={msg.content}
|
| 754 |
+
isRunning={!isCompleted}
|
| 755 |
+
hasError={hasError}
|
| 756 |
+
/>
|
| 757 |
+
</div>
|
| 758 |
+
</div>
|
| 759 |
+
);
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
return (
|
| 763 |
+
<div key={key} className="flex justify-start">
|
| 764 |
+
<div className="p-3 rounded-lg max-w-md bg-gray-700">
|
| 765 |
+
<p className="text-sm whitespace-pre-wrap">
|
| 766 |
+
{msg.content}
|
| 767 |
+
</p>
|
| 768 |
+
</div>
|
| 769 |
+
</div>
|
| 770 |
+
);
|
| 771 |
+
} else if (msg.role === "tool") {
|
| 772 |
+
const visibleToolResults = msg.renderInfo.filter(
|
| 773 |
+
(info) =>
|
| 774 |
+
info.error || (info.result != null && info.renderer)
|
| 775 |
+
);
|
| 776 |
+
|
| 777 |
+
if (visibleToolResults.length === 0) return null;
|
| 778 |
+
|
| 779 |
+
return (
|
| 780 |
+
<div key={key} className="flex justify-start">
|
| 781 |
+
<div className="p-3 rounded-lg bg-gray-700 max-w-lg">
|
| 782 |
+
<div className="space-y-3">
|
| 783 |
+
{visibleToolResults.map((info, idx) => (
|
| 784 |
+
<div className="flex flex-col gap-2" key={idx}>
|
| 785 |
+
<div className="text-xs text-gray-400 font-mono">
|
| 786 |
+
{info.call}
|
| 787 |
+
</div>
|
| 788 |
+
{info.error ? (
|
| 789 |
+
<ResultBlock error={info.error} />
|
| 790 |
+
) : (
|
| 791 |
+
<ToolResultRenderer
|
| 792 |
+
result={info.result}
|
| 793 |
+
rendererCode={info.renderer}
|
| 794 |
+
input={info.input}
|
| 795 |
+
/>
|
| 796 |
+
)}
|
| 797 |
+
</div>
|
| 798 |
+
))}
|
| 799 |
+
</div>
|
| 800 |
+
</div>
|
| 801 |
+
</div>
|
| 802 |
+
);
|
| 803 |
+
}
|
| 804 |
+
return null;
|
| 805 |
+
})
|
| 806 |
+
)}
|
| 807 |
+
</div>
|
| 808 |
+
|
| 809 |
+
<div className="flex">
|
| 810 |
+
<input
|
| 811 |
+
ref={inputRef}
|
| 812 |
+
type="text"
|
| 813 |
+
value={input}
|
| 814 |
+
onChange={(e) => setInput(e.target.value)}
|
| 815 |
+
onKeyDown={(e) =>
|
| 816 |
+
e.key === "Enter" &&
|
| 817 |
+
!isGenerating &&
|
| 818 |
+
isReady &&
|
| 819 |
+
handleSendMessage()
|
| 820 |
+
}
|
| 821 |
+
disabled={isGenerating || !isReady}
|
| 822 |
+
className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
| 823 |
+
placeholder={
|
| 824 |
+
isReady
|
| 825 |
+
? "Type your message here..."
|
| 826 |
+
: "Load model first to enable chat"
|
| 827 |
+
}
|
| 828 |
+
/>
|
| 829 |
+
<button
|
| 830 |
+
onClick={handleSendMessage}
|
| 831 |
+
disabled={isGenerating || !isReady}
|
| 832 |
+
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"
|
| 833 |
+
>
|
| 834 |
+
<Play size={20} />
|
| 835 |
+
</button>
|
| 836 |
+
</div>
|
| 837 |
+
</div>
|
| 838 |
+
|
| 839 |
+
{isToolsPanelVisible && (
|
| 840 |
+
<div className="w-1/2 flex flex-col p-4 border-l border-gray-700 transition-all duration-300">
|
| 841 |
+
<div className="flex justify-between items-center mb-4">
|
| 842 |
+
<h2 className="text-2xl font-bold text-teal-400">Tools</h2>
|
| 843 |
+
<button
|
| 844 |
+
onClick={addTool}
|
| 845 |
+
className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
|
| 846 |
+
>
|
| 847 |
+
<Plus size={16} className="mr-2" /> Add Tool
|
| 848 |
+
</button>
|
| 849 |
+
</div>
|
| 850 |
+
<div
|
| 851 |
+
ref={toolsContainerRef}
|
| 852 |
+
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3"
|
| 853 |
+
>
|
| 854 |
+
{tools.map((tool) => (
|
| 855 |
+
<ToolItem
|
| 856 |
+
key={tool.id}
|
| 857 |
+
tool={tool}
|
| 858 |
+
onToggleEnabled={() => toggleToolEnabled(tool.id)}
|
| 859 |
+
onToggleCollapsed={() => toggleToolCollapsed(tool.id)}
|
| 860 |
+
onExpand={() => expandTool(tool.id)}
|
| 861 |
+
onDelete={() => deleteTool(tool.id)}
|
| 862 |
+
onCodeChange={(newCode) =>
|
| 863 |
+
handleToolCodeChange(tool.id, newCode)
|
| 864 |
+
}
|
| 865 |
+
/>
|
| 866 |
+
))}
|
| 867 |
+
</div>
|
| 868 |
+
</div>
|
| 869 |
+
)}
|
| 870 |
+
</div>
|
| 871 |
+
)}
|
| 872 |
+
|
| 873 |
+
{isSystemPromptModalOpen && (
|
| 874 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
| 875 |
+
<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">
|
| 876 |
+
<div className="flex justify-between items-center mb-4">
|
| 877 |
+
<h2 className="text-xl font-bold text-indigo-400">
|
| 878 |
+
Edit System Prompt
|
| 879 |
+
</h2>
|
| 880 |
+
<button
|
| 881 |
+
onClick={handleCancelSystemPrompt}
|
| 882 |
+
className="text-gray-400 hover:text-white"
|
| 883 |
+
>
|
| 884 |
+
<X size={20} />
|
| 885 |
+
</button>
|
| 886 |
+
</div>
|
| 887 |
+
<div className="flex-grow mb-4">
|
| 888 |
+
<textarea
|
| 889 |
+
value={tempSystemPrompt}
|
| 890 |
+
onChange={(e) => setTempSystemPrompt(e.target.value)}
|
| 891 |
+
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"
|
| 892 |
+
placeholder="Enter your system prompt here..."
|
| 893 |
+
style={{ minHeight: "300px" }}
|
| 894 |
+
/>
|
| 895 |
+
</div>
|
| 896 |
+
<div className="flex justify-between">
|
| 897 |
+
<button
|
| 898 |
+
onClick={handleResetSystemPrompt}
|
| 899 |
+
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors"
|
| 900 |
+
>
|
| 901 |
+
Reset
|
| 902 |
+
</button>
|
| 903 |
+
<div className="flex gap-3">
|
| 904 |
+
<button
|
| 905 |
+
onClick={handleSaveSystemPrompt}
|
| 906 |
+
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
| 907 |
+
>
|
| 908 |
+
Save
|
| 909 |
+
</button>
|
| 910 |
+
</div>
|
| 911 |
+
</div>
|
| 912 |
+
</div>
|
| 913 |
+
</div>
|
| 914 |
+
)}
|
| 915 |
+
|
| 916 |
+
{/* MCP Server Manager Modal */}
|
| 917 |
+
<MCPServerManager
|
| 918 |
+
isOpen={isMCPManagerOpen}
|
| 919 |
+
onClose={() => setIsMCPManagerOpen(false)}
|
| 920 |
+
/>
|
| 921 |
+
</div>
|
| 922 |
+
);
|
| 923 |
+
};
|
| 924 |
+
|
| 925 |
+
export default App;
|
src/components/ExamplePrompts.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
// If examples are provided, use them. Otherwise, generate from enabled tools.
|
| 14 |
+
let dynamicExamples = examples;
|
| 15 |
+
if (!dynamicExamples) {
|
| 16 |
+
// Try to get tools from props (if passed as examples)
|
| 17 |
+
dynamicExamples = undefined;
|
| 18 |
+
}
|
| 19 |
+
// If still undefined, fallback to DEFAULT_EXAMPLES
|
| 20 |
+
if (!dynamicExamples) {
|
| 21 |
+
dynamicExamples = DEFAULT_EXAMPLES;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="flex flex-col items-center justify-center h-full space-y-6">
|
| 26 |
+
<div className="text-center mb-6">
|
| 27 |
+
<h2 className="text-2xl font-semibold text-gray-300 mb-1">
|
| 28 |
+
Try an example
|
| 29 |
+
</h2>
|
| 30 |
+
<p className="text-sm text-gray-500">Click one to get started</p>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl w-full px-4">
|
| 34 |
+
{dynamicExamples.map((example, index) => (
|
| 35 |
+
<button
|
| 36 |
+
key={index}
|
| 37 |
+
onClick={() => onExampleClick(example.messageText)}
|
| 38 |
+
className="flex items-center gap-3 p-4 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-left group cursor-pointer"
|
| 39 |
+
>
|
| 40 |
+
<span className="text-xl flex-shrink-0 group-hover:scale-110 transition-transform">
|
| 41 |
+
{example.icon}
|
| 42 |
+
</span>
|
| 43 |
+
<span className="text-sm text-gray-200 group-hover:text-white transition-colors">
|
| 44 |
+
{example.displayText}
|
| 45 |
+
</span>
|
| 46 |
+
</button>
|
| 47 |
+
))}
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
export default ExamplePrompts;
|
src/components/LoadingScreen.tsx
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ChevronDown } from "lucide-react";
|
| 2 |
+
import { MODEL_OPTIONS } from "../constants/models";
|
| 3 |
+
import HfLogo from "./icons/HfLogo";
|
| 4 |
+
import MCPLogo from "./icons/MCPLogo";
|
| 5 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
| 6 |
+
import ReactDOM from "react-dom";
|
| 7 |
+
|
| 8 |
+
type Dot = {
|
| 9 |
+
x: number;
|
| 10 |
+
y: number;
|
| 11 |
+
radius: number;
|
| 12 |
+
speed: number;
|
| 13 |
+
opacity: number;
|
| 14 |
+
blur: number;
|
| 15 |
+
pulse: number;
|
| 16 |
+
pulseSpeed: number;
|
| 17 |
+
};
|
| 18 |
+
export const LoadingScreen = ({
|
| 19 |
+
isLoading,
|
| 20 |
+
progress,
|
| 21 |
+
error,
|
| 22 |
+
loadSelectedModel,
|
| 23 |
+
selectedModelId,
|
| 24 |
+
isModelDropdownOpen,
|
| 25 |
+
setIsModelDropdownOpen,
|
| 26 |
+
handleModelSelect,
|
| 27 |
+
}: {
|
| 28 |
+
isLoading: boolean;
|
| 29 |
+
progress: number;
|
| 30 |
+
error: string | null;
|
| 31 |
+
loadSelectedModel: () => void;
|
| 32 |
+
selectedModelId: string;
|
| 33 |
+
isModelDropdownOpen: boolean;
|
| 34 |
+
setIsModelDropdownOpen: (isOpen: boolean) => void;
|
| 35 |
+
handleModelSelect: (modelId: string) => void;
|
| 36 |
+
}) => {
|
| 37 |
+
const model = useMemo(
|
| 38 |
+
() => MODEL_OPTIONS.find((opt) => opt.id === selectedModelId),
|
| 39 |
+
[selectedModelId]
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
// Refs
|
| 43 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 44 |
+
const dropdownBtnRef = useRef<HTMLButtonElement>(null);
|
| 45 |
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
| 46 |
+
const wrapperRef = useRef<HTMLDivElement>(null); // NEW: anchor for centering
|
| 47 |
+
|
| 48 |
+
// For keyboard navigation
|
| 49 |
+
const [activeIndex, setActiveIndex] = useState(
|
| 50 |
+
Math.max(
|
| 51 |
+
0,
|
| 52 |
+
MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)
|
| 53 |
+
)
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
// Background Animation Effect (crisper dots + reduced motion)
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
const canvas = canvasRef.current;
|
| 59 |
+
if (!canvas) return;
|
| 60 |
+
|
| 61 |
+
const ctx = canvas.getContext("2d");
|
| 62 |
+
if (!ctx) return;
|
| 63 |
+
|
| 64 |
+
const prefersReduced =
|
| 65 |
+
typeof window !== "undefined" &&
|
| 66 |
+
window.matchMedia &&
|
| 67 |
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 68 |
+
|
| 69 |
+
let animationFrameId: number;
|
| 70 |
+
let dots: Dot[] = [];
|
| 71 |
+
|
| 72 |
+
const setup = () => {
|
| 73 |
+
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
| 74 |
+
const { innerWidth, innerHeight } = window;
|
| 75 |
+
canvas.width = Math.floor(innerWidth * dpr);
|
| 76 |
+
canvas.height = Math.floor(innerHeight * dpr);
|
| 77 |
+
canvas.style.width = `${innerWidth}px`;
|
| 78 |
+
canvas.style.height = `${innerHeight}px`;
|
| 79 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 80 |
+
|
| 81 |
+
dots = [];
|
| 82 |
+
const numDots = Math.floor((innerWidth * innerHeight) / 12000);
|
| 83 |
+
for (let i = 0; i < numDots; ++i) {
|
| 84 |
+
dots.push({
|
| 85 |
+
x: Math.random() * innerWidth,
|
| 86 |
+
y: Math.random() * innerHeight,
|
| 87 |
+
radius: Math.random() * 2 + 0.3,
|
| 88 |
+
speed: prefersReduced ? 0 : Math.random() * 0.3 + 0.05,
|
| 89 |
+
opacity: Math.random() * 0.4 + 0.1,
|
| 90 |
+
blur: Math.random() > 0.8 ? Math.random() * 1.5 + 0.5 : 0,
|
| 91 |
+
pulse: Math.random() * Math.PI * 2,
|
| 92 |
+
pulseSpeed: prefersReduced ? 0 : Math.random() * 0.02 + 0.01,
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const draw = () => {
|
| 98 |
+
if (!ctx) return;
|
| 99 |
+
const width = canvas.clientWidth;
|
| 100 |
+
const height = canvas.clientHeight;
|
| 101 |
+
ctx.clearRect(0, 0, width, height);
|
| 102 |
+
|
| 103 |
+
dots.forEach((dot) => {
|
| 104 |
+
dot.y += dot.speed;
|
| 105 |
+
dot.pulse += dot.pulseSpeed;
|
| 106 |
+
|
| 107 |
+
if (dot.y > height + dot.radius) {
|
| 108 |
+
dot.y = -dot.radius;
|
| 109 |
+
dot.x = Math.random() * width;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const pulseFactor = 1 + Math.sin(dot.pulse) * 0.2;
|
| 113 |
+
const currentRadius = dot.radius * pulseFactor;
|
| 114 |
+
const currentOpacity = dot.opacity * (0.8 + Math.sin(dot.pulse) * 0.2);
|
| 115 |
+
|
| 116 |
+
ctx.beginPath();
|
| 117 |
+
ctx.arc(dot.x, dot.y, currentRadius, 0, Math.PI * 2);
|
| 118 |
+
ctx.fillStyle = `rgba(255, 255, 255, ${currentOpacity})`;
|
| 119 |
+
if (dot.blur > 0) ctx.filter = `blur(${dot.blur}px)`;
|
| 120 |
+
ctx.fill();
|
| 121 |
+
ctx.filter = "none";
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
animationFrameId = requestAnimationFrame(draw);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const handleResize = () => {
|
| 128 |
+
cancelAnimationFrame(animationFrameId);
|
| 129 |
+
setup();
|
| 130 |
+
draw();
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
setup();
|
| 134 |
+
draw();
|
| 135 |
+
window.addEventListener("resize", handleResize);
|
| 136 |
+
|
| 137 |
+
return () => {
|
| 138 |
+
window.removeEventListener("resize", handleResize);
|
| 139 |
+
cancelAnimationFrame(animationFrameId);
|
| 140 |
+
};
|
| 141 |
+
}, []);
|
| 142 |
+
|
| 143 |
+
// Close dropdown on Escape / click outside
|
| 144 |
+
useEffect(() => {
|
| 145 |
+
if (!isModelDropdownOpen) return;
|
| 146 |
+
|
| 147 |
+
const onKey = (e: KeyboardEvent) => {
|
| 148 |
+
if (e.key === "Escape") setIsModelDropdownOpen(false);
|
| 149 |
+
if (e.key === "ArrowDown") {
|
| 150 |
+
e.preventDefault();
|
| 151 |
+
setActiveIndex((i) => Math.min(MODEL_OPTIONS.length - 1, i + 1));
|
| 152 |
+
}
|
| 153 |
+
if (e.key === "ArrowUp") {
|
| 154 |
+
e.preventDefault();
|
| 155 |
+
setActiveIndex((i) => Math.max(0, i - 1));
|
| 156 |
+
}
|
| 157 |
+
if (e.key === "Enter") {
|
| 158 |
+
e.preventDefault();
|
| 159 |
+
const opt = MODEL_OPTIONS[activeIndex];
|
| 160 |
+
if (opt) {
|
| 161 |
+
handleModelSelect(opt.id);
|
| 162 |
+
setIsModelDropdownOpen(false);
|
| 163 |
+
dropdownBtnRef.current?.focus();
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const onClick = (e: MouseEvent) => {
|
| 169 |
+
const target = e.target as Node;
|
| 170 |
+
if (
|
| 171 |
+
dropdownRef.current &&
|
| 172 |
+
!dropdownRef.current.contains(target) &&
|
| 173 |
+
!dropdownBtnRef.current?.contains(target)
|
| 174 |
+
) {
|
| 175 |
+
setIsModelDropdownOpen(false);
|
| 176 |
+
}
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
document.addEventListener("keydown", onKey);
|
| 180 |
+
document.addEventListener("mousedown", onClick);
|
| 181 |
+
return () => {
|
| 182 |
+
document.removeEventListener("keydown", onKey);
|
| 183 |
+
document.removeEventListener("mousedown", onClick);
|
| 184 |
+
};
|
| 185 |
+
}, [
|
| 186 |
+
isModelDropdownOpen,
|
| 187 |
+
activeIndex,
|
| 188 |
+
setIsModelDropdownOpen,
|
| 189 |
+
handleModelSelect,
|
| 190 |
+
]);
|
| 191 |
+
|
| 192 |
+
// Recompute portal position on open + resize
|
| 193 |
+
const [, forceRerender] = useState(0);
|
| 194 |
+
useEffect(() => {
|
| 195 |
+
const onResize = () => forceRerender((x) => x + 1);
|
| 196 |
+
window.addEventListener("resize", onResize);
|
| 197 |
+
return () => window.removeEventListener("resize", onResize);
|
| 198 |
+
}, []);
|
| 199 |
+
|
| 200 |
+
// Compute portal style based on the whole button group (center + clamp + optional drop-up)
|
| 201 |
+
const portalStyle = useMemo(() => {
|
| 202 |
+
if (typeof window === "undefined") return {};
|
| 203 |
+
const anchor = wrapperRef.current || dropdownBtnRef.current;
|
| 204 |
+
if (!anchor) return {};
|
| 205 |
+
|
| 206 |
+
const rect = anchor.getBoundingClientRect();
|
| 207 |
+
|
| 208 |
+
const margin = 8;
|
| 209 |
+
const minWidth = 320;
|
| 210 |
+
const dropdownWidth = Math.max(rect.width, minWidth);
|
| 211 |
+
|
| 212 |
+
// Center
|
| 213 |
+
let left = Math.round(rect.left + rect.width / 2 - dropdownWidth / 2);
|
| 214 |
+
// Clamp to viewport
|
| 215 |
+
left = Math.min(
|
| 216 |
+
Math.max(margin, left),
|
| 217 |
+
window.innerWidth - dropdownWidth - margin
|
| 218 |
+
);
|
| 219 |
+
|
| 220 |
+
// Flip up if not enough space below
|
| 221 |
+
const spaceBelow = window.innerHeight - rect.bottom;
|
| 222 |
+
const spaceAbove = rect.top;
|
| 223 |
+
const estimatedItemH = 56; // rough item height
|
| 224 |
+
const estimatedPad = 16;
|
| 225 |
+
const estimatedHeight =
|
| 226 |
+
estimatedItemH * Math.min(MODEL_OPTIONS.length, 6) + estimatedPad;
|
| 227 |
+
const dropUp = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
|
| 228 |
+
|
| 229 |
+
const top = dropUp ? rect.top - estimatedHeight - 8 : rect.bottom + 8;
|
| 230 |
+
|
| 231 |
+
return {
|
| 232 |
+
position: "fixed" as const,
|
| 233 |
+
left: `${left}px`,
|
| 234 |
+
top: `${top}px`,
|
| 235 |
+
width: `${dropdownWidth}px`,
|
| 236 |
+
zIndex: 100,
|
| 237 |
+
};
|
| 238 |
+
}, []);
|
| 239 |
+
|
| 240 |
+
return (
|
| 241 |
+
<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">
|
| 242 |
+
{/* Background Canvas */}
|
| 243 |
+
<canvas
|
| 244 |
+
ref={canvasRef}
|
| 245 |
+
className="absolute top-0 left-0 w-full h-full z-0"
|
| 246 |
+
/>
|
| 247 |
+
|
| 248 |
+
{/* Vignette Overlay */}
|
| 249 |
+
<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%)]" />
|
| 250 |
+
|
| 251 |
+
{/* Grid Overlay */}
|
| 252 |
+
<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]" />
|
| 253 |
+
|
| 254 |
+
{/* Main Content */}
|
| 255 |
+
<div className="relative z-20 max-w-4xl w-full flex flex-col items-center">
|
| 256 |
+
{/* Logos */}
|
| 257 |
+
<div className="flex items-center justify-center mb-8 gap-5">
|
| 258 |
+
<a
|
| 259 |
+
href="https://huggingface.co/docs/transformers.js"
|
| 260 |
+
target="_blank"
|
| 261 |
+
rel="noopener noreferrer"
|
| 262 |
+
title="Transformers.js"
|
| 263 |
+
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"
|
| 264 |
+
>
|
| 265 |
+
<HfLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />
|
| 266 |
+
</a>
|
| 267 |
+
<span className="text-gray-500 text-3xl font-extralight">×</span>
|
| 268 |
+
<a
|
| 269 |
+
href="https://modelcontextprotocol.io/"
|
| 270 |
+
target="_blank"
|
| 271 |
+
rel="noopener noreferrer"
|
| 272 |
+
title="Model Context Protocol"
|
| 273 |
+
className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"
|
| 274 |
+
>
|
| 275 |
+
<MCPLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />
|
| 276 |
+
</a>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
{/* Hero */}
|
| 280 |
+
<div className="text-center mb-8 space-y-4">
|
| 281 |
+
<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">
|
| 282 |
+
WebGPU MCP
|
| 283 |
+
</h1>
|
| 284 |
+
<p className="text-lg sm:text-xl md:text-2xl text-gray-300 font-light leading-relaxed">
|
| 285 |
+
Run WebGPU-based models with tool calling in your browser, powered by the{" "}
|
| 286 |
+
<a
|
| 287 |
+
href="https://modelcontextprotocol.io/"
|
| 288 |
+
target="_blank"
|
| 289 |
+
rel="noopener noreferrer"
|
| 290 |
+
>
|
| 291 |
+
<span className="text-indigo-400 font-medium">
|
| 292 |
+
Model Context Protocol (MCP)
|
| 293 |
+
</span>{" "}
|
| 294 |
+
enabling secure, real-time connections to remote servers.
|
| 295 |
+
</a>
|
| 296 |
+
</p>
|
| 297 |
+
<div className="w-24 h-1 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full mx-auto" />
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
{/* Description Cards */}
|
| 301 |
+
<div className="grid md:grid-cols-2 gap-6 text-gray-400 mb-10">
|
| 302 |
+
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">
|
| 303 |
+
<h3 className="text-white font-semibold mb-3 flex items-center">
|
| 304 |
+
<div className="w-2 h-2 bg-indigo-500 rounded-full mr-3" />
|
| 305 |
+
Model Context Protocol
|
| 306 |
+
</h3>
|
| 307 |
+
<p className="text-sm leading-relaxed">
|
| 308 |
+
Connect seamlessly to remote{" "}
|
| 309 |
+
<a
|
| 310 |
+
href="https://modelcontextprotocol.io/"
|
| 311 |
+
target="_blank"
|
| 312 |
+
rel="noopener noreferrer"
|
| 313 |
+
className="text-indigo-400 hover:underline"
|
| 314 |
+
>
|
| 315 |
+
MCP servers
|
| 316 |
+
</a>{" "}
|
| 317 |
+
using streaming or SSE protocols with support for no-auth, basic
|
| 318 |
+
auth, and OAuth.
|
| 319 |
+
</p>
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">
|
| 323 |
+
<h3 className="text-white font-semibold mb-3 flex items-center">
|
| 324 |
+
<div className="w-2 h-2 bg-purple-500 rounded-full mr-3" />
|
| 325 |
+
WebGPU Models
|
| 326 |
+
</h3>
|
| 327 |
+
<p className="text-sm leading-relaxed">
|
| 328 |
+
Supports any WebGPU-compatible models from{" "}
|
| 329 |
+
<a
|
| 330 |
+
href="https://huggingface.co/onnx-community"
|
| 331 |
+
target="_blank"
|
| 332 |
+
rel="noopener noreferrer"
|
| 333 |
+
className="text-indigo-400 hover:underline"
|
| 334 |
+
>
|
| 335 |
+
Hugging Face ONNX Community
|
| 336 |
+
</a>{" "}
|
| 337 |
+
that support tool calling, optimized for on-device deployment.
|
| 338 |
+
</p>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
|
| 342 |
+
<p className="text-gray-400 text-base sm:text-lg mb-10">
|
| 343 |
+
Everything runs entirely in your browser with{" "}
|
| 344 |
+
<a
|
| 345 |
+
href="https://huggingface.co/docs/transformers.js"
|
| 346 |
+
target="_blank"
|
| 347 |
+
rel="noopener noreferrer"
|
| 348 |
+
className="text-indigo-400 hover:underline font-medium"
|
| 349 |
+
>
|
| 350 |
+
Transformers.js
|
| 351 |
+
</a>{" "}
|
| 352 |
+
and ONNX Runtime Web.
|
| 353 |
+
</p>
|
| 354 |
+
|
| 355 |
+
{/* Action */}
|
| 356 |
+
<div className="text-center space-y-6">
|
| 357 |
+
<p className="text-gray-400 text-base sm:text-lg font-medium">
|
| 358 |
+
Select a model to load locally, and connect to a remote MCP server
|
| 359 |
+
to get started.
|
| 360 |
+
</p>
|
| 361 |
+
|
| 362 |
+
<div className="relative">
|
| 363 |
+
<div
|
| 364 |
+
ref={wrapperRef} // anchor for dropdown centering
|
| 365 |
+
className="flex rounded-2xl shadow-2xl overflow-hidden"
|
| 366 |
+
>
|
| 367 |
+
<button
|
| 368 |
+
onClick={isLoading ? undefined : loadSelectedModel}
|
| 369 |
+
disabled={isLoading}
|
| 370 |
+
className={`flex items-center justify-center font-bold transition-all text-lg flex-1 ${
|
| 371 |
+
isLoading
|
| 372 |
+
? "bg-gray-700 text-gray-400 cursor-not-allowed"
|
| 373 |
+
: "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]"
|
| 374 |
+
}`}
|
| 375 |
+
aria-live="polite"
|
| 376 |
+
aria-busy={isLoading}
|
| 377 |
+
aria-label={
|
| 378 |
+
isLoading
|
| 379 |
+
? `Loading ${model?.label ?? "model"} ${progress}%`
|
| 380 |
+
: `Load ${model?.label ?? "model"}`
|
| 381 |
+
}
|
| 382 |
+
>
|
| 383 |
+
<div className="px-8 py-4">
|
| 384 |
+
{isLoading ? (
|
| 385 |
+
<div className="flex items-center">
|
| 386 |
+
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 387 |
+
<span className="ml-3 font-semibold">
|
| 388 |
+
Loading... {progress}%
|
| 389 |
+
</span>
|
| 390 |
+
</div>
|
| 391 |
+
) : (
|
| 392 |
+
<span className="font-semibold">Load {model?.label}</span>
|
| 393 |
+
)}
|
| 394 |
+
</div>
|
| 395 |
+
</button>
|
| 396 |
+
|
| 397 |
+
<button
|
| 398 |
+
ref={dropdownBtnRef}
|
| 399 |
+
onClick={(e) => {
|
| 400 |
+
if (!isLoading) {
|
| 401 |
+
e.stopPropagation();
|
| 402 |
+
setIsModelDropdownOpen(!isModelDropdownOpen);
|
| 403 |
+
setActiveIndex(
|
| 404 |
+
Math.max(
|
| 405 |
+
0,
|
| 406 |
+
MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)
|
| 407 |
+
)
|
| 408 |
+
);
|
| 409 |
+
}
|
| 410 |
+
}}
|
| 411 |
+
onKeyDown={(e) => {
|
| 412 |
+
if (isLoading) return;
|
| 413 |
+
if (
|
| 414 |
+
e.key === " " ||
|
| 415 |
+
e.key === "Enter" ||
|
| 416 |
+
e.key === "ArrowDown"
|
| 417 |
+
) {
|
| 418 |
+
e.preventDefault();
|
| 419 |
+
if (!isModelDropdownOpen) setIsModelDropdownOpen(true);
|
| 420 |
+
}
|
| 421 |
+
}}
|
| 422 |
+
aria-haspopup="menu"
|
| 423 |
+
aria-expanded={isModelDropdownOpen}
|
| 424 |
+
aria-controls="model-dropdown"
|
| 425 |
+
aria-label="Select model"
|
| 426 |
+
className={`px-4 py-4 border-l border-white/20 transition-all ${
|
| 427 |
+
isLoading
|
| 428 |
+
? "bg-gray-700 cursor-not-allowed"
|
| 429 |
+
: "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]"
|
| 430 |
+
}`}
|
| 431 |
+
disabled={isLoading}
|
| 432 |
+
>
|
| 433 |
+
<ChevronDown
|
| 434 |
+
size={20}
|
| 435 |
+
className={`transition-transform duration-200 ${
|
| 436 |
+
isModelDropdownOpen ? "rotate-180" : ""
|
| 437 |
+
}`}
|
| 438 |
+
/>
|
| 439 |
+
</button>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
+
{/* Dropdown (Portal) */}
|
| 443 |
+
{isModelDropdownOpen &&
|
| 444 |
+
typeof document !== "undefined" &&
|
| 445 |
+
ReactDOM.createPortal(
|
| 446 |
+
<div
|
| 447 |
+
id="model-dropdown"
|
| 448 |
+
ref={dropdownRef}
|
| 449 |
+
style={portalStyle}
|
| 450 |
+
role="menu"
|
| 451 |
+
aria-label="Model options"
|
| 452 |
+
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"
|
| 453 |
+
>
|
| 454 |
+
{MODEL_OPTIONS.map((option, index) => {
|
| 455 |
+
const selected = selectedModelId === option.id;
|
| 456 |
+
const isActive = activeIndex === index;
|
| 457 |
+
return (
|
| 458 |
+
<button
|
| 459 |
+
key={option.id}
|
| 460 |
+
role="menuitem"
|
| 461 |
+
aria-checked={selected}
|
| 462 |
+
onMouseEnter={() => setActiveIndex(index)}
|
| 463 |
+
onClick={() => {
|
| 464 |
+
handleModelSelect(option.id);
|
| 465 |
+
setIsModelDropdownOpen(false);
|
| 466 |
+
dropdownBtnRef.current?.focus();
|
| 467 |
+
}}
|
| 468 |
+
className={`w-full px-6 py-4 text-left transition-all duration-200 relative group outline-none ${
|
| 469 |
+
selected
|
| 470 |
+
? "bg-gradient-to-r from-indigo-600/50 to-purple-600/50 text-white border-l-4 border-indigo-400"
|
| 471 |
+
: "text-gray-200 hover:bg-white/10 hover:text-white"
|
| 472 |
+
} ${index === 0 ? "rounded-t-2xl" : ""} ${
|
| 473 |
+
index === MODEL_OPTIONS.length - 1
|
| 474 |
+
? "rounded-b-2xl"
|
| 475 |
+
: ""
|
| 476 |
+
} ${isActive && !selected ? "bg-white/5" : ""}`}
|
| 477 |
+
>
|
| 478 |
+
<div className="flex items-center justify-between">
|
| 479 |
+
<div>
|
| 480 |
+
<div className="font-semibold text-lg">
|
| 481 |
+
{option.label}
|
| 482 |
+
</div>
|
| 483 |
+
<div className="text-sm text-gray-400 mt-1">
|
| 484 |
+
{option.size}
|
| 485 |
+
</div>
|
| 486 |
+
</div>
|
| 487 |
+
{selected && (
|
| 488 |
+
<div className="w-2 h-2 bg-indigo-400 rounded-full" />
|
| 489 |
+
)}
|
| 490 |
+
</div>
|
| 491 |
+
{!selected && (
|
| 492 |
+
<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" />
|
| 493 |
+
)}
|
| 494 |
+
</button>
|
| 495 |
+
);
|
| 496 |
+
})}
|
| 497 |
+
</div>,
|
| 498 |
+
document.body
|
| 499 |
+
)}
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
|
| 503 |
+
{/* Error */}
|
| 504 |
+
{error && (
|
| 505 |
+
<div
|
| 506 |
+
role="alert"
|
| 507 |
+
className="bg-red-900/30 backdrop-blur-sm border border-red-500/50 rounded-2xl p-6 mt-8 max-w-md text-center"
|
| 508 |
+
>
|
| 509 |
+
<p className="text-red-200 mb-4 font-medium">Error: {error}</p>
|
| 510 |
+
<button
|
| 511 |
+
onClick={loadSelectedModel}
|
| 512 |
+
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"
|
| 513 |
+
>
|
| 514 |
+
Try Again
|
| 515 |
+
</button>
|
| 516 |
+
</div>
|
| 517 |
+
)}
|
| 518 |
+
</div>
|
| 519 |
+
|
| 520 |
+
{/* Click-away fallback for touch devices */}
|
| 521 |
+
{isModelDropdownOpen && (
|
| 522 |
+
<div
|
| 523 |
+
className="fixed inset-0 z-40 bg-black/20"
|
| 524 |
+
onClick={(e) => {
|
| 525 |
+
const target = e.target as Node;
|
| 526 |
+
if (
|
| 527 |
+
dropdownRef.current &&
|
| 528 |
+
!dropdownRef.current.contains(target) &&
|
| 529 |
+
!dropdownBtnRef.current?.contains(target)
|
| 530 |
+
) {
|
| 531 |
+
setIsModelDropdownOpen(false);
|
| 532 |
+
}
|
| 533 |
+
}}
|
| 534 |
+
/>
|
| 535 |
+
)}
|
| 536 |
+
</div>
|
| 537 |
+
);
|
| 538 |
+
};
|
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/index.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import TEMPLATE_TOOL from "./template.js?raw";
|
| 2 |
+
export const TEMPLATE = TEMPLATE_TOOL;
|
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/template.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Description of the tool.
|
| 3 |
+
* @param {any} parameter1 - Description of the first parameter.
|
| 4 |
+
* @param {any} parameter2 - Description of the second parameter.
|
| 5 |
+
* @returns {any} Description of the return value.
|
| 6 |
+
*/
|
| 7 |
+
export function new_tool(parameter1, parameter2) {
|
| 8 |
+
// TODO: Implement the tool logic here
|
| 9 |
+
return true; // Placeholder return value
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default (input, output) =>
|
| 13 |
+
React.createElement(
|
| 14 |
+
"div",
|
| 15 |
+
{ className: "bg-amber-50 border border-amber-200 rounded-lg p-4" },
|
| 16 |
+
React.createElement(
|
| 17 |
+
"div",
|
| 18 |
+
{ className: "flex items-center mb-2" },
|
| 19 |
+
React.createElement(
|
| 20 |
+
"div",
|
| 21 |
+
{
|
| 22 |
+
className:
|
| 23 |
+
"w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center mr-3",
|
| 24 |
+
},
|
| 25 |
+
"🛠️"
|
| 26 |
+
),
|
| 27 |
+
React.createElement(
|
| 28 |
+
"h3",
|
| 29 |
+
{ className: "text-amber-900 font-semibold" },
|
| 30 |
+
"Tool Name"
|
| 31 |
+
)
|
| 32 |
+
),
|
| 33 |
+
React.createElement(
|
| 34 |
+
"div",
|
| 35 |
+
{ className: "text-sm space-y-1" },
|
| 36 |
+
React.createElement(
|
| 37 |
+
"p",
|
| 38 |
+
{ className: "text-amber-700 font-medium" },
|
| 39 |
+
`Input: ${JSON.stringify(input)}`
|
| 40 |
+
),
|
| 41 |
+
React.createElement(
|
| 42 |
+
"p",
|
| 43 |
+
{ className: "text-amber-600 text-xs" },
|
| 44 |
+
`Output: ${output}`
|
| 45 |
+
)
|
| 46 |
+
)
|
| 47 |
+
);
|
src/types/mcp.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js";
|
| 2 |
+
|
| 3 |
+
export interface MCPServerConfig {
|
| 4 |
+
id: string;
|
| 5 |
+
name: string;
|
| 6 |
+
url: string;
|
| 7 |
+
enabled: boolean;
|
| 8 |
+
transport: "sse" | "streamable-http";
|
| 9 |
+
auth?: {
|
| 10 |
+
type: "bearer" | "basic" | "oauth";
|
| 11 |
+
token?: string;
|
| 12 |
+
username?: string;
|
| 13 |
+
password?: string;
|
| 14 |
+
};
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface MCPServerConnection {
|
| 18 |
+
config: MCPServerConfig;
|
| 19 |
+
isConnected: boolean;
|
| 20 |
+
tools: MCPTool[];
|
| 21 |
+
lastError?: string;
|
| 22 |
+
lastConnected?: Date;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Extended Tool interface to support both local and MCP tools
|
| 26 |
+
export interface ExtendedTool {
|
| 27 |
+
id: number | string;
|
| 28 |
+
name: string;
|
| 29 |
+
enabled: boolean;
|
| 30 |
+
isCollapsed?: boolean;
|
| 31 |
+
|
| 32 |
+
// Local tool properties
|
| 33 |
+
code?: string;
|
| 34 |
+
renderer?: string;
|
| 35 |
+
|
| 36 |
+
// MCP tool properties
|
| 37 |
+
mcpServerId?: string;
|
| 38 |
+
mcpTool?: MCPTool;
|
| 39 |
+
isRemote?: boolean;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// MCP Tool execution result
|
| 43 |
+
export interface MCPToolResult {
|
| 44 |
+
content: Array<{
|
| 45 |
+
type: string;
|
| 46 |
+
text?: string;
|
| 47 |
+
data?: unknown;
|
| 48 |
+
mimeType?: string;
|
| 49 |
+
}>;
|
| 50 |
+
isError?: boolean;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// MCP Client state
|
| 54 |
+
export interface MCPClientState {
|
| 55 |
+
servers: Record<string, MCPServerConnection>;
|
| 56 |
+
isLoading: boolean;
|
| 57 |
+
error?: string;
|
| 58 |
+
}
|
src/utils.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface ParsedCall {
|
| 2 |
+
name: string;
|
| 3 |
+
positionalArgs: any[];
|
| 4 |
+
keywordArgs: Record<string, any>;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
interface Schema {
|
| 8 |
+
name: string;
|
| 9 |
+
description: string;
|
| 10 |
+
parameters: {
|
| 11 |
+
type: string;
|
| 12 |
+
properties: Record<
|
| 13 |
+
string,
|
| 14 |
+
{
|
| 15 |
+
type: string;
|
| 16 |
+
description: string;
|
| 17 |
+
default?: any;
|
| 18 |
+
}
|
| 19 |
+
>;
|
| 20 |
+
required: string[];
|
| 21 |
+
};
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface JSDocParam {
|
| 25 |
+
type: string;
|
| 26 |
+
description: string;
|
| 27 |
+
isOptional: boolean;
|
| 28 |
+
defaultValue?: string;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const parseArguments = (argsString: string): string[] => {
|
| 32 |
+
const args: string[] = [];
|
| 33 |
+
let current = "";
|
| 34 |
+
let inQuotes = false;
|
| 35 |
+
let quoteChar = "";
|
| 36 |
+
let depth = 0;
|
| 37 |
+
|
| 38 |
+
for (let i = 0; i < argsString.length; i++) {
|
| 39 |
+
const char = argsString[i];
|
| 40 |
+
|
| 41 |
+
if (!inQuotes && (char === '"' || char === "'")) {
|
| 42 |
+
inQuotes = true;
|
| 43 |
+
quoteChar = char;
|
| 44 |
+
current += char;
|
| 45 |
+
} else if (inQuotes && char === quoteChar) {
|
| 46 |
+
inQuotes = false;
|
| 47 |
+
quoteChar = "";
|
| 48 |
+
current += char;
|
| 49 |
+
} else if (!inQuotes && char === "(") {
|
| 50 |
+
depth++;
|
| 51 |
+
current += char;
|
| 52 |
+
} else if (!inQuotes && char === ")") {
|
| 53 |
+
depth--;
|
| 54 |
+
current += char;
|
| 55 |
+
} else if (!inQuotes && char === "," && depth === 0) {
|
| 56 |
+
args.push(current.trim());
|
| 57 |
+
current = "";
|
| 58 |
+
} else {
|
| 59 |
+
current += char;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (current.trim()) {
|
| 64 |
+
args.push(current.trim());
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return args;
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
export const extractPythonicCalls = (toolCallContent: string): string[] => {
|
| 71 |
+
try {
|
| 72 |
+
const cleanContent = toolCallContent.trim();
|
| 73 |
+
|
| 74 |
+
try {
|
| 75 |
+
const parsed = JSON.parse(cleanContent);
|
| 76 |
+
if (Array.isArray(parsed)) {
|
| 77 |
+
return parsed;
|
| 78 |
+
}
|
| 79 |
+
} catch {
|
| 80 |
+
// Fallback to manual parsing
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if (cleanContent.startsWith("[") && cleanContent.endsWith("]")) {
|
| 84 |
+
const inner = cleanContent.slice(1, -1).trim();
|
| 85 |
+
if (!inner) return [];
|
| 86 |
+
return parseArguments(inner).map((call) =>
|
| 87 |
+
call.trim().replace(/^['"]|['"]$/g, ""),
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return [cleanContent];
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error("Error parsing tool calls:", error);
|
| 94 |
+
return [];
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
export const parsePythonicCalls = (command: string): ParsedCall | null => {
|
| 99 |
+
const callMatch = command.match(/^([a-zA-Z0-9_]+)\((.*)\)$/);
|
| 100 |
+
if (!callMatch) return null;
|
| 101 |
+
|
| 102 |
+
const [, name, argsStr] = callMatch;
|
| 103 |
+
const args = parseArguments(argsStr);
|
| 104 |
+
const positionalArgs: any[] = [];
|
| 105 |
+
const keywordArgs: Record<string, any> = {};
|
| 106 |
+
|
| 107 |
+
for (const arg of args) {
|
| 108 |
+
const kwargMatch = arg.match(/^([a-zA-Z0-9_]+)\s*=\s*(.*)$/);
|
| 109 |
+
if (kwargMatch) {
|
| 110 |
+
const [, key, value] = kwargMatch;
|
| 111 |
+
try {
|
| 112 |
+
keywordArgs[key] = JSON.parse(value);
|
| 113 |
+
} catch {
|
| 114 |
+
keywordArgs[key] = value;
|
| 115 |
+
}
|
| 116 |
+
} else {
|
| 117 |
+
try {
|
| 118 |
+
positionalArgs.push(JSON.parse(arg));
|
| 119 |
+
} catch {
|
| 120 |
+
positionalArgs.push(arg);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
return { name, positionalArgs, keywordArgs };
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
export const extractFunctionAndRenderer = (
|
| 128 |
+
code: string,
|
| 129 |
+
): { functionCode: string; rendererCode?: string } => {
|
| 130 |
+
if (typeof code !== "string") {
|
| 131 |
+
return { functionCode: code };
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const exportMatch = code.match(/export\s+default\s+/);
|
| 135 |
+
if (!exportMatch) {
|
| 136 |
+
return { functionCode: code };
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const exportIndex = exportMatch.index!;
|
| 140 |
+
const functionCode = code.substring(0, exportIndex).trim();
|
| 141 |
+
const rendererCode = code.substring(exportIndex).trim();
|
| 142 |
+
|
| 143 |
+
return { functionCode, rendererCode };
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
/**
|
| 147 |
+
* Helper function to extract JSDoc parameters from JSDoc comments.
|
| 148 |
+
*/
|
| 149 |
+
const extractJSDocParams = (
|
| 150 |
+
jsdoc: string,
|
| 151 |
+
): Record<string, JSDocParam & { jsdocDefault?: string }> => {
|
| 152 |
+
const jsdocParams: Record<string, JSDocParam & { jsdocDefault?: string }> =
|
| 153 |
+
{};
|
| 154 |
+
const lines = jsdoc
|
| 155 |
+
.split("\n")
|
| 156 |
+
.map((line) => line.trim().replace(/^\*\s?/, ""));
|
| 157 |
+
const paramRegex =
|
| 158 |
+
/@param\s+\{([^}]+)\}\s+(\[?[a-zA-Z0-9_]+(?:=[^\]]+)?\]?|\S+)\s*-?\s*(.*)?/;
|
| 159 |
+
|
| 160 |
+
for (const line of lines) {
|
| 161 |
+
const paramMatch = line.match(paramRegex);
|
| 162 |
+
if (paramMatch) {
|
| 163 |
+
let [, type, namePart, description] = paramMatch;
|
| 164 |
+
description = description || "";
|
| 165 |
+
let isOptional = false;
|
| 166 |
+
let name = namePart;
|
| 167 |
+
let jsdocDefault: string | undefined = undefined;
|
| 168 |
+
|
| 169 |
+
if (name.startsWith("[") && name.endsWith("]")) {
|
| 170 |
+
isOptional = true;
|
| 171 |
+
name = name.slice(1, -1);
|
| 172 |
+
}
|
| 173 |
+
if (name.includes("=")) {
|
| 174 |
+
const [n, def] = name.split("=");
|
| 175 |
+
name = n.trim();
|
| 176 |
+
jsdocDefault = def.trim().replace(/['"]/g, "");
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
jsdocParams[name] = {
|
| 180 |
+
type: type.toLowerCase(),
|
| 181 |
+
description: description.trim(),
|
| 182 |
+
isOptional,
|
| 183 |
+
defaultValue: undefined,
|
| 184 |
+
jsdocDefault,
|
| 185 |
+
};
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
return jsdocParams;
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Helper function to extract function signature information.
|
| 193 |
+
*/
|
| 194 |
+
const extractFunctionSignature = (
|
| 195 |
+
functionCode: string,
|
| 196 |
+
): {
|
| 197 |
+
name: string;
|
| 198 |
+
params: { name: string; defaultValue?: string }[];
|
| 199 |
+
} | null => {
|
| 200 |
+
const functionSignatureMatch = functionCode.match(
|
| 201 |
+
/function\s+([a-zA-Z0-9_]+)\s*\(([^)]*)\)/,
|
| 202 |
+
);
|
| 203 |
+
if (!functionSignatureMatch) {
|
| 204 |
+
return null;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const functionName = functionSignatureMatch[1];
|
| 208 |
+
const params = functionSignatureMatch[2]
|
| 209 |
+
.split(",")
|
| 210 |
+
.map((p) => p.trim())
|
| 211 |
+
.filter(Boolean)
|
| 212 |
+
.map((p) => {
|
| 213 |
+
const [name, defaultValue] = p.split("=").map((s) => s.trim());
|
| 214 |
+
return { name, defaultValue };
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
return { name: functionName, params };
|
| 218 |
+
};
|
| 219 |
+
|
| 220 |
+
export const generateSchemaFromCode = (code: string): Schema => {
|
| 221 |
+
const { functionCode } = extractFunctionAndRenderer(code);
|
| 222 |
+
|
| 223 |
+
if (typeof functionCode !== "string") {
|
| 224 |
+
return {
|
| 225 |
+
name: "invalid_code",
|
| 226 |
+
description: "Code is not a valid string.",
|
| 227 |
+
parameters: { type: "object", properties: {}, required: [] },
|
| 228 |
+
};
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// 1. Extract function signature, name, and parameter names directly from the code
|
| 232 |
+
const signatureInfo = extractFunctionSignature(functionCode);
|
| 233 |
+
if (!signatureInfo) {
|
| 234 |
+
return {
|
| 235 |
+
name: "invalid_function",
|
| 236 |
+
description: "Could not parse function signature.",
|
| 237 |
+
parameters: { type: "object", properties: {}, required: [] },
|
| 238 |
+
};
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const { name: functionName, params: paramsFromSignature } = signatureInfo;
|
| 242 |
+
|
| 243 |
+
const schema: Schema = {
|
| 244 |
+
name: functionName,
|
| 245 |
+
description: "",
|
| 246 |
+
parameters: {
|
| 247 |
+
type: "object",
|
| 248 |
+
properties: {},
|
| 249 |
+
required: [],
|
| 250 |
+
},
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
// 2. Parse JSDoc comments to get descriptions and types
|
| 254 |
+
const jsdocMatch = functionCode.match(/\/\*\*([\s\S]*?)\*\//);
|
| 255 |
+
let jsdocParams: Record<string, JSDocParam & { jsdocDefault?: string }> = {};
|
| 256 |
+
if (jsdocMatch) {
|
| 257 |
+
const jsdoc = jsdocMatch[1];
|
| 258 |
+
jsdocParams = extractJSDocParams(jsdoc);
|
| 259 |
+
|
| 260 |
+
const descriptionLines = jsdoc
|
| 261 |
+
.split("\n")
|
| 262 |
+
.map((line) => line.trim().replace(/^\*\s?/, ""))
|
| 263 |
+
.filter((line) => !line.startsWith("@") && line);
|
| 264 |
+
|
| 265 |
+
schema.description = descriptionLines.join(" ").trim();
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// 3. Combine signature parameters with JSDoc info
|
| 269 |
+
for (const param of paramsFromSignature) {
|
| 270 |
+
const paramName = param.name;
|
| 271 |
+
const jsdocInfo = jsdocParams[paramName];
|
| 272 |
+
schema.parameters.properties[paramName] = {
|
| 273 |
+
type: jsdocInfo ? jsdocInfo.type : "any",
|
| 274 |
+
description: jsdocInfo ? jsdocInfo.description : "",
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
// Prefer default from signature, then from JSDoc
|
| 278 |
+
if (param.defaultValue !== undefined) {
|
| 279 |
+
// Try to parse as JSON, fallback to string
|
| 280 |
+
try {
|
| 281 |
+
schema.parameters.properties[paramName].default = JSON.parse(
|
| 282 |
+
param.defaultValue.replace(/'/g, '"'),
|
| 283 |
+
);
|
| 284 |
+
} catch {
|
| 285 |
+
schema.parameters.properties[paramName].default = param.defaultValue;
|
| 286 |
+
}
|
| 287 |
+
} else if (jsdocInfo && jsdocInfo.jsdocDefault !== undefined) {
|
| 288 |
+
schema.parameters.properties[paramName].default = jsdocInfo.jsdocDefault;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// A parameter is required if:
|
| 292 |
+
// - Not optional in JSDoc
|
| 293 |
+
// - No default in signature
|
| 294 |
+
// - No default in JSDoc
|
| 295 |
+
const hasDefault =
|
| 296 |
+
param.defaultValue !== undefined ||
|
| 297 |
+
(jsdocInfo && jsdocInfo.jsdocDefault !== undefined);
|
| 298 |
+
if (!jsdocInfo || (!jsdocInfo.isOptional && !hasDefault)) {
|
| 299 |
+
schema.parameters.required.push(paramName);
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
return schema;
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
/**
|
| 307 |
+
* Extracts tool call content from a string using the tool call markers.
|
| 308 |
+
*/
|
| 309 |
+
export const extractToolCallContent = (content: string): string | null => {
|
| 310 |
+
const toolCallMatch = content.match(
|
| 311 |
+
/<\|tool_call_start\|>(.*?)<\|tool_call_end\|>/s,
|
| 312 |
+
);
|
| 313 |
+
return toolCallMatch ? toolCallMatch[1].trim() : null;
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
/**
|
| 317 |
+
* Maps positional and keyword arguments to named parameters based on schema.
|
| 318 |
+
*/
|
| 319 |
+
export const mapArgsToNamedParams = (
|
| 320 |
+
paramNames: string[],
|
| 321 |
+
positionalArgs: any[],
|
| 322 |
+
keywordArgs: Record<string, any>,
|
| 323 |
+
): Record<string, any> => {
|
| 324 |
+
const namedParams: Record<string, any> = Object.create(null);
|
| 325 |
+
positionalArgs.forEach((arg, idx) => {
|
| 326 |
+
if (idx < paramNames.length) {
|
| 327 |
+
namedParams[paramNames[idx]] = arg;
|
| 328 |
+
}
|
| 329 |
+
});
|
| 330 |
+
Object.assign(namedParams, keywordArgs);
|
| 331 |
+
return namedParams;
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
export const getErrorMessage = (error: unknown): string => {
|
| 335 |
+
if (error instanceof Error) {
|
| 336 |
+
return error.message;
|
| 337 |
+
}
|
| 338 |
+
if (typeof error === "string") {
|
| 339 |
+
return error;
|
| 340 |
+
}
|
| 341 |
+
if (error && typeof error === "object") {
|
| 342 |
+
return JSON.stringify(error);
|
| 343 |
+
}
|
| 344 |
+
return String(error);
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
/**
|
| 348 |
+
* Adapted from https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser.
|
| 349 |
+
*/
|
| 350 |
+
export function isMobileOrTablet() {
|
| 351 |
+
let check = false;
|
| 352 |
+
(function (a: string) {
|
| 353 |
+
if (
|
| 354 |
+
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(
|
| 355 |
+
a,
|
| 356 |
+
) ||
|
| 357 |
+
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
|
| 358 |
+
a.slice(0, 4),
|
| 359 |
+
)
|
| 360 |
+
)
|
| 361 |
+
check = true;
|
| 362 |
+
})(
|
| 363 |
+
navigator.userAgent ||
|
| 364 |
+
navigator.vendor ||
|
| 365 |
+
("opera" in window && typeof window.opera === "string"
|
| 366 |
+
? window.opera
|
| 367 |
+
: ""),
|
| 368 |
+
);
|
| 369 |
+
return check;
|
| 370 |
+
}
|
src/utils/storage.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Secure storage utilities for sensitive data like OAuth tokens
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
const ENCRYPTION_KEY_NAME = 'mcp-encryption-key';
|
| 6 |
+
|
| 7 |
+
// Generate or retrieve encryption key
|
| 8 |
+
async function getEncryptionKey(): Promise<CryptoKey> {
|
| 9 |
+
const keyData = localStorage.getItem(ENCRYPTION_KEY_NAME);
|
| 10 |
+
|
| 11 |
+
if (keyData) {
|
| 12 |
+
try {
|
| 13 |
+
const keyBuffer = new Uint8Array(JSON.parse(keyData));
|
| 14 |
+
return await crypto.subtle.importKey(
|
| 15 |
+
'raw',
|
| 16 |
+
keyBuffer,
|
| 17 |
+
{ name: 'AES-GCM' },
|
| 18 |
+
false,
|
| 19 |
+
['encrypt', 'decrypt']
|
| 20 |
+
);
|
| 21 |
+
} catch {
|
| 22 |
+
// Key corrupted, generate new one
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Generate new key
|
| 27 |
+
const key = await crypto.subtle.generateKey(
|
| 28 |
+
{ name: 'AES-GCM', length: 256 },
|
| 29 |
+
true,
|
| 30 |
+
['encrypt', 'decrypt']
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
// Store key for future use
|
| 34 |
+
const keyBuffer = await crypto.subtle.exportKey('raw', key);
|
| 35 |
+
localStorage.setItem(ENCRYPTION_KEY_NAME, JSON.stringify(Array.from(new Uint8Array(keyBuffer))));
|
| 36 |
+
|
| 37 |
+
return key;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Encrypt sensitive data
|
| 41 |
+
export async function encryptData(data: string): Promise<string> {
|
| 42 |
+
try {
|
| 43 |
+
const key = await getEncryptionKey();
|
| 44 |
+
const encoder = new TextEncoder();
|
| 45 |
+
const dataBuffer = encoder.encode(data);
|
| 46 |
+
|
| 47 |
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
| 48 |
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
| 49 |
+
{ name: 'AES-GCM', iv },
|
| 50 |
+
key,
|
| 51 |
+
dataBuffer
|
| 52 |
+
);
|
| 53 |
+
|
| 54 |
+
// Combine IV and encrypted data
|
| 55 |
+
const result = new Uint8Array(iv.length + encryptedBuffer.byteLength);
|
| 56 |
+
result.set(iv);
|
| 57 |
+
result.set(new Uint8Array(encryptedBuffer), iv.length);
|
| 58 |
+
|
| 59 |
+
return btoa(String.fromCharCode(...result));
|
| 60 |
+
} catch (error) {
|
| 61 |
+
console.warn('Encryption failed, storing data unencrypted:', error);
|
| 62 |
+
return data;
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Decrypt sensitive data
|
| 67 |
+
export async function decryptData(encryptedData: string): Promise<string> {
|
| 68 |
+
try {
|
| 69 |
+
const key = await getEncryptionKey();
|
| 70 |
+
const dataBuffer = new Uint8Array(
|
| 71 |
+
atob(encryptedData).split('').map(char => char.charCodeAt(0))
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
const iv = dataBuffer.slice(0, 12);
|
| 75 |
+
const encrypted = dataBuffer.slice(12);
|
| 76 |
+
|
| 77 |
+
const decryptedBuffer = await crypto.subtle.decrypt(
|
| 78 |
+
{ name: 'AES-GCM', iv },
|
| 79 |
+
key,
|
| 80 |
+
encrypted
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
const decoder = new TextDecoder();
|
| 84 |
+
return decoder.decode(decryptedBuffer);
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.warn('Decryption failed, returning data as-is:', error);
|
| 87 |
+
return encryptedData;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Secure storage wrapper for sensitive data
|
| 92 |
+
export const secureStorage = {
|
| 93 |
+
async setItem(key: string, value: string): Promise<void> {
|
| 94 |
+
const encrypted = await encryptData(value);
|
| 95 |
+
localStorage.setItem(`secure_${key}`, encrypted);
|
| 96 |
+
},
|
| 97 |
+
|
| 98 |
+
async getItem(key: string): Promise<string | null> {
|
| 99 |
+
const encrypted = localStorage.getItem(`secure_${key}`);
|
| 100 |
+
if (!encrypted) return null;
|
| 101 |
+
|
| 102 |
+
return await decryptData(encrypted);
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
+
removeItem(key: string): void {
|
| 106 |
+
localStorage.removeItem(`secure_${key}`);
|
| 107 |
+
}
|
| 108 |
+
};
|