Skip to content

Commit 5c39492

Browse files
committed
chore: tool calling (wip)
1 parent 74958ba commit 5c39492

File tree

1 file changed

+159
-35
lines changed

1 file changed

+159
-35
lines changed

demo/llm.ts

Lines changed: 159 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
11
import type { MLCEngine } from "@mlc-ai/web-llm";
22

33
// https://github.com/mlc-ai/web-llm
4+
// Tool use via structural tags
5+
// Ref: https://github.com/mlc-ai/web-llm/blob/main/examples/structural-tag-tool-use
6+
7+
type ToolInvocation = { name: string; arguments: Record<string, unknown> };
8+
9+
const tools = [
10+
{
11+
name: "get_current_time",
12+
description: "Return the current date and time in a given timezone.",
13+
schema: {
14+
type: "object",
15+
properties: {
16+
timezone: {
17+
type: "string",
18+
description: "IANA timezone name, defaults to UTC",
19+
},
20+
},
21+
required: [] as string[],
22+
},
23+
},
24+
];
25+
26+
const toolResponseFormat = {
27+
type: "structural_tag",
28+
structural_tag: {
29+
type: "structural_tag",
30+
format: {
31+
type: "triggered_tags",
32+
triggers: ["<tool_call>"],
33+
tags: tools.map((t) => ({
34+
begin: `<tool_call>\n{"name": "${t.name}", "arguments": `,
35+
content: { type: "json_schema", json_schema: t.schema },
36+
end: "}\n</tool_call>",
37+
})),
38+
at_least_one: false,
39+
stop_after_first: false,
40+
},
41+
},
42+
};
443

544
let engine: MLCEngine | null = null;
645
let chatHistory: { role: string; content: string }[] = [];
@@ -18,10 +57,17 @@ export function isReady() {
1857
}
1958

2059
export function setAgent(name: string) {
60+
const toolList = tools
61+
.map((t) => `- ${t.name}: ${t.description}`)
62+
.join("\n");
2163
chatHistory = [
2264
{
2365
role: "system",
24-
content: `You are ${name}, a helpful desktop assistant from Windows 98. Keep responses very short and fun (1 sentence max).`,
66+
content: [
67+
`You are ${name}, a helpful desktop assistant from Windows 98. Keep responses very short and fun (1 sentence max).`,
68+
`You have tools available. To use one, emit a <tool_call> block.`,
69+
toolList,
70+
].join("\n"),
2571
},
2672
];
2773
}
@@ -69,7 +115,9 @@ chatBtn.addEventListener("click", async () => {
69115

70116
let onReplyStream: ((stream: AsyncIterable<string>) => void) | null = null;
71117

72-
export function onAgentReplyStream(cb: (stream: AsyncIterable<string>) => void) {
118+
export function onAgentReplyStream(
119+
cb: (stream: AsyncIterable<string>) => void,
120+
) {
73121
onReplyStream = cb;
74122
}
75123

@@ -85,34 +133,44 @@ async function sendChat() {
85133
chatHistory.push({ role: "user", content: text });
86134

87135
try {
88-
const chunks = await engine.chat.completions.create({
136+
// First pass (non-streaming) to detect tool calls via structural tags
137+
const firstReply = await engine.chat.completions.create({
89138
messages: chatHistory as any,
90-
stream: true,
139+
stream: false,
140+
max_tokens: 512,
141+
response_format: toolResponseFormat as any,
91142
});
92143

93-
let reply = "";
94-
const msgEl = appendChatMsg("Agent", "", "assistant");
95-
96-
async function* deltaStream() {
97-
for await (const chunk of chunks) {
98-
const delta = chunk.choices[0]?.delta?.content || "";
99-
if (!delta) continue;
100-
reply += delta;
101-
msgEl.querySelector(".chat-msg-text").textContent = reply;
102-
chatMessages.scrollTop = chatMessages.scrollHeight;
103-
yield delta;
104-
}
105-
}
106-
107-
const stream = deltaStream();
108-
if (onReplyStream) {
109-
onReplyStream(stream);
144+
console.log("First pass reply:", firstReply);
145+
146+
const content = firstReply.choices[0]?.message?.content || "";
147+
const calls = parseToolCalls(content);
148+
149+
if (calls.length > 0) {
150+
// Execute tools and follow up with a streamed response
151+
chatHistory.push({ role: "assistant", content });
152+
const results = calls.map((c) => ({
153+
tool: c.name,
154+
result: executeTool(c),
155+
}));
156+
chatHistory.push({
157+
role: "user",
158+
content: `[Tool results]: ${JSON.stringify(results)}`,
159+
});
160+
const msgEl = appendChatMsg("Agent", "", "assistant");
161+
await streamReply(msgEl);
110162
} else {
111-
for await (const _ of stream) {
163+
// No tool calls — display response directly
164+
appendChatMsg("Agent", content, "assistant");
165+
chatHistory.push({ role: "assistant", content });
166+
if (onReplyStream) {
167+
onReplyStream(
168+
(async function* () {
169+
yield content;
170+
})(),
171+
);
112172
}
113173
}
114-
115-
chatHistory.push({ role: "assistant", content: reply });
116174
} catch (err) {
117175
appendChatMsg("System", "Error: " + (err as Error).message, "user");
118176
}
@@ -122,24 +180,15 @@ async function sendChat() {
122180
chatInput.focus();
123181
}
124182

125-
function appendChatMsg(sender: string, text: string, role: string) {
126-
const div = document.createElement("div");
127-
div.className = `chat-msg chat-msg-${role}`;
128-
div.innerHTML = `<b>${sender}:</b> <span class="chat-msg-text"></span>`;
129-
div.querySelector(".chat-msg-text").textContent = text;
130-
chatMessages.appendChild(div);
131-
chatMessages.scrollTop = chatMessages.scrollHeight;
132-
return div;
133-
}
134-
135183
chatSend.addEventListener("click", sendChat);
136184
chatInput.addEventListener("keydown", (e) => {
137185
if (e.key === "Enter") sendChat();
138186
});
139187

140188
// Speech-to-text (optional, Chrome/Edge)
141189
const SpeechRecognition =
142-
(globalThis as any).SpeechRecognition || (globalThis as any).webkitSpeechRecognition;
190+
(globalThis as any).SpeechRecognition ||
191+
(globalThis as any).webkitSpeechRecognition;
143192

144193
if (SpeechRecognition) {
145194
chatMic.style.display = "";
@@ -207,3 +256,78 @@ if (SpeechRecognition) {
207256
setInterval(pollTTS, 200);
208257
}
209258
}
259+
260+
// --- Internal helpers ---
261+
262+
function appendChatMsg(sender: string, text: string, role: string) {
263+
const div = document.createElement("div");
264+
div.className = `chat-msg chat-msg-${role}`;
265+
div.innerHTML = `<b>${sender}:</b> <span class="chat-msg-text"></span>`;
266+
div.querySelector(".chat-msg-text").textContent = text;
267+
chatMessages.appendChild(div);
268+
chatMessages.scrollTop = chatMessages.scrollHeight;
269+
return div;
270+
}
271+
272+
async function streamReply(msgEl: HTMLElement) {
273+
const chunks = await engine!.chat.completions.create({
274+
messages: chatHistory as any,
275+
stream: true,
276+
});
277+
278+
let reply = "";
279+
280+
async function* deltaStream() {
281+
for await (const chunk of chunks) {
282+
const delta = chunk.choices[0]?.delta?.content || "";
283+
if (!delta) continue;
284+
reply += delta;
285+
msgEl.querySelector(".chat-msg-text").textContent = reply;
286+
chatMessages.scrollTop = chatMessages.scrollHeight;
287+
yield delta;
288+
}
289+
}
290+
291+
const stream = deltaStream();
292+
if (onReplyStream) {
293+
onReplyStream(stream);
294+
} else {
295+
for await (const _ of stream) {
296+
/* drain */
297+
}
298+
}
299+
300+
chatHistory.push({ role: "assistant", content: reply });
301+
}
302+
303+
function parseToolCalls(content: string): ToolInvocation[] {
304+
const regex = /<tool_call>\s*(\{[\s\S]*?\})\s*<\/tool_call>/g;
305+
const calls: ToolInvocation[] = [];
306+
let match: RegExpExecArray | null;
307+
while ((match = regex.exec(content)) !== null) {
308+
try {
309+
const payload = JSON.parse(match[1]);
310+
if (typeof payload.name === "string" && payload.arguments !== undefined) {
311+
calls.push({ name: payload.name, arguments: payload.arguments });
312+
}
313+
} catch {
314+
// skip malformed tool calls
315+
}
316+
}
317+
return calls;
318+
}
319+
320+
function executeTool(call: ToolInvocation): Record<string, unknown> {
321+
if (call.name === "get_current_time") {
322+
const timezone = String(call.arguments.timezone || "UTC");
323+
try {
324+
return {
325+
timezone,
326+
time: new Date().toLocaleString("en-US", { timeZone: timezone }),
327+
};
328+
} catch {
329+
return { timezone: "UTC", time: new Date().toISOString() };
330+
}
331+
}
332+
return { error: `Unknown tool: ${call.name}` };
333+
}

0 commit comments

Comments
 (0)