diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..60f2e42537e21320c941ce15828fef2c9bc8d5db 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +public/thumbnail.png filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 4d29575de80483b005c29bfcac5061cd2f45313e..0fd69139e3de1bb68ef3fd7548d7b1e38ede53df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,25 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# production -/build - -# misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - +# Logs +logs +*.log npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +package-lock.json diff --git a/README.md b/README.md index 277d937e4f28418e28ac760139e70df7ca07b22a..c9931c8e5863c0b1c21efede23fc68b72a3041e2 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ emoji: 🐠 colorFrom: indigo colorTo: red sdk: static -pinned: false +pinned: true app_build_command: npm run build app_file: build/index.html license: apache-2.0 -short_description: Use MCP and LLM with WebGPU +short_description: Use MCP and WebGPU-based LLMs with tool calling --- -# Getting Started with Create React App +# WebGPU MCP -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +Run WebGPU-based language models with tool calling capabilities in your browser, powered by the Model Context Protocol (MCP). This project supports any WebGPU-compatible models from [Hugging Face ONNX Community](https://huggingface.co/onnx-community) that support tool calling. ## Available Scripts diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..f4616740a7d28f84d875b7faba57a092d686d4e4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { globalIgnores } from "eslint/config"; + +export default tseslint.config([ + globalIgnores(["dist"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs["recommended-latest"], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]); diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..720fd74b4e0fea6fc4b1630a94217536ca884094 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + WebGPU MCP - In-Browser Tool Calling + + +
+ + + diff --git a/package.json b/package.json index 9623fe016ea523afac617d6d68ddc568905ee0fb..47588ae555565a1e53d31d3b5088c15f67da218f 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,37 @@ { - "name": "react-template", - "version": "0.1.0", + "name": "mcp-webgpu", "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, "dependencies": { - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^13.5.0", + "@huggingface/transformers": "^3.7.1", + "@modelcontextprotocol/sdk": "^1.17.3", + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/vite": "^4.1.11", + "idb": "^8.0.3", + "lucide-react": "^0.535.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-scripts": "5.0.1", - "web-vitals": "^2.1.4" - }, - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] + "react-router-dom": "^7.8.0", + "tailwindcss": "^4.1.11" }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "devDependencies": { + "@eslint/js": "^9.30.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.35.1", + "vite": "^7.0.4" } } diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index a11777cc471a4344702741ab1c8a588998b1311a..0000000000000000000000000000000000000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/index.html b/public/index.html deleted file mode 100644 index aa069f27cbd9d53394428171c3989fd03db73c76..0000000000000000000000000000000000000000 --- a/public/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - React App - - - -
- - - diff --git a/public/liquidai-logo.svg b/public/liquidai-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..5e698b89cfb732a3fe9975b4238bc7b21f1f12a6 --- /dev/null +++ b/public/liquidai-logo.svg @@ -0,0 +1 @@ +Liquid \ No newline at end of file diff --git a/public/logo192.png b/public/logo192.png deleted file mode 100644 index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..0000000000000000000000000000000000000000 Binary files a/public/logo192.png and /dev/null differ diff --git a/public/logo512.png b/public/logo512.png deleted file mode 100644 index a4e47a6545bc15971f8f63fba70e4013df88a664..0000000000000000000000000000000000000000 Binary files a/public/logo512.png and /dev/null differ diff --git a/public/manifest.json b/public/manifest.json deleted file mode 100644 index 080d6c77ac21bb2ef88a6992b2b73ad93daaca92..0000000000000000000000000000000000000000 --- a/public/manifest.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "short_name": "React App", - "name": "Create React App Sample", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index e9e57dc4d41b9b46e05112e9f45b7ea6ac0ba15e..0000000000000000000000000000000000000000 --- a/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/public/thumbnail.png b/public/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..8c2ba23abf977597f17567aa4153b2da1c8aa11e --- /dev/null +++ b/public/thumbnail.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:389f1e853f6f9be5fcad644a6092e4283a004072598ffdba0d9da6b6d7d6d751 +size 623617 diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 74b5e053450a48a6bdb4d71aad648e7af821975c..0000000000000000000000000000000000000000 --- a/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/App.js b/src/App.js deleted file mode 100644 index 37845757234ccb68531c10cf7a2ffc589c47e342..0000000000000000000000000000000000000000 --- a/src/App.js +++ /dev/null @@ -1,25 +0,0 @@ -import logo from './logo.svg'; -import './App.css'; - -function App() { - return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
-
- ); -} - -export default App; diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1f03afeece5ac28064fa3c73a29215037465f789..0000000000000000000000000000000000000000 --- a/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5e797b6dad4e7bbe73756bde59b2488e7347864 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,925 @@ +import React, { + useState, + useEffect, + useCallback, + useRef, + useMemo, +} from "react"; +import { openDB, type IDBPDatabase } from "idb"; +import { + Play, + Plus, + Zap, + RotateCcw, + Settings, + X, + PanelRightClose, + PanelRightOpen, +} from "lucide-react"; +import { useLLM } from "./hooks/useLLM"; +import { useMCP } from "./hooks/useMCP"; + +import type { Tool } from "./components/ToolItem"; + +import { + parsePythonicCalls, + extractPythonicCalls, + extractFunctionAndRenderer, + generateSchemaFromCode, + extractToolCallContent, + mapArgsToNamedParams, + getErrorMessage, + isMobileOrTablet, +} from "./utils"; + +import { DEFAULT_SYSTEM_PROMPT } from "./constants/systemPrompt"; +import { DB_NAME, STORE_NAME, SETTINGS_STORE_NAME } from "./constants/db"; + +import { TEMPLATE } from "./tools"; +import ToolResultRenderer from "./components/ToolResultRenderer"; +import ToolCallIndicator from "./components/ToolCallIndicator"; +import ToolItem from "./components/ToolItem"; +import ResultBlock from "./components/ResultBlock"; +import ExamplePrompts from "./components/ExamplePrompts"; +import { MCPServerManager } from "./components/MCPServerManager"; + +import { LoadingScreen } from "./components/LoadingScreen"; + +interface RenderInfo { + call: string; + result?: unknown; + renderer?: string; + input?: Record; + error?: string; +} + +interface BaseMessage { + role: "system" | "user" | "assistant"; + content: string; +} +interface ToolMessage { + role: "tool"; + content: string; + renderInfo: RenderInfo[]; // Rich data for the UI +} +type Message = BaseMessage | ToolMessage; + +async function getDB(): Promise { + return openDB(DB_NAME, 1, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { + keyPath: "id", + autoIncrement: true, + }); + } + if (!db.objectStoreNames.contains(SETTINGS_STORE_NAME)) { + db.createObjectStore(SETTINGS_STORE_NAME, { keyPath: "key" }); + } + }, + }); +} + +const App: React.FC = () => { + const [systemPrompt, setSystemPrompt] = useState( + DEFAULT_SYSTEM_PROMPT + ); + const [isSystemPromptModalOpen, setIsSystemPromptModalOpen] = + useState(false); + const [tempSystemPrompt, setTempSystemPrompt] = useState(""); + const [messages, setMessages] = useState([]); + const [tools, setTools] = useState([]); + const [input, setInput] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + const isMobile = useMemo(isMobileOrTablet, []); + const [selectedModelId, setSelectedModelId] = useState( + isMobile ? "350M" : "1.2B" + ); + const [isModelDropdownOpen, setIsModelDropdownOpen] = + useState(false); + const [isMCPManagerOpen, setIsMCPManagerOpen] = useState(false); + const [isToolsPanelVisible, setIsToolsPanelVisible] = useState(true); + const chatContainerRef = useRef(null); + const debounceTimers = useRef>({}); + const toolsContainerRef = useRef(null); + const inputRef = useRef(null); + const { + isLoading, + isReady, + error, + progress, + loadModel, + generateResponse, + clearPastKeyValues, + } = useLLM(selectedModelId); + + // MCP integration + const { + getMCPToolsAsOriginalTools, + callMCPTool, + connectAll: connectAllMCPServers, + } = useMCP(); + + const loadTools = useCallback(async (): Promise => { + const db = await getDB(); + const allTools: Tool[] = await db.getAll(STORE_NAME); + setTools(allTools.map((t) => ({ ...t, isCollapsed: false }))); + + // Load MCP tools and merge them + const mcpTools = getMCPToolsAsOriginalTools(); + setTools((prevTools) => [...prevTools, ...mcpTools]); + }, [getMCPToolsAsOriginalTools]); + + useEffect(() => { + loadTools(); + // Connect to MCP servers on startup + connectAllMCPServers().catch((error) => { + console.error("Failed to connect to MCP servers:", error); + }); + }, [loadTools, connectAllMCPServers]); + + useEffect(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTop = + chatContainerRef.current.scrollHeight; + } + }, [messages]); + + const updateToolInDB = async (tool: Tool): Promise => { + const db = await getDB(); + await db.put(STORE_NAME, tool); + }; + + const saveToolDebounced = (tool: Tool): void => { + if (tool.id !== undefined && debounceTimers.current[tool.id]) { + clearTimeout(debounceTimers.current[tool.id]); + } + if (tool.id !== undefined) { + debounceTimers.current[tool.id] = setTimeout(() => { + updateToolInDB(tool); + }, 300); + } + }; + + const clearChat = useCallback(() => { + setMessages([]); + clearPastKeyValues(); + }, [clearPastKeyValues]); + + const addTool = async (): Promise => { + const newTool: Omit = { + name: "new_tool", + code: TEMPLATE, + enabled: true, + isCollapsed: false, + }; + const db = await getDB(); + const id = await db.add(STORE_NAME, newTool); + setTools((prev) => { + const updated = [...prev, { ...newTool, id: id as number }]; + setTimeout(() => { + if (toolsContainerRef.current) { + toolsContainerRef.current.scrollTop = + toolsContainerRef.current.scrollHeight; + } + }, 0); + return updated; + }); + clearChat(); + }; + + const deleteTool = async (id: number): Promise => { + if (debounceTimers.current[id]) { + clearTimeout(debounceTimers.current[id]); + } + const db = await getDB(); + await db.delete(STORE_NAME, id); + setTools(tools.filter((tool) => tool.id !== id)); + clearChat(); + }; + + const toggleToolEnabled = (id: number): void => { + let changedTool: Tool | undefined; + const newTools = tools.map((tool) => { + if (tool.id === id) { + changedTool = { ...tool, enabled: !tool.enabled }; + return changedTool; + } + return tool; + }); + setTools(newTools); + if (changedTool) saveToolDebounced(changedTool); + }; + + const toggleToolCollapsed = (id: number): void => { + setTools( + tools.map((tool) => + tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool + ) + ); + }; + + const expandTool = (id: number): void => { + setTools( + tools.map((tool) => + tool.id === id ? { ...tool, isCollapsed: false } : tool + ) + ); + }; + + const handleToolCodeChange = (id: number, newCode: string): void => { + let changedTool: Tool | undefined; + const newTools = tools.map((tool) => { + if (tool.id === id) { + const { functionCode } = extractFunctionAndRenderer(newCode); + const schema = generateSchemaFromCode(functionCode); + changedTool = { ...tool, code: newCode, name: schema.name }; + return changedTool; + } + return tool; + }); + setTools(newTools); + if (changedTool) saveToolDebounced(changedTool); + }; + + const executeToolCall = async (callString: string): Promise => { + const parsedCall = parsePythonicCalls(callString); + if (!parsedCall) throw new Error(`Invalid tool call format: ${callString}`); + + const { name, positionalArgs, keywordArgs } = parsedCall; + const toolToUse = tools.find((t) => t.name === name && t.enabled); + if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`); + + // Check if this is an MCP tool + const isMCPTool = toolToUse.code?.includes("mcpServerId:"); + if (isMCPTool) { + // Extract MCP server ID and tool name from the code + const mcpServerMatch = toolToUse.code?.match(/mcpServerId: "([^"]+)"/); + const mcpToolMatch = toolToUse.code?.match(/toolName: "([^"]+)"/); + + if (mcpServerMatch && mcpToolMatch) { + const serverId = mcpServerMatch[1]; + const toolName = mcpToolMatch[1]; + + // Convert positional and keyword args to a single args object + const { functionCode } = extractFunctionAndRenderer(toolToUse.code); + const schema = generateSchemaFromCode(functionCode); + const paramNames = Object.keys(schema.parameters.properties); + + const args: Record = {}; + + // Map positional args + for ( + let i = 0; + i < Math.min(positionalArgs.length, paramNames.length); + i++ + ) { + args[paramNames[i]] = positionalArgs[i]; + } + + // Map keyword args + Object.entries(keywordArgs).forEach(([key, value]) => { + args[key] = value; + }); + + // Call MCP tool + const result = await callMCPTool(serverId, toolName, args); + return JSON.stringify(result); + } + } + + // Handle local tools as before + const { functionCode } = extractFunctionAndRenderer(toolToUse.code); + const schema = generateSchemaFromCode(functionCode); + const paramNames = Object.keys(schema.parameters.properties); + + const finalArgs: unknown[] = []; + const requiredParams = schema.parameters.required || []; + + for (let i = 0; i < paramNames.length; ++i) { + const paramName = paramNames[i]; + if (i < positionalArgs.length) { + finalArgs.push(positionalArgs[i]); + } else if (Object.prototype.hasOwnProperty.call(keywordArgs, paramName)) { + finalArgs.push(keywordArgs[paramName]); + } else if ( + Object.prototype.hasOwnProperty.call( + schema.parameters.properties[paramName], + "default" + ) + ) { + finalArgs.push(schema.parameters.properties[paramName].default); + } else if (!requiredParams.includes(paramName)) { + finalArgs.push(undefined); + } else { + throw new Error(`Missing required argument: ${paramName}`); + } + } + + const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/); + if (!bodyMatch) { + throw new Error( + "Could not parse function body. Ensure it's a standard `function` declaration." + ); + } + const body = bodyMatch[1]; + const AsyncFunction = Object.getPrototypeOf( + async function () {} + ).constructor; + const func = new AsyncFunction(...paramNames, body); + const result = await func(...finalArgs); + return JSON.stringify(result); + }; + + const executeToolCalls = async ( + toolCallContent: string + ): Promise => { + const toolCalls = extractPythonicCalls(toolCallContent); + if (toolCalls.length === 0) + return [{ call: "", error: "No valid tool calls found." }]; + + const results: RenderInfo[] = []; + for (const call of toolCalls) { + try { + const result = await executeToolCall(call); + const parsedCall = parsePythonicCalls(call); + const toolUsed = parsedCall + ? tools.find((t) => t.name === parsedCall.name && t.enabled) + : null; + const { rendererCode } = toolUsed + ? extractFunctionAndRenderer(toolUsed.code) + : { rendererCode: undefined }; + + let parsedResult; + try { + parsedResult = JSON.parse(result); + } catch { + parsedResult = result; + } + + let namedParams: Record = Object.create(null); + if (parsedCall && toolUsed) { + const schema = generateSchemaFromCode( + extractFunctionAndRenderer(toolUsed.code).functionCode + ); + const paramNames = Object.keys(schema.parameters.properties); + namedParams = mapArgsToNamedParams( + paramNames, + parsedCall.positionalArgs, + parsedCall.keywordArgs + ); + } + + const renderInfo: RenderInfo = { + call, + result: parsedResult, + renderer: rendererCode, + }; + if (namedParams && Object.keys(namedParams).length > 0) { + renderInfo.input = namedParams; + } + results.push(renderInfo); + } catch (error) { + const errorMessage = getErrorMessage(error); + results.push({ call, error: errorMessage }); + } + } + return results; + }; + + const handleSendMessage = async (): Promise => { + if (!input.trim() || !isReady) return; + + const userMessage: Message = { role: "user", content: input }; + const currentMessages: Message[] = [...messages, userMessage]; + setMessages(currentMessages); + setInput(""); + setIsGenerating(true); + + try { + const toolSchemas = tools + .filter((tool) => tool.enabled) + .map((tool) => generateSchemaFromCode(tool.code)); + + while (true) { + const messagesForGeneration = [ + { role: "system" as const, content: systemPrompt }, + ...currentMessages, + ]; + + setMessages([...currentMessages, { role: "assistant", content: "" }]); + + let accumulatedContent = ""; + const response = await generateResponse( + messagesForGeneration, + toolSchemas, + (token: string) => { + accumulatedContent += token; + setMessages((current) => { + const updated = [...current]; + updated[updated.length - 1] = { + role: "assistant", + content: accumulatedContent, + }; + return updated; + }); + } + ); + + currentMessages.push({ role: "assistant", content: response }); + const toolCallContent = extractToolCallContent(response); + + if (toolCallContent) { + const toolResults = await executeToolCalls(toolCallContent); + + const toolMessage: ToolMessage = { + role: "tool", + content: JSON.stringify(toolResults.map((r) => r.result ?? null)), + renderInfo: toolResults, + }; + currentMessages.push(toolMessage); + setMessages([...currentMessages]); + continue; + } else { + setMessages(currentMessages); + break; + } + } + } catch (error) { + const errorMessage = getErrorMessage(error); + setMessages([ + ...currentMessages, + { + role: "assistant", + content: `Error generating response: ${errorMessage}`, + }, + ]); + } finally { + setIsGenerating(false); + setTimeout(() => inputRef.current?.focus(), 0); + } + }; + + const loadSystemPrompt = useCallback(async (): Promise => { + try { + const db = await getDB(); + const stored = await db.get(SETTINGS_STORE_NAME, "systemPrompt"); + if (stored && stored.value) setSystemPrompt(stored.value); + } catch (error) { + console.error("Failed to load system prompt:", error); + } + }, []); + + const saveSystemPrompt = useCallback( + async (prompt: string): Promise => { + try { + const db = await getDB(); + await db.put(SETTINGS_STORE_NAME, { + key: "systemPrompt", + value: prompt, + }); + } catch (error) { + console.error("Failed to save system prompt:", error); + } + }, + [] + ); + + const loadSelectedModel = useCallback(async (): Promise => { + try { + await loadModel(); + } catch (error) { + console.error("Failed to load model:", error); + } + }, [loadModel]); + + const loadSelectedModelId = useCallback(async (): Promise => { + try { + const db = await getDB(); + const stored = await db.get(SETTINGS_STORE_NAME, "selectedModelId"); + if (stored && stored.value) { + setSelectedModelId(stored.value); + } + } catch (error) { + console.error("Failed to load selected model ID:", error); + } + }, []); + + useEffect(() => { + loadSystemPrompt(); + }, [loadSystemPrompt]); + + const handleOpenSystemPromptModal = (): void => { + setTempSystemPrompt(systemPrompt); + setIsSystemPromptModalOpen(true); + }; + + const handleSaveSystemPrompt = (): void => { + setSystemPrompt(tempSystemPrompt); + saveSystemPrompt(tempSystemPrompt); + setIsSystemPromptModalOpen(false); + }; + + const handleCancelSystemPrompt = (): void => { + setTempSystemPrompt(""); + setIsSystemPromptModalOpen(false); + }; + + const handleResetSystemPrompt = (): void => { + setTempSystemPrompt(DEFAULT_SYSTEM_PROMPT); + }; + + const saveSelectedModel = useCallback( + async (modelId: string): Promise => { + try { + const db = await getDB(); + await db.put(SETTINGS_STORE_NAME, { + key: "selectedModelId", + value: modelId, + }); + } catch (error) { + console.error("Failed to save selected model ID:", error); + } + }, + [] + ); + + useEffect(() => { + loadSystemPrompt(); + loadSelectedModelId(); + }, [loadSystemPrompt, loadSelectedModelId]); + + const handleModelSelect = async (modelId: string) => { + setSelectedModelId(modelId); + setIsModelDropdownOpen(false); + await saveSelectedModel(modelId); + }; + + const handleExampleClick = async (messageText: string): Promise => { + if (!isReady || isGenerating) return; + setInput(messageText); + + const userMessage: Message = { role: "user", content: messageText }; + const currentMessages: Message[] = [...messages, userMessage]; + setMessages(currentMessages); + setInput(""); + setIsGenerating(true); + + try { + const toolSchemas = tools + .filter((tool) => tool.enabled) + .map((tool) => generateSchemaFromCode(tool.code)); + + while (true) { + const messagesForGeneration = [ + { role: "system" as const, content: systemPrompt }, + ...currentMessages, + ]; + + setMessages([...currentMessages, { role: "assistant", content: "" }]); + + let accumulatedContent = ""; + const response = await generateResponse( + messagesForGeneration, + toolSchemas, + (token: string) => { + accumulatedContent += token; + setMessages((current) => { + const updated = [...current]; + updated[updated.length - 1] = { + role: "assistant", + content: accumulatedContent, + }; + return updated; + }); + } + ); + + currentMessages.push({ role: "assistant", content: response }); + const toolCallContent = extractToolCallContent(response); + + if (toolCallContent) { + const toolResults = await executeToolCalls(toolCallContent); + + const toolMessage: ToolMessage = { + role: "tool", + content: JSON.stringify(toolResults.map((r) => r.result ?? null)), + renderInfo: toolResults, + }; + currentMessages.push(toolMessage); + setMessages([...currentMessages]); + continue; + } else { + setMessages(currentMessages); + break; + } + } + } catch (error) { + const errorMessage = getErrorMessage(error); + setMessages([ + ...currentMessages, + { + role: "assistant", + content: `Error generating response: ${errorMessage}`, + }, + ]); + } finally { + setIsGenerating(false); + setTimeout(() => inputRef.current?.focus(), 0); + } + }; + + return ( +
+ {!isReady ? ( + + ) : ( +
+
+
+
+

WebGPU MCP

+
+
+
+ + Ready +
+ + + + +
+
+ +
+ {messages.length === 0 && isReady ? ( + tool.enabled) + .map((tool) => ({ + icon: "🛠️", + displayText: tool.name, + messageText: `${tool.name}()`, + })) + .filter((ex) => ex.displayText)} + onExampleClick={handleExampleClick} + /> + ) : ( + messages.map((msg, index) => { + const key = `${msg.role}-${index}`; + + if (msg.role === "user") { + return ( +
+
+

+ {msg.content} +

+
+
+ ); + } else if (msg.role === "assistant") { + const isToolCall = msg.content.includes( + "<|tool_call_start|>" + ); + + if (isToolCall) { + const nextMessage = messages[index + 1]; + const isCompleted = nextMessage?.role === "tool"; + const hasError = + isCompleted && + (nextMessage as ToolMessage).renderInfo.some( + (info) => !!info.error + ); + + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+

+ {msg.content} +

+
+
+ ); + } else if (msg.role === "tool") { + const visibleToolResults = msg.renderInfo.filter( + (info) => + info.error || (info.result != null && info.renderer) + ); + + if (visibleToolResults.length === 0) return null; + + return ( +
+
+
+ {visibleToolResults.map((info, idx) => ( +
+
+ {info.call} +
+ {info.error ? ( + + ) : ( + + )} +
+ ))} +
+
+
+ ); + } + return null; + }) + )} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && + !isGenerating && + isReady && + handleSendMessage() + } + disabled={isGenerating || !isReady} + className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50" + placeholder={ + isReady + ? "Type your message here..." + : "Load model first to enable chat" + } + /> + +
+
+ + {isToolsPanelVisible && ( +
+
+

Tools

+ +
+
+ {tools.map((tool) => ( + toggleToolEnabled(tool.id)} + onToggleCollapsed={() => toggleToolCollapsed(tool.id)} + onExpand={() => expandTool(tool.id)} + onDelete={() => deleteTool(tool.id)} + onCodeChange={(newCode) => + handleToolCodeChange(tool.id, newCode) + } + /> + ))} +
+
+ )} +
+ )} + + {isSystemPromptModalOpen && ( +
+
+
+

+ Edit System Prompt +

+ +
+
+