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