Spaces:
Running
Running
Add demo source code (#1)
Browse files- [WIP] Add demo source code (c054ddc14df3d34e21fd4a414b151e3e441e4efc)
- Update README.md (c5702c050471a4bcf162a581b589a9e204e34eaa)
Co-authored-by: Joshua <[email protected]>
- .gitignore +24 -0
- README.md +9 -5
- eslint.config.js +23 -0
- index.html +13 -0
- package.json +35 -0
- public/liquidai-logo.svg +1 -0
- src/App.tsx +826 -0
- src/components/ExamplePrompts.tsx +40 -0
- src/components/LoadingScreen.tsx +255 -0
- src/components/ResultBlock.tsx +21 -0
- src/components/ToolCallIndicator.tsx +98 -0
- src/components/ToolItem.tsx +144 -0
- src/components/ToolResultRenderer.tsx +42 -0
- src/components/icons/HfLogo.tsx +35 -0
- src/components/icons/LiquidAILogo.tsx +12 -0
- src/constants/db.ts +3 -0
- src/constants/examples.ts +39 -0
- src/constants/models.ts +5 -0
- src/constants/systemPrompt.ts +11 -0
- src/hooks/useLLM.ts +234 -0
- src/index.css +1 -0
- src/main.tsx +10 -0
- src/tools/get_location.js +84 -0
- src/tools/get_time.js +51 -0
- src/tools/index.ts +19 -0
- src/tools/math_eval.js +54 -0
- src/tools/open_webpage.js +49 -0
- src/tools/random_number.js +51 -0
- src/tools/sleep.js +45 -0
- src/tools/speak.js +58 -0
- src/tools/template.js +47 -0
- src/utils.ts +370 -0
- src/vite-env.d.ts +1 -0
- tsconfig.app.json +27 -0
- tsconfig.json +7 -0
- tsconfig.node.json +25 -0
- vite.config.ts +8 -0
.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.md
CHANGED
@@ -1,10 +1,14 @@
|
|
1 |
---
|
2 |
-
title: WebGPU
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
colorTo: red
|
6 |
-
sdk:
|
7 |
pinned: false
|
|
|
|
|
|
|
|
|
8 |
---
|
9 |
|
10 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
1 |
---
|
2 |
+
title: LFM2 WebGPU – In-browser tool calling
|
3 |
+
emoji: 🛠️
|
4 |
+
colorFrom: yellow
|
5 |
colorTo: red
|
6 |
+
sdk: static
|
7 |
pinned: false
|
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 |
---
|
13 |
|
14 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
eslint.config.js
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import js from "@eslint/js";
|
2 |
+
import globals from "globals";
|
3 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
4 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
5 |
+
import tseslint from "typescript-eslint";
|
6 |
+
import { globalIgnores } from "eslint/config";
|
7 |
+
|
8 |
+
export default tseslint.config([
|
9 |
+
globalIgnores(["dist"]),
|
10 |
+
{
|
11 |
+
files: ["**/*.{ts,tsx}"],
|
12 |
+
extends: [
|
13 |
+
js.configs.recommended,
|
14 |
+
tseslint.configs.recommended,
|
15 |
+
reactHooks.configs["recommended-latest"],
|
16 |
+
reactRefresh.configs.vite,
|
17 |
+
],
|
18 |
+
languageOptions: {
|
19 |
+
ecmaVersion: 2020,
|
20 |
+
globals: globals.browser,
|
21 |
+
},
|
22 |
+
},
|
23 |
+
]);
|
index.html
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<link rel="icon" type="image/svg+xml" href="/liquidai-logo.svg" />
|
6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
7 |
+
<title>LFM2 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.json
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
"@monaco-editor/react": "^4.7.0",
|
15 |
+
"@tailwindcss/vite": "^4.1.11",
|
16 |
+
"idb": "^8.0.3",
|
17 |
+
"lucide-react": "^0.535.0",
|
18 |
+
"react": "^19.1.0",
|
19 |
+
"react-dom": "^19.1.0",
|
20 |
+
"tailwindcss": "^4.1.11"
|
21 |
+
},
|
22 |
+
"devDependencies": {
|
23 |
+
"@eslint/js": "^9.30.1",
|
24 |
+
"@types/react": "^19.1.8",
|
25 |
+
"@types/react-dom": "^19.1.6",
|
26 |
+
"@vitejs/plugin-react": "^4.6.0",
|
27 |
+
"eslint": "^9.30.1",
|
28 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
29 |
+
"eslint-plugin-react-refresh": "^0.4.20",
|
30 |
+
"globals": "^16.3.0",
|
31 |
+
"typescript": "~5.8.3",
|
32 |
+
"typescript-eslint": "^8.35.1",
|
33 |
+
"vite": "^7.0.4"
|
34 |
+
}
|
35 |
+
}
|
public/liquidai-logo.svg
ADDED
|
src/App.tsx
ADDED
@@ -0,0 +1,826 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { Play, Plus, Zap, RotateCcw, Settings, X } from "lucide-react";
|
10 |
+
import { useLLM } from "./hooks/useLLM";
|
11 |
+
|
12 |
+
import type { Tool } from "./components/ToolItem";
|
13 |
+
|
14 |
+
import {
|
15 |
+
parsePythonicCalls,
|
16 |
+
extractPythonicCalls,
|
17 |
+
extractFunctionAndRenderer,
|
18 |
+
generateSchemaFromCode,
|
19 |
+
extractToolCallContent,
|
20 |
+
mapArgsToNamedParams,
|
21 |
+
getErrorMessage,
|
22 |
+
isMobileOrTablet,
|
23 |
+
} from "./utils";
|
24 |
+
|
25 |
+
import { DEFAULT_SYSTEM_PROMPT } from "./constants/systemPrompt";
|
26 |
+
import { DB_NAME, STORE_NAME, SETTINGS_STORE_NAME } from "./constants/db";
|
27 |
+
|
28 |
+
import { DEFAULT_TOOLS, TEMPLATE } from "./tools";
|
29 |
+
import ToolResultRenderer from "./components/ToolResultRenderer";
|
30 |
+
import ToolCallIndicator from "./components/ToolCallIndicator";
|
31 |
+
import ToolItem from "./components/ToolItem";
|
32 |
+
import ResultBlock from "./components/ResultBlock";
|
33 |
+
import ExamplePrompts from "./components/ExamplePrompts";
|
34 |
+
|
35 |
+
import { LoadingScreen } from "./components/LoadingScreen";
|
36 |
+
|
37 |
+
interface RenderInfo {
|
38 |
+
call: string;
|
39 |
+
result?: any;
|
40 |
+
renderer?: string;
|
41 |
+
input?: Record<string, any>;
|
42 |
+
error?: string;
|
43 |
+
}
|
44 |
+
|
45 |
+
interface BaseMessage {
|
46 |
+
role: "system" | "user" | "assistant";
|
47 |
+
content: string;
|
48 |
+
}
|
49 |
+
interface ToolMessage {
|
50 |
+
role: "tool";
|
51 |
+
content: string;
|
52 |
+
renderInfo: RenderInfo[]; // Rich data for the UI
|
53 |
+
}
|
54 |
+
type Message = BaseMessage | ToolMessage;
|
55 |
+
|
56 |
+
async function getDB(): Promise<IDBPDatabase> {
|
57 |
+
return openDB(DB_NAME, 1, {
|
58 |
+
upgrade(db) {
|
59 |
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
60 |
+
db.createObjectStore(STORE_NAME, {
|
61 |
+
keyPath: "id",
|
62 |
+
autoIncrement: true,
|
63 |
+
});
|
64 |
+
}
|
65 |
+
if (!db.objectStoreNames.contains(SETTINGS_STORE_NAME)) {
|
66 |
+
db.createObjectStore(SETTINGS_STORE_NAME, { keyPath: "key" });
|
67 |
+
}
|
68 |
+
},
|
69 |
+
});
|
70 |
+
}
|
71 |
+
|
72 |
+
const App: React.FC = () => {
|
73 |
+
const [systemPrompt, setSystemPrompt] = useState<string>(
|
74 |
+
DEFAULT_SYSTEM_PROMPT,
|
75 |
+
);
|
76 |
+
const [isSystemPromptModalOpen, setIsSystemPromptModalOpen] =
|
77 |
+
useState<boolean>(false);
|
78 |
+
const [tempSystemPrompt, setTempSystemPrompt] = useState<string>("");
|
79 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
80 |
+
const [tools, setTools] = useState<Tool[]>([]);
|
81 |
+
const [input, setInput] = useState<string>("");
|
82 |
+
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
83 |
+
const isMobile = useMemo(isMobileOrTablet, []);
|
84 |
+
const [selectedModelId, setSelectedModelId] = useState<string>(
|
85 |
+
isMobile ? "350M" : "1.2B",
|
86 |
+
);
|
87 |
+
const [isModelDropdownOpen, setIsModelDropdownOpen] =
|
88 |
+
useState<boolean>(false);
|
89 |
+
const chatContainerRef = useRef<HTMLDivElement>(null);
|
90 |
+
const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
|
91 |
+
const toolsContainerRef = useRef<HTMLDivElement>(null);
|
92 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
93 |
+
const {
|
94 |
+
isLoading,
|
95 |
+
isReady,
|
96 |
+
error,
|
97 |
+
progress,
|
98 |
+
loadModel,
|
99 |
+
generateResponse,
|
100 |
+
clearPastKeyValues,
|
101 |
+
} = useLLM(selectedModelId);
|
102 |
+
|
103 |
+
const loadTools = useCallback(async (): Promise<void> => {
|
104 |
+
const db = await getDB();
|
105 |
+
const allTools: Tool[] = await db.getAll(STORE_NAME);
|
106 |
+
if (allTools.length === 0) {
|
107 |
+
const defaultTools: Tool[] = Object.entries(DEFAULT_TOOLS).map(
|
108 |
+
([name, code], id) => ({
|
109 |
+
id,
|
110 |
+
name,
|
111 |
+
code,
|
112 |
+
enabled: true,
|
113 |
+
isCollapsed: false,
|
114 |
+
}),
|
115 |
+
);
|
116 |
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
117 |
+
await Promise.all(defaultTools.map((tool) => tx.store.put(tool)));
|
118 |
+
await tx.done;
|
119 |
+
setTools(defaultTools);
|
120 |
+
} else {
|
121 |
+
setTools(allTools.map((t) => ({ ...t, isCollapsed: false })));
|
122 |
+
}
|
123 |
+
}, []);
|
124 |
+
|
125 |
+
useEffect(() => {
|
126 |
+
loadTools();
|
127 |
+
}, [loadTools]);
|
128 |
+
|
129 |
+
useEffect(() => {
|
130 |
+
if (chatContainerRef.current) {
|
131 |
+
chatContainerRef.current.scrollTop =
|
132 |
+
chatContainerRef.current.scrollHeight;
|
133 |
+
}
|
134 |
+
}, [messages]);
|
135 |
+
|
136 |
+
const updateToolInDB = async (tool: Tool): Promise<void> => {
|
137 |
+
const db = await getDB();
|
138 |
+
await db.put(STORE_NAME, tool);
|
139 |
+
};
|
140 |
+
|
141 |
+
const saveToolDebounced = (tool: Tool): void => {
|
142 |
+
if (tool.id !== undefined && debounceTimers.current[tool.id]) {
|
143 |
+
clearTimeout(debounceTimers.current[tool.id]);
|
144 |
+
}
|
145 |
+
if (tool.id !== undefined) {
|
146 |
+
debounceTimers.current[tool.id] = setTimeout(() => {
|
147 |
+
updateToolInDB(tool);
|
148 |
+
}, 300);
|
149 |
+
}
|
150 |
+
};
|
151 |
+
|
152 |
+
const clearChat = useCallback(() => {
|
153 |
+
setMessages([]);
|
154 |
+
clearPastKeyValues();
|
155 |
+
}, [clearPastKeyValues]);
|
156 |
+
|
157 |
+
const addTool = async (): Promise<void> => {
|
158 |
+
const newTool: Omit<Tool, "id"> = {
|
159 |
+
name: "new_tool",
|
160 |
+
code: TEMPLATE,
|
161 |
+
enabled: true,
|
162 |
+
isCollapsed: false,
|
163 |
+
};
|
164 |
+
const db = await getDB();
|
165 |
+
const id = await db.add(STORE_NAME, newTool);
|
166 |
+
setTools((prev) => {
|
167 |
+
const updated = [...prev, { ...newTool, id: id as number }];
|
168 |
+
setTimeout(() => {
|
169 |
+
if (toolsContainerRef.current) {
|
170 |
+
toolsContainerRef.current.scrollTop =
|
171 |
+
toolsContainerRef.current.scrollHeight;
|
172 |
+
}
|
173 |
+
}, 0);
|
174 |
+
return updated;
|
175 |
+
});
|
176 |
+
clearChat();
|
177 |
+
};
|
178 |
+
|
179 |
+
const deleteTool = async (id: number): Promise<void> => {
|
180 |
+
if (debounceTimers.current[id]) {
|
181 |
+
clearTimeout(debounceTimers.current[id]);
|
182 |
+
}
|
183 |
+
const db = await getDB();
|
184 |
+
await db.delete(STORE_NAME, id);
|
185 |
+
setTools(tools.filter((tool) => tool.id !== id));
|
186 |
+
clearChat();
|
187 |
+
};
|
188 |
+
|
189 |
+
const toggleToolEnabled = (id: number): void => {
|
190 |
+
let changedTool: Tool | undefined;
|
191 |
+
const newTools = tools.map((tool) => {
|
192 |
+
if (tool.id === id) {
|
193 |
+
changedTool = { ...tool, enabled: !tool.enabled };
|
194 |
+
return changedTool;
|
195 |
+
}
|
196 |
+
return tool;
|
197 |
+
});
|
198 |
+
setTools(newTools);
|
199 |
+
if (changedTool) saveToolDebounced(changedTool);
|
200 |
+
};
|
201 |
+
|
202 |
+
const toggleToolCollapsed = (id: number): void => {
|
203 |
+
setTools(
|
204 |
+
tools.map((tool) =>
|
205 |
+
tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool,
|
206 |
+
),
|
207 |
+
);
|
208 |
+
};
|
209 |
+
|
210 |
+
const expandTool = (id: number): void => {
|
211 |
+
setTools(
|
212 |
+
tools.map((tool) =>
|
213 |
+
tool.id === id ? { ...tool, isCollapsed: false } : tool,
|
214 |
+
),
|
215 |
+
);
|
216 |
+
};
|
217 |
+
|
218 |
+
const handleToolCodeChange = (id: number, newCode: string): void => {
|
219 |
+
let changedTool: Tool | undefined;
|
220 |
+
const newTools = tools.map((tool) => {
|
221 |
+
if (tool.id === id) {
|
222 |
+
const { functionCode } = extractFunctionAndRenderer(newCode);
|
223 |
+
const schema = generateSchemaFromCode(functionCode);
|
224 |
+
changedTool = { ...tool, code: newCode, name: schema.name };
|
225 |
+
return changedTool;
|
226 |
+
}
|
227 |
+
return tool;
|
228 |
+
});
|
229 |
+
setTools(newTools);
|
230 |
+
if (changedTool) saveToolDebounced(changedTool);
|
231 |
+
};
|
232 |
+
|
233 |
+
const executeToolCall = async (callString: string): Promise<string> => {
|
234 |
+
const parsedCall = parsePythonicCalls(callString);
|
235 |
+
if (!parsedCall) throw new Error(`Invalid tool call format: ${callString}`);
|
236 |
+
|
237 |
+
const { name, positionalArgs, keywordArgs } = parsedCall;
|
238 |
+
const toolToUse = tools.find((t) => t.name === name && t.enabled);
|
239 |
+
if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`);
|
240 |
+
|
241 |
+
const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
|
242 |
+
const schema = generateSchemaFromCode(functionCode);
|
243 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
244 |
+
|
245 |
+
const finalArgs: any[] = [];
|
246 |
+
const requiredParams = schema.parameters.required || [];
|
247 |
+
|
248 |
+
for (let i = 0; i < paramNames.length; ++i) {
|
249 |
+
const paramName = paramNames[i];
|
250 |
+
if (i < positionalArgs.length) {
|
251 |
+
finalArgs.push(positionalArgs[i]);
|
252 |
+
} else if (keywordArgs.hasOwnProperty(paramName)) {
|
253 |
+
finalArgs.push(keywordArgs[paramName]);
|
254 |
+
} else if (
|
255 |
+
schema.parameters.properties[paramName].hasOwnProperty("default")
|
256 |
+
) {
|
257 |
+
finalArgs.push(schema.parameters.properties[paramName].default);
|
258 |
+
} else if (!requiredParams.includes(paramName)) {
|
259 |
+
finalArgs.push(undefined);
|
260 |
+
} else {
|
261 |
+
throw new Error(`Missing required argument: ${paramName}`);
|
262 |
+
}
|
263 |
+
}
|
264 |
+
|
265 |
+
const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/);
|
266 |
+
if (!bodyMatch) {
|
267 |
+
throw new Error(
|
268 |
+
"Could not parse function body. Ensure it's a standard `function` declaration.",
|
269 |
+
);
|
270 |
+
}
|
271 |
+
const body = bodyMatch[1];
|
272 |
+
const AsyncFunction = Object.getPrototypeOf(
|
273 |
+
async function () {},
|
274 |
+
).constructor;
|
275 |
+
const func = new AsyncFunction(...paramNames, body);
|
276 |
+
const result = await func(...finalArgs);
|
277 |
+
return JSON.stringify(result);
|
278 |
+
};
|
279 |
+
|
280 |
+
const executeToolCalls = async (
|
281 |
+
toolCallContent: string,
|
282 |
+
): Promise<RenderInfo[]> => {
|
283 |
+
const toolCalls = extractPythonicCalls(toolCallContent);
|
284 |
+
if (toolCalls.length === 0)
|
285 |
+
return [{ call: "", error: "No valid tool calls found." }];
|
286 |
+
|
287 |
+
const results: RenderInfo[] = [];
|
288 |
+
for (const call of toolCalls) {
|
289 |
+
try {
|
290 |
+
const result = await executeToolCall(call);
|
291 |
+
const parsedCall = parsePythonicCalls(call);
|
292 |
+
const toolUsed = parsedCall
|
293 |
+
? tools.find((t) => t.name === parsedCall.name && t.enabled)
|
294 |
+
: null;
|
295 |
+
const { rendererCode } = toolUsed
|
296 |
+
? extractFunctionAndRenderer(toolUsed.code)
|
297 |
+
: { rendererCode: undefined };
|
298 |
+
|
299 |
+
let parsedResult;
|
300 |
+
try {
|
301 |
+
parsedResult = JSON.parse(result);
|
302 |
+
} catch {
|
303 |
+
parsedResult = result;
|
304 |
+
}
|
305 |
+
|
306 |
+
let namedParams: Record<string, any> = Object.create(null);
|
307 |
+
if (parsedCall && toolUsed) {
|
308 |
+
const schema = generateSchemaFromCode(
|
309 |
+
extractFunctionAndRenderer(toolUsed.code).functionCode,
|
310 |
+
);
|
311 |
+
const paramNames = Object.keys(schema.parameters.properties);
|
312 |
+
namedParams = mapArgsToNamedParams(
|
313 |
+
paramNames,
|
314 |
+
parsedCall.positionalArgs,
|
315 |
+
parsedCall.keywordArgs,
|
316 |
+
);
|
317 |
+
}
|
318 |
+
|
319 |
+
results.push({
|
320 |
+
call,
|
321 |
+
result: parsedResult,
|
322 |
+
renderer: rendererCode,
|
323 |
+
input: namedParams,
|
324 |
+
});
|
325 |
+
} catch (error) {
|
326 |
+
const errorMessage = getErrorMessage(error);
|
327 |
+
results.push({ call, error: errorMessage });
|
328 |
+
}
|
329 |
+
}
|
330 |
+
return results;
|
331 |
+
};
|
332 |
+
|
333 |
+
const handleSendMessage = async (): Promise<void> => {
|
334 |
+
if (!input.trim() || !isReady) return;
|
335 |
+
|
336 |
+
const userMessage: Message = { role: "user", content: input };
|
337 |
+
let currentMessages: Message[] = [...messages, userMessage];
|
338 |
+
setMessages(currentMessages);
|
339 |
+
setInput("");
|
340 |
+
setIsGenerating(true);
|
341 |
+
|
342 |
+
try {
|
343 |
+
const toolSchemas = tools
|
344 |
+
.filter((tool) => tool.enabled)
|
345 |
+
.map((tool) => generateSchemaFromCode(tool.code));
|
346 |
+
|
347 |
+
while (true) {
|
348 |
+
const messagesForGeneration = [
|
349 |
+
{ role: "system" as const, content: systemPrompt },
|
350 |
+
...currentMessages,
|
351 |
+
];
|
352 |
+
|
353 |
+
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
354 |
+
|
355 |
+
let accumulatedContent = "";
|
356 |
+
const response = await generateResponse(
|
357 |
+
messagesForGeneration,
|
358 |
+
toolSchemas,
|
359 |
+
(token: string) => {
|
360 |
+
accumulatedContent += token;
|
361 |
+
setMessages((current) => {
|
362 |
+
const updated = [...current];
|
363 |
+
updated[updated.length - 1] = {
|
364 |
+
role: "assistant",
|
365 |
+
content: accumulatedContent,
|
366 |
+
};
|
367 |
+
return updated;
|
368 |
+
});
|
369 |
+
},
|
370 |
+
);
|
371 |
+
|
372 |
+
currentMessages.push({ role: "assistant", content: response });
|
373 |
+
const toolCallContent = extractToolCallContent(response);
|
374 |
+
|
375 |
+
if (toolCallContent) {
|
376 |
+
const toolResults = await executeToolCalls(toolCallContent);
|
377 |
+
|
378 |
+
const toolMessage: ToolMessage = {
|
379 |
+
role: "tool",
|
380 |
+
content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
|
381 |
+
renderInfo: toolResults,
|
382 |
+
};
|
383 |
+
currentMessages.push(toolMessage);
|
384 |
+
setMessages([...currentMessages]);
|
385 |
+
continue;
|
386 |
+
} else {
|
387 |
+
setMessages(currentMessages);
|
388 |
+
break;
|
389 |
+
}
|
390 |
+
}
|
391 |
+
} catch (error) {
|
392 |
+
const errorMessage = getErrorMessage(error);
|
393 |
+
setMessages([
|
394 |
+
...currentMessages,
|
395 |
+
{
|
396 |
+
role: "assistant",
|
397 |
+
content: `Error generating response: ${errorMessage}`,
|
398 |
+
},
|
399 |
+
]);
|
400 |
+
} finally {
|
401 |
+
setIsGenerating(false);
|
402 |
+
setTimeout(() => inputRef.current?.focus(), 0);
|
403 |
+
}
|
404 |
+
};
|
405 |
+
|
406 |
+
const loadSystemPrompt = useCallback(async (): Promise<void> => {
|
407 |
+
try {
|
408 |
+
const db = await getDB();
|
409 |
+
const stored = await db.get(SETTINGS_STORE_NAME, "systemPrompt");
|
410 |
+
if (stored && stored.value) setSystemPrompt(stored.value);
|
411 |
+
} catch (error) {
|
412 |
+
console.error("Failed to load system prompt:", error);
|
413 |
+
}
|
414 |
+
}, []);
|
415 |
+
|
416 |
+
const saveSystemPrompt = useCallback(
|
417 |
+
async (prompt: string): Promise<void> => {
|
418 |
+
try {
|
419 |
+
const db = await getDB();
|
420 |
+
await db.put(SETTINGS_STORE_NAME, {
|
421 |
+
key: "systemPrompt",
|
422 |
+
value: prompt,
|
423 |
+
});
|
424 |
+
} catch (error) {
|
425 |
+
console.error("Failed to save system prompt:", error);
|
426 |
+
}
|
427 |
+
},
|
428 |
+
[],
|
429 |
+
);
|
430 |
+
|
431 |
+
const loadSelectedModel = useCallback(async (): Promise<void> => {
|
432 |
+
try {
|
433 |
+
await loadModel();
|
434 |
+
} catch (error) {
|
435 |
+
console.error("Failed to load model:", error);
|
436 |
+
}
|
437 |
+
}, [selectedModelId, loadModel]);
|
438 |
+
|
439 |
+
const loadSelectedModelId = useCallback(async (): Promise<void> => {
|
440 |
+
try {
|
441 |
+
const db = await getDB();
|
442 |
+
const stored = await db.get(SETTINGS_STORE_NAME, "selectedModelId");
|
443 |
+
if (stored && stored.value) {
|
444 |
+
setSelectedModelId(stored.value);
|
445 |
+
}
|
446 |
+
} catch (error) {
|
447 |
+
console.error("Failed to load selected model ID:", error);
|
448 |
+
}
|
449 |
+
}, []);
|
450 |
+
|
451 |
+
useEffect(() => {
|
452 |
+
loadSystemPrompt();
|
453 |
+
}, [loadSystemPrompt]);
|
454 |
+
|
455 |
+
const handleOpenSystemPromptModal = (): void => {
|
456 |
+
setTempSystemPrompt(systemPrompt);
|
457 |
+
setIsSystemPromptModalOpen(true);
|
458 |
+
};
|
459 |
+
|
460 |
+
const handleSaveSystemPrompt = (): void => {
|
461 |
+
setSystemPrompt(tempSystemPrompt);
|
462 |
+
saveSystemPrompt(tempSystemPrompt);
|
463 |
+
setIsSystemPromptModalOpen(false);
|
464 |
+
};
|
465 |
+
|
466 |
+
const handleCancelSystemPrompt = (): void => {
|
467 |
+
setTempSystemPrompt("");
|
468 |
+
setIsSystemPromptModalOpen(false);
|
469 |
+
};
|
470 |
+
|
471 |
+
const handleResetSystemPrompt = (): void => {
|
472 |
+
setTempSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
473 |
+
};
|
474 |
+
|
475 |
+
const saveSelectedModel = useCallback(
|
476 |
+
async (modelId: string): Promise<void> => {
|
477 |
+
try {
|
478 |
+
const db = await getDB();
|
479 |
+
await db.put(SETTINGS_STORE_NAME, {
|
480 |
+
key: "selectedModelId",
|
481 |
+
value: modelId,
|
482 |
+
});
|
483 |
+
} catch (error) {
|
484 |
+
console.error("Failed to save selected model ID:", error);
|
485 |
+
}
|
486 |
+
},
|
487 |
+
[],
|
488 |
+
);
|
489 |
+
|
490 |
+
useEffect(() => {
|
491 |
+
loadSystemPrompt();
|
492 |
+
loadSelectedModelId();
|
493 |
+
}, [loadSystemPrompt, loadSelectedModelId]);
|
494 |
+
|
495 |
+
const handleModelSelect = async (modelId: string) => {
|
496 |
+
setSelectedModelId(modelId);
|
497 |
+
setIsModelDropdownOpen(false);
|
498 |
+
await saveSelectedModel(modelId);
|
499 |
+
};
|
500 |
+
|
501 |
+
const handleExampleClick = async (messageText: string): Promise<void> => {
|
502 |
+
if (!isReady || isGenerating) return;
|
503 |
+
setInput(messageText);
|
504 |
+
|
505 |
+
const userMessage: Message = { role: "user", content: messageText };
|
506 |
+
const currentMessages: Message[] = [...messages, userMessage];
|
507 |
+
setMessages(currentMessages);
|
508 |
+
setInput("");
|
509 |
+
setIsGenerating(true);
|
510 |
+
|
511 |
+
try {
|
512 |
+
const toolSchemas = tools
|
513 |
+
.filter((tool) => tool.enabled)
|
514 |
+
.map((tool) => generateSchemaFromCode(tool.code));
|
515 |
+
|
516 |
+
while (true) {
|
517 |
+
const messagesForGeneration = [
|
518 |
+
{ role: "system" as const, content: systemPrompt },
|
519 |
+
...currentMessages,
|
520 |
+
];
|
521 |
+
|
522 |
+
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
523 |
+
|
524 |
+
let accumulatedContent = "";
|
525 |
+
const response = await generateResponse(
|
526 |
+
messagesForGeneration,
|
527 |
+
toolSchemas,
|
528 |
+
(token: string) => {
|
529 |
+
accumulatedContent += token;
|
530 |
+
setMessages((current) => {
|
531 |
+
const updated = [...current];
|
532 |
+
updated[updated.length - 1] = {
|
533 |
+
role: "assistant",
|
534 |
+
content: accumulatedContent,
|
535 |
+
};
|
536 |
+
return updated;
|
537 |
+
});
|
538 |
+
},
|
539 |
+
);
|
540 |
+
|
541 |
+
currentMessages.push({ role: "assistant", content: response });
|
542 |
+
const toolCallContent = extractToolCallContent(response);
|
543 |
+
|
544 |
+
if (toolCallContent) {
|
545 |
+
const toolResults = await executeToolCalls(toolCallContent);
|
546 |
+
|
547 |
+
const toolMessage: ToolMessage = {
|
548 |
+
role: "tool",
|
549 |
+
content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
|
550 |
+
renderInfo: toolResults,
|
551 |
+
};
|
552 |
+
currentMessages.push(toolMessage);
|
553 |
+
setMessages([...currentMessages]);
|
554 |
+
continue;
|
555 |
+
} else {
|
556 |
+
setMessages(currentMessages);
|
557 |
+
break;
|
558 |
+
}
|
559 |
+
}
|
560 |
+
} catch (error) {
|
561 |
+
const errorMessage = getErrorMessage(error);
|
562 |
+
setMessages([
|
563 |
+
...currentMessages,
|
564 |
+
{
|
565 |
+
role: "assistant",
|
566 |
+
content: `Error generating response: ${errorMessage}`,
|
567 |
+
},
|
568 |
+
]);
|
569 |
+
} finally {
|
570 |
+
setIsGenerating(false);
|
571 |
+
setTimeout(() => inputRef.current?.focus(), 0);
|
572 |
+
}
|
573 |
+
};
|
574 |
+
|
575 |
+
return (
|
576 |
+
<div className="font-sans bg-gray-900">
|
577 |
+
{!isReady ? (
|
578 |
+
<LoadingScreen
|
579 |
+
isLoading={isLoading}
|
580 |
+
progress={progress}
|
581 |
+
error={error}
|
582 |
+
loadSelectedModel={loadSelectedModel}
|
583 |
+
selectedModelId={selectedModelId}
|
584 |
+
isModelDropdownOpen={isModelDropdownOpen}
|
585 |
+
setIsModelDropdownOpen={setIsModelDropdownOpen}
|
586 |
+
handleModelSelect={handleModelSelect}
|
587 |
+
/>
|
588 |
+
) : (
|
589 |
+
<div className="flex h-screen text-white">
|
590 |
+
<div className="w-1/2 flex flex-col p-4">
|
591 |
+
<div className="flex items-center justify-between mb-4">
|
592 |
+
<div className="flex items-center gap-3">
|
593 |
+
<h1 className="text-3xl font-bold text-gray-200">
|
594 |
+
LFM2 WebGPU
|
595 |
+
</h1>
|
596 |
+
</div>
|
597 |
+
<div className="flex items-center gap-3">
|
598 |
+
<div className="flex items-center text-green-400">
|
599 |
+
<Zap size={16} className="mr-2" />
|
600 |
+
Ready
|
601 |
+
</div>
|
602 |
+
<button
|
603 |
+
disabled={isGenerating}
|
604 |
+
onClick={clearChat}
|
605 |
+
className={`h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors text-sm ${
|
606 |
+
isGenerating
|
607 |
+
? "bg-gray-600 cursor-not-allowed opacity-50"
|
608 |
+
: "bg-gray-600 hover:bg-gray-700"
|
609 |
+
}`}
|
610 |
+
title="Clear chat"
|
611 |
+
>
|
612 |
+
<RotateCcw size={14} className="mr-2" /> Clear
|
613 |
+
</button>
|
614 |
+
<button
|
615 |
+
onClick={handleOpenSystemPromptModal}
|
616 |
+
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"
|
617 |
+
title="Edit system prompt"
|
618 |
+
>
|
619 |
+
<Settings size={16} />
|
620 |
+
</button>
|
621 |
+
</div>
|
622 |
+
</div>
|
623 |
+
|
624 |
+
<div
|
625 |
+
ref={chatContainerRef}
|
626 |
+
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto mb-4 space-y-4"
|
627 |
+
>
|
628 |
+
{messages.length === 0 && isReady ? (
|
629 |
+
<ExamplePrompts onExampleClick={handleExampleClick} />
|
630 |
+
) : (
|
631 |
+
messages.map((msg, index) => {
|
632 |
+
const key = `${msg.role}-${index}`;
|
633 |
+
|
634 |
+
if (msg.role === "user") {
|
635 |
+
return (
|
636 |
+
<div key={key} className="flex justify-end">
|
637 |
+
<div className="p-3 rounded-lg max-w-md bg-indigo-600">
|
638 |
+
<p className="text-sm whitespace-pre-wrap">
|
639 |
+
{msg.content}
|
640 |
+
</p>
|
641 |
+
</div>
|
642 |
+
</div>
|
643 |
+
);
|
644 |
+
} else if (msg.role === "assistant") {
|
645 |
+
const isToolCall = msg.content.includes(
|
646 |
+
"<|tool_call_start|>",
|
647 |
+
);
|
648 |
+
|
649 |
+
if (isToolCall) {
|
650 |
+
const nextMessage = messages[index + 1];
|
651 |
+
const isCompleted = nextMessage?.role === "tool";
|
652 |
+
const hasError =
|
653 |
+
isCompleted &&
|
654 |
+
(nextMessage as ToolMessage).renderInfo.some(
|
655 |
+
(info) => !!info.error,
|
656 |
+
);
|
657 |
+
|
658 |
+
return (
|
659 |
+
<div key={key} className="flex justify-start">
|
660 |
+
<div className="p-3 rounded-lg bg-gray-700">
|
661 |
+
<ToolCallIndicator
|
662 |
+
content={msg.content}
|
663 |
+
isRunning={!isCompleted}
|
664 |
+
hasError={hasError}
|
665 |
+
/>
|
666 |
+
</div>
|
667 |
+
</div>
|
668 |
+
);
|
669 |
+
}
|
670 |
+
|
671 |
+
return (
|
672 |
+
<div key={key} className="flex justify-start">
|
673 |
+
<div className="p-3 rounded-lg max-w-md bg-gray-700">
|
674 |
+
<p className="text-sm whitespace-pre-wrap">
|
675 |
+
{msg.content}
|
676 |
+
</p>
|
677 |
+
</div>
|
678 |
+
</div>
|
679 |
+
);
|
680 |
+
} else if (msg.role === "tool") {
|
681 |
+
const visibleToolResults = msg.renderInfo.filter(
|
682 |
+
(info) =>
|
683 |
+
info.error || (info.result != null && info.renderer),
|
684 |
+
);
|
685 |
+
|
686 |
+
if (visibleToolResults.length === 0) return null;
|
687 |
+
|
688 |
+
return (
|
689 |
+
<div key={key} className="flex justify-start">
|
690 |
+
<div className="p-3 rounded-lg bg-gray-700 max-w-lg">
|
691 |
+
<div className="space-y-3">
|
692 |
+
{visibleToolResults.map((info, idx) => (
|
693 |
+
<div className="flex flex-col gap-2" key={idx}>
|
694 |
+
<div className="text-xs text-gray-400 font-mono">
|
695 |
+
{info.call}
|
696 |
+
</div>
|
697 |
+
{info.error ? (
|
698 |
+
<ResultBlock error={info.error} />
|
699 |
+
) : (
|
700 |
+
<ToolResultRenderer
|
701 |
+
result={info.result}
|
702 |
+
rendererCode={info.renderer}
|
703 |
+
input={info.input}
|
704 |
+
/>
|
705 |
+
)}
|
706 |
+
</div>
|
707 |
+
))}
|
708 |
+
</div>
|
709 |
+
</div>
|
710 |
+
</div>
|
711 |
+
);
|
712 |
+
}
|
713 |
+
return null;
|
714 |
+
})
|
715 |
+
)}
|
716 |
+
</div>
|
717 |
+
|
718 |
+
<div className="flex">
|
719 |
+
<input
|
720 |
+
ref={inputRef}
|
721 |
+
type="text"
|
722 |
+
value={input}
|
723 |
+
onChange={(e) => setInput(e.target.value)}
|
724 |
+
onKeyDown={(e) =>
|
725 |
+
e.key === "Enter" &&
|
726 |
+
!isGenerating &&
|
727 |
+
isReady &&
|
728 |
+
handleSendMessage()
|
729 |
+
}
|
730 |
+
disabled={isGenerating || !isReady}
|
731 |
+
className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
732 |
+
placeholder={
|
733 |
+
isReady
|
734 |
+
? "Type your message here..."
|
735 |
+
: "Load model first to enable chat"
|
736 |
+
}
|
737 |
+
/>
|
738 |
+
<button
|
739 |
+
onClick={handleSendMessage}
|
740 |
+
disabled={isGenerating || !isReady}
|
741 |
+
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"
|
742 |
+
>
|
743 |
+
<Play size={20} />
|
744 |
+
</button>
|
745 |
+
</div>
|
746 |
+
</div>
|
747 |
+
|
748 |
+
<div className="w-1/2 flex flex-col p-4 border-l border-gray-700">
|
749 |
+
<div className="flex justify-between items-center mb-4">
|
750 |
+
<h2 className="text-2xl font-bold text-teal-400">Tools</h2>
|
751 |
+
<button
|
752 |
+
onClick={addTool}
|
753 |
+
className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
|
754 |
+
>
|
755 |
+
<Plus size={16} className="mr-2" /> Add Tool
|
756 |
+
</button>
|
757 |
+
</div>
|
758 |
+
<div
|
759 |
+
ref={toolsContainerRef}
|
760 |
+
className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3"
|
761 |
+
>
|
762 |
+
{tools.map((tool) => (
|
763 |
+
<ToolItem
|
764 |
+
key={tool.id}
|
765 |
+
tool={tool}
|
766 |
+
onToggleEnabled={() => toggleToolEnabled(tool.id)}
|
767 |
+
onToggleCollapsed={() => toggleToolCollapsed(tool.id)}
|
768 |
+
onExpand={() => expandTool(tool.id)}
|
769 |
+
onDelete={() => deleteTool(tool.id)}
|
770 |
+
onCodeChange={(newCode) =>
|
771 |
+
handleToolCodeChange(tool.id, newCode)
|
772 |
+
}
|
773 |
+
/>
|
774 |
+
))}
|
775 |
+
</div>
|
776 |
+
</div>
|
777 |
+
</div>
|
778 |
+
)}
|
779 |
+
|
780 |
+
{isSystemPromptModalOpen && (
|
781 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
782 |
+
<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">
|
783 |
+
<div className="flex justify-between items-center mb-4">
|
784 |
+
<h2 className="text-xl font-bold text-indigo-400">
|
785 |
+
Edit System Prompt
|
786 |
+
</h2>
|
787 |
+
<button
|
788 |
+
onClick={handleCancelSystemPrompt}
|
789 |
+
className="text-gray-400 hover:text-white"
|
790 |
+
>
|
791 |
+
<X size={20} />
|
792 |
+
</button>
|
793 |
+
</div>
|
794 |
+
<div className="flex-grow mb-4">
|
795 |
+
<textarea
|
796 |
+
value={tempSystemPrompt}
|
797 |
+
onChange={(e) => setTempSystemPrompt(e.target.value)}
|
798 |
+
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"
|
799 |
+
placeholder="Enter your system prompt here..."
|
800 |
+
style={{ minHeight: "300px" }}
|
801 |
+
/>
|
802 |
+
</div>
|
803 |
+
<div className="flex justify-between">
|
804 |
+
<button
|
805 |
+
onClick={handleResetSystemPrompt}
|
806 |
+
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors"
|
807 |
+
>
|
808 |
+
Reset
|
809 |
+
</button>
|
810 |
+
<div className="flex gap-3">
|
811 |
+
<button
|
812 |
+
onClick={handleSaveSystemPrompt}
|
813 |
+
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
814 |
+
>
|
815 |
+
Save
|
816 |
+
</button>
|
817 |
+
</div>
|
818 |
+
</div>
|
819 |
+
</div>
|
820 |
+
</div>
|
821 |
+
)}
|
822 |
+
</div>
|
823 |
+
);
|
824 |
+
};
|
825 |
+
|
826 |
+
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/ResultBlock.tsx
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type React from "react";
|
2 |
+
|
3 |
+
const ResultBlock: React.FC<{ error?: string; result?: any }> = ({
|
4 |
+
error,
|
5 |
+
result,
|
6 |
+
}) => (
|
7 |
+
<div
|
8 |
+
className={
|
9 |
+
error
|
10 |
+
? "bg-red-900 border border-red-600 rounded p-3"
|
11 |
+
: "bg-gray-700 border border-gray-600 rounded p-3"
|
12 |
+
}
|
13 |
+
>
|
14 |
+
{error ? <p className="text-red-300 text-sm">Error: {error}</p> : null}
|
15 |
+
<pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto mt-2">
|
16 |
+
{typeof result === "object" ? JSON.stringify(result, null, 2) : result}
|
17 |
+
</pre>
|
18 |
+
</div>
|
19 |
+
);
|
20 |
+
|
21 |
+
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,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import ResultBlock from "./ResultBlock";
|
3 |
+
|
4 |
+
const ToolResultRenderer: React.FC<{
|
5 |
+
result: any;
|
6 |
+
rendererCode?: string;
|
7 |
+
input?: any;
|
8 |
+
}> = ({ result, rendererCode, input }) => {
|
9 |
+
if (!rendererCode) {
|
10 |
+
return <ResultBlock result={result} />;
|
11 |
+
}
|
12 |
+
|
13 |
+
try {
|
14 |
+
const exportMatch = rendererCode.match(/export\s+default\s+(.*)/s);
|
15 |
+
if (!exportMatch) {
|
16 |
+
throw new Error("Invalid renderer format - no export default found");
|
17 |
+
}
|
18 |
+
|
19 |
+
const componentCode = exportMatch[1].trim();
|
20 |
+
const componentFunction = new Function(
|
21 |
+
"React",
|
22 |
+
"input",
|
23 |
+
"output",
|
24 |
+
`
|
25 |
+
const { createElement: h, Fragment } = React;
|
26 |
+
const JSXComponent = ${componentCode};
|
27 |
+
return JSXComponent(input, output);
|
28 |
+
`,
|
29 |
+
);
|
30 |
+
|
31 |
+
const element = componentFunction(React, input || {}, result);
|
32 |
+
return element;
|
33 |
+
} catch (error) {
|
34 |
+
return (
|
35 |
+
<ResultBlock
|
36 |
+
error={error instanceof Error ? error.message : "Unknown error"}
|
37 |
+
result={result}
|
38 |
+
/>
|
39 |
+
);
|
40 |
+
}
|
41 |
+
};
|
42 |
+
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/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/index.css
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
@import "tailwindcss";
|
src/main.tsx
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { StrictMode } from "react";
|
2 |
+
import { createRoot } from "react-dom/client";
|
3 |
+
import "./index.css";
|
4 |
+
import App from "./App.tsx";
|
5 |
+
|
6 |
+
createRoot(document.getElementById("root")!).render(
|
7 |
+
<StrictMode>
|
8 |
+
<App />
|
9 |
+
</StrictMode>,
|
10 |
+
);
|
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/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/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 |
+
});
|