Spaces:
Running
Running
fix
Browse files- README.md +1 -1
- package.json +5 -1
- src/App.tsx +130 -45
- src/components/ExamplePrompts.tsx +3 -3
- src/components/LoadingScreen.tsx +40 -56
- src/config/constants.ts +2 -2
- src/constants/models.ts +20 -3
- src/hooks/useLLM.ts +67 -21
- src/index.css +1 -0
- src/utils.ts +24 -2
README.md
CHANGED
|
@@ -6,7 +6,7 @@ colorTo: red
|
|
| 6 |
sdk: static
|
| 7 |
pinned: true
|
| 8 |
app_build_command: npm run build
|
| 9 |
-
app_file:
|
| 10 |
license: apache-2.0
|
| 11 |
short_description: Use MCP and WebGPU-based LLMs with tool calling
|
| 12 |
---
|
|
|
|
| 6 |
sdk: static
|
| 7 |
pinned: true
|
| 8 |
app_build_command: npm run build
|
| 9 |
+
app_file: dist/index.html
|
| 10 |
license: apache-2.0
|
| 11 |
short_description: Use MCP and WebGPU-based LLMs with tool calling
|
| 12 |
---
|
package.json
CHANGED
|
@@ -10,12 +10,14 @@
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
-
"@huggingface/transformers": "^3.7.
|
| 14 |
"@modelcontextprotocol/sdk": "^1.17.3",
|
| 15 |
"@monaco-editor/react": "^4.7.0",
|
| 16 |
"@tailwindcss/vite": "^4.1.11",
|
|
|
|
| 17 |
"idb": "^8.0.3",
|
| 18 |
"lucide-react": "^0.535.0",
|
|
|
|
| 19 |
"react": "^19.1.0",
|
| 20 |
"react-dom": "^19.1.0",
|
| 21 |
"react-router-dom": "^7.8.0",
|
|
@@ -23,6 +25,8 @@
|
|
| 23 |
},
|
| 24 |
"devDependencies": {
|
| 25 |
"@eslint/js": "^9.30.1",
|
|
|
|
|
|
|
| 26 |
"@types/react": "^19.1.8",
|
| 27 |
"@types/react-dom": "^19.1.6",
|
| 28 |
"@vitejs/plugin-react": "^4.6.0",
|
|
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"@huggingface/transformers": "^3.7.5",
|
| 14 |
"@modelcontextprotocol/sdk": "^1.17.3",
|
| 15 |
"@monaco-editor/react": "^4.7.0",
|
| 16 |
"@tailwindcss/vite": "^4.1.11",
|
| 17 |
+
"dompurify": "^3.2.7",
|
| 18 |
"idb": "^8.0.3",
|
| 19 |
"lucide-react": "^0.535.0",
|
| 20 |
+
"marked": "^16.3.0",
|
| 21 |
"react": "^19.1.0",
|
| 22 |
"react-dom": "^19.1.0",
|
| 23 |
"react-router-dom": "^7.8.0",
|
|
|
|
| 25 |
},
|
| 26 |
"devDependencies": {
|
| 27 |
"@eslint/js": "^9.30.1",
|
| 28 |
+
"@tailwindcss/typography": "^0.5.19",
|
| 29 |
+
"@types/dompurify": "^3.0.5",
|
| 30 |
"@types/react": "^19.1.8",
|
| 31 |
"@types/react-dom": "^19.1.6",
|
| 32 |
"@vitejs/plugin-react": "^4.6.0",
|
src/App.tsx
CHANGED
|
@@ -15,7 +15,10 @@ import {
|
|
| 15 |
X,
|
| 16 |
PanelRightClose,
|
| 17 |
PanelRightOpen,
|
|
|
|
| 18 |
} from "lucide-react";
|
|
|
|
|
|
|
| 19 |
import { useLLM } from "./hooks/useLLM";
|
| 20 |
import { useMCP } from "./hooks/useMCP";
|
| 21 |
|
|
@@ -29,7 +32,6 @@ import {
|
|
| 29 |
extractToolCallContent,
|
| 30 |
mapArgsToNamedParams,
|
| 31 |
getErrorMessage,
|
| 32 |
-
isMobileOrTablet,
|
| 33 |
} from "./utils";
|
| 34 |
|
| 35 |
import { DEFAULT_SYSTEM_PROMPT } from "./constants/systemPrompt";
|
|
@@ -80,6 +82,10 @@ async function getDB(): Promise<IDBPDatabase> {
|
|
| 80 |
});
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
const App: React.FC = () => {
|
| 84 |
const [systemPrompt, setSystemPrompt] = useState<string>(
|
| 85 |
DEFAULT_SYSTEM_PROMPT
|
|
@@ -91,14 +97,14 @@ const App: React.FC = () => {
|
|
| 91 |
const [tools, setTools] = useState<Tool[]>([]);
|
| 92 |
const [input, setInput] = useState<string>("");
|
| 93 |
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
| 94 |
-
const isMobile = useMemo(isMobileOrTablet, []);
|
| 95 |
const [selectedModelId, setSelectedModelId] = useState<string>(
|
| 96 |
-
|
| 97 |
);
|
| 98 |
const [isModelDropdownOpen, setIsModelDropdownOpen] =
|
| 99 |
useState<boolean>(false);
|
| 100 |
const [isMCPManagerOpen, setIsMCPManagerOpen] = useState<boolean>(false);
|
| 101 |
-
const [isToolsPanelVisible, setIsToolsPanelVisible] = useState<boolean>(
|
| 102 |
const chatContainerRef = useRef<HTMLDivElement>(null);
|
| 103 |
const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
|
| 104 |
const toolsContainerRef = useRef<HTMLDivElement>(null);
|
|
@@ -111,6 +117,9 @@ const App: React.FC = () => {
|
|
| 111 |
loadModel,
|
| 112 |
generateResponse,
|
| 113 |
clearPastKeyValues,
|
|
|
|
|
|
|
|
|
|
| 114 |
} = useLLM(selectedModelId);
|
| 115 |
|
| 116 |
// MCP integration
|
|
@@ -708,14 +717,50 @@ const App: React.FC = () => {
|
|
| 708 |
>
|
| 709 |
{messages.length === 0 && isReady ? (
|
| 710 |
<ExamplePrompts
|
| 711 |
-
examples={
|
| 712 |
-
.filter((tool) => tool.enabled)
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 719 |
onExampleClick={handleExampleClick}
|
| 720 |
/>
|
| 721 |
) : (
|
|
@@ -733,9 +778,9 @@ const App: React.FC = () => {
|
|
| 733 |
</div>
|
| 734 |
);
|
| 735 |
} else if (msg.role === "assistant") {
|
| 736 |
-
const isToolCall =
|
| 737 |
-
"<|tool_call_start|>"
|
| 738 |
-
|
| 739 |
|
| 740 |
if (isToolCall) {
|
| 741 |
const nextMessage = messages[index + 1];
|
|
@@ -762,9 +807,26 @@ const App: React.FC = () => {
|
|
| 762 |
return (
|
| 763 |
<div key={key} className="flex justify-start">
|
| 764 |
<div className="p-3 rounded-lg max-w-md bg-gray-700">
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
</div>
|
| 769 |
</div>
|
| 770 |
);
|
|
@@ -806,33 +868,56 @@ const App: React.FC = () => {
|
|
| 806 |
)}
|
| 807 |
</div>
|
| 808 |
|
| 809 |
-
<div
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
</div>
|
| 837 |
</div>
|
| 838 |
|
|
|
|
| 15 |
X,
|
| 16 |
PanelRightClose,
|
| 17 |
PanelRightOpen,
|
| 18 |
+
StopCircle,
|
| 19 |
} from "lucide-react";
|
| 20 |
+
import { marked } from "marked";
|
| 21 |
+
import DOMPurify from "dompurify";
|
| 22 |
import { useLLM } from "./hooks/useLLM";
|
| 23 |
import { useMCP } from "./hooks/useMCP";
|
| 24 |
|
|
|
|
| 32 |
extractToolCallContent,
|
| 33 |
mapArgsToNamedParams,
|
| 34 |
getErrorMessage,
|
|
|
|
| 35 |
} from "./utils";
|
| 36 |
|
| 37 |
import { DEFAULT_SYSTEM_PROMPT } from "./constants/systemPrompt";
|
|
|
|
| 82 |
});
|
| 83 |
}
|
| 84 |
|
| 85 |
+
function renderMarkdown(text: string): string {
|
| 86 |
+
return DOMPurify.sanitize(marked.parse(text) as string);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
const App: React.FC = () => {
|
| 90 |
const [systemPrompt, setSystemPrompt] = useState<string>(
|
| 91 |
DEFAULT_SYSTEM_PROMPT
|
|
|
|
| 97 |
const [tools, setTools] = useState<Tool[]>([]);
|
| 98 |
const [input, setInput] = useState<string>("");
|
| 99 |
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
| 100 |
+
// const isMobile = useMemo(isMobileOrTablet, []);
|
| 101 |
const [selectedModelId, setSelectedModelId] = useState<string>(
|
| 102 |
+
"onnx-community/granite-4.0-micro-ONNX-web"
|
| 103 |
);
|
| 104 |
const [isModelDropdownOpen, setIsModelDropdownOpen] =
|
| 105 |
useState<boolean>(false);
|
| 106 |
const [isMCPManagerOpen, setIsMCPManagerOpen] = useState<boolean>(false);
|
| 107 |
+
const [isToolsPanelVisible, setIsToolsPanelVisible] = useState<boolean>(false);
|
| 108 |
const chatContainerRef = useRef<HTMLDivElement>(null);
|
| 109 |
const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
|
| 110 |
const toolsContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 117 |
loadModel,
|
| 118 |
generateResponse,
|
| 119 |
clearPastKeyValues,
|
| 120 |
+
interruptGeneration,
|
| 121 |
+
tokensPerSecond,
|
| 122 |
+
numTokens,
|
| 123 |
} = useLLM(selectedModelId);
|
| 124 |
|
| 125 |
// MCP integration
|
|
|
|
| 717 |
>
|
| 718 |
{messages.length === 0 && isReady ? (
|
| 719 |
<ExamplePrompts
|
| 720 |
+
examples={(() => {
|
| 721 |
+
const enabledTools = tools.filter((tool) => tool.enabled);
|
| 722 |
+
|
| 723 |
+
// Group tools by server (MCP tools have mcpServerId in their code)
|
| 724 |
+
const toolsByServer = enabledTools.reduce((acc, tool) => {
|
| 725 |
+
const mcpServerMatch = tool.code?.match(/mcpServerId: "([^"]+)"/);
|
| 726 |
+
const serverId = mcpServerMatch ? mcpServerMatch[1] : 'local';
|
| 727 |
+
if (!acc[serverId]) acc[serverId] = [];
|
| 728 |
+
acc[serverId].push(tool);
|
| 729 |
+
return acc;
|
| 730 |
+
}, {} as Record<string, typeof enabledTools>);
|
| 731 |
+
|
| 732 |
+
// Pick one tool from each server (up to 3 servers)
|
| 733 |
+
const serverIds = Object.keys(toolsByServer).slice(0, 3);
|
| 734 |
+
const selectedTools = serverIds.map(serverId => {
|
| 735 |
+
const serverTools = toolsByServer[serverId];
|
| 736 |
+
return serverTools[Math.floor(Math.random() * serverTools.length)];
|
| 737 |
+
});
|
| 738 |
+
|
| 739 |
+
return selectedTools.map((tool) => {
|
| 740 |
+
const schema = generateSchemaFromCode(tool.code);
|
| 741 |
+
const description = schema.description || tool.name;
|
| 742 |
+
|
| 743 |
+
// Create a cleaner natural language prompt
|
| 744 |
+
let displayText = description;
|
| 745 |
+
if (description !== tool.name) {
|
| 746 |
+
// If there's a description, make it conversational
|
| 747 |
+
displayText = description.charAt(0).toUpperCase() + description.slice(1);
|
| 748 |
+
if (!displayText.endsWith('?') && !displayText.endsWith('.')) {
|
| 749 |
+
displayText += '?';
|
| 750 |
+
}
|
| 751 |
+
} else {
|
| 752 |
+
// Fallback to tool name in a readable format
|
| 753 |
+
displayText = tool.name.replace(/_/g, ' ');
|
| 754 |
+
displayText = displayText.charAt(0).toUpperCase() + displayText.slice(1);
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
return {
|
| 758 |
+
icon: "🛠️",
|
| 759 |
+
displayText,
|
| 760 |
+
messageText: displayText,
|
| 761 |
+
};
|
| 762 |
+
});
|
| 763 |
+
})()}
|
| 764 |
onExampleClick={handleExampleClick}
|
| 765 |
/>
|
| 766 |
) : (
|
|
|
|
| 778 |
</div>
|
| 779 |
);
|
| 780 |
} else if (msg.role === "assistant") {
|
| 781 |
+
const isToolCall =
|
| 782 |
+
msg.content.includes("<|tool_call_start|>") ||
|
| 783 |
+
msg.content.includes("<tool_call>");
|
| 784 |
|
| 785 |
if (isToolCall) {
|
| 786 |
const nextMessage = messages[index + 1];
|
|
|
|
| 807 |
return (
|
| 808 |
<div key={key} className="flex justify-start">
|
| 809 |
<div className="p-3 rounded-lg max-w-md bg-gray-700">
|
| 810 |
+
{msg.content.length > 0 ? (
|
| 811 |
+
<div
|
| 812 |
+
className="text-sm prose prose-invert prose-sm max-w-none"
|
| 813 |
+
dangerouslySetInnerHTML={{
|
| 814 |
+
__html: renderMarkdown(msg.content),
|
| 815 |
+
}}
|
| 816 |
+
/>
|
| 817 |
+
) : (
|
| 818 |
+
<div className="flex items-center gap-1 h-6">
|
| 819 |
+
<span className="w-2 h-2 bg-gray-400 rounded-full animate-pulse"></span>
|
| 820 |
+
<span
|
| 821 |
+
className="w-2 h-2 bg-gray-400 rounded-full animate-pulse"
|
| 822 |
+
style={{ animationDelay: "0.2s" }}
|
| 823 |
+
></span>
|
| 824 |
+
<span
|
| 825 |
+
className="w-2 h-2 bg-gray-400 rounded-full animate-pulse"
|
| 826 |
+
style={{ animationDelay: "0.4s" }}
|
| 827 |
+
></span>
|
| 828 |
+
</div>
|
| 829 |
+
)}
|
| 830 |
</div>
|
| 831 |
</div>
|
| 832 |
);
|
|
|
|
| 868 |
)}
|
| 869 |
</div>
|
| 870 |
|
| 871 |
+
<div>
|
| 872 |
+
{/* TPS Display */}
|
| 873 |
+
{isGenerating && tokensPerSecond !== null && (
|
| 874 |
+
<div className="mb-2 text-sm text-gray-400 flex items-center gap-2">
|
| 875 |
+
<span>
|
| 876 |
+
{tokensPerSecond.toFixed(1)} tokens/sec
|
| 877 |
+
</span>
|
| 878 |
+
<span>•</span>
|
| 879 |
+
<span>{numTokens} tokens</span>
|
| 880 |
+
</div>
|
| 881 |
+
)}
|
| 882 |
+
|
| 883 |
+
<div className="flex gap-2">
|
| 884 |
+
<input
|
| 885 |
+
ref={inputRef}
|
| 886 |
+
type="text"
|
| 887 |
+
value={input}
|
| 888 |
+
onChange={(e) => setInput(e.target.value)}
|
| 889 |
+
onKeyDown={(e) =>
|
| 890 |
+
e.key === "Enter" &&
|
| 891 |
+
!isGenerating &&
|
| 892 |
+
isReady &&
|
| 893 |
+
handleSendMessage()
|
| 894 |
+
}
|
| 895 |
+
disabled={isGenerating || !isReady}
|
| 896 |
+
className="flex-grow bg-gray-700 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
| 897 |
+
placeholder={
|
| 898 |
+
isReady
|
| 899 |
+
? "Type your message here..."
|
| 900 |
+
: "Load model first to enable chat"
|
| 901 |
+
}
|
| 902 |
+
/>
|
| 903 |
+
{isGenerating ? (
|
| 904 |
+
<button
|
| 905 |
+
onClick={interruptGeneration}
|
| 906 |
+
className="bg-red-600 hover:bg-red-700 text-white font-bold px-4 py-3 rounded-lg transition-colors flex items-center gap-2"
|
| 907 |
+
>
|
| 908 |
+
<StopCircle size={20} />
|
| 909 |
+
<span className="hidden sm:inline">Stop</span>
|
| 910 |
+
</button>
|
| 911 |
+
) : (
|
| 912 |
+
<button
|
| 913 |
+
onClick={handleSendMessage}
|
| 914 |
+
disabled={!isReady}
|
| 915 |
+
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold px-4 py-3 rounded-lg transition-colors"
|
| 916 |
+
>
|
| 917 |
+
<Play size={20} />
|
| 918 |
+
</button>
|
| 919 |
+
)}
|
| 920 |
+
</div>
|
| 921 |
</div>
|
| 922 |
</div>
|
| 923 |
|
src/components/ExamplePrompts.tsx
CHANGED
|
@@ -30,17 +30,17 @@ const ExamplePrompts: React.FC<ExamplePromptsProps> = ({
|
|
| 30 |
<p className="text-sm text-gray-500">Click one to get started</p>
|
| 31 |
</div>
|
| 32 |
|
| 33 |
-
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-
|
| 34 |
{dynamicExamples.map((example, index) => (
|
| 35 |
<button
|
| 36 |
key={index}
|
| 37 |
onClick={() => onExampleClick(example.messageText)}
|
| 38 |
-
className="flex items-
|
| 39 |
>
|
| 40 |
<span className="text-xl flex-shrink-0 group-hover:scale-110 transition-transform">
|
| 41 |
{example.icon}
|
| 42 |
</span>
|
| 43 |
-
<span className="text-sm text-gray-200 group-hover:text-white transition-colors">
|
| 44 |
{example.displayText}
|
| 45 |
</span>
|
| 46 |
</button>
|
|
|
|
| 30 |
<p className="text-sm text-gray-500">Click one to get started</p>
|
| 31 |
</div>
|
| 32 |
|
| 33 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-w-4xl w-full px-4">
|
| 34 |
{dynamicExamples.map((example, index) => (
|
| 35 |
<button
|
| 36 |
key={index}
|
| 37 |
onClick={() => onExampleClick(example.messageText)}
|
| 38 |
+
className="flex items-start gap-3 p-4 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-left group cursor-pointer"
|
| 39 |
>
|
| 40 |
<span className="text-xl flex-shrink-0 group-hover:scale-110 transition-transform">
|
| 41 |
{example.icon}
|
| 42 |
</span>
|
| 43 |
+
<span className="text-sm text-gray-200 group-hover:text-white transition-colors break-words line-clamp-3">
|
| 44 |
{example.displayText}
|
| 45 |
</span>
|
| 46 |
</button>
|
src/components/LoadingScreen.tsx
CHANGED
|
@@ -359,15 +359,12 @@ export const LoadingScreen = ({
|
|
| 359 |
to get started.
|
| 360 |
</p>
|
| 361 |
|
| 362 |
-
<div className="relative">
|
| 363 |
-
<div
|
| 364 |
-
ref={wrapperRef} // anchor for dropdown centering
|
| 365 |
-
className="flex rounded-2xl shadow-2xl overflow-hidden"
|
| 366 |
-
>
|
| 367 |
<button
|
| 368 |
onClick={isLoading ? undefined : loadSelectedModel}
|
| 369 |
disabled={isLoading}
|
| 370 |
-
className={`flex items-center justify-center font-bold transition-all text-lg
|
| 371 |
isLoading
|
| 372 |
? "bg-gray-700 text-gray-400 cursor-not-allowed"
|
| 373 |
: "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-lg hover:shadow-xl transform hover:scale-[1.01] active:scale-[0.99]"
|
|
@@ -408,20 +405,8 @@ export const LoadingScreen = ({
|
|
| 408 |
);
|
| 409 |
}
|
| 410 |
}}
|
| 411 |
-
onKeyDown={(e) => {
|
| 412 |
-
if (isLoading) return;
|
| 413 |
-
if (
|
| 414 |
-
e.key === " " ||
|
| 415 |
-
e.key === "Enter" ||
|
| 416 |
-
e.key === "ArrowDown"
|
| 417 |
-
) {
|
| 418 |
-
e.preventDefault();
|
| 419 |
-
if (!isModelDropdownOpen) setIsModelDropdownOpen(true);
|
| 420 |
-
}
|
| 421 |
-
}}
|
| 422 |
aria-haspopup="menu"
|
| 423 |
aria-expanded={isModelDropdownOpen}
|
| 424 |
-
aria-controls="model-dropdown"
|
| 425 |
aria-label="Select model"
|
| 426 |
className={`px-4 py-4 border-l border-white/20 transition-all ${
|
| 427 |
isLoading
|
|
@@ -439,17 +424,28 @@ export const LoadingScreen = ({
|
|
| 439 |
</button>
|
| 440 |
</div>
|
| 441 |
|
| 442 |
-
{/* Dropdown
|
| 443 |
-
{isModelDropdownOpen &&
|
| 444 |
-
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
<div
|
| 447 |
-
id="model-dropdown"
|
| 448 |
ref={dropdownRef}
|
| 449 |
-
style={portalStyle}
|
| 450 |
role="menu"
|
| 451 |
aria-label="Model options"
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
>
|
| 454 |
{MODEL_OPTIONS.map((option, index) => {
|
| 455 |
const selected = selectedModelId === option.id;
|
|
@@ -460,43 +456,47 @@ export const LoadingScreen = ({
|
|
| 460 |
role="menuitem"
|
| 461 |
aria-checked={selected}
|
| 462 |
onMouseEnter={() => setActiveIndex(index)}
|
| 463 |
-
onClick={() => {
|
|
|
|
| 464 |
handleModelSelect(option.id);
|
| 465 |
setIsModelDropdownOpen(false);
|
| 466 |
dropdownBtnRef.current?.focus();
|
| 467 |
}}
|
| 468 |
-
className={`w-full px-6 py-4 text-left transition-all duration-
|
| 469 |
selected
|
| 470 |
-
? "bg-
|
| 471 |
-
: "text-gray-
|
| 472 |
} ${index === 0 ? "rounded-t-2xl" : ""} ${
|
| 473 |
index === MODEL_OPTIONS.length - 1
|
| 474 |
? "rounded-b-2xl"
|
| 475 |
: ""
|
| 476 |
-
} ${isActive && !selected ? "bg-
|
| 477 |
>
|
| 478 |
<div className="flex items-center justify-between">
|
| 479 |
-
<div>
|
| 480 |
-
<div className=
|
| 481 |
{option.label}
|
| 482 |
</div>
|
| 483 |
-
<div className=
|
| 484 |
{option.size}
|
| 485 |
</div>
|
| 486 |
</div>
|
| 487 |
{selected && (
|
| 488 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
)}
|
| 490 |
</div>
|
| 491 |
-
{!selected && (
|
| 492 |
-
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl" />
|
| 493 |
-
)}
|
| 494 |
</button>
|
| 495 |
);
|
| 496 |
})}
|
| 497 |
-
</div
|
| 498 |
-
|
| 499 |
-
|
|
|
|
| 500 |
</div>
|
| 501 |
</div>
|
| 502 |
|
|
@@ -517,22 +517,6 @@ export const LoadingScreen = ({
|
|
| 517 |
)}
|
| 518 |
</div>
|
| 519 |
|
| 520 |
-
{/* Click-away fallback for touch devices */}
|
| 521 |
-
{isModelDropdownOpen && (
|
| 522 |
-
<div
|
| 523 |
-
className="fixed inset-0 z-40 bg-black/20"
|
| 524 |
-
onClick={(e) => {
|
| 525 |
-
const target = e.target as Node;
|
| 526 |
-
if (
|
| 527 |
-
dropdownRef.current &&
|
| 528 |
-
!dropdownRef.current.contains(target) &&
|
| 529 |
-
!dropdownBtnRef.current?.contains(target)
|
| 530 |
-
) {
|
| 531 |
-
setIsModelDropdownOpen(false);
|
| 532 |
-
}
|
| 533 |
-
}}
|
| 534 |
-
/>
|
| 535 |
-
)}
|
| 536 |
</div>
|
| 537 |
);
|
| 538 |
};
|
|
|
|
| 359 |
to get started.
|
| 360 |
</p>
|
| 361 |
|
| 362 |
+
<div className="relative inline-block">
|
| 363 |
+
<div className="flex rounded-2xl shadow-2xl overflow-hidden">
|
|
|
|
|
|
|
|
|
|
| 364 |
<button
|
| 365 |
onClick={isLoading ? undefined : loadSelectedModel}
|
| 366 |
disabled={isLoading}
|
| 367 |
+
className={`flex items-center justify-center font-bold transition-all text-lg ${
|
| 368 |
isLoading
|
| 369 |
? "bg-gray-700 text-gray-400 cursor-not-allowed"
|
| 370 |
: "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-lg hover:shadow-xl transform hover:scale-[1.01] active:scale-[0.99]"
|
|
|
|
| 405 |
);
|
| 406 |
}
|
| 407 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
aria-haspopup="menu"
|
| 409 |
aria-expanded={isModelDropdownOpen}
|
|
|
|
| 410 |
aria-label="Select model"
|
| 411 |
className={`px-4 py-4 border-l border-white/20 transition-all ${
|
| 412 |
isLoading
|
|
|
|
| 424 |
</button>
|
| 425 |
</div>
|
| 426 |
|
| 427 |
+
{/* Dropdown - render in portal to avoid z-index issues */}
|
| 428 |
+
{isModelDropdownOpen && typeof document !== "undefined" && ReactDOM.createPortal(
|
| 429 |
+
<>
|
| 430 |
+
{/* Backdrop */}
|
| 431 |
+
<div
|
| 432 |
+
className="fixed inset-0 z-[999]"
|
| 433 |
+
onClick={() => setIsModelDropdownOpen(false)}
|
| 434 |
+
/>
|
| 435 |
+
{/* Dropdown */}
|
| 436 |
<div
|
|
|
|
| 437 |
ref={dropdownRef}
|
|
|
|
| 438 |
role="menu"
|
| 439 |
aria-label="Model options"
|
| 440 |
+
style={{
|
| 441 |
+
position: 'fixed',
|
| 442 |
+
bottom: dropdownBtnRef.current ? `${window.innerHeight - dropdownBtnRef.current.getBoundingClientRect().top}px` : 'auto',
|
| 443 |
+
left: dropdownBtnRef.current ? `${dropdownBtnRef.current.getBoundingClientRect().left}px` : 'auto',
|
| 444 |
+
width: dropdownBtnRef.current ? `${dropdownBtnRef.current.getBoundingClientRect().width + 200}px` : '320px',
|
| 445 |
+
zIndex: 1000,
|
| 446 |
+
}}
|
| 447 |
+
className="bg-gray-900 border-2 border-gray-600 rounded-2xl shadow-2xl overflow-y-auto max-h-[300px] min-w-[320px]"
|
| 448 |
+
onClick={(e) => e.stopPropagation()}
|
| 449 |
>
|
| 450 |
{MODEL_OPTIONS.map((option, index) => {
|
| 451 |
const selected = selectedModelId === option.id;
|
|
|
|
| 456 |
role="menuitem"
|
| 457 |
aria-checked={selected}
|
| 458 |
onMouseEnter={() => setActiveIndex(index)}
|
| 459 |
+
onClick={(e) => {
|
| 460 |
+
e.stopPropagation();
|
| 461 |
handleModelSelect(option.id);
|
| 462 |
setIsModelDropdownOpen(false);
|
| 463 |
dropdownBtnRef.current?.focus();
|
| 464 |
}}
|
| 465 |
+
className={`w-full px-6 py-4 text-left transition-all duration-150 relative outline-none border-b border-gray-700/50 last:border-b-0 cursor-pointer ${
|
| 466 |
selected
|
| 467 |
+
? "bg-indigo-600 text-white hover:bg-indigo-500"
|
| 468 |
+
: "bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white active:bg-gray-600"
|
| 469 |
} ${index === 0 ? "rounded-t-2xl" : ""} ${
|
| 470 |
index === MODEL_OPTIONS.length - 1
|
| 471 |
? "rounded-b-2xl"
|
| 472 |
: ""
|
| 473 |
+
} ${isActive && !selected ? "bg-gray-700" : ""}`}
|
| 474 |
>
|
| 475 |
<div className="flex items-center justify-between">
|
| 476 |
+
<div className="flex-1">
|
| 477 |
+
<div className={`font-semibold text-base mb-1 ${selected ? "text-white" : "text-gray-100"}`}>
|
| 478 |
{option.label}
|
| 479 |
</div>
|
| 480 |
+
<div className={`text-sm ${selected ? "text-indigo-200" : "text-gray-500"}`}>
|
| 481 |
{option.size}
|
| 482 |
</div>
|
| 483 |
</div>
|
| 484 |
{selected && (
|
| 485 |
+
<div className="flex items-center gap-2 ml-4">
|
| 486 |
+
<span className="text-xs font-medium text-indigo-200">Selected</span>
|
| 487 |
+
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
| 488 |
+
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
| 489 |
+
</svg>
|
| 490 |
+
</div>
|
| 491 |
)}
|
| 492 |
</div>
|
|
|
|
|
|
|
|
|
|
| 493 |
</button>
|
| 494 |
);
|
| 495 |
})}
|
| 496 |
+
</div>
|
| 497 |
+
</>,
|
| 498 |
+
document.body
|
| 499 |
+
)}
|
| 500 |
</div>
|
| 501 |
</div>
|
| 502 |
|
|
|
|
| 517 |
)}
|
| 518 |
</div>
|
| 519 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
</div>
|
| 521 |
);
|
| 522 |
};
|
src/config/constants.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
| 4 |
|
| 5 |
// MCP Client Configuration
|
| 6 |
export const MCP_CLIENT_CONFIG = {
|
| 7 |
-
NAME: "
|
| 8 |
VERSION: "1.0.0",
|
| 9 |
-
TEST_CLIENT_NAME: "
|
| 10 |
} as const;
|
| 11 |
|
| 12 |
// Storage Keys
|
|
|
|
| 4 |
|
| 5 |
// MCP Client Configuration
|
| 6 |
export const MCP_CLIENT_CONFIG = {
|
| 7 |
+
NAME: "WebGPU-MCP",
|
| 8 |
VERSION: "1.0.0",
|
| 9 |
+
TEST_CLIENT_NAME: "WebGPU-MCP-Test",
|
| 10 |
} as const;
|
| 11 |
|
| 12 |
// Storage Keys
|
src/constants/models.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
| 1 |
export const MODEL_OPTIONS = [
|
| 2 |
-
{
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
];
|
|
|
|
| 1 |
export const MODEL_OPTIONS = [
|
| 2 |
+
{
|
| 3 |
+
id: "onnx-community/granite-4.0-micro-ONNX-web",
|
| 4 |
+
label: "Granite 4.0 Micro",
|
| 5 |
+
size: "400M parameters"
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
id: "onnx-community/LFM2-350M-ONNX",
|
| 9 |
+
label: "LFM2-350M",
|
| 10 |
+
size: "350M parameters (312 MB)"
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
id: "onnx-community/LFM2-700M-ONNX",
|
| 14 |
+
label: "LFM2-700M",
|
| 15 |
+
size: "700M parameters (579 MB)"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
id: "onnx-community/LFM2-1.2B-ONNX",
|
| 19 |
+
label: "LFM2-1.2B",
|
| 20 |
+
size: "1.2B parameters (868 MB)"
|
| 21 |
+
},
|
| 22 |
];
|
src/hooks/useLLM.ts
CHANGED
|
@@ -10,6 +10,8 @@ interface LLMState {
|
|
| 10 |
isReady: boolean;
|
| 11 |
error: string | null;
|
| 12 |
progress: number;
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
interface LLMInstance {
|
|
@@ -30,6 +32,8 @@ export const useLLM = (modelId?: string) => {
|
|
| 30 |
isReady: false,
|
| 31 |
error: null,
|
| 32 |
progress: 0,
|
|
|
|
|
|
|
| 33 |
});
|
| 34 |
|
| 35 |
const instanceRef = useRef<LLMInstance | null>(null);
|
|
@@ -37,13 +41,14 @@ export const useLLM = (modelId?: string) => {
|
|
| 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 =
|
| 47 |
|
| 48 |
if (!moduleCache[modelId]) {
|
| 49 |
moduleCache[modelId] = {
|
|
@@ -99,7 +104,7 @@ export const useLLM = (modelId?: string) => {
|
|
| 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 |
}
|
|
@@ -115,6 +120,20 @@ export const useLLM = (modelId?: string) => {
|
|
| 115 |
progress_callback: progressCallback,
|
| 116 |
});
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
const instance = { model, tokenizer };
|
| 119 |
instanceRef.current = instance;
|
| 120 |
cache.instance = instance;
|
|
@@ -150,7 +169,7 @@ export const useLLM = (modelId?: string) => {
|
|
| 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) {
|
|
@@ -159,6 +178,9 @@ export const useLLM = (modelId?: string) => {
|
|
| 159 |
|
| 160 |
const { model, tokenizer } = instance;
|
| 161 |
|
|
|
|
|
|
|
|
|
|
| 162 |
// Apply chat template with tools
|
| 163 |
const input = tokenizer.apply_chat_template(messages, {
|
| 164 |
tools,
|
|
@@ -166,39 +188,62 @@ export const useLLM = (modelId?: string) => {
|
|
| 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 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
| 196 |
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
| 198 |
},
|
| 199 |
-
[]
|
| 200 |
);
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
const clearPastKeyValues = useCallback(() => {
|
| 203 |
pastKeyValuesRef.current = null;
|
| 204 |
}, []);
|
|
@@ -230,5 +275,6 @@ export const useLLM = (modelId?: string) => {
|
|
| 230 |
generateResponse,
|
| 231 |
clearPastKeyValues,
|
| 232 |
cleanup,
|
|
|
|
| 233 |
};
|
| 234 |
};
|
|
|
|
| 10 |
isReady: boolean;
|
| 11 |
error: string | null;
|
| 12 |
progress: number;
|
| 13 |
+
tokensPerSecond: number | null;
|
| 14 |
+
numTokens: number;
|
| 15 |
}
|
| 16 |
|
| 17 |
interface LLMInstance {
|
|
|
|
| 32 |
isReady: false,
|
| 33 |
error: null,
|
| 34 |
progress: 0,
|
| 35 |
+
tokensPerSecond: null,
|
| 36 |
+
numTokens: 0,
|
| 37 |
});
|
| 38 |
|
| 39 |
const instanceRef = useRef<LLMInstance | null>(null);
|
|
|
|
| 41 |
|
| 42 |
const abortControllerRef = useRef<AbortController | null>(null);
|
| 43 |
const pastKeyValuesRef = useRef<any>(null);
|
| 44 |
+
const generationAbortControllerRef = useRef<AbortController | null>(null);
|
| 45 |
|
| 46 |
const loadModel = useCallback(async () => {
|
| 47 |
if (!modelId) {
|
| 48 |
throw new Error("Model ID is required");
|
| 49 |
}
|
| 50 |
|
| 51 |
+
const MODEL_ID = modelId;
|
| 52 |
|
| 53 |
if (!moduleCache[modelId]) {
|
| 54 |
moduleCache[modelId] = {
|
|
|
|
| 104 |
progress.file.endsWith(".onnx_data")
|
| 105 |
) {
|
| 106 |
const percentage = Math.round(
|
| 107 |
+
(progress.loaded / progress.total) * 100
|
| 108 |
);
|
| 109 |
setState((prev) => ({ ...prev, progress: percentage }));
|
| 110 |
}
|
|
|
|
| 120 |
progress_callback: progressCallback,
|
| 121 |
});
|
| 122 |
|
| 123 |
+
// Pre-warm the model with a dummy input for shader compilation
|
| 124 |
+
console.log("Pre-warming model...");
|
| 125 |
+
const dummyInput = tokenizer("Hello", {
|
| 126 |
+
return_tensors: "pt",
|
| 127 |
+
padding: false,
|
| 128 |
+
truncation: false,
|
| 129 |
+
});
|
| 130 |
+
await model.generate({
|
| 131 |
+
...dummyInput,
|
| 132 |
+
max_new_tokens: 1,
|
| 133 |
+
do_sample: false,
|
| 134 |
+
});
|
| 135 |
+
console.log("Model pre-warmed");
|
| 136 |
+
|
| 137 |
const instance = { model, tokenizer };
|
| 138 |
instanceRef.current = instance;
|
| 139 |
cache.instance = instance;
|
|
|
|
| 169 |
async (
|
| 170 |
messages: Array<{ role: string; content: string }>,
|
| 171 |
tools: Array<any>,
|
| 172 |
+
onToken?: (token: string) => void
|
| 173 |
): Promise<string> => {
|
| 174 |
const instance = instanceRef.current;
|
| 175 |
if (!instance) {
|
|
|
|
| 178 |
|
| 179 |
const { model, tokenizer } = instance;
|
| 180 |
|
| 181 |
+
// Create abort controller for this generation
|
| 182 |
+
generationAbortControllerRef.current = new AbortController();
|
| 183 |
+
|
| 184 |
// Apply chat template with tools
|
| 185 |
const input = tokenizer.apply_chat_template(messages, {
|
| 186 |
tools,
|
|
|
|
| 188 |
return_dict: true,
|
| 189 |
});
|
| 190 |
|
| 191 |
+
// Track tokens and timing
|
| 192 |
+
const startTime = performance.now();
|
| 193 |
+
let tokenCount = 0;
|
| 194 |
+
|
| 195 |
const streamer = onToken
|
| 196 |
? new TextStreamer(tokenizer, {
|
| 197 |
skip_prompt: true,
|
| 198 |
skip_special_tokens: false,
|
| 199 |
callback_function: (token: string) => {
|
| 200 |
+
tokenCount++;
|
| 201 |
+
const elapsed = (performance.now() - startTime) / 1000;
|
| 202 |
+
const tps = tokenCount / elapsed;
|
| 203 |
+
setState((prev) => ({
|
| 204 |
+
...prev,
|
| 205 |
+
tokensPerSecond: tps,
|
| 206 |
+
numTokens: tokenCount,
|
| 207 |
+
}));
|
| 208 |
onToken(token);
|
| 209 |
},
|
| 210 |
})
|
| 211 |
: undefined;
|
| 212 |
|
| 213 |
+
try {
|
| 214 |
+
// Generate the response
|
| 215 |
+
const { sequences, past_key_values } = await model.generate({
|
| 216 |
+
...input,
|
| 217 |
+
past_key_values: pastKeyValuesRef.current,
|
| 218 |
+
max_new_tokens: 1024,
|
| 219 |
+
do_sample: false,
|
| 220 |
+
streamer,
|
| 221 |
+
return_dict_in_generate: true,
|
| 222 |
+
});
|
| 223 |
+
pastKeyValuesRef.current = past_key_values;
|
| 224 |
|
| 225 |
+
// Decode the generated text with special tokens preserved (except end tokens) for tool call detection
|
| 226 |
+
const response = tokenizer
|
| 227 |
+
.batch_decode(sequences.slice(null, [input.input_ids.dims[1], null]), {
|
| 228 |
+
skip_special_tokens: false,
|
| 229 |
+
})[0]
|
| 230 |
+
.replace(/<\|im_end\|>$/, "")
|
| 231 |
+
.replace(/<\|end_of_text\|>$/, "");
|
| 232 |
|
| 233 |
+
return response;
|
| 234 |
+
} finally {
|
| 235 |
+
generationAbortControllerRef.current = null;
|
| 236 |
+
}
|
| 237 |
},
|
| 238 |
+
[]
|
| 239 |
);
|
| 240 |
|
| 241 |
+
const interruptGeneration = useCallback(() => {
|
| 242 |
+
if (generationAbortControllerRef.current) {
|
| 243 |
+
generationAbortControllerRef.current.abort();
|
| 244 |
+
}
|
| 245 |
+
}, []);
|
| 246 |
+
|
| 247 |
const clearPastKeyValues = useCallback(() => {
|
| 248 |
pastKeyValuesRef.current = null;
|
| 249 |
}, []);
|
|
|
|
| 275 |
generateResponse,
|
| 276 |
clearPastKeyValues,
|
| 277 |
cleanup,
|
| 278 |
+
interruptGeneration,
|
| 279 |
};
|
| 280 |
};
|
src/index.css
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
@import "tailwindcss";
|
|
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
+
@plugin "@tailwindcss/typography";
|
src/utils.ts
CHANGED
|
@@ -71,8 +71,17 @@ 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 |
}
|
|
@@ -305,12 +314,25 @@ export const generateSchemaFromCode = (code: string): Schema => {
|
|
| 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 |
-
|
|
|
|
| 311 |
/<\|tool_call_start\|>(.*?)<\|tool_call_end\|>/s,
|
| 312 |
);
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
};
|
| 315 |
|
| 316 |
/**
|
|
|
|
| 71 |
try {
|
| 72 |
const cleanContent = toolCallContent.trim();
|
| 73 |
|
| 74 |
+
// Try to parse as Granite format (JSON object with name and arguments)
|
| 75 |
try {
|
| 76 |
const parsed = JSON.parse(cleanContent);
|
| 77 |
+
if (parsed && typeof parsed === 'object' && parsed.name) {
|
| 78 |
+
// Convert Granite JSON format to Pythonic format
|
| 79 |
+
const args = parsed.arguments || {};
|
| 80 |
+
const argPairs = Object.entries(args).map(([key, value]) =>
|
| 81 |
+
`${key}=${JSON.stringify(value)}`
|
| 82 |
+
);
|
| 83 |
+
return [`${parsed.name}(${argPairs.join(', ')})`];
|
| 84 |
+
}
|
| 85 |
if (Array.isArray(parsed)) {
|
| 86 |
return parsed;
|
| 87 |
}
|
|
|
|
| 314 |
|
| 315 |
/**
|
| 316 |
* Extracts tool call content from a string using the tool call markers.
|
| 317 |
+
* Supports both LFM2 format (<|tool_call_start|>...<|tool_call_end|>)
|
| 318 |
+
* and Granite format (<tool_call>...</tool_call>)
|
| 319 |
*/
|
| 320 |
export const extractToolCallContent = (content: string): string | null => {
|
| 321 |
+
// Try LFM2 format first
|
| 322 |
+
const lfm2Match = content.match(
|
| 323 |
/<\|tool_call_start\|>(.*?)<\|tool_call_end\|>/s,
|
| 324 |
);
|
| 325 |
+
if (lfm2Match) {
|
| 326 |
+
return lfm2Match[1].trim();
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
// Try Granite format (XML-style)
|
| 330 |
+
const graniteMatch = content.match(/<tool_call>(.*?)<\/tool_call>/s);
|
| 331 |
+
if (graniteMatch) {
|
| 332 |
+
return graniteMatch[1].trim();
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
return null;
|
| 336 |
};
|
| 337 |
|
| 338 |
/**
|