11import 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
544let engine : MLCEngine | null = null ;
645let chatHistory : { role : string ; content : string } [ ] = [ ] ;
@@ -18,10 +57,17 @@ export function isReady() {
1857}
1958
2059export 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
70116let 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-
135183chatSend . addEventListener ( "click" , sendChat ) ;
136184chatInput . addEventListener ( "keydown" , ( e ) => {
137185 if ( e . key === "Enter" ) sendChat ( ) ;
138186} ) ;
139187
140188// Speech-to-text (optional, Chrome/Edge)
141189const SpeechRecognition =
142- ( globalThis as any ) . SpeechRecognition || ( globalThis as any ) . webkitSpeechRecognition ;
190+ ( globalThis as any ) . SpeechRecognition ||
191+ ( globalThis as any ) . webkitSpeechRecognition ;
143192
144193if ( 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 = / < t o o l _ c a l l > \s * ( \{ [ \s \S ] * ?\} ) \s * < \/ t o o l _ c a l l > / 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