nico-martin HF Staff commited on
Commit
db78b1a
·
1 Parent(s): 9b72f0d
.idea/php.xml CHANGED
@@ -10,9 +10,24 @@
10
  <option name="highlightLevel" value="WARNING" />
11
  <option name="transferred" value="true" />
12
  </component>
 
 
 
 
 
 
 
 
 
 
13
  <component name="PhpStanOptionsConfiguration">
14
  <option name="transferred" value="true" />
15
  </component>
 
 
 
 
 
16
  <component name="PsalmOptionsConfiguration">
17
  <option name="transferred" value="true" />
18
  </component>
 
10
  <option name="highlightLevel" value="WARNING" />
11
  <option name="transferred" value="true" />
12
  </component>
13
+ <component name="PhpCodeSniffer">
14
+ <phpcs_settings>
15
+ <phpcs_by_interpreter asDefaultInterpreter="true" interpreter_id="7f620b7f-b413-40f6-9b51-4ffe32f7487a" timeout="30000" />
16
+ </phpcs_settings>
17
+ </component>
18
+ <component name="PhpStan">
19
+ <PhpStan_settings>
20
+ <phpstan_by_interpreter asDefaultInterpreter="true" interpreter_id="7f620b7f-b413-40f6-9b51-4ffe32f7487a" timeout="60000" />
21
+ </PhpStan_settings>
22
+ </component>
23
  <component name="PhpStanOptionsConfiguration">
24
  <option name="transferred" value="true" />
25
  </component>
26
+ <component name="Psalm">
27
+ <Psalm_settings>
28
+ <psalm_fixer_by_interpreter asDefaultInterpreter="true" interpreter_id="7f620b7f-b413-40f6-9b51-4ffe32f7487a" timeout="60000" />
29
+ </Psalm_settings>
30
+ </component>
31
  <component name="PsalmOptionsConfiguration">
32
  <option name="transferred" value="true" />
33
  </component>
.idea/prettier.xml CHANGED
@@ -1,6 +1,7 @@
1
  <?xml version="1.0" encoding="UTF-8"?>
2
  <project version="4">
3
  <component name="PrettierConfiguration">
4
- <option name="myConfigurationMode" value="AUTOMATIC" />
 
5
  </component>
6
  </project>
 
1
  <?xml version="1.0" encoding="UTF-8"?>
2
  <project version="4">
3
  <component name="PrettierConfiguration">
4
+ <option name="myConfigurationMode" value="MANUAL" />
5
+ <option name="myRunOnSave" value="true" />
6
  </component>
7
  </project>
src/App.tsx CHANGED
@@ -1,103 +1,18 @@
1
- import { type Message } from "@huggingface/transformers";
2
  import ChatSettingsContextProvider from "@utils/context/chatSettings/ChatSettingsContextProvider.tsx";
3
- import { type FormEvent, useMemo, useState } from "react";
4
 
5
  import Chat from "./chat/Chat.tsx";
6
- import TextGeneration from "./textGeneration/TextGeneration.ts";
7
  import ThemeContextProvider from "./utils/context/theme/ThemeContextProvider.tsx";
8
 
9
  export default function App() {
10
  return (
11
  <ThemeContextProvider>
12
  <ChatSettingsContextProvider>
13
- <Chat className="mx-auto h-screen max-w-4xl" />
 
 
 
14
  </ChatSettingsContextProvider>
15
  </ThemeContextProvider>
16
  );
17
  }
18
-
19
- /*export default function App() {
20
- const [initialized, setInitialized] = useState<boolean>(false);
21
- const [input, setInput] = useState<string>("");
22
- const [messages, setMessages] = useState<Array<Message>>([
23
- {
24
- role: "system",
25
- content: "You are a helpful assistant",
26
- },
27
- ]);
28
- const [isLoading, setIsLoading] = useState(false);
29
-
30
- const textGeneration = useMemo(() => new TextGeneration(), []);
31
-
32
- const init = async () => {
33
- await textGeneration.initializeModel(console.log);
34
- setInitialized(true);
35
- };
36
-
37
- const handleSubmit = async (e: FormEvent) => {
38
- e.preventDefault();
39
- if (!input.trim()) return;
40
-
41
- setIsLoading(true);
42
- const requestMessages = messages;
43
- requestMessages.push({
44
- role: "user",
45
- content: input,
46
- });
47
-
48
- try {
49
- const resp = await textGeneration.generateText(
50
- requestMessages,
51
- [],
52
- console.log
53
- );
54
- console.log(resp);
55
- requestMessages.push({
56
- role: "assistant",
57
- content: resp.response,
58
- });
59
- setMessages(requestMessages);
60
- setInput("");
61
- } catch (error) {
62
- console.error("Error:", error);
63
- //setOutput("Error generating text. Check console for details.");
64
- } finally {
65
- setIsLoading(false);
66
- }
67
- };
68
-
69
- return (
70
- <div className="mx-auto max-w-3xl">
71
- <h1 className="mb-6 text-3xl font-bold text-gray-900">Text Generation</h1>
72
- {initialized ? (
73
- <form onSubmit={handleSubmit} className="space-y-4">
74
- <textarea
75
- value={input}
76
- onChange={(e) => setInput(e.target.value)}
77
- placeholder="Enter your prompt here..."
78
- rows={5}
79
- className="w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-gray-100"
80
- disabled={isLoading}
81
- />
82
- <button
83
- type="submit"
84
- disabled={isLoading}
85
- className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
86
- >
87
- {isLoading ? "Generating..." : "Answer"}
88
- </button>
89
- </form>
90
- ) : (
91
- <button
92
- type="button"
93
- onClick={init}
94
- disabled={isLoading}
95
- className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
96
- >
97
- initialize
98
- </button>
99
- )}
100
- </div>
101
- );
102
- }
103
- */
 
 
1
  import ChatSettingsContextProvider from "@utils/context/chatSettings/ChatSettingsContextProvider.tsx";
 
2
 
3
  import Chat from "./chat/Chat.tsx";
4
+ import Header from "./layout/Header.tsx";
5
  import ThemeContextProvider from "./utils/context/theme/ThemeContextProvider.tsx";
6
 
7
  export default function App() {
8
  return (
9
  <ThemeContextProvider>
10
  <ChatSettingsContextProvider>
11
+ <div className="mx-auto flex h-screen w-full max-w-4xl flex-col">
12
+ <Header className="" />
13
+ <Chat className="flex-grow" />
14
+ </div>
15
  </ChatSettingsContextProvider>
16
  </ThemeContextProvider>
17
  );
18
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/chat/Chat.tsx CHANGED
@@ -1,15 +1,22 @@
1
- import { type Message } from "@huggingface/transformers";
2
  import { Button, Card, Slider } from "@theme";
3
  import { useChatSettings } from "@utils/context/chatSettings/useChatSettings.ts";
4
  import { formatBytes } from "@utils/format.ts";
5
  import { MODELS } from "@utils/models.ts";
6
  import { TOOLS } from "@utils/tools.ts";
7
  import { Settings } from "lucide-react";
8
- import { useEffect, useMemo, useRef, useState } from "react";
 
 
 
 
 
 
9
 
10
  import TextGeneration from "../textGeneration/TextGeneration.ts";
11
  import cn from "../utils/classnames.ts";
12
  import ChatForm from "./ChatForm.tsx";
 
 
13
 
14
  enum State {
15
  IDLE,
@@ -23,16 +30,25 @@ export default function Chat({ className = "" }: { className?: string }) {
23
  const initializedModelKey = useRef<string>(null);
24
  const [downloadProgress, setDownloadProgress] = useState<number>(0);
25
  const [state, setState] = useState<State>(State.IDLE);
26
- const [messages, setMessages] = useState<Array<Message>>([]);
27
  const settingsKey = useRef<string>(null);
28
 
29
  const generator = useMemo(() => new TextGeneration(), []);
 
 
 
 
 
30
 
31
  useEffect(() => {
32
  const newSettingsKey = JSON.stringify(settings);
33
  if (!settings || settingsKey.current === newSettingsKey) return;
34
  settingsKey.current = newSettingsKey;
35
- setMessages([{ role: "system", content: settings.systemPrompt }]);
 
 
 
 
 
36
  }, [settings]);
37
 
38
  if (!settings) return;
@@ -54,18 +70,34 @@ export default function Chat({ className = "" }: { className?: string }) {
54
  await initializeModel();
55
  }
56
  setState(State.GENERATING);
57
- const conversation = [...messages, { role: "user", content: prompt }];
58
 
59
- const { response, modelUsage } = await generator.generateText(
60
- settings.modelKey,
61
- conversation,
62
- [],
63
- settings.temperature,
64
- settings.enableThinking,
65
- console.log
66
- );
67
- console.log(response, modelUsage);
68
- setMessages([...conversation, { role: "assistant", content: response }]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  };
70
 
71
  const model = MODELS[settings.modelKey];
@@ -107,14 +139,42 @@ export default function Chat({ className = "" }: { className?: string }) {
107
  </div>
108
  </div>
109
  ) : (
110
- <div>
111
- {messages.map((message, index) => (
112
- <p>{message.content}</p>
113
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
  )}
116
  <Card className="mt-auto">
117
- <ChatForm disabled={!ready} onSubmit={(prompt) => generate(prompt)} />
 
 
 
 
 
118
  </Card>
119
  <div className="flex justify-between dark:border-gray-700">
120
  <Button
 
 
1
  import { Button, Card, Slider } from "@theme";
2
  import { useChatSettings } from "@utils/context/chatSettings/useChatSettings.ts";
3
  import { formatBytes } from "@utils/format.ts";
4
  import { MODELS } from "@utils/models.ts";
5
  import { TOOLS } from "@utils/tools.ts";
6
  import { Settings } from "lucide-react";
7
+ import {
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ useSyncExternalStore,
13
+ } from "react";
14
 
15
  import TextGeneration from "../textGeneration/TextGeneration.ts";
16
  import cn from "../utils/classnames.ts";
17
  import ChatForm from "./ChatForm.tsx";
18
+ import MessageContent from "./MessageContent.tsx";
19
+ import MessageToolCall from "./MessageToolCall.tsx";
20
 
21
  enum State {
22
  IDLE,
 
30
  const initializedModelKey = useRef<string>(null);
31
  const [downloadProgress, setDownloadProgress] = useState<number>(0);
32
  const [state, setState] = useState<State>(State.IDLE);
 
33
  const settingsKey = useRef<string>(null);
34
 
35
  const generator = useMemo(() => new TextGeneration(), []);
36
+ const getSnapshot = () => generator.chatMessages;
37
+ const messages = useSyncExternalStore(
38
+ generator.onChatMessageUpdate,
39
+ getSnapshot
40
+ );
41
 
42
  useEffect(() => {
43
  const newSettingsKey = JSON.stringify(settings);
44
  if (!settings || settingsKey.current === newSettingsKey) return;
45
  settingsKey.current = newSettingsKey;
46
+ generator.initializeConversation(
47
+ TOOLS.filter((tool) => settings.tools.includes(tool.name)),
48
+ settings.temperature,
49
+ settings.enableThinking,
50
+ settings.systemPrompt
51
+ );
52
  }, [settings]);
53
 
54
  if (!settings) return;
 
70
  await initializeModel();
71
  }
72
  setState(State.GENERATING);
73
+ await generator.runAgent(prompt);
74
 
75
+ /*let nextPrompt = prompt;
76
+ while (nextPrompt) {
77
+ const conversation = [...messages, { role: "user", content: prompt }];
78
+ setMessages(conversation);
79
+
80
+ const answer: Message = {
81
+ role: "assistant",
82
+ content: "",
83
+ };
84
+
85
+ const { response, modelUsage } = await generator.generateText(
86
+ settings.modelKey,
87
+ conversation,
88
+ [],
89
+ settings.temperature,
90
+ settings.enableThinking,
91
+ (chunk) => {
92
+ answer.content += chunk;
93
+ setMessages([...conversation, answer]);
94
+ }
95
+ );
96
+ console.log(response, modelUsage);
97
+ setMessages([...conversation, { role: "assistant", content: response }]);
98
+ }*/
99
+
100
+ setState(State.READY);
101
  };
102
 
103
  const model = MODELS[settings.modelKey];
 
139
  </div>
140
  </div>
141
  ) : (
142
+ <div className="flex flex-col gap-4">
143
+ {messages
144
+ .filter(({ role }) => role !== "system")
145
+ .map((message, index) => (
146
+ <div
147
+ className={cn("w-3/4", {
148
+ "self-end": message.role === "user",
149
+ })}
150
+ key={index}
151
+ >
152
+ {message.role === "user" ? (
153
+ <Card>
154
+ <p>{message.content}</p>
155
+ </Card>
156
+ ) : message.role === "assistant" ? (
157
+ <div className="space-y-1">
158
+ {message.content.map((part) =>
159
+ part.type === "tool" ? (
160
+ <MessageToolCall tool={part} />
161
+ ) : (
162
+ <MessageContent content={part.content} />
163
+ )
164
+ )}
165
+ </div>
166
+ ) : null}
167
+ </div>
168
+ ))}
169
  </div>
170
  )}
171
  <Card className="mt-auto">
172
+ <ChatForm
173
+ disabled={!ready}
174
+ onSubmit={(prompt) => generate(prompt)}
175
+ isGenerating={state === State.GENERATING}
176
+ onAbort={() => generator.abort()}
177
+ />
178
  </Card>
179
  <div className="flex justify-between dark:border-gray-700">
180
  <Button
src/chat/ChatForm.tsx CHANGED
@@ -1,8 +1,8 @@
1
- import { InputText, InputTextarea } from "@theme";
 
 
2
  import { Controller, useForm } from "react-hook-form";
3
 
4
- import cn from "../utils/classnames.ts";
5
-
6
  interface FormParams {
7
  input: string;
8
  }
@@ -11,31 +11,29 @@ export default function ChatForm({
11
  className = "",
12
  onSubmit,
13
  disabled,
 
 
14
  }: {
15
  className?: string;
16
  onSubmit: (prompt: string) => void;
17
  disabled: boolean;
 
 
18
  }) {
19
- const {
20
- control,
21
- formState: { errors },
22
- handleSubmit,
23
- reset,
24
- watch,
25
- setValue,
26
- } = useForm<FormParams>({
27
  defaultValues: {
28
  input: "",
29
  },
30
  });
31
 
32
- const userPrompt = watch("input");
33
-
34
  return (
35
  <div className={cn(className)}>
36
  <form
37
- className="-mx-2"
38
- onSubmit={handleSubmit((data) => onSubmit(data.input))}
 
 
 
39
  >
40
  <Controller
41
  name="input"
@@ -45,7 +43,6 @@ export default function ChatForm({
45
  <input
46
  id="text-input"
47
  placeholder="Type your message..."
48
- //error={errors.input?.message as string}
49
  disabled={disabled}
50
  value={field.value}
51
  onChange={field.onChange}
@@ -56,6 +53,13 @@ export default function ChatForm({
56
  />
57
  )}
58
  />
 
 
 
 
 
 
 
59
  </form>
60
  </div>
61
  );
 
1
+ import { Button, Tooltip } from "@theme";
2
+ import cn from "@utils/classnames.ts";
3
+ import { ArrowUp, Square } from "lucide-react";
4
  import { Controller, useForm } from "react-hook-form";
5
 
 
 
6
  interface FormParams {
7
  input: string;
8
  }
 
11
  className = "",
12
  onSubmit,
13
  disabled,
14
+ isGenerating,
15
+ onAbort,
16
  }: {
17
  className?: string;
18
  onSubmit: (prompt: string) => void;
19
  disabled: boolean;
20
+ isGenerating: boolean;
21
+ onAbort: () => Promise<void>;
22
  }) {
23
+ const { control, handleSubmit, reset } = useForm<FormParams>({
 
 
 
 
 
 
 
24
  defaultValues: {
25
  input: "",
26
  },
27
  });
28
 
 
 
29
  return (
30
  <div className={cn(className)}>
31
  <form
32
+ className="flex items-center"
33
+ onSubmit={handleSubmit((data) => {
34
+ onSubmit(data.input);
35
+ reset();
36
+ })}
37
  >
38
  <Controller
39
  name="input"
 
43
  <input
44
  id="text-input"
45
  placeholder="Type your message..."
 
46
  disabled={disabled}
47
  value={field.value}
48
  onChange={field.onChange}
 
53
  />
54
  )}
55
  />
56
+ {isGenerating ? (
57
+ <Tooltip text="Cancel">
58
+ <Button type="button" onClick={onAbort} iconLeft={<Square />} />
59
+ </Tooltip>
60
+ ) : (
61
+ <Button type="submit" iconLeft={<ArrowUp />} disabled={disabled} />
62
+ )}
63
  </form>
64
  </div>
65
  );
src/chat/MessageContent.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@theme";
2
+ import cn from "@utils/classnames.ts";
3
+ import { Lightbulb } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+ import showdown from "showdown";
6
+
7
+ const converter = new showdown.Converter();
8
+ interface ParsedContent {
9
+ thinkContent: string | null;
10
+ afterContent: string;
11
+ isThinking: boolean;
12
+ }
13
+
14
+ function parseThinkTags(content: string): ParsedContent {
15
+ const openTagIndex = content.indexOf("<think>");
16
+
17
+ if (openTagIndex === -1) {
18
+ return {
19
+ thinkContent: null,
20
+ afterContent: content,
21
+ isThinking: false,
22
+ };
23
+ }
24
+
25
+ const closeTagIndex = content.indexOf("</think>");
26
+
27
+ if (closeTagIndex === -1) {
28
+ return {
29
+ thinkContent: content.slice(openTagIndex + 7),
30
+ afterContent: "",
31
+ isThinking: true,
32
+ };
33
+ }
34
+
35
+ return {
36
+ thinkContent: content.slice(openTagIndex + 7, closeTagIndex),
37
+ afterContent: content.slice(closeTagIndex + 8),
38
+ isThinking: false,
39
+ };
40
+ }
41
+
42
+ const PROSE_CLASS_NAME =
43
+ "prose dark:prose-invert prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2 prose-h3:text-base prose-p:my-2 prose-ul:my-2 prose-li:my-0";
44
+
45
+ export default function MessageContent({ content }: { content: string }) {
46
+ const [showThinking, setShowThinking] = useState(false);
47
+ const [thinkingTime, setThinkingTime] = useState(0);
48
+ const [forceThinkingComplete, setForceThinkingComplete] = useState(false);
49
+ const parsed = parseThinkTags(content);
50
+
51
+ // Detect when content stops changing and force thinking to complete
52
+ useEffect(() => {
53
+ if (parsed.isThinking) {
54
+ const timeout = setTimeout(() => {
55
+ setForceThinkingComplete(true);
56
+ }, 1000);
57
+
58
+ return () => clearTimeout(timeout);
59
+ } else {
60
+ setForceThinkingComplete(false);
61
+ }
62
+ }, [content, parsed.isThinking]);
63
+
64
+ useEffect(() => {
65
+ if (parsed.isThinking && !forceThinkingComplete) {
66
+ const startTime = Date.now();
67
+ const interval = setInterval(() => {
68
+ setThinkingTime((Date.now() - startTime) / 1000);
69
+ }, 100);
70
+ return () => clearInterval(interval);
71
+ }
72
+ }, [parsed.isThinking, forceThinkingComplete]);
73
+
74
+ const isThinking = parsed.isThinking && !forceThinkingComplete;
75
+
76
+ if (!parsed.thinkContent) {
77
+ return (
78
+ <div
79
+ className={cn(PROSE_CLASS_NAME, "max-w-none")}
80
+ dangerouslySetInnerHTML={{
81
+ __html: converter.makeHtml(content),
82
+ }}
83
+ />
84
+ );
85
+ }
86
+
87
+ return (
88
+ <div className="space-y-2">
89
+ <div>
90
+ <Button
91
+ variant="ghost"
92
+ color="mono"
93
+ size="xs"
94
+ onClick={() => setShowThinking(!showThinking)}
95
+ className="-ml-2 opacity-50"
96
+ loading={isThinking}
97
+ iconLeft={<Lightbulb />}
98
+ >
99
+ {isThinking ? "Thinking for" : "Thought for"}{" "}
100
+ {thinkingTime.toFixed(1)}s ({showThinking ? "Hide" : "Show"})
101
+ </Button>
102
+ {showThinking && (
103
+ <p className="max-w-none text-sm">{parsed.thinkContent}</p>
104
+ )}
105
+ </div>
106
+ {parsed.afterContent && (
107
+ <div
108
+ className={cn(PROSE_CLASS_NAME, "max-w-none")}
109
+ dangerouslySetInnerHTML={{
110
+ __html: converter.makeHtml(parsed.afterContent),
111
+ }}
112
+ />
113
+ )}
114
+ </div>
115
+ );
116
+ }
src/chat/MessageToolCall.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Wrench } from "lucide-react";
2
+ import { useState } from "react";
3
+
4
+ import type { ChatMessageAssistantTool } from "../textGeneration/types.ts";
5
+ import { Loader } from "../theme";
6
+ import cn from "../utils/classnames.ts";
7
+
8
+ export default function MessageToolCall({
9
+ tool,
10
+ className = "",
11
+ }: {
12
+ tool: ChatMessageAssistantTool;
13
+ className?: string;
14
+ }) {
15
+ const [expanded, setExpanded] = useState<boolean>(false);
16
+
17
+ const isLoading = tool.result === "";
18
+
19
+ return (
20
+ <div
21
+ className={cn(
22
+ className,
23
+ "rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
24
+ )}
25
+ >
26
+ <div className="flex items-center justify-between gap-3">
27
+ <button
28
+ className="flex cursor-pointer items-center gap-2 text-xs text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
29
+ onClick={() => setExpanded(!expanded)}
30
+ >
31
+ {isLoading ? <Loader size="xs" /> : <Wrench className="h-3 w-3" />}
32
+ {isLoading ? "calling tool" : "called tool"} <b>{tool.name}</b>
33
+ </button>
34
+ </div>
35
+ {expanded && (
36
+ <div className="mt-2 space-y-2">
37
+ <div>
38
+ <div className="mb-1 text-xs text-gray-600 dark:text-gray-400">
39
+ Function:
40
+ </div>
41
+ <code className="block overflow-hidden rounded bg-white p-2 font-mono text-xs text-blue-600 dark:bg-gray-900 dark:text-blue-400">
42
+ {tool.functionSignature}
43
+ </code>
44
+ </div>
45
+ <div>
46
+ <div className="mb-1 text-xs text-gray-600 dark:text-gray-400">
47
+ Result:
48
+ </div>
49
+ <div className="overflow-hidden rounded bg-white p-2 text-sm whitespace-pre-wrap text-gray-900 dark:bg-gray-900 dark:text-gray-100">
50
+ {tool.result || "loading.."}
51
+ </div>
52
+ </div>
53
+ </div>
54
+ )}
55
+ </div>
56
+ );
57
+ }
src/layout/Header.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Button } from "@theme";
2
+ import cn from "@utils/classnames.ts";
3
+ import { useTheme } from "@utils/context/theme/useTheme.ts";
4
+ import { MonitorCog, Moon, Sun } from "lucide-react";
5
+
6
+ function Header({ className = "" }: { className?: string }) {
7
+ const { storedTheme, toggleTheme } = useTheme();
8
+
9
+ return (
10
+ <div className={cn(className)}>
11
+ <div className="flex items-center justify-between gap-2 border-b-1 border-gray-200 px-4 py-2 dark:border-gray-800">
12
+ <h1 className="font-bold">Transformers.js TextGeneration</h1>
13
+ <Button
14
+ color="mono"
15
+ variant="ghost"
16
+ onClick={toggleTheme}
17
+ size="sm"
18
+ aria-label="Toggle theme"
19
+ iconRight={
20
+ storedTheme === "system" ? (
21
+ <MonitorCog className="h-3 w-3" />
22
+ ) : storedTheme === "light" ? (
23
+ <Sun className="h-3 w-3" />
24
+ ) : (
25
+ <Moon className="h-3 w-3" />
26
+ )
27
+ }
28
+ >
29
+ Theme:{" "}
30
+ </Button>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export default Header;
src/textGeneration/TextGeneration.ts CHANGED
@@ -1,7 +1,16 @@
1
  import { type Message } from "@huggingface/transformers";
2
 
3
- import type { ChatTemplateTool } from "../utils/webMcp.ts";
4
  import {
 
 
 
 
 
 
 
 
 
 
5
  type ModelUsage,
6
  type Request,
7
  RequestType,
@@ -12,6 +21,15 @@ import {
12
  export default class TextGeneration {
13
  private worker: Worker;
14
  private requestId: number = 0;
 
 
 
 
 
 
 
 
 
15
 
16
  constructor() {
17
  this.worker = new Worker(
@@ -22,6 +40,26 @@ export default class TextGeneration {
22
  );
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  private postWorkerMessage = (request: Request) =>
26
  this.worker.postMessage(request);
27
  private addWorkerEventListener = (
@@ -47,6 +85,7 @@ export default class TextGeneration {
47
  if (data.type !== ResponseType.INITIALIZE_MODEL) return;
48
  if (data.done) {
49
  this.removeWorkerEventListener(listener);
 
50
  resolve(data.progress);
51
  }
52
  onDownload(data.progress);
@@ -61,10 +100,22 @@ export default class TextGeneration {
61
  });
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  public async abort() {
65
  return new Promise<void>((resolve, reject) => {
66
  const requestId = this.requestId++;
67
-
68
  const listener = ({ data }: MessageEvent<Response>) => {
69
  if (data.requestId !== requestId) return;
70
  if (data.type === ResponseType.ERROR) {
@@ -85,47 +136,156 @@ export default class TextGeneration {
85
  });
86
  }
87
 
88
- public async generateText(
89
- modelKey: string,
90
- messages: Array<Message>,
91
- tools: Array<ChatTemplateTool> = [],
92
- temperature: number,
93
- enableThinking: boolean,
94
- onChunkUpdate: (chunk: string) => void = () => {}
95
- ): Promise<{ response: string; modelUsage: ModelUsage }> {
96
- return new Promise<{ response: string; modelUsage: ModelUsage }>(
97
- (resolve, reject) => {
98
- const requestId = this.requestId++;
99
-
100
- const listener = ({ data }: MessageEvent<Response>) => {
101
- if (data.requestId !== requestId) return;
102
- if (data.type === ResponseType.ERROR) {
103
- this.removeWorkerEventListener(listener);
104
- reject(data.message);
105
- }
106
- if (data.type === ResponseType.GENERATE_TEXT_DONE) {
107
- this.removeWorkerEventListener(listener);
108
- resolve({
109
- response: data.response,
110
- modelUsage: data.modelUsage,
111
- });
112
- }
113
- if (data.type === ResponseType.GENERATE_TEXT_CHUNK) {
114
- onChunkUpdate(data.chunk);
115
- }
116
- };
117
-
118
- this.addWorkerEventListener(listener);
119
- this.postWorkerMessage({
120
- type: RequestType.GENERATE_MESSAGE,
121
- modelKey,
122
- messages,
123
- tools,
124
- requestId,
125
- temperature,
126
- enableThinking,
127
- });
128
  }
129
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
131
  }
 
1
  import { type Message } from "@huggingface/transformers";
2
 
 
3
  import {
4
+ type WebMCPTool,
5
+ executeToolCall,
6
+ splitResponse,
7
+ webMCPToolToChatTemplateTool,
8
+ } from "../utils/webMcp.ts";
9
+ import {
10
+ type ChatMessage,
11
+ type ChatMessageAssistant,
12
+ type ChatMessageAssistantResponse,
13
+ type ChatMessageAssistantTool,
14
  type ModelUsage,
15
  type Request,
16
  RequestType,
 
21
  export default class TextGeneration {
22
  private worker: Worker;
23
  private requestId: number = 0;
24
+ private modelKey: string | null = null;
25
+ private tools: Array<WebMCPTool> | null = null;
26
+ private temperature: number | null = null;
27
+ private enableThinking: boolean | null = null;
28
+ private messages: Array<Message> = [];
29
+ private _chatMessages: Array<ChatMessage> = [];
30
+ private chatMessagesListener: Array<
31
+ (chatMessages: Array<ChatMessage>) => void
32
+ > = [];
33
 
34
  constructor() {
35
  this.worker = new Worker(
 
40
  );
41
  }
42
 
43
+ get chatMessages() {
44
+ return this._chatMessages;
45
+ }
46
+
47
+ set chatMessages(chatMessages: Array<ChatMessage>) {
48
+ this._chatMessages = chatMessages;
49
+ this.chatMessagesListener.forEach((listener) => listener(chatMessages));
50
+ }
51
+
52
+ public onChatMessageUpdate = (
53
+ callback: (messages: Array<ChatMessage>) => void
54
+ ) => {
55
+ this.chatMessagesListener.push(callback);
56
+ return () => {
57
+ this.chatMessagesListener = this.chatMessagesListener.filter(
58
+ (listener) => listener !== callback
59
+ );
60
+ };
61
+ };
62
+
63
  private postWorkerMessage = (request: Request) =>
64
  this.worker.postMessage(request);
65
  private addWorkerEventListener = (
 
85
  if (data.type !== ResponseType.INITIALIZE_MODEL) return;
86
  if (data.done) {
87
  this.removeWorkerEventListener(listener);
88
+ this.modelKey = modelKey;
89
  resolve(data.progress);
90
  }
91
  onDownload(data.progress);
 
100
  });
101
  }
102
 
103
+ public initializeConversation(
104
+ tools: Array<WebMCPTool> = [],
105
+ temperature: number,
106
+ enableThinking: boolean,
107
+ systemPrompt: string
108
+ ) {
109
+ this.tools = tools;
110
+ this.temperature = temperature;
111
+ this.enableThinking = enableThinking;
112
+ this.messages = [{ role: "system", content: systemPrompt }];
113
+ this.chatMessages = [{ role: "system", content: systemPrompt }];
114
+ }
115
+
116
  public async abort() {
117
  return new Promise<void>((resolve, reject) => {
118
  const requestId = this.requestId++;
 
119
  const listener = ({ data }: MessageEvent<Response>) => {
120
  if (data.requestId !== requestId) return;
121
  if (data.type === ResponseType.ERROR) {
 
136
  });
137
  }
138
 
139
+ private generateText = (
140
+ prompt: string,
141
+ role: "user" | "tool",
142
+ onResponseUpdate: (response: string) => void = () => {}
143
+ ) => {
144
+ return new Promise<{
145
+ response: string;
146
+ modelUsage: ModelUsage;
147
+ interrupted: boolean;
148
+ }>((resolve, reject) => {
149
+ if (this.modelKey === null) {
150
+ reject("Model not initialized");
151
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
153
+
154
+ if (
155
+ this.tools === null ||
156
+ this.temperature === null ||
157
+ this.enableThinking === null
158
+ ) {
159
+ reject("Conversation not initialized");
160
+ return;
161
+ }
162
+
163
+ const requestId = this.requestId++;
164
+ this.messages = [...this.messages, { role, content: prompt }];
165
+ this.messages.push({ role: "assistant", content: "" });
166
+
167
+ let response = "";
168
+
169
+ const listener = ({ data }: MessageEvent<Response>) => {
170
+ if (data.requestId !== requestId) return;
171
+ if (data.type === ResponseType.ERROR) {
172
+ this.removeWorkerEventListener(listener);
173
+ reject(data.message);
174
+ }
175
+ if (data.type === ResponseType.GENERATE_TEXT_DONE) {
176
+ this.removeWorkerEventListener(listener);
177
+ resolve({
178
+ response: data.response,
179
+ modelUsage: data.modelUsage,
180
+ interrupted: data.interrupted,
181
+ });
182
+ }
183
+ if (data.type === ResponseType.GENERATE_TEXT_CHUNK) {
184
+ response = response + data.chunk;
185
+ onResponseUpdate(response);
186
+ }
187
+ };
188
+
189
+ this.addWorkerEventListener(listener);
190
+ this.postWorkerMessage({
191
+ type: RequestType.GENERATE_MESSAGE,
192
+ modelKey: this.modelKey,
193
+ messages: this.messages,
194
+ tools: this.tools.map(webMCPToolToChatTemplateTool),
195
+ requestId,
196
+ temperature: this.temperature,
197
+ enableThinking: this.enableThinking,
198
+ });
199
+ });
200
+ };
201
+
202
+ public async runAgent(prompt: string): Promise<void> {
203
+ let isUser = true;
204
+
205
+ this.chatMessages = [
206
+ ...this.chatMessages,
207
+ { role: "user", content: prompt },
208
+ ];
209
+
210
+ while (prompt) {
211
+ const prevChatMessages = this.chatMessages;
212
+ const assistantMessage: ChatMessageAssistant = {
213
+ role: "assistant",
214
+ content: [],
215
+ interrupted: false,
216
+ };
217
+
218
+ this.chatMessages = [...prevChatMessages, assistantMessage];
219
+
220
+ const { modelUsage, interrupted } = await this.generateText(
221
+ prompt,
222
+ isUser ? "user" : "tool",
223
+ (partialResponse) => {
224
+ const parts = splitResponse(partialResponse);
225
+ assistantMessage.content = parts.map((part) =>
226
+ typeof part === "string"
227
+ ? ({
228
+ type: "response",
229
+ content: part,
230
+ } as ChatMessageAssistantResponse)
231
+ : ({
232
+ type: "tool",
233
+ result: null,
234
+ functionSignature: `${part.name}(${JSON.stringify(
235
+ part.arguments
236
+ )})`,
237
+ ...part,
238
+ } as ChatMessageAssistantTool)
239
+ );
240
+ this.chatMessages = [...prevChatMessages, assistantMessage];
241
+ }
242
+ );
243
+ isUser = false;
244
+
245
+ assistantMessage.modelUsage = modelUsage;
246
+ assistantMessage.interrupted = interrupted;
247
+ this.chatMessages = [...prevChatMessages, assistantMessage];
248
+
249
+ const toolCalls = assistantMessage.content.filter(
250
+ (c) => c.type === "tool"
251
+ );
252
+ if (toolCalls.length === 0) {
253
+ prompt = "";
254
+ continue;
255
+ }
256
+
257
+ const toolResponses = await Promise.all(
258
+ toolCalls.map((tool) =>
259
+ executeToolCall(
260
+ {
261
+ name: tool.name,
262
+ arguments: tool.arguments,
263
+ id: tool.id,
264
+ },
265
+ this.tools || []
266
+ )
267
+ )
268
+ );
269
+
270
+ assistantMessage.modelUsage = modelUsage;
271
+ assistantMessage.content = assistantMessage.content.map((message) => {
272
+ if (message.type === "tool") {
273
+ const toolResponse = toolResponses.find(
274
+ (response) => response.id === message.id
275
+ );
276
+ if (toolResponse) {
277
+ return {
278
+ ...message,
279
+ result: toolResponse.result,
280
+ };
281
+ }
282
+ return message;
283
+ } else {
284
+ return message;
285
+ }
286
+ });
287
+ this.chatMessages = [...prevChatMessages, assistantMessage];
288
+ prompt = toolResponses.map(({ result }) => result).join("\n");
289
+ }
290
  }
291
  }
src/textGeneration/types.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { type Message } from "@huggingface/transformers";
2
 
3
  import type { MODELS } from "../utils/models.ts";
4
- import type { ChatTemplateTool } from "../utils/webMcp.ts";
5
 
6
  export enum RequestType {
7
  INITIALIZE_MODEL,
@@ -59,6 +59,7 @@ interface ResponseGenerateTextDone {
59
  type: ResponseType.GENERATE_TEXT_DONE;
60
  response: string;
61
  modelUsage: ModelUsage;
 
62
  requestId: number;
63
  }
64
 
@@ -96,6 +97,39 @@ export type Response =
96
  | ResponseInitializeModel
97
  | ResponseError;
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  /*
100
  interface ResponseGenerateTextDone {}
101
 
 
1
  import { type Message } from "@huggingface/transformers";
2
 
3
  import type { MODELS } from "../utils/models.ts";
4
+ import type { ChatTemplateTool, ToolCallPayload } from "../utils/webMcp.ts";
5
 
6
  export enum RequestType {
7
  INITIALIZE_MODEL,
 
59
  type: ResponseType.GENERATE_TEXT_DONE;
60
  response: string;
61
  modelUsage: ModelUsage;
62
+ interrupted: boolean;
63
  requestId: number;
64
  }
65
 
 
97
  | ResponseInitializeModel
98
  | ResponseError;
99
 
100
+ export interface ChatMessageUser {
101
+ role: "user";
102
+ content: string;
103
+ }
104
+
105
+ export interface ChatMessageAssistant {
106
+ role: "assistant";
107
+ content: Array<ChatMessageAssistantResponse | ChatMessageAssistantTool>;
108
+ interrupted: boolean;
109
+ modelUsage?: ModelUsage;
110
+ }
111
+
112
+ export interface ChatMessageSystem {
113
+ role: "system";
114
+ content: string;
115
+ }
116
+
117
+ export interface ChatMessageAssistantResponse {
118
+ type: "response";
119
+ content: string;
120
+ }
121
+
122
+ export interface ChatMessageAssistantTool extends ToolCallPayload {
123
+ type: "tool";
124
+ functionSignature: string;
125
+ result: string;
126
+ }
127
+
128
+ export type ChatMessage =
129
+ | ChatMessageUser
130
+ | ChatMessageAssistant
131
+ | ChatMessageSystem;
132
+
133
  /*
134
  interface ResponseGenerateTextDone {}
135
 
src/textGeneration/worker/textGenerationWorker.ts CHANGED
@@ -7,8 +7,8 @@ import {
7
  Tensor,
8
  TextStreamer,
9
  } from "@huggingface/transformers";
10
- import { calculateDownloadProgress } from "@utils/calculateDownloadProgress.ts";
11
- import { MODELS } from "@utils/models.ts";
12
 
13
  import {
14
  type Request,
@@ -29,7 +29,6 @@ let cache: { pastKeyValues: any | null; key: string } = {
29
  key: "",
30
  };
31
  let stoppingCriteria: any | null = null;
32
- let abortController: AbortController = new AbortController();
33
 
34
  const getTextGenerationPipeline = async (
35
  modelKey: keyof typeof MODELS,
@@ -89,7 +88,6 @@ self.onmessage = async ({ data }: MessageEvent<Request>) => {
89
  }
90
 
91
  if (data.type === RequestType.GENERATE_MESSAGE_ABORT) {
92
- abortController.abort();
93
  stoppingCriteria.interrupt();
94
  postMessage({
95
  type: ResponseType.GENERATE_TEXT_ABORTED,
@@ -99,6 +97,7 @@ self.onmessage = async ({ data }: MessageEvent<Request>) => {
99
 
100
  if (data.type === RequestType.GENERATE_MESSAGE) {
101
  const MODEL = MODELS[data.modelKey];
 
102
 
103
  const { messages, tools, requestId } = data;
104
  console.log(messages, tools);
@@ -111,6 +110,8 @@ self.onmessage = async ({ data }: MessageEvent<Request>) => {
111
  tools,
112
  add_generation_prompt: true,
113
  return_dict: true,
 
 
114
  }) as {
115
  input_ids: Tensor;
116
  attention_mask: number[] | number[][] | Tensor;
@@ -128,16 +129,12 @@ self.onmessage = async ({ data }: MessageEvent<Request>) => {
128
  }
129
  };
130
 
131
- const callbackFunction = (chunk: string) => {
132
- if (abortController.signal?.aborted) {
133
- throw new DOMException("Request cancelled", "AbortError");
134
- }
135
  postMessage({
136
  type: ResponseType.GENERATE_TEXT_CHUNK,
137
  chunk,
138
  requestId,
139
  });
140
- };
141
 
142
  const streamer = new TextStreamer(tokenizer, {
143
  skip_prompt: true,
@@ -153,8 +150,10 @@ self.onmessage = async ({ data }: MessageEvent<Request>) => {
153
  const { sequences, past_key_values } = (await model.generate({
154
  ...input,
155
  max_new_tokens: 1024,
156
- past_key_values: useCache ? cache.pastKeyValues : null,
157
  return_dict_in_generate: true,
 
 
158
  streamer,
159
  })) as { sequences: Tensor; past_key_values: any };
160
  const ended = performance.now();
@@ -184,6 +183,8 @@ self.onmessage = async ({ data }: MessageEvent<Request>) => {
184
  ]),
185
  };
186
 
 
 
187
  postMessage({
188
  type: ResponseType.GENERATE_TEXT_DONE,
189
  response,
@@ -196,6 +197,7 @@ self.onmessage = async ({ data }: MessageEvent<Request>) => {
196
  modelKey: MODEL.modelId,
197
  model: MODEL.title,
198
  },
 
199
  requestId,
200
  });
201
  }
 
7
  Tensor,
8
  TextStreamer,
9
  } from "@huggingface/transformers";
10
+ import { calculateDownloadProgress } from "../../utils/calculateDownloadProgress.ts";
11
+ import { MODELS } from "../../utils/models.ts";
12
 
13
  import {
14
  type Request,
 
29
  key: "",
30
  };
31
  let stoppingCriteria: any | null = null;
 
32
 
33
  const getTextGenerationPipeline = async (
34
  modelKey: keyof typeof MODELS,
 
88
  }
89
 
90
  if (data.type === RequestType.GENERATE_MESSAGE_ABORT) {
 
91
  stoppingCriteria.interrupt();
92
  postMessage({
93
  type: ResponseType.GENERATE_TEXT_ABORTED,
 
97
 
98
  if (data.type === RequestType.GENERATE_MESSAGE) {
99
  const MODEL = MODELS[data.modelKey];
100
+ stoppingCriteria = new InterruptableStoppingCriteria();
101
 
102
  const { messages, tools, requestId } = data;
103
  console.log(messages, tools);
 
110
  tools,
111
  add_generation_prompt: true,
112
  return_dict: true,
113
+ // @ts-expect-error
114
+ enable_thinking: data.enableThinking,
115
  }) as {
116
  input_ids: Tensor;
117
  attention_mask: number[] | number[][] | Tensor;
 
129
  }
130
  };
131
 
132
+ const callbackFunction = (chunk: string) =>
 
 
 
133
  postMessage({
134
  type: ResponseType.GENERATE_TEXT_CHUNK,
135
  chunk,
136
  requestId,
137
  });
 
138
 
139
  const streamer = new TextStreamer(tokenizer, {
140
  skip_prompt: true,
 
150
  const { sequences, past_key_values } = (await model.generate({
151
  ...input,
152
  max_new_tokens: 1024,
153
+ //past_key_values: useCache ? cache.pastKeyValues : null,
154
  return_dict_in_generate: true,
155
+ temperature: data.temperature,
156
+ stopping_criteria: stoppingCriteria,
157
  streamer,
158
  })) as { sequences: Tensor; past_key_values: any };
159
  const ended = performance.now();
 
183
  ]),
184
  };
185
 
186
+ console.log("response", response);
187
+
188
  postMessage({
189
  type: ResponseType.GENERATE_TEXT_DONE,
190
  response,
 
197
  modelKey: MODEL.modelId,
198
  model: MODEL.title,
199
  },
200
+ interrupted: stoppingCriteria.interrupted,
201
  requestId,
202
  });
203
  }
src/theme/button/Button.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { type ReactNode, cloneElement, isValidElement } from "react";
2
 
3
  import cn from "../../utils/classnames.ts";
 
4
 
5
  type ButtonColor = "primary" | "secondary" | "mono" | "danger";
6
  type ButtonVariant = "solid" | "outline" | "ghost";
@@ -129,28 +130,7 @@ export default function Button({
129
 
130
  const content = (
131
  <>
132
- {loading && (
133
- <svg
134
- className="h-4 w-4 animate-spin"
135
- xmlns="http://www.w3.org/2000/svg"
136
- fill="none"
137
- viewBox="0 0 24 24"
138
- >
139
- <circle
140
- className="opacity-25"
141
- cx="12"
142
- cy="12"
143
- r="10"
144
- stroke="currentColor"
145
- strokeWidth="4"
146
- />
147
- <path
148
- className="opacity-75"
149
- fill="currentColor"
150
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
151
- />
152
- </svg>
153
- )}
154
  {!loading && iconLeft && renderIcon(iconLeft)}
155
  {children && <span>{children}</span>}
156
  {!loading && iconRight && renderIcon(iconRight)}
 
1
  import { type ReactNode, cloneElement, isValidElement } from "react";
2
 
3
  import cn from "../../utils/classnames.ts";
4
+ import { Loader } from "../index.ts";
5
 
6
  type ButtonColor = "primary" | "secondary" | "mono" | "danger";
7
  type ButtonVariant = "solid" | "outline" | "ghost";
 
130
 
131
  const content = (
132
  <>
133
+ {loading && <Loader size={size} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  {!loading && iconLeft && renderIcon(iconLeft)}
135
  {children && <span>{children}</span>}
136
  {!loading && iconRight && renderIcon(iconRight)}
src/theme/misc/Loader.tsx CHANGED
@@ -1,9 +1,28 @@
1
  import cn from "@utils/classnames.ts";
2
 
3
- export default function Loader({ className = "" }: { className?: string }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  return (
5
- <div className={cn(className, "flex items-center justify-center py-12")}>
6
- <div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-yellow-500 dark:border-gray-600 dark:border-t-yellow-400" />
 
 
 
 
 
7
  </div>
8
  );
9
  }
 
1
  import cn from "@utils/classnames.ts";
2
 
3
+ type LoaderSize = "xs" | "sm" | "md" | "lg";
4
+
5
+ interface LoaderProps {
6
+ className?: string;
7
+ size?: LoaderSize;
8
+ }
9
+
10
+ const sizeClasses: Record<LoaderSize, string> = {
11
+ xs: "h-3 w-3 border-2",
12
+ sm: "h-4 w-4 border-2",
13
+ md: "h-8 w-8 border-4",
14
+ lg: "h-12 w-12 border-[5px]",
15
+ };
16
+
17
+ export default function Loader({ className = "", size = "md" }: LoaderProps) {
18
  return (
19
+ <div className={cn(className, "flex items-center justify-center")}>
20
+ <div
21
+ className={cn(
22
+ "animate-spin rounded-full border-gray-300 border-t-yellow-500 dark:border-gray-600 dark:border-t-yellow-400",
23
+ sizeClasses[size]
24
+ )}
25
+ />
26
  </div>
27
  );
28
  }
src/utils/context/chatSettings/ChatSettingsContext.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { DEFAULT_SYSTEM_PROMPT, MODELS } from "@utils/models.ts";
2
  import { createContext } from "react";
3
 
4
- import type { ChatSettingsContextType } from "./types.ts";
5
 
6
  const ChatSettingsContext = createContext<ChatSettingsContextType>({
7
  settings: null,
@@ -12,7 +12,7 @@ const ChatSettingsContext = createContext<ChatSettingsContextType>({
12
 
13
  export default ChatSettingsContext;
14
 
15
- export const DEFAULT_CHAT_SETTINGS = {
16
  tools: [],
17
  modelKey: Object.keys(MODELS)[0],
18
  systemPrompt: DEFAULT_SYSTEM_PROMPT,
 
1
  import { DEFAULT_SYSTEM_PROMPT, MODELS } from "@utils/models.ts";
2
  import { createContext } from "react";
3
 
4
+ import type { ChatSettings, ChatSettingsContextType } from "./types.ts";
5
 
6
  const ChatSettingsContext = createContext<ChatSettingsContextType>({
7
  settings: null,
 
12
 
13
  export default ChatSettingsContext;
14
 
15
+ export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
16
  tools: [],
17
  modelKey: Object.keys(MODELS)[0],
18
  systemPrompt: DEFAULT_SYSTEM_PROMPT,
src/utils/context/theme/ThemeContext.ts CHANGED
@@ -2,6 +2,6 @@ import { createContext } from "react";
2
 
3
  import type { ThemeContextType } from "./types.ts";
4
 
5
- const ThemeContext = createContext<ThemeContextType | null>(null);
6
 
7
  export default ThemeContext;
 
2
 
3
  import type { ThemeContextType } from "./types.ts";
4
 
5
+ const ThemeContext = createContext<ThemeContextType>(null);
6
 
7
  export default ThemeContext;
src/utils/models.ts CHANGED
@@ -1,7 +1,7 @@
1
  export interface Model {
2
  modelId: string;
3
  title: string;
4
- dtype: "fp16" | "q4" | "q4f16";
5
  device: "webgpu";
6
  size: number;
7
  files: Record<string, number>;
@@ -38,6 +38,21 @@ export const MODELS: Record<string, Model> = {
38
  "onnx/model_q4.onnx_data_1": 2_095_841_280,
39
  },
40
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  granite350m: {
42
  modelId: "onnx-community/granite-4.0-350m-ONNX-web",
43
  title: "Granite-4.0 350M (fp16)",
@@ -84,6 +99,37 @@ export const MODELS: Record<string, Model> = {
84
  "generation_config.json": 152,
85
  },
86
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  } as const;
88
 
89
  export type ModelKey = keyof typeof MODELS;
 
1
  export interface Model {
2
  modelId: string;
3
  title: string;
4
+ dtype: "fp16" | "q4" | "q4f16" | "fp32";
5
  device: "webgpu";
6
  size: number;
7
  files: Record<string, number>;
 
38
  "onnx/model_q4.onnx_data_1": 2_095_841_280,
39
  },
40
  },
41
+ gemma3270m: {
42
+ modelId: "onnx-community/gemma-3-270m-it-ONNX",
43
+ title: "Gemma-3-270M (fp32)",
44
+ dtype: "fp32",
45
+ device: "webgpu",
46
+ size: 1_161_186_669,
47
+ files: {
48
+ "config.json": 1_612,
49
+ "tokenizer_config.json": 1_158_469,
50
+ "tokenizer.json": 20_323_106,
51
+ "generation_config.json": 172,
52
+ "onnx/model.onnx": 201_742,
53
+ "onnx/model.onnx_data": 1_139_501_568,
54
+ },
55
+ },
56
  granite350m: {
57
  modelId: "onnx-community/granite-4.0-350m-ONNX-web",
58
  title: "Granite-4.0 350M (fp16)",
 
99
  "generation_config.json": 152,
100
  },
101
  },
102
+ qwen34B: {
103
+ modelId: "onnx-community/Qwen3-4B-ONNX",
104
+ title: "Qwen3-4B (q4f16)",
105
+ dtype: "q4f16",
106
+ device: "webgpu",
107
+ size: 2_842_047_473,
108
+ files: {
109
+ "tokenizer.json": 9_117_040,
110
+ "tokenizer_config.json": 9_761,
111
+ "config.json": 1_780,
112
+ "onnx/model_q4f16.onnx": 59_762_833,
113
+ "generation_config.json": 219,
114
+ "onnx/model_q4f16.onnx_data_1": 677_150_720,
115
+ "onnx/model_q4f16.onnx_data": 2_096_005_120,
116
+ },
117
+ },
118
+ smolLM33B: {
119
+ modelId: "HuggingFaceTB/SmolLM3-3B-ONNX",
120
+ title: "SmolLM3-3B (q4f16)",
121
+ dtype: "q4f16",
122
+ device: "webgpu",
123
+ size: 2_136_257_855,
124
+ files: {
125
+ "tokenizer.json": 11_574_059,
126
+ "tokenizer_config.json": 56_256,
127
+ "config.json": 2_056,
128
+ "onnx/model_q4f16.onnx": 301_534,
129
+ "generation_config.json": 182,
130
+ "onnx/model_q4f16.onnx_data": 2_124_320_768,
131
+ },
132
+ },
133
  } as const;
134
 
135
  export type ModelKey = keyof typeof MODELS;
src/utils/tools.ts CHANGED
@@ -2,15 +2,85 @@ import type { WebMCPTool } from "@utils/webMcp.ts";
2
 
3
  const getCurrentTimeTool: WebMCPTool = {
4
  name: "get_current_time",
5
- description: "Returns the current date and time in ISO 8601 format",
6
  inputSchema: {
7
  type: "object",
8
  properties: {},
9
  required: [],
10
  },
11
  execute: async () => {
12
- return new Date().toISOString();
 
13
  },
14
  };
15
 
16
- export const TOOLS = [getCurrentTimeTool];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  const getCurrentTimeTool: WebMCPTool = {
4
  name: "get_current_time",
5
+ description: "Returns the current date and time.",
6
  inputSchema: {
7
  type: "object",
8
  properties: {},
9
  required: [],
10
  },
11
  execute: async () => {
12
+ const date = new Date();
13
+ return `ISO 8601: ${date.toISOString()}\ndd.mm.yyyy: ${date.toLocaleDateString()}\nhh:mm:ss: ${date.toLocaleTimeString()}`;
14
  },
15
  };
16
 
17
+ const getJokeTool: WebMCPTool = {
18
+ name: "get_joke",
19
+ description:
20
+ "Fetches a joke from the jokes API. Can filter by category, type, or search text.",
21
+ inputSchema: {
22
+ type: "object",
23
+ properties: {
24
+ category: {
25
+ type: "string",
26
+ description:
27
+ "Joke category: Any, Programming, Misc, Pun, Spooky, Christmas",
28
+ default: "Any",
29
+ },
30
+ type: {
31
+ type: "string",
32
+ description:
33
+ "Joke format: single (one-liner) or twopart (setup/delivery)",
34
+ default: "Any",
35
+ },
36
+ contains: {
37
+ type: "string",
38
+ description: "Search for jokes containing this text",
39
+ },
40
+ },
41
+ required: [],
42
+ },
43
+ execute: async (args) => {
44
+ const params = new URLSearchParams({ amount: "1" });
45
+
46
+ if (args.category && args.category !== "Any") {
47
+ params.append("category", args.category);
48
+ }
49
+ if (args.type && args.type !== "Any") {
50
+ params.append("type", args.type);
51
+ }
52
+ if (args.contains) {
53
+ params.append("contains", args.contains);
54
+ }
55
+
56
+ try {
57
+ const response = await fetch(
58
+ `https://jokes.nico.dev/joke?${params.toString()}`
59
+ );
60
+
61
+ if (!response.ok) {
62
+ return `Error fetching joke: ${response.statusText}`;
63
+ }
64
+
65
+ const data = await response.json();
66
+
67
+ if (data.error) {
68
+ return `Error: ${data.error}`;
69
+ }
70
+
71
+ if (data.joke) {
72
+ return data.joke;
73
+ }
74
+
75
+ if (data.jokes && data.jokes.length > 0) {
76
+ return data.jokes[0].text;
77
+ }
78
+
79
+ return "No joke found";
80
+ } catch (error) {
81
+ return `Error fetching joke: ${error instanceof Error ? error.message : "Unknown error"}`;
82
+ }
83
+ },
84
+ };
85
+
86
+ export const TOOLS = [getCurrentTimeTool, getJokeTool];
src/utils/webMcp.ts CHANGED
@@ -139,6 +139,63 @@ export const extractToolCalls = (
139
  return { toolCalls, message };
140
  };
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  export const executeToolCall = async (
143
  toolCall: ToolCallPayload,
144
  tools: Array<WebMCPTool>
 
139
  return { toolCalls, message };
140
  };
141
 
142
+ export const splitResponse = (
143
+ text: string
144
+ ): Array<string | ToolCallPayload> => {
145
+ const result: Array<string | ToolCallPayload> = [];
146
+ let lastIndex = 0;
147
+
148
+ // Match only complete tool calls (with closing tag)
149
+ const regex = /<tool_call>([\s\S]*?)<\/tool_call>/g;
150
+ let match: RegExpExecArray | null;
151
+
152
+ while ((match = regex.exec(text)) !== null) {
153
+ // Add text before the tool call
154
+ const textBefore = text.slice(lastIndex, match.index);
155
+ if (textBefore) {
156
+ result.push(textBefore);
157
+ }
158
+
159
+ // Parse and add the tool call
160
+ try {
161
+ const parsed = JSON.parse(match[1].trim());
162
+ if (parsed && typeof parsed.name === "string") {
163
+ result.push({
164
+ name: parsed.name,
165
+ arguments: parsed.arguments ?? {},
166
+ id: JSON.stringify({
167
+ name: parsed.name,
168
+ arguments: parsed.arguments ?? {},
169
+ }),
170
+ });
171
+ }
172
+ } catch {
173
+ // ignore malformed tool call payloads
174
+ }
175
+
176
+ lastIndex = regex.lastIndex;
177
+ }
178
+
179
+ // Check if there's an incomplete tool call
180
+ const incompleteToolCallIndex = text.indexOf("<tool_call>", lastIndex);
181
+
182
+ if (incompleteToolCallIndex !== -1) {
183
+ // There's an incomplete tool call, only add text up to it
184
+ const textBefore = text.slice(lastIndex, incompleteToolCallIndex);
185
+ if (textBefore) {
186
+ result.push(textBefore);
187
+ }
188
+ } else {
189
+ // No incomplete tool call, add remaining text
190
+ const remainingText = text.slice(lastIndex);
191
+ if (remainingText) {
192
+ result.push(remainingText);
193
+ }
194
+ }
195
+
196
+ return result;
197
+ };
198
+
199
  export const executeToolCall = async (
200
  toolCall: ToolCallPayload,
201
  tools: Array<WebMCPTool>
tsconfig.app.json CHANGED
@@ -18,6 +18,7 @@
18
 
19
  /* Linting */
20
  "strict": true,
 
21
  "noUnusedLocals": true,
22
  "noUnusedParameters": true,
23
  "erasableSyntaxOnly": false,
 
18
 
19
  /* Linting */
20
  "strict": true,
21
+ "strictNullChecks": false,
22
  "noUnusedLocals": true,
23
  "noUnusedParameters": true,
24
  "erasableSyntaxOnly": false,
vite.config.ts CHANGED
@@ -8,10 +8,18 @@ import tsconfigPaths from "vite-tsconfig-paths";
8
 
9
  dotenv.config();
10
 
11
- const TITLE = "Transformers.js LLMs";
12
 
13
  // https://vite.dev/config/
14
  export default defineConfig({
 
 
 
 
 
 
 
 
15
  server: {
16
  ...(fs.existsSync(process.env.SSL_KEY || "") &&
17
  fs.existsSync(process.env.SSL_CRT || "")
@@ -24,12 +32,4 @@ export default defineConfig({
24
  : {}),
25
  port: process.env.PORT ? parseInt(process.env.PORT) : 8080,
26
  },
27
- plugins: [
28
- react(),
29
- tailwindcss(),
30
- tsconfigPaths(),
31
- htmlPlugin({
32
- title: TITLE,
33
- }),
34
- ],
35
  });
 
8
 
9
  dotenv.config();
10
 
11
+ const TITLE = "Transformers.js TextGeneration";
12
 
13
  // https://vite.dev/config/
14
  export default defineConfig({
15
+ plugins: [
16
+ react(),
17
+ tsconfigPaths(),
18
+ tailwindcss(),
19
+ htmlPlugin({
20
+ title: TITLE,
21
+ }),
22
+ ],
23
  server: {
24
  ...(fs.existsSync(process.env.SSL_KEY || "") &&
25
  fs.existsSync(process.env.SSL_CRT || "")
 
32
  : {}),
33
  port: process.env.PORT ? parseInt(process.env.PORT) : 8080,
34
  },
 
 
 
 
 
 
 
 
35
  });