shreyask commited on
Commit
d417126
·
1 Parent(s): 8b70f87

initial checkin

Browse files
.gitattributes copy ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz 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
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 copy.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LFM2 WebGPU – In-browser tool calling
3
+ emoji: 🛠️
4
+ colorFrom: yellow
5
+ colorTo: red
6
+ sdk: static
7
+ pinned: true
8
+ license: apache-2.0
9
+ short_description: In-browser tool calling, 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-WebGPU/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 copy.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 WebGPU - 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 ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/liquidai-logo.svg ADDED
src/App.tsx ADDED
@@ -0,0 +1,930 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
668
+ LFM2 WebGPU
669
+ </h1>
670
+ </div>
671
+ <div className="flex items-center gap-3">
672
+ <div className="flex items-center text-green-400">
673
+ <Zap size={16} className="mr-2" />
674
+ Ready
675
+ </div>
676
+ <button
677
+ disabled={isGenerating}
678
+ onClick={clearChat}
679
+ className={`h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors text-sm ${
680
+ isGenerating
681
+ ? "bg-gray-600 cursor-not-allowed opacity-50"
682
+ : "bg-gray-600 hover:bg-gray-700"
683
+ }`}
684
+ title="Clear chat"
685
+ >
686
+ <RotateCcw size={14} className="mr-2" /> Clear
687
+ </button>
688
+ <button
689
+ onClick={handleOpenSystemPromptModal}
690
+ 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"
691
+ title="Edit system prompt"
692
+ >
693
+ <Settings size={16} />
694
+ </button>
695
+ <button
696
+ onClick={() => setIsMCPManagerOpen(true)}
697
+ 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"
698
+ title="Manage MCP Servers"
699
+ >
700
+ 🌐
701
+ </button>
702
+ <button
703
+ onClick={() => setIsToolsPanelVisible(!isToolsPanelVisible)}
704
+ 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"
705
+ title={
706
+ isToolsPanelVisible
707
+ ? "Hide Tools Panel"
708
+ : "Show Tools Panel"
709
+ }
710
+ >
711
+ {isToolsPanelVisible ? (
712
+ <PanelRightClose size={16} />
713
+ ) : (
714
+ <PanelRightOpen size={16} />
715
+ )}
716
+ </button>
717
+ </div>
718
+ </div>
719
+
720
+ <div
721
+ ref={chatContainerRef}
722
+ className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto mb-4 space-y-4"
723
+ >
724
+ {messages.length === 0 && isReady ? (
725
+ <ExamplePrompts onExampleClick={handleExampleClick} />
726
+ ) : (
727
+ messages.map((msg, index) => {
728
+ const key = `${msg.role}-${index}`;
729
+
730
+ if (msg.role === "user") {
731
+ return (
732
+ <div key={key} className="flex justify-end">
733
+ <div className="p-3 rounded-lg max-w-md bg-indigo-600">
734
+ <p className="text-sm whitespace-pre-wrap">
735
+ {msg.content}
736
+ </p>
737
+ </div>
738
+ </div>
739
+ );
740
+ } else if (msg.role === "assistant") {
741
+ const isToolCall = msg.content.includes(
742
+ "<|tool_call_start|>"
743
+ );
744
+
745
+ if (isToolCall) {
746
+ const nextMessage = messages[index + 1];
747
+ const isCompleted = nextMessage?.role === "tool";
748
+ const hasError =
749
+ isCompleted &&
750
+ (nextMessage as ToolMessage).renderInfo.some(
751
+ (info) => !!info.error
752
+ );
753
+
754
+ return (
755
+ <div key={key} className="flex justify-start">
756
+ <div className="p-3 rounded-lg bg-gray-700">
757
+ <ToolCallIndicator
758
+ content={msg.content}
759
+ isRunning={!isCompleted}
760
+ hasError={hasError}
761
+ />
762
+ </div>
763
+ </div>
764
+ );
765
+ }
766
+
767
+ return (
768
+ <div key={key} className="flex justify-start">
769
+ <div className="p-3 rounded-lg max-w-md bg-gray-700">
770
+ <p className="text-sm whitespace-pre-wrap">
771
+ {msg.content}
772
+ </p>
773
+ </div>
774
+ </div>
775
+ );
776
+ } else if (msg.role === "tool") {
777
+ const visibleToolResults = msg.renderInfo.filter(
778
+ (info) =>
779
+ info.error || (info.result != null && info.renderer)
780
+ );
781
+
782
+ if (visibleToolResults.length === 0) return null;
783
+
784
+ return (
785
+ <div key={key} className="flex justify-start">
786
+ <div className="p-3 rounded-lg bg-gray-700 max-w-lg">
787
+ <div className="space-y-3">
788
+ {visibleToolResults.map((info, idx) => (
789
+ <div className="flex flex-col gap-2" key={idx}>
790
+ <div className="text-xs text-gray-400 font-mono">
791
+ {info.call}
792
+ </div>
793
+ {info.error ? (
794
+ <ResultBlock error={info.error} />
795
+ ) : (
796
+ <ToolResultRenderer
797
+ result={info.result}
798
+ rendererCode={info.renderer}
799
+ input={info.input}
800
+ />
801
+ )}
802
+ </div>
803
+ ))}
804
+ </div>
805
+ </div>
806
+ </div>
807
+ );
808
+ }
809
+ return null;
810
+ })
811
+ )}
812
+ </div>
813
+
814
+ <div className="flex">
815
+ <input
816
+ ref={inputRef}
817
+ type="text"
818
+ value={input}
819
+ onChange={(e) => setInput(e.target.value)}
820
+ onKeyDown={(e) =>
821
+ e.key === "Enter" &&
822
+ !isGenerating &&
823
+ isReady &&
824
+ handleSendMessage()
825
+ }
826
+ disabled={isGenerating || !isReady}
827
+ className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
828
+ placeholder={
829
+ isReady
830
+ ? "Type your message here..."
831
+ : "Load model first to enable chat"
832
+ }
833
+ />
834
+ <button
835
+ onClick={handleSendMessage}
836
+ disabled={isGenerating || !isReady}
837
+ 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"
838
+ >
839
+ <Play size={20} />
840
+ </button>
841
+ </div>
842
+ </div>
843
+
844
+ {isToolsPanelVisible && (
845
+ <div className="w-1/2 flex flex-col p-4 border-l border-gray-700 transition-all duration-300">
846
+ <div className="flex justify-between items-center mb-4">
847
+ <h2 className="text-2xl font-bold text-teal-400">Tools</h2>
848
+ <button
849
+ onClick={addTool}
850
+ className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
851
+ >
852
+ <Plus size={16} className="mr-2" /> Add Tool
853
+ </button>
854
+ </div>
855
+ <div
856
+ ref={toolsContainerRef}
857
+ className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3"
858
+ >
859
+ {tools.map((tool) => (
860
+ <ToolItem
861
+ key={tool.id}
862
+ tool={tool}
863
+ onToggleEnabled={() => toggleToolEnabled(tool.id)}
864
+ onToggleCollapsed={() => toggleToolCollapsed(tool.id)}
865
+ onExpand={() => expandTool(tool.id)}
866
+ onDelete={() => deleteTool(tool.id)}
867
+ onCodeChange={(newCode) =>
868
+ handleToolCodeChange(tool.id, newCode)
869
+ }
870
+ />
871
+ ))}
872
+ </div>
873
+ </div>
874
+ )}
875
+ </div>
876
+ )}
877
+
878
+ {isSystemPromptModalOpen && (
879
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
880
+ <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">
881
+ <div className="flex justify-between items-center mb-4">
882
+ <h2 className="text-xl font-bold text-indigo-400">
883
+ Edit System Prompt
884
+ </h2>
885
+ <button
886
+ onClick={handleCancelSystemPrompt}
887
+ className="text-gray-400 hover:text-white"
888
+ >
889
+ <X size={20} />
890
+ </button>
891
+ </div>
892
+ <div className="flex-grow mb-4">
893
+ <textarea
894
+ value={tempSystemPrompt}
895
+ onChange={(e) => setTempSystemPrompt(e.target.value)}
896
+ 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"
897
+ placeholder="Enter your system prompt here..."
898
+ style={{ minHeight: "300px" }}
899
+ />
900
+ </div>
901
+ <div className="flex justify-between">
902
+ <button
903
+ onClick={handleResetSystemPrompt}
904
+ className="px-4 py-2 bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors"
905
+ >
906
+ Reset
907
+ </button>
908
+ <div className="flex gap-3">
909
+ <button
910
+ onClick={handleSaveSystemPrompt}
911
+ className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
912
+ >
913
+ Save
914
+ </button>
915
+ </div>
916
+ </div>
917
+ </div>
918
+ </div>
919
+ )}
920
+
921
+ {/* MCP Server Manager Modal */}
922
+ <MCPServerManager
923
+ isOpen={isMCPManagerOpen}
924
+ onClose={() => setIsMCPManagerOpen(false)}
925
+ />
926
+ </div>
927
+ );
928
+ };
929
+
930
+ 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,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChevronDown } from "lucide-react";
2
+
3
+ import { MODEL_OPTIONS } from "../constants/models";
4
+ import LiquidAILogo from "./icons/LiquidAILogo";
5
+ import HfLogo from "./icons/HfLogo";
6
+
7
+ import { useEffect, useRef } from "react";
8
+
9
+ export const LoadingScreen = ({
10
+ isLoading,
11
+ progress,
12
+ error,
13
+ loadSelectedModel,
14
+ selectedModelId,
15
+ isModelDropdownOpen,
16
+ setIsModelDropdownOpen,
17
+ handleModelSelect,
18
+ }: {
19
+ isLoading: boolean;
20
+ progress: number;
21
+ error: string | null;
22
+ loadSelectedModel: () => void;
23
+ selectedModelId: string;
24
+ isModelDropdownOpen: boolean;
25
+ setIsModelDropdownOpen: (isOpen: boolean) => void;
26
+ handleModelSelect: (modelId: string) => void;
27
+ }) => {
28
+ const model = MODEL_OPTIONS.find((opt) => opt.id === selectedModelId);
29
+ const canvasRef = useRef<HTMLCanvasElement>(null);
30
+
31
+ // Background Animation Effect
32
+ useEffect(() => {
33
+ const canvas = canvasRef.current;
34
+ if (!canvas) return;
35
+
36
+ const ctx = canvas.getContext("2d");
37
+ if (!ctx) return;
38
+
39
+ let animationFrameId: number;
40
+ let dots: {
41
+ x: number;
42
+ y: number;
43
+ radius: number;
44
+ speed: number;
45
+ opacity: number;
46
+ blur: number;
47
+ }[] = [];
48
+
49
+ const setup = () => {
50
+ canvas.width = window.innerWidth;
51
+ canvas.height = window.innerHeight;
52
+ dots = [];
53
+ const numDots = Math.floor((canvas.width * canvas.height) / 15000);
54
+ for (let i = 0; i < numDots; ++i) {
55
+ dots.push({
56
+ x: Math.random() * canvas.width,
57
+ y: Math.random() * canvas.height,
58
+ radius: Math.random() * 1.5 + 0.5,
59
+ speed: Math.random() * 0.5 + 0.1,
60
+ opacity: Math.random() * 0.5 + 0.2,
61
+ blur: Math.random() > 0.7 ? Math.random() * 2 + 1 : 0,
62
+ });
63
+ }
64
+ };
65
+
66
+ const draw = () => {
67
+ if (!ctx) return;
68
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
69
+
70
+ dots.forEach((dot) => {
71
+ // Update dot position
72
+ dot.y += dot.speed;
73
+ if (dot.y > canvas.height) {
74
+ dot.y = 0 - dot.radius;
75
+ dot.x = Math.random() * canvas.width;
76
+ }
77
+
78
+ // Draw dot
79
+ ctx.beginPath();
80
+ ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
81
+ ctx.fillStyle = `rgba(255, 255, 255, ${dot.opacity})`;
82
+ if (dot.blur > 0) {
83
+ ctx.filter = `blur(${dot.blur}px)`;
84
+ }
85
+ ctx.fill();
86
+ ctx.filter = "none"; // Reset filter
87
+ });
88
+
89
+ animationFrameId = requestAnimationFrame(draw);
90
+ };
91
+
92
+ const handleResize = () => {
93
+ cancelAnimationFrame(animationFrameId);
94
+ setup();
95
+ draw();
96
+ };
97
+
98
+ setup();
99
+ draw();
100
+
101
+ window.addEventListener("resize", handleResize);
102
+
103
+ return () => {
104
+ window.removeEventListener("resize", handleResize);
105
+ cancelAnimationFrame(animationFrameId);
106
+ };
107
+ }, []);
108
+
109
+ return (
110
+ <div className="relative flex flex-col items-center justify-center h-screen bg-gray-900 text-white p-4 overflow-hidden">
111
+ {/* Background Canvas for Animation */}
112
+ <canvas
113
+ ref={canvasRef}
114
+ className="absolute top-0 left-0 w-full h-full z-0"
115
+ />
116
+
117
+ {/* Vignette Overlay */}
118
+ <div className="absolute top-0 left-0 w-full h-full z-10 bg-[radial-gradient(ellipse_at_center,_rgba(17,24,39,0)_30%,_#111827_95%)]"></div>
119
+
120
+ {/* Main Content */}
121
+ <div className="relative z-20 max-w-2xl w-full flex flex-col items-center">
122
+ <div className="flex items-center justify-center mb-6 gap-6 text-5xl md:text-6xl">
123
+ <a
124
+ href="https://www.liquid.ai/"
125
+ target="_blank"
126
+ rel="noopener noreferrer"
127
+ title="Liquid AI"
128
+ >
129
+ <LiquidAILogo className="h-20 md:h-24 text-gray-300 hover:text-white transition-colors" />
130
+ </a>
131
+ <span className="text-gray-600">×</span>
132
+ <a
133
+ href="https://huggingface.co/docs/transformers.js"
134
+ target="_blank"
135
+ rel="noopener noreferrer"
136
+ title="Transformers.js"
137
+ >
138
+ <HfLogo className="h-24 md:h-28 text-gray-300 hover:text-white transition-colors" />
139
+ </a>
140
+ </div>
141
+
142
+ <div className="w-full text-center mb-6">
143
+ <h1 className="text-5xl font-bold mb-2 text-gray-100 tracking-tight">
144
+ LFM2 WebGPU
145
+ </h1>
146
+ <p className="text-md md:text-lg text-gray-400">
147
+ In-browser tool calling, powered by Transformers.js
148
+ </p>
149
+ </div>
150
+
151
+ <div className="w-full text-left text-gray-300 space-y-4 mb-6 text-base max-w-xl">
152
+ <p>
153
+ This demo showcases in-browser tool calling with LFM2, a new
154
+ generation of hybrid models by{" "}
155
+ <a
156
+ href="https://www.liquid.ai/"
157
+ target="_blank"
158
+ rel="noopener noreferrer"
159
+ className="text-indigo-400 hover:underline font-medium"
160
+ >
161
+ Liquid AI
162
+ </a>{" "}
163
+ designed for edge AI and on-device deployment.
164
+ </p>
165
+ <p>
166
+ Everything runs entirely in your browser with{" "}
167
+ <a
168
+ href="https://huggingface.co/docs/transformers.js"
169
+ target="_blank"
170
+ rel="noopener noreferrer"
171
+ className="text-indigo-400 hover:underline font-medium"
172
+ >
173
+ Transformers.js
174
+ </a>{" "}
175
+ and ONNX Runtime Web, meaning no data is sent to a server. It can
176
+ even run offline!
177
+ </p>
178
+ </div>
179
+
180
+ <p className="text-gray-400 mb-6">
181
+ Select a model and click load to get started.
182
+ </p>
183
+
184
+ <div className="relative">
185
+ <div className="flex rounded-lg shadow-lg bg-indigo-600">
186
+ <button
187
+ onClick={isLoading ? undefined : loadSelectedModel}
188
+ disabled={isLoading}
189
+ className={`flex items-center justify-center rounded-l-lg font-bold transition-all text-lg ${isLoading ? "bg-gray-700 text-gray-400 cursor-not-allowed" : "bg-indigo-600 hover:bg-indigo-700"}`}
190
+ >
191
+ <div className="px-6 py-3">
192
+ {isLoading ? (
193
+ <div className="flex items-center">
194
+ <span className="inline-block w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
195
+ <span className="ml-3">Loading... ({progress}%)</span>
196
+ </div>
197
+ ) : (
198
+ `Load ${model?.label}`
199
+ )}
200
+ </div>
201
+ </button>
202
+ <button
203
+ onClick={(e) => {
204
+ if (!isLoading) {
205
+ e.stopPropagation();
206
+ setIsModelDropdownOpen(!isModelDropdownOpen);
207
+ }
208
+ }}
209
+ aria-label="Select model"
210
+ className="px-3 py-3 border-l border-indigo-800 hover:bg-indigo-700 transition-colors rounded-r-lg disabled:cursor-not-allowed disabled:bg-gray-700"
211
+ disabled={isLoading}
212
+ >
213
+ <ChevronDown size={24} />
214
+ </button>
215
+ </div>
216
+
217
+ {isModelDropdownOpen && (
218
+ <div className="absolute left-0 right-0 top-full mt-2 bg-gray-800 border border-gray-700 rounded-lg shadow-lg z-10 w-full overflow-hidden">
219
+ {MODEL_OPTIONS.map((option) => (
220
+ <button
221
+ key={option.id}
222
+ onClick={() => handleModelSelect(option.id)}
223
+ className={`w-full px-4 py-2 text-left hover:bg-gray-700 transition-colors ${selectedModelId === option.id ? "bg-indigo-600 text-white" : "text-gray-200"}`}
224
+ >
225
+ <div className="font-medium">{option.label}</div>
226
+ <div className="text-sm text-gray-400">{option.size}</div>
227
+ </button>
228
+ ))}
229
+ </div>
230
+ )}
231
+ </div>
232
+
233
+ {error && (
234
+ <div className="bg-red-900/50 border border-red-700/60 rounded-lg p-4 mt-6 max-w-md text-center">
235
+ <p className="text-sm text-red-200">Error: {error}</p>
236
+ <button
237
+ onClick={loadSelectedModel}
238
+ className="mt-3 text-sm bg-red-600 hover:bg-red-700 px-4 py-1.5 rounded-md font-semibold transition-colors"
239
+ >
240
+ Retry
241
+ </button>
242
+ </div>
243
+ )}
244
+ </div>
245
+
246
+ {/* Click-away listener for dropdown */}
247
+ {isModelDropdownOpen && (
248
+ <div
249
+ className="fixed inset-0 z-5"
250
+ onClick={() => setIsModelDropdownOpen(false)}
251
+ />
252
+ )}
253
+ </div>
254
+ );
255
+ };
src/components/MCPServerManager.tsx ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(STORAGE_KEYS.MCP_SERVER_TRANSPORT, newServer.transport);
57
+
58
+ try {
59
+ await addServer(serverConfig);
60
+ setNewServer({
61
+ name: "",
62
+ url: "",
63
+ enabled: true,
64
+ transport: "streamable-http",
65
+ auth: {
66
+ type: "bearer",
67
+ },
68
+ });
69
+ setShowAddForm(false);
70
+ } catch (error) {
71
+ setNotification({
72
+ message: `Failed to add server: ${error instanceof Error ? error.message : 'Unknown error'}`,
73
+ type: 'error'
74
+ });
75
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
76
+ }
77
+ };
78
+
79
+ const handleTestConnection = async (config: MCPServerConfig) => {
80
+ setTestingConnection(config.id);
81
+ try {
82
+ const success = await testConnection(config);
83
+ if (success) {
84
+ setNotification({ message: "Connection test successful!", type: 'success' });
85
+ } else {
86
+ setNotification({ message: "Connection test failed. Please check your configuration.", type: 'error' });
87
+ }
88
+ } catch (error) {
89
+ setNotification({ message: `Connection test failed: ${error}`, type: 'error' });
90
+ } finally {
91
+ setTestingConnection(null);
92
+ // Auto-hide notification after 3 seconds
93
+ setTimeout(() => setNotification(null), DEFAULTS.NOTIFICATION_TIMEOUT);
94
+ }
95
+ };
96
+
97
+ const handleToggleConnection = async (
98
+ serverId: string,
99
+ isConnected: boolean
100
+ ) => {
101
+ try {
102
+ if (isConnected) {
103
+ await disconnectFromServer(serverId);
104
+ } else {
105
+ await connectToServer(serverId);
106
+ }
107
+ } catch (error) {
108
+ setNotification({
109
+ message: `Failed to toggle connection: ${error instanceof Error ? error.message : 'Unknown error'}`,
110
+ type: 'error'
111
+ });
112
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
113
+ }
114
+ };
115
+
116
+ return (
117
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
118
+ <div className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl max-h-[80vh] overflow-y-auto">
119
+ <div className="flex justify-between items-center mb-6">
120
+ <h2 className="text-2xl font-bold text-white flex items-center gap-2">
121
+ <Server className="text-blue-400" />
122
+ MCP Server Manager
123
+ </h2>
124
+ <button onClick={onClose} className="text-gray-400 hover:text-white">
125
+
126
+ </button>
127
+ </div>
128
+
129
+ {/* Add Server Button */}
130
+ <div className="mb-6">
131
+ <button
132
+ onClick={() => setShowAddForm(!showAddForm)}
133
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
134
+ >
135
+ <Plus size={16} />
136
+ Add MCP Server
137
+ </button>
138
+ </div>
139
+
140
+ {/* Add Server Form */}
141
+ {showAddForm && (
142
+ <div className="bg-gray-700 rounded-lg p-4 mb-6">
143
+ <h3 className="text-lg font-semibold text-white mb-4">
144
+ Add New MCP Server
145
+ </h3>
146
+ <div className="space-y-4">
147
+ <div>
148
+ <label className="block text-sm font-medium text-gray-300 mb-1">
149
+ Server Name
150
+ </label>
151
+ <input
152
+ type="text"
153
+ value={newServer.name}
154
+ onChange={(e) =>
155
+ setNewServer({ ...newServer, name: e.target.value })
156
+ }
157
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
158
+ placeholder="My MCP Server"
159
+ />
160
+ </div>
161
+
162
+ <div>
163
+ <label className="block text-sm font-medium text-gray-300 mb-1">
164
+ Server URL
165
+ </label>
166
+ <input
167
+ type="url"
168
+ value={newServer.url}
169
+ onChange={(e) =>
170
+ setNewServer({ ...newServer, url: e.target.value })
171
+ }
172
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
173
+ placeholder="http://localhost:3000/mcp"
174
+ />
175
+ </div>
176
+
177
+ <div>
178
+ <label className="block text-sm font-medium text-gray-300 mb-1">
179
+ Transport
180
+ </label>
181
+ <select
182
+ value={newServer.transport}
183
+ onChange={(e) =>
184
+ setNewServer({
185
+ ...newServer,
186
+ transport: e.target.value as MCPServerConfig["transport"],
187
+ })
188
+ }
189
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
190
+ >
191
+ <option value="streamable-http">Streamable HTTP</option>
192
+ <option value="sse">Server-Sent Events</option>
193
+ <option value="websocket">WebSocket</option>
194
+ </select>
195
+ </div>
196
+
197
+ <div>
198
+ <label className="block text-sm font-medium text-gray-300 mb-1">
199
+ Authentication
200
+ </label>
201
+ <select
202
+ value={newServer.auth?.type || "none"}
203
+ onChange={(e) => {
204
+ const authType = e.target.value;
205
+ if (authType === "none") {
206
+ setNewServer({ ...newServer, auth: undefined });
207
+ } else {
208
+ setNewServer({
209
+ ...newServer,
210
+ auth: {
211
+ type: authType as "bearer" | "basic" | "oauth",
212
+ ...(authType === "bearer" ? { token: "" } : {}),
213
+ ...(authType === "basic"
214
+ ? { username: "", password: "" }
215
+ : {}),
216
+ ...(authType === "oauth" ? { token: "" } : {}),
217
+ },
218
+ });
219
+ }
220
+ }}
221
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
222
+ >
223
+ <option value="none">No Authentication</option>
224
+ <option value="bearer">Bearer Token</option>
225
+ <option value="basic">Basic Auth</option>
226
+ <option value="oauth">OAuth Token</option>
227
+ </select>
228
+ </div>
229
+
230
+ {/* Auth-specific fields */}
231
+ {newServer.auth?.type === "bearer" && (
232
+ <div>
233
+ <label className="block text-sm font-medium text-gray-300 mb-1">
234
+ Bearer Token
235
+ </label>
236
+ <input
237
+ type="password"
238
+ value={newServer.auth.token || ""}
239
+ onChange={(e) =>
240
+ setNewServer({
241
+ ...newServer,
242
+ auth: { ...newServer.auth!, token: e.target.value },
243
+ })
244
+ }
245
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
246
+ placeholder="your-bearer-token"
247
+ />
248
+ </div>
249
+ )}
250
+
251
+ {newServer.auth?.type === "basic" && (
252
+ <>
253
+ <div>
254
+ <label className="block text-sm font-medium text-gray-300 mb-1">
255
+ Username
256
+ </label>
257
+ <input
258
+ type="text"
259
+ value={newServer.auth.username || ""}
260
+ onChange={(e) =>
261
+ setNewServer({
262
+ ...newServer,
263
+ auth: {
264
+ ...newServer.auth!,
265
+ username: e.target.value,
266
+ },
267
+ })
268
+ }
269
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
270
+ placeholder="username"
271
+ />
272
+ </div>
273
+ <div>
274
+ <label className="block text-sm font-medium text-gray-300 mb-1">
275
+ Password
276
+ </label>
277
+ <input
278
+ type="password"
279
+ value={newServer.auth.password || ""}
280
+ onChange={(e) =>
281
+ setNewServer({
282
+ ...newServer,
283
+ auth: {
284
+ ...newServer.auth!,
285
+ password: e.target.value,
286
+ },
287
+ })
288
+ }
289
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
290
+ placeholder="password"
291
+ />
292
+ </div>
293
+ </>
294
+ )}
295
+
296
+ {newServer.auth?.type === "oauth" && (
297
+ <div>
298
+ <label className="block text-sm font-medium text-gray-300 mb-1">
299
+ OAuth Authorization
300
+ </label>
301
+ <button
302
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded mb-2"
303
+ type="button"
304
+ onClick={async () => {
305
+ try {
306
+ // Persist name and transport for OAuthCallback
307
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVER_NAME, newServer.name);
308
+ localStorage.setItem(
309
+ STORAGE_KEYS.MCP_SERVER_TRANSPORT,
310
+ newServer.transport
311
+ );
312
+ const endpoints = await discoverOAuthEndpoints(
313
+ newServer.url
314
+ );
315
+
316
+ if (!endpoints.clientId || !endpoints.redirectUri) {
317
+ throw new Error("Missing required OAuth configuration (clientId or redirectUri)");
318
+ }
319
+
320
+ startOAuthFlow({
321
+ authorizationEndpoint:
322
+ endpoints.authorizationEndpoint,
323
+ clientId: endpoints.clientId as string,
324
+ redirectUri: endpoints.redirectUri as string,
325
+ scopes: (endpoints.scopes || []) as string[],
326
+ });
327
+ } catch (err) {
328
+ setNotification({
329
+ message: "OAuth discovery failed: " +
330
+ (err instanceof Error ? err.message : String(err)),
331
+ type: 'error'
332
+ });
333
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
334
+ }
335
+ }}
336
+ >
337
+ Connect with OAuth
338
+ </button>
339
+ <p className="text-xs text-gray-400">
340
+ You will be redirected to authorize this app with the MCP
341
+ server.
342
+ </p>
343
+ </div>
344
+ )}
345
+
346
+ <div className="flex items-center gap-2">
347
+ <input
348
+ type="checkbox"
349
+ id="enabled"
350
+ checked={newServer.enabled}
351
+ onChange={(e) =>
352
+ setNewServer({ ...newServer, enabled: e.target.checked })
353
+ }
354
+ className="rounded"
355
+ />
356
+ <label htmlFor="enabled" className="text-sm text-gray-300">
357
+ Auto-connect when added
358
+ </label>
359
+ </div>
360
+
361
+ <div className="flex gap-2">
362
+ <button
363
+ onClick={handleAddServer}
364
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded"
365
+ >
366
+ Add Server
367
+ </button>
368
+ <button
369
+ onClick={() => setShowAddForm(false)}
370
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
371
+ >
372
+ Cancel
373
+ </button>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ )}
378
+
379
+ {/* Server List */}
380
+ <div className="space-y-4">
381
+ <h3 className="text-lg font-semibold text-white">
382
+ Configured Servers
383
+ </h3>
384
+
385
+ {Object.values(mcpState.servers).length === 0 ? (
386
+ <div className="text-gray-400 text-center py-8">
387
+ No MCP servers configured. Add one to get started!
388
+ </div>
389
+ ) : (
390
+ Object.values(mcpState.servers).map((connection) => (
391
+ <div
392
+ key={connection.config.id}
393
+ className="bg-gray-700 rounded-lg p-4"
394
+ >
395
+ <div className="flex items-center justify-between">
396
+ <div className="flex items-center gap-3">
397
+ <div
398
+ className={`w-3 h-3 rounded-full ${
399
+ connection.isConnected ? "bg-green-400" : "bg-red-400"
400
+ }`}
401
+ />
402
+ <div>
403
+ <h4 className="text-white font-medium">
404
+ {connection.config.name}
405
+ </h4>
406
+ <p className="text-gray-400 text-sm">
407
+ {connection.config.url}
408
+ </p>
409
+ <p className="text-gray-500 text-xs">
410
+ Transport: {connection.config.transport}
411
+ {connection.config.auth &&
412
+ ` • Auth: ${connection.config.auth.type}`}
413
+ {connection.isConnected &&
414
+ ` • ${connection.tools.length} tools available`}
415
+ </p>
416
+ </div>
417
+ </div>
418
+
419
+ <div className="flex items-center gap-2">
420
+ {/* Test Connection */}
421
+ <button
422
+ onClick={() => handleTestConnection(connection.config)}
423
+ disabled={testingConnection === connection.config.id}
424
+ className="p-2 text-yellow-400 hover:text-yellow-300 disabled:opacity-50"
425
+ title="Test Connection"
426
+ >
427
+ <TestTube size={16} />
428
+ </button>
429
+
430
+ {/* Connect/Disconnect */}
431
+ <button
432
+ onClick={() =>
433
+ handleToggleConnection(
434
+ connection.config.id,
435
+ connection.isConnected
436
+ )
437
+ }
438
+ className={`p-2 ${
439
+ connection.isConnected
440
+ ? "text-green-400 hover:text-green-300"
441
+ : "text-gray-400 hover:text-gray-300"
442
+ }`}
443
+ title={connection.isConnected ? "Disconnect" : "Connect"}
444
+ >
445
+ {connection.isConnected ? (
446
+ <Wifi size={16} />
447
+ ) : (
448
+ <WifiOff size={16} />
449
+ )}
450
+ </button>
451
+
452
+ {/* Remove Server */}
453
+ <button
454
+ onClick={() => removeServer(connection.config.id)}
455
+ className="p-2 text-red-400 hover:text-red-300"
456
+ title="Remove Server"
457
+ >
458
+ <Trash2 size={16} />
459
+ </button>
460
+ </div>
461
+ </div>
462
+
463
+ {connection.lastError && (
464
+ <div className="mt-2 text-red-400 text-sm">
465
+ Error: {connection.lastError}
466
+ </div>
467
+ )}
468
+
469
+ {connection.isConnected && connection.tools.length > 0 && (
470
+ <div className="mt-3">
471
+ <details className="text-sm">
472
+ <summary className="text-gray-300 cursor-pointer">
473
+ Available Tools ({connection.tools.length})
474
+ </summary>
475
+ <div className="mt-2 space-y-1">
476
+ {connection.tools.map((tool) => (
477
+ <div key={tool.name} className="text-gray-400 pl-4">
478
+ • {tool.name} -{" "}
479
+ {tool.description || "No description"}
480
+ </div>
481
+ ))}
482
+ </div>
483
+ </details>
484
+ </div>
485
+ )}
486
+ </div>
487
+ ))
488
+ )}
489
+ </div>
490
+
491
+ {mcpState.error && (
492
+ <div className="mt-4 p-4 bg-red-900 border border-red-700 rounded-lg text-red-200">
493
+ <strong>Error:</strong> {mcpState.error}
494
+ </div>
495
+ )}
496
+
497
+ {notification && (
498
+ <div className={`mt-4 p-4 border rounded-lg ${
499
+ notification.type === 'success'
500
+ ? 'bg-green-900 border-green-700 text-green-200'
501
+ : 'bg-red-900 border-red-700 text-red-200'
502
+ }`}>
503
+ {notification.message}
504
+ </div>
505
+ )}
506
+ </div>
507
+ </div>
508
+ );
509
+ };
src/components/OAuthCallback.tsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { exchangeCodeForToken } from "../services/oauth";
3
+ import { secureStorage } from "../utils/storage";
4
+ import type { MCPServerConfig } from "../types/mcp";
5
+ import { STORAGE_KEYS, DEFAULTS } from "../config/constants";
6
+
7
+ interface OAuthTokens {
8
+ access_token: string;
9
+ refresh_token?: string;
10
+ expires_in?: number;
11
+ token_type?: string;
12
+ [key: string]: string | number | undefined;
13
+ }
14
+
15
+ interface OAuthCallbackProps {
16
+ serverUrl: string;
17
+ onSuccess?: (tokens: OAuthTokens) => void;
18
+ onError?: (error: Error) => void;
19
+ }
20
+
21
+ const OAuthCallback: React.FC<OAuthCallbackProps> = ({
22
+ serverUrl,
23
+ onSuccess,
24
+ onError,
25
+ }) => {
26
+ const [status, setStatus] = useState<string>("Authorizing...");
27
+
28
+ useEffect(() => {
29
+ const params = new URLSearchParams(window.location.search);
30
+ const code = params.get("code");
31
+ // Always persist MCP server URL for robustness
32
+ localStorage.setItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL, serverUrl);
33
+ if (code) {
34
+ exchangeCodeForToken({
35
+ serverUrl,
36
+ code,
37
+ redirectUri: window.location.origin + DEFAULTS.OAUTH_REDIRECT_PATH,
38
+ })
39
+ .then(async (tokens) => {
40
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
41
+ // Add MCP server to MCPClientService for UI
42
+ const mcpServerUrl = localStorage.getItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
43
+ if (mcpServerUrl) {
44
+ // Use persisted name and transport from initial add
45
+ const serverName =
46
+ localStorage.getItem(STORAGE_KEYS.MCP_SERVER_NAME) || mcpServerUrl;
47
+ const serverTransport =
48
+ (localStorage.getItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT) as MCPServerConfig['transport']) || DEFAULTS.MCP_TRANSPORT;
49
+ // Build config and add to mcp-servers
50
+ const serverConfig = {
51
+ id: `server_${Date.now()}`,
52
+ name: serverName,
53
+ url: mcpServerUrl,
54
+ enabled: true,
55
+ transport: serverTransport,
56
+ auth: {
57
+ type: "bearer" as const,
58
+ token: tokens.access_token,
59
+ },
60
+ };
61
+ // Load existing servers
62
+ let servers: MCPServerConfig[] = [];
63
+ try {
64
+ const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
65
+ if (stored) servers = JSON.parse(stored);
66
+ } catch {}
67
+ // Add or update
68
+ const exists = servers.some((s: MCPServerConfig) => s.url === mcpServerUrl);
69
+ if (!exists) {
70
+ servers.push(serverConfig);
71
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(servers));
72
+ }
73
+ // Clear temp values from localStorage for clean slate
74
+ localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_NAME);
75
+ localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT);
76
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
77
+ }
78
+ setStatus("Authorization successful! Redirecting...");
79
+ if (onSuccess) onSuccess(tokens);
80
+ // Redirect to main app page after short delay
81
+ setTimeout(() => {
82
+ window.location.replace("/");
83
+ }, 1000);
84
+ })
85
+ .catch((err) => {
86
+ setStatus("OAuth token exchange failed: " + err.message);
87
+ if (onError) onError(err);
88
+ });
89
+ } else {
90
+ setStatus("Missing authorization code in callback URL.");
91
+ }
92
+ }, [serverUrl, onSuccess, onError]);
93
+
94
+ return <div>{status}</div>;
95
+ };
96
+
97
+ 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/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 ADDED
@@ -0,0 +1 @@
 
 
1
+ @import "tailwindcss";
src/main.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { BrowserRouter, 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
+ <BrowserRouter>
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
+ </BrowserRouter>
23
+ </StrictMode>
24
+ );
src/services/mcpClient.ts ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
6
+
7
+ import type {
8
+ MCPServerConfig,
9
+ MCPServerConnection,
10
+ MCPClientState,
11
+ MCPToolResult,
12
+ } from "../types/mcp.js";
13
+ import { MCP_CLIENT_CONFIG, STORAGE_KEYS } from "../config/constants";
14
+
15
+ export class MCPClientService {
16
+ private clients: Map<string, Client> = new Map();
17
+ private connections: Map<string, MCPServerConnection> = new Map();
18
+ private listeners: Array<(state: MCPClientState) => void> = [];
19
+
20
+ constructor() {
21
+ // Load saved server configurations from localStorage
22
+ this.loadServerConfigs();
23
+ }
24
+
25
+ // Add state change listener
26
+ addStateListener(listener: (state: MCPClientState) => void) {
27
+ this.listeners.push(listener);
28
+ }
29
+
30
+ // Remove state change listener
31
+ removeStateListener(listener: (state: MCPClientState) => void) {
32
+ const index = this.listeners.indexOf(listener);
33
+ if (index > -1) {
34
+ this.listeners.splice(index, 1);
35
+ }
36
+ }
37
+
38
+ // Notify all listeners of state changes
39
+ private notifyStateChange() {
40
+ const state = this.getState();
41
+ this.listeners.forEach((listener) => listener(state));
42
+ }
43
+
44
+ // Get current MCP client state
45
+ getState(): MCPClientState {
46
+ const servers: Record<string, MCPServerConnection> = {};
47
+ for (const [id, connection] of this.connections) {
48
+ servers[id] = connection;
49
+ }
50
+
51
+ return {
52
+ servers,
53
+ isLoading: false,
54
+ error: undefined,
55
+ };
56
+ }
57
+
58
+ // Load server configurations from localStorage
59
+ private loadServerConfigs() {
60
+ try {
61
+ const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
62
+ if (stored) {
63
+ const configs: MCPServerConfig[] = JSON.parse(stored);
64
+ configs.forEach((config) => {
65
+ const connection: MCPServerConnection = {
66
+ config,
67
+ isConnected: false,
68
+ tools: [],
69
+ lastError: undefined,
70
+ lastConnected: undefined,
71
+ };
72
+ this.connections.set(config.id, connection);
73
+ });
74
+ }
75
+ } catch (error) {
76
+ // Silently handle missing or corrupted config
77
+ }
78
+ }
79
+
80
+ // Save server configurations to localStorage
81
+ private saveServerConfigs() {
82
+ try {
83
+ const configs = Array.from(this.connections.values()).map(
84
+ (conn) => conn.config
85
+ );
86
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(configs));
87
+ } catch (error) {
88
+ // Handle storage errors gracefully
89
+ throw new Error(`Failed to save server configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
90
+ }
91
+ }
92
+
93
+ // Add a new MCP server
94
+ async addServer(config: MCPServerConfig): Promise<void> {
95
+ const connection: MCPServerConnection = {
96
+ config,
97
+ isConnected: false,
98
+ tools: [],
99
+ lastError: undefined,
100
+ lastConnected: undefined,
101
+ };
102
+
103
+ this.connections.set(config.id, connection);
104
+ this.saveServerConfigs();
105
+ this.notifyStateChange();
106
+
107
+ // Auto-connect if enabled
108
+ if (config.enabled) {
109
+ await this.connectToServer(config.id);
110
+ }
111
+ }
112
+
113
+ // Remove an MCP server
114
+ async removeServer(serverId: string): Promise<void> {
115
+ // Disconnect first if connected
116
+ await this.disconnectFromServer(serverId);
117
+
118
+ // Remove from our maps
119
+ this.connections.delete(serverId);
120
+ this.clients.delete(serverId);
121
+
122
+ this.saveServerConfigs();
123
+ this.notifyStateChange();
124
+ }
125
+
126
+ // Connect to an MCP server
127
+ async connectToServer(serverId: string): Promise<void> {
128
+ const connection = this.connections.get(serverId);
129
+ if (!connection) {
130
+ throw new Error(`Server ${serverId} not found`);
131
+ }
132
+
133
+ if (connection.isConnected) {
134
+ return; // Already connected
135
+ }
136
+
137
+ try {
138
+ // Create client
139
+ const client = new Client(
140
+ {
141
+ name: MCP_CLIENT_CONFIG.NAME,
142
+ version: MCP_CLIENT_CONFIG.VERSION,
143
+ },
144
+ {
145
+ capabilities: {
146
+ tools: {},
147
+ },
148
+ }
149
+ );
150
+
151
+ // Create transport based on config
152
+ let transport;
153
+ const url = new URL(connection.config.url);
154
+
155
+ // Prepare headers for authentication
156
+ const headers: Record<string, string> = {};
157
+ if (connection.config.auth) {
158
+ switch (connection.config.auth.type) {
159
+ case "bearer":
160
+ if (connection.config.auth.token) {
161
+ headers[
162
+ "Authorization"
163
+ ] = `Bearer ${connection.config.auth.token}`;
164
+ }
165
+ break;
166
+ case "basic":
167
+ if (
168
+ connection.config.auth.username &&
169
+ connection.config.auth.password
170
+ ) {
171
+ const credentials = btoa(
172
+ `${connection.config.auth.username}:${connection.config.auth.password}`
173
+ );
174
+ headers["Authorization"] = `Basic ${credentials}`;
175
+ }
176
+ break;
177
+ case "oauth":
178
+ if (connection.config.auth.token) {
179
+ headers[
180
+ "Authorization"
181
+ ] = `Bearer ${connection.config.auth.token}`;
182
+ }
183
+ break;
184
+ }
185
+ }
186
+
187
+ switch (connection.config.transport) {
188
+ case "websocket": {
189
+ // Convert HTTP/HTTPS URLs to WS/WSS
190
+ const wsUrl = new URL(connection.config.url);
191
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
192
+ transport = new WebSocketClientTransport(wsUrl);
193
+ // Note: WebSocket auth headers would need to be passed differently
194
+ // For now, auth is only supported on HTTP-based transports
195
+ break;
196
+ }
197
+
198
+ case "streamable-http":
199
+ transport = new StreamableHTTPClientTransport(url, {
200
+ requestInit:
201
+ Object.keys(headers).length > 0 ? { headers } : undefined,
202
+ });
203
+ break;
204
+
205
+ case "sse":
206
+ transport = new SSEClientTransport(url, {
207
+ requestInit:
208
+ Object.keys(headers).length > 0 ? { headers } : undefined,
209
+ });
210
+ break;
211
+
212
+ default:
213
+ throw new Error(
214
+ `Unsupported transport: ${connection.config.transport}`
215
+ );
216
+ }
217
+
218
+ // Set up error handling
219
+ client.onerror = (error) => {
220
+ connection.lastError = error.message;
221
+ connection.isConnected = false;
222
+ this.notifyStateChange();
223
+ };
224
+
225
+ // Connect to the server
226
+ await client.connect(transport);
227
+
228
+ // List available tools
229
+ const toolsResult = await client.listTools();
230
+
231
+ // Update connection state
232
+ connection.isConnected = true;
233
+ connection.tools = toolsResult.tools;
234
+ connection.lastError = undefined;
235
+ connection.lastConnected = new Date();
236
+
237
+ // Store client reference
238
+ this.clients.set(serverId, client);
239
+
240
+ this.notifyStateChange();
241
+ } catch (error) {
242
+ connection.isConnected = false;
243
+ connection.lastError =
244
+ error instanceof Error ? error.message : "Connection failed";
245
+ this.notifyStateChange();
246
+ throw error;
247
+ }
248
+ }
249
+
250
+ // Disconnect from an MCP server
251
+ async disconnectFromServer(serverId: string): Promise<void> {
252
+ const client = this.clients.get(serverId);
253
+ const connection = this.connections.get(serverId);
254
+
255
+ if (client) {
256
+ try {
257
+ await client.close();
258
+ } catch (error) {
259
+ // Handle disconnect error silently
260
+ }
261
+ this.clients.delete(serverId);
262
+ }
263
+
264
+ if (connection) {
265
+ connection.isConnected = false;
266
+ connection.tools = [];
267
+ this.notifyStateChange();
268
+ }
269
+ }
270
+
271
+ // Get all tools from all connected servers
272
+ getAllTools(): Tool[] {
273
+ const allTools: Tool[] = [];
274
+
275
+ for (const connection of this.connections.values()) {
276
+ if (connection.isConnected && connection.config.enabled) {
277
+ allTools.push(...connection.tools);
278
+ }
279
+ }
280
+
281
+ return allTools;
282
+ }
283
+
284
+ // Call a tool on an MCP server
285
+ async callTool(
286
+ serverId: string,
287
+ toolName: string,
288
+ args: Record<string, unknown>
289
+ ): Promise<MCPToolResult> {
290
+ const client = this.clients.get(serverId);
291
+ const connection = this.connections.get(serverId);
292
+
293
+ if (!client || !connection?.isConnected) {
294
+ throw new Error(`Not connected to server ${serverId}`);
295
+ }
296
+
297
+ try {
298
+ const result = await client.callTool({
299
+ name: toolName,
300
+ arguments: args,
301
+ });
302
+
303
+ return {
304
+ content: Array.isArray(result.content) ? result.content : [],
305
+ isError: Boolean(result.isError),
306
+ };
307
+ } catch (error) {
308
+ throw new Error(`Tool execution failed (${toolName}): ${error instanceof Error ? error.message : 'Unknown error'}`);
309
+ }
310
+ }
311
+
312
+ // Test connection to a server without saving it
313
+ async testConnection(config: MCPServerConfig): Promise<boolean> {
314
+ try {
315
+ const client = new Client(
316
+ {
317
+ name: MCP_CLIENT_CONFIG.TEST_CLIENT_NAME,
318
+ version: MCP_CLIENT_CONFIG.VERSION,
319
+ },
320
+ {
321
+ capabilities: {
322
+ tools: {},
323
+ },
324
+ }
325
+ );
326
+
327
+ let transport;
328
+ const url = new URL(config.url);
329
+
330
+ switch (config.transport) {
331
+ case "websocket": {
332
+ const wsUrl = new URL(config.url);
333
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
334
+ transport = new WebSocketClientTransport(wsUrl);
335
+ break;
336
+ }
337
+
338
+ case "streamable-http":
339
+ transport = new StreamableHTTPClientTransport(url);
340
+ break;
341
+
342
+ case "sse":
343
+ transport = new SSEClientTransport(url);
344
+ break;
345
+
346
+ default:
347
+ throw new Error(`Unsupported transport: ${config.transport}`);
348
+ }
349
+
350
+ await client.connect(transport);
351
+ await client.close();
352
+ return true;
353
+ } catch (error) {
354
+ throw new Error(`Connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
355
+ }
356
+ }
357
+
358
+ // Connect to all enabled servers
359
+ async connectAll(): Promise<void> {
360
+ const promises = Array.from(this.connections.entries())
361
+ .filter(
362
+ ([, connection]) => connection.config.enabled && !connection.isConnected
363
+ )
364
+ .map(([serverId]) =>
365
+ this.connectToServer(serverId).catch(() => {
366
+ // Handle auto-connection error silently
367
+ })
368
+ );
369
+
370
+ await Promise.all(promises);
371
+ }
372
+
373
+ // Disconnect from all servers
374
+ async disconnectAll(): Promise<void> {
375
+ const promises = Array.from(this.connections.keys()).map((serverId) =>
376
+ this.disconnectFromServer(serverId)
377
+ );
378
+
379
+ await Promise.all(promises);
380
+ }
381
+ }
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/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/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
+ );
src/tools/sleep.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Sleep for a given number of seconds.
3
+ * @param {number} seconds - The number of seconds to sleep.
4
+ * @return {void}
5
+ */
6
+ export async function sleep(seconds) {
7
+ return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
8
+ }
9
+
10
+ export default (input, output) =>
11
+ React.createElement(
12
+ "div",
13
+ { className: "bg-purple-50 border border-purple-200 rounded-lg p-4" },
14
+ React.createElement(
15
+ "div",
16
+ { className: "flex items-center mb-2" },
17
+ React.createElement(
18
+ "div",
19
+ {
20
+ className:
21
+ "w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center mr-3",
22
+ },
23
+ "😴",
24
+ ),
25
+ React.createElement(
26
+ "h3",
27
+ { className: "text-purple-900 font-semibold" },
28
+ "Sleep",
29
+ ),
30
+ ),
31
+ React.createElement(
32
+ "div",
33
+ { className: "text-sm space-y-1" },
34
+ React.createElement(
35
+ "p",
36
+ { className: "text-purple-700 font-medium" },
37
+ `Slept for ${input.seconds || "unknown"} seconds`,
38
+ ),
39
+ React.createElement(
40
+ "p",
41
+ { className: "text-purple-600 text-xs" },
42
+ output,
43
+ ),
44
+ ),
45
+ );
src/tools/speak.js ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Speak text using the browser's speech synthesis API.
3
+ * @param {string} text - The text to speak.
4
+ * @param {string} [voice] - The name of the voice to use (optional).
5
+ * @return {void}
6
+ */
7
+ export function speak(text, voice = undefined) {
8
+ const utter = new window.SpeechSynthesisUtterance(text);
9
+ if (voice) {
10
+ const voices = window.speechSynthesis.getVoices();
11
+ const match = voices.find((v) => v.name === voice);
12
+ if (match) utter.voice = match;
13
+ }
14
+ window.speechSynthesis.speak(utter);
15
+ }
16
+
17
+ export default (input, output) =>
18
+ React.createElement(
19
+ "div",
20
+ { className: "bg-blue-50 border border-blue-200 rounded-lg p-4" },
21
+ React.createElement(
22
+ "div",
23
+ { className: "flex items-center mb-2" },
24
+ React.createElement(
25
+ "div",
26
+ {
27
+ className:
28
+ "w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3",
29
+ },
30
+ "🔊",
31
+ ),
32
+ React.createElement(
33
+ "h3",
34
+ { className: "text-blue-900 font-semibold" },
35
+ "Speech Synthesis",
36
+ ),
37
+ ),
38
+ React.createElement(
39
+ "div",
40
+ { className: "text-sm space-y-1" },
41
+ React.createElement(
42
+ "p",
43
+ { className: "text-blue-700 font-medium" },
44
+ `Speaking: "${input.text || "Unknown text"}"`,
45
+ ),
46
+ input.voice &&
47
+ React.createElement(
48
+ "p",
49
+ { className: "text-blue-600 text-xs" },
50
+ `Voice: ${input.voice}`,
51
+ ),
52
+ React.createElement(
53
+ "p",
54
+ { className: "text-blue-600 text-xs" },
55
+ typeof output === "string" ? output : "Speech completed successfully",
56
+ ),
57
+ ),
58
+ );
src/tools/template.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Description of the tool.
3
+ * @param {any} parameter1 - Description of the first parameter.
4
+ * @param {any} parameter2 - Description of the second parameter.
5
+ * @returns {any} Description of the return value.
6
+ */
7
+ export function new_tool(parameter1, parameter2) {
8
+ // TODO: Implement the tool logic here
9
+ return true; // Placeholder return value
10
+ }
11
+
12
+ export default (input, output) =>
13
+ React.createElement(
14
+ "div",
15
+ { className: "bg-amber-50 border border-amber-200 rounded-lg p-4" },
16
+ React.createElement(
17
+ "div",
18
+ { className: "flex items-center mb-2" },
19
+ React.createElement(
20
+ "div",
21
+ {
22
+ className:
23
+ "w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center mr-3",
24
+ },
25
+ "🛠️",
26
+ ),
27
+ React.createElement(
28
+ "h3",
29
+ { className: "text-amber-900 font-semibold" },
30
+ "Tool Name",
31
+ ),
32
+ ),
33
+ React.createElement(
34
+ "div",
35
+ { className: "text-sm space-y-1" },
36
+ React.createElement(
37
+ "p",
38
+ { className: "text-amber-700 font-medium" },
39
+ `Input: ${JSON.stringify(input)}`,
40
+ ),
41
+ React.createElement(
42
+ "p",
43
+ { className: "text-amber-600 text-xs" },
44
+ `Output: ${output}`,
45
+ ),
46
+ ),
47
+ );
src/types/mcp.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js";
2
+
3
+ export interface MCPServerConfig {
4
+ id: string;
5
+ name: string;
6
+ url: string;
7
+ enabled: boolean;
8
+ transport: 'websocket' | 'sse' | 'streamable-http';
9
+ auth?: {
10
+ type: 'bearer' | 'basic' | 'oauth';
11
+ token?: string;
12
+ username?: string;
13
+ password?: string;
14
+ };
15
+ }
16
+
17
+ export interface MCPServerConnection {
18
+ config: MCPServerConfig;
19
+ isConnected: boolean;
20
+ tools: MCPTool[];
21
+ lastError?: string;
22
+ lastConnected?: Date;
23
+ }
24
+
25
+ // Extended Tool interface to support both local and MCP tools
26
+ export interface ExtendedTool {
27
+ id: number | string;
28
+ name: string;
29
+ enabled: boolean;
30
+ isCollapsed?: boolean;
31
+
32
+ // Local tool properties
33
+ code?: string;
34
+ renderer?: string;
35
+
36
+ // MCP tool properties
37
+ mcpServerId?: string;
38
+ mcpTool?: MCPTool;
39
+ isRemote?: boolean;
40
+ }
41
+
42
+ // MCP Tool execution result
43
+ export interface MCPToolResult {
44
+ content: Array<{
45
+ type: string;
46
+ text?: string;
47
+ data?: unknown;
48
+ mimeType?: string;
49
+ }>;
50
+ isError?: boolean;
51
+ }
52
+
53
+ // MCP Client state
54
+ export interface MCPClientState {
55
+ servers: Record<string, MCPServerConnection>;
56
+ isLoading: boolean;
57
+ error?: string;
58
+ }
src/utils.ts ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface ParsedCall {
2
+ name: string;
3
+ positionalArgs: any[];
4
+ keywordArgs: Record<string, any>;
5
+ }
6
+
7
+ interface Schema {
8
+ name: string;
9
+ description: string;
10
+ parameters: {
11
+ type: string;
12
+ properties: Record<
13
+ string,
14
+ {
15
+ type: string;
16
+ description: string;
17
+ default?: any;
18
+ }
19
+ >;
20
+ required: string[];
21
+ };
22
+ }
23
+
24
+ interface JSDocParam {
25
+ type: string;
26
+ description: string;
27
+ isOptional: boolean;
28
+ defaultValue?: string;
29
+ }
30
+
31
+ const parseArguments = (argsString: string): string[] => {
32
+ const args: string[] = [];
33
+ let current = "";
34
+ let inQuotes = false;
35
+ let quoteChar = "";
36
+ let depth = 0;
37
+
38
+ for (let i = 0; i < argsString.length; i++) {
39
+ const char = argsString[i];
40
+
41
+ if (!inQuotes && (char === '"' || char === "'")) {
42
+ inQuotes = true;
43
+ quoteChar = char;
44
+ current += char;
45
+ } else if (inQuotes && char === quoteChar) {
46
+ inQuotes = false;
47
+ quoteChar = "";
48
+ current += char;
49
+ } else if (!inQuotes && char === "(") {
50
+ depth++;
51
+ current += char;
52
+ } else if (!inQuotes && char === ")") {
53
+ depth--;
54
+ current += char;
55
+ } else if (!inQuotes && char === "," && depth === 0) {
56
+ args.push(current.trim());
57
+ current = "";
58
+ } else {
59
+ current += char;
60
+ }
61
+ }
62
+
63
+ if (current.trim()) {
64
+ args.push(current.trim());
65
+ }
66
+
67
+ return args;
68
+ };
69
+
70
+ export const extractPythonicCalls = (toolCallContent: string): string[] => {
71
+ try {
72
+ const cleanContent = toolCallContent.trim();
73
+
74
+ try {
75
+ const parsed = JSON.parse(cleanContent);
76
+ if (Array.isArray(parsed)) {
77
+ return parsed;
78
+ }
79
+ } catch {
80
+ // Fallback to manual parsing
81
+ }
82
+
83
+ if (cleanContent.startsWith("[") && cleanContent.endsWith("]")) {
84
+ const inner = cleanContent.slice(1, -1).trim();
85
+ if (!inner) return [];
86
+ return parseArguments(inner).map((call) =>
87
+ call.trim().replace(/^['"]|['"]$/g, ""),
88
+ );
89
+ }
90
+
91
+ return [cleanContent];
92
+ } catch (error) {
93
+ console.error("Error parsing tool calls:", error);
94
+ return [];
95
+ }
96
+ };
97
+
98
+ export const parsePythonicCalls = (command: string): ParsedCall | null => {
99
+ const callMatch = command.match(/^([a-zA-Z0-9_]+)\((.*)\)$/);
100
+ if (!callMatch) return null;
101
+
102
+ const [, name, argsStr] = callMatch;
103
+ const args = parseArguments(argsStr);
104
+ const positionalArgs: any[] = [];
105
+ const keywordArgs: Record<string, any> = {};
106
+
107
+ for (const arg of args) {
108
+ const kwargMatch = arg.match(/^([a-zA-Z0-9_]+)\s*=\s*(.*)$/);
109
+ if (kwargMatch) {
110
+ const [, key, value] = kwargMatch;
111
+ try {
112
+ keywordArgs[key] = JSON.parse(value);
113
+ } catch {
114
+ keywordArgs[key] = value;
115
+ }
116
+ } else {
117
+ try {
118
+ positionalArgs.push(JSON.parse(arg));
119
+ } catch {
120
+ positionalArgs.push(arg);
121
+ }
122
+ }
123
+ }
124
+ return { name, positionalArgs, keywordArgs };
125
+ };
126
+
127
+ export const extractFunctionAndRenderer = (
128
+ code: string,
129
+ ): { functionCode: string; rendererCode?: string } => {
130
+ if (typeof code !== "string") {
131
+ return { functionCode: code };
132
+ }
133
+
134
+ const exportMatch = code.match(/export\s+default\s+/);
135
+ if (!exportMatch) {
136
+ return { functionCode: code };
137
+ }
138
+
139
+ const exportIndex = exportMatch.index!;
140
+ const functionCode = code.substring(0, exportIndex).trim();
141
+ const rendererCode = code.substring(exportIndex).trim();
142
+
143
+ return { functionCode, rendererCode };
144
+ };
145
+
146
+ /**
147
+ * Helper function to extract JSDoc parameters from JSDoc comments.
148
+ */
149
+ const extractJSDocParams = (
150
+ jsdoc: string,
151
+ ): Record<string, JSDocParam & { jsdocDefault?: string }> => {
152
+ const jsdocParams: Record<string, JSDocParam & { jsdocDefault?: string }> =
153
+ {};
154
+ const lines = jsdoc
155
+ .split("\n")
156
+ .map((line) => line.trim().replace(/^\*\s?/, ""));
157
+ const paramRegex =
158
+ /@param\s+\{([^}]+)\}\s+(\[?[a-zA-Z0-9_]+(?:=[^\]]+)?\]?|\S+)\s*-?\s*(.*)?/;
159
+
160
+ for (const line of lines) {
161
+ const paramMatch = line.match(paramRegex);
162
+ if (paramMatch) {
163
+ let [, type, namePart, description] = paramMatch;
164
+ description = description || "";
165
+ let isOptional = false;
166
+ let name = namePart;
167
+ let jsdocDefault: string | undefined = undefined;
168
+
169
+ if (name.startsWith("[") && name.endsWith("]")) {
170
+ isOptional = true;
171
+ name = name.slice(1, -1);
172
+ }
173
+ if (name.includes("=")) {
174
+ const [n, def] = name.split("=");
175
+ name = n.trim();
176
+ jsdocDefault = def.trim().replace(/['"]/g, "");
177
+ }
178
+
179
+ jsdocParams[name] = {
180
+ type: type.toLowerCase(),
181
+ description: description.trim(),
182
+ isOptional,
183
+ defaultValue: undefined,
184
+ jsdocDefault,
185
+ };
186
+ }
187
+ }
188
+ return jsdocParams;
189
+ };
190
+
191
+ /**
192
+ * Helper function to extract function signature information.
193
+ */
194
+ const extractFunctionSignature = (
195
+ functionCode: string,
196
+ ): {
197
+ name: string;
198
+ params: { name: string; defaultValue?: string }[];
199
+ } | null => {
200
+ const functionSignatureMatch = functionCode.match(
201
+ /function\s+([a-zA-Z0-9_]+)\s*\(([^)]*)\)/,
202
+ );
203
+ if (!functionSignatureMatch) {
204
+ return null;
205
+ }
206
+
207
+ const functionName = functionSignatureMatch[1];
208
+ const params = functionSignatureMatch[2]
209
+ .split(",")
210
+ .map((p) => p.trim())
211
+ .filter(Boolean)
212
+ .map((p) => {
213
+ const [name, defaultValue] = p.split("=").map((s) => s.trim());
214
+ return { name, defaultValue };
215
+ });
216
+
217
+ return { name: functionName, params };
218
+ };
219
+
220
+ export const generateSchemaFromCode = (code: string): Schema => {
221
+ const { functionCode } = extractFunctionAndRenderer(code);
222
+
223
+ if (typeof functionCode !== "string") {
224
+ return {
225
+ name: "invalid_code",
226
+ description: "Code is not a valid string.",
227
+ parameters: { type: "object", properties: {}, required: [] },
228
+ };
229
+ }
230
+
231
+ // 1. Extract function signature, name, and parameter names directly from the code
232
+ const signatureInfo = extractFunctionSignature(functionCode);
233
+ if (!signatureInfo) {
234
+ return {
235
+ name: "invalid_function",
236
+ description: "Could not parse function signature.",
237
+ parameters: { type: "object", properties: {}, required: [] },
238
+ };
239
+ }
240
+
241
+ const { name: functionName, params: paramsFromSignature } = signatureInfo;
242
+
243
+ const schema: Schema = {
244
+ name: functionName,
245
+ description: "",
246
+ parameters: {
247
+ type: "object",
248
+ properties: {},
249
+ required: [],
250
+ },
251
+ };
252
+
253
+ // 2. Parse JSDoc comments to get descriptions and types
254
+ const jsdocMatch = functionCode.match(/\/\*\*([\s\S]*?)\*\//);
255
+ let jsdocParams: Record<string, JSDocParam & { jsdocDefault?: string }> = {};
256
+ if (jsdocMatch) {
257
+ const jsdoc = jsdocMatch[1];
258
+ jsdocParams = extractJSDocParams(jsdoc);
259
+
260
+ const descriptionLines = jsdoc
261
+ .split("\n")
262
+ .map((line) => line.trim().replace(/^\*\s?/, ""))
263
+ .filter((line) => !line.startsWith("@") && line);
264
+
265
+ schema.description = descriptionLines.join(" ").trim();
266
+ }
267
+
268
+ // 3. Combine signature parameters with JSDoc info
269
+ for (const param of paramsFromSignature) {
270
+ const paramName = param.name;
271
+ const jsdocInfo = jsdocParams[paramName];
272
+ schema.parameters.properties[paramName] = {
273
+ type: jsdocInfo ? jsdocInfo.type : "any",
274
+ description: jsdocInfo ? jsdocInfo.description : "",
275
+ };
276
+
277
+ // Prefer default from signature, then from JSDoc
278
+ if (param.defaultValue !== undefined) {
279
+ // Try to parse as JSON, fallback to string
280
+ try {
281
+ schema.parameters.properties[paramName].default = JSON.parse(
282
+ param.defaultValue.replace(/'/g, '"'),
283
+ );
284
+ } catch {
285
+ schema.parameters.properties[paramName].default = param.defaultValue;
286
+ }
287
+ } else if (jsdocInfo && jsdocInfo.jsdocDefault !== undefined) {
288
+ schema.parameters.properties[paramName].default = jsdocInfo.jsdocDefault;
289
+ }
290
+
291
+ // A parameter is required if:
292
+ // - Not optional in JSDoc
293
+ // - No default in signature
294
+ // - No default in JSDoc
295
+ const hasDefault =
296
+ param.defaultValue !== undefined ||
297
+ (jsdocInfo && jsdocInfo.jsdocDefault !== undefined);
298
+ if (!jsdocInfo || (!jsdocInfo.isOptional && !hasDefault)) {
299
+ schema.parameters.required.push(paramName);
300
+ }
301
+ }
302
+
303
+ return schema;
304
+ };
305
+
306
+ /**
307
+ * Extracts tool call content from a string using the tool call markers.
308
+ */
309
+ export const extractToolCallContent = (content: string): string | null => {
310
+ const toolCallMatch = content.match(
311
+ /<\|tool_call_start\|>(.*?)<\|tool_call_end\|>/s,
312
+ );
313
+ return toolCallMatch ? toolCallMatch[1].trim() : null;
314
+ };
315
+
316
+ /**
317
+ * Maps positional and keyword arguments to named parameters based on schema.
318
+ */
319
+ export const mapArgsToNamedParams = (
320
+ paramNames: string[],
321
+ positionalArgs: any[],
322
+ keywordArgs: Record<string, any>,
323
+ ): Record<string, any> => {
324
+ const namedParams: Record<string, any> = Object.create(null);
325
+ positionalArgs.forEach((arg, idx) => {
326
+ if (idx < paramNames.length) {
327
+ namedParams[paramNames[idx]] = arg;
328
+ }
329
+ });
330
+ Object.assign(namedParams, keywordArgs);
331
+ return namedParams;
332
+ };
333
+
334
+ export const getErrorMessage = (error: unknown): string => {
335
+ if (error instanceof Error) {
336
+ return error.message;
337
+ }
338
+ if (typeof error === "string") {
339
+ return error;
340
+ }
341
+ if (error && typeof error === "object") {
342
+ return JSON.stringify(error);
343
+ }
344
+ return String(error);
345
+ };
346
+
347
+ /**
348
+ * Adapted from https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser.
349
+ */
350
+ export function isMobileOrTablet() {
351
+ let check = false;
352
+ (function (a: string) {
353
+ if (
354
+ /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(
355
+ a,
356
+ ) ||
357
+ /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
358
+ a.slice(0, 4),
359
+ )
360
+ )
361
+ check = true;
362
+ })(
363
+ navigator.userAgent ||
364
+ navigator.vendor ||
365
+ ("opera" in window && typeof window.opera === "string"
366
+ ? window.opera
367
+ : ""),
368
+ );
369
+ return check;
370
+ }
src/utils/storage.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Secure storage utilities for sensitive data like OAuth tokens
3
+ */
4
+
5
+ const ENCRYPTION_KEY_NAME = 'mcp-encryption-key';
6
+
7
+ // Generate or retrieve encryption key
8
+ async function getEncryptionKey(): Promise<CryptoKey> {
9
+ const keyData = localStorage.getItem(ENCRYPTION_KEY_NAME);
10
+
11
+ if (keyData) {
12
+ try {
13
+ const keyBuffer = new Uint8Array(JSON.parse(keyData));
14
+ return await crypto.subtle.importKey(
15
+ 'raw',
16
+ keyBuffer,
17
+ { name: 'AES-GCM' },
18
+ false,
19
+ ['encrypt', 'decrypt']
20
+ );
21
+ } catch {
22
+ // Key corrupted, generate new one
23
+ }
24
+ }
25
+
26
+ // Generate new key
27
+ const key = await crypto.subtle.generateKey(
28
+ { name: 'AES-GCM', length: 256 },
29
+ true,
30
+ ['encrypt', 'decrypt']
31
+ );
32
+
33
+ // Store key for future use
34
+ const keyBuffer = await crypto.subtle.exportKey('raw', key);
35
+ localStorage.setItem(ENCRYPTION_KEY_NAME, JSON.stringify(Array.from(new Uint8Array(keyBuffer))));
36
+
37
+ return key;
38
+ }
39
+
40
+ // Encrypt sensitive data
41
+ export async function encryptData(data: string): Promise<string> {
42
+ try {
43
+ const key = await getEncryptionKey();
44
+ const encoder = new TextEncoder();
45
+ const dataBuffer = encoder.encode(data);
46
+
47
+ const iv = crypto.getRandomValues(new Uint8Array(12));
48
+ const encryptedBuffer = await crypto.subtle.encrypt(
49
+ { name: 'AES-GCM', iv },
50
+ key,
51
+ dataBuffer
52
+ );
53
+
54
+ // Combine IV and encrypted data
55
+ const result = new Uint8Array(iv.length + encryptedBuffer.byteLength);
56
+ result.set(iv);
57
+ result.set(new Uint8Array(encryptedBuffer), iv.length);
58
+
59
+ return btoa(String.fromCharCode(...result));
60
+ } catch (error) {
61
+ console.warn('Encryption failed, storing data unencrypted:', error);
62
+ return data;
63
+ }
64
+ }
65
+
66
+ // Decrypt sensitive data
67
+ export async function decryptData(encryptedData: string): Promise<string> {
68
+ try {
69
+ const key = await getEncryptionKey();
70
+ const dataBuffer = new Uint8Array(
71
+ atob(encryptedData).split('').map(char => char.charCodeAt(0))
72
+ );
73
+
74
+ const iv = dataBuffer.slice(0, 12);
75
+ const encrypted = dataBuffer.slice(12);
76
+
77
+ const decryptedBuffer = await crypto.subtle.decrypt(
78
+ { name: 'AES-GCM', iv },
79
+ key,
80
+ encrypted
81
+ );
82
+
83
+ const decoder = new TextDecoder();
84
+ return decoder.decode(decryptedBuffer);
85
+ } catch (error) {
86
+ console.warn('Decryption failed, returning data as-is:', error);
87
+ return encryptedData;
88
+ }
89
+ }
90
+
91
+ // Secure storage wrapper for sensitive data
92
+ export const secureStorage = {
93
+ async setItem(key: string, value: string): Promise<void> {
94
+ const encrypted = await encryptData(value);
95
+ localStorage.setItem(`secure_${key}`, encrypted);
96
+ },
97
+
98
+ async getItem(key: string): Promise<string | null> {
99
+ const encrypted = localStorage.getItem(`secure_${key}`);
100
+ if (!encrypted) return null;
101
+
102
+ return await decryptData(encrypted);
103
+ },
104
+
105
+ removeItem(key: string): void {
106
+ localStorage.removeItem(`secure_${key}`);
107
+ }
108
+ };
src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
tsconfig.app.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+ "jsx": "react-jsx",
17
+
18
+ /* Linting */
19
+ "strict": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "erasableSyntaxOnly": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedSideEffectImports": true
25
+ },
26
+ "include": ["src"]
27
+ }
tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "erasableSyntaxOnly": true,
21
+ "noFallthroughCasesInSwitch": true,
22
+ "noUncheckedSideEffectImports": true
23
+ },
24
+ "include": ["vite.config.ts"]
25
+ }
vite.config.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ });