shreyask commited on
Commit
0beab82
·
verified ·
1 Parent(s): 220a682
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: build/index.html
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.1",
14
  "@modelcontextprotocol/sdk": "^1.17.3",
15
  "@monaco-editor/react": "^4.7.0",
16
  "@tailwindcss/vite": "^4.1.11",
 
17
  "idb": "^8.0.3",
18
  "lucide-react": "^0.535.0",
 
19
  "react": "^19.1.0",
20
  "react-dom": "^19.1.0",
21
  "react-router-dom": "^7.8.0",
@@ -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
- isMobile ? "350M" : "1.2B"
97
  );
98
  const [isModelDropdownOpen, setIsModelDropdownOpen] =
99
  useState<boolean>(false);
100
  const [isMCPManagerOpen, setIsMCPManagerOpen] = useState<boolean>(false);
101
- const [isToolsPanelVisible, setIsToolsPanelVisible] = useState<boolean>(true);
102
  const chatContainerRef = useRef<HTMLDivElement>(null);
103
  const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
104
  const toolsContainerRef = useRef<HTMLDivElement>(null);
@@ -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={tools
712
- .filter((tool) => tool.enabled)
713
- .map((tool) => ({
714
- icon: "🛠️",
715
- displayText: tool.name,
716
- messageText: `${tool.name}()`,
717
- }))
718
- .filter((ex) => ex.displayText)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = msg.content.includes(
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
- <p className="text-sm whitespace-pre-wrap">
766
- {msg.content}
767
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768
  </div>
769
  </div>
770
  );
@@ -806,33 +868,56 @@ const App: React.FC = () => {
806
  )}
807
  </div>
808
 
809
- <div className="flex">
810
- <input
811
- ref={inputRef}
812
- type="text"
813
- value={input}
814
- onChange={(e) => setInput(e.target.value)}
815
- onKeyDown={(e) =>
816
- e.key === "Enter" &&
817
- !isGenerating &&
818
- isReady &&
819
- handleSendMessage()
820
- }
821
- disabled={isGenerating || !isReady}
822
- className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
823
- placeholder={
824
- isReady
825
- ? "Type your message here..."
826
- : "Load model first to enable chat"
827
- }
828
- />
829
- <button
830
- onClick={handleSendMessage}
831
- disabled={isGenerating || !isReady}
832
- 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"
833
- >
834
- <Play size={20} />
835
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-2xl w-full px-4">
34
  {dynamicExamples.map((example, index) => (
35
  <button
36
  key={index}
37
  onClick={() => onExampleClick(example.messageText)}
38
- className="flex items-center 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">
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 flex-1 ${
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 (Portal) */}
443
- {isModelDropdownOpen &&
444
- typeof document !== "undefined" &&
445
- ReactDOM.createPortal(
 
 
 
 
 
446
  <div
447
- id="model-dropdown"
448
  ref={dropdownRef}
449
- style={portalStyle}
450
  role="menu"
451
  aria-label="Model options"
452
- className="bg-gray-800/95 border border-gray-600/50 rounded-2xl shadow-2xl overflow-hidden animate-in slide-in-from-top-2 duration-200 dropdown-z30"
 
 
 
 
 
 
 
 
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-200 relative group outline-none ${
469
  selected
470
- ? "bg-gradient-to-r from-indigo-600/50 to-purple-600/50 text-white border-l-4 border-indigo-400"
471
- : "text-gray-200 hover:bg-white/10 hover:text-white"
472
  } ${index === 0 ? "rounded-t-2xl" : ""} ${
473
  index === MODEL_OPTIONS.length - 1
474
  ? "rounded-b-2xl"
475
  : ""
476
- } ${isActive && !selected ? "bg-white/5" : ""}`}
477
  >
478
  <div className="flex items-center justify-between">
479
- <div>
480
- <div className="font-semibold text-lg">
481
  {option.label}
482
  </div>
483
- <div className="text-sm text-gray-400 mt-1">
484
  {option.size}
485
  </div>
486
  </div>
487
  {selected && (
488
- <div className="w-2 h-2 bg-indigo-400 rounded-full" />
 
 
 
 
 
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
- document.body
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: "LFM2-WebGPU",
8
  VERSION: "1.0.0",
9
- TEST_CLIENT_NAME: "LFM2-WebGPU-Test",
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
- { 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
  ];
 
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 = `onnx-community/LFM2-${modelId}-ONNX`;
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
- // 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
  }, []);
@@ -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
- const toolCallMatch = content.match(
 
311
  /<\|tool_call_start\|>(.*?)<\|tool_call_end\|>/s,
312
  );
313
- return toolCallMatch ? toolCallMatch[1].trim() : null;
 
 
 
 
 
 
 
 
 
 
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
  /**