@@ -106,6 +106,8 @@ function KittLoader() {
106106}
107107
108108const abortControllers = new Map < string , AbortController > ( ) ;
109+ const MAX_RETRIES = 2 ;
110+ const RETRY_DELAY_MS = 1500 ;
109111
110112function ReasoningBlock ( {
111113 text,
@@ -288,120 +290,165 @@ async function streamToWidget(
288290 env : s . env ,
289291 } ) ) ;
290292
291- const res = await fetch ( "/api/chat" , {
292- method : "POST" ,
293- headers : { "Content-Type" : "application/json" } ,
294- body : JSON . stringify ( {
295- messages,
296- widgetId,
297- model,
298- apiKey,
299- ...( searchProvider && searchApiKey ? { searchProvider, searchApiKey } : { } ) ,
300- ...( enabledMcpServers . length > 0 ? { mcpServers : enabledMcpServers } : { } ) ,
301- ...( customApi ? { customApi } : { } ) ,
302- } ) ,
303- signal : controller . signal ,
293+ const body = JSON . stringify ( {
294+ messages,
295+ widgetId,
296+ model,
297+ apiKey,
298+ ...( searchProvider && searchApiKey ? { searchProvider, searchApiKey } : { } ) ,
299+ ...( enabledMcpServers . length > 0 ? { mcpServers : enabledMcpServers } : { } ) ,
300+ ...( customApi ? { customApi } : { } ) ,
304301 } ) ;
305302
306- if ( ! res . ok ) {
307- const err = await res . text ( ) ;
308- updateAssistantMessage ( widgetId , currentMsgId , `Error: ${ err } ` ) ;
309- return ;
303+ function isNetworkError ( err : unknown ) : boolean {
304+ if ( err instanceof TypeError ) return true ;
305+ const msg = String ( err ) . toLowerCase ( ) ;
306+ return msg . includes ( "network" ) || msg . includes ( "failed to fetch" ) || msg . includes ( "econnreset" ) || msg . includes ( "socket hang up" ) ;
310307 }
311308
312- const reader = res . body ?. getReader ( ) ;
313- if ( ! reader ) return ;
309+ for ( let attempt = 0 ; attempt <= MAX_RETRIES ; attempt ++ ) {
310+ if ( controller . signal . aborted ) break ;
314311
315- const decoder = new TextDecoder ( ) ;
316- let buffer = "" ;
312+ if ( attempt > 0 ) {
313+ showAction ( `Reconnecting (attempt ${ attempt + 1 } )…` ) ;
314+ await new Promise ( ( r ) => setTimeout ( r , RETRY_DELAY_MS * attempt ) ) ;
315+ if ( controller . signal . aborted ) break ;
316+ }
317317
318- while ( true ) {
319- const { done, value } = await reader . read ( ) ;
320- if ( done ) break ;
318+ try {
319+ const res = await fetch ( "/api/chat" , {
320+ method : "POST" ,
321+ headers : { "Content-Type" : "application/json" } ,
322+ body,
323+ signal : controller . signal ,
324+ } ) ;
325+
326+ if ( ! res . ok ) {
327+ const err = await res . text ( ) ;
328+ updateAssistantMessage ( widgetId , currentMsgId , `Error: ${ err } ` ) ;
329+ return ;
330+ }
321331
322- buffer += decoder . decode ( value , { stream : true } ) ;
323- const parts = buffer . split ( "\n\n" ) ;
324- buffer = parts . pop ( ) ?? "" ;
332+ const reader = res . body ?. getReader ( ) ;
333+ if ( ! reader ) return ;
325334
326- for ( const part of parts ) {
327- const line = part . trim ( ) ;
328- if ( ! line . startsWith ( "data:" ) ) continue ;
329- const payload = line . slice ( 5 ) . trim ( ) ;
330- if ( payload === "[DONE]" ) continue ;
335+ const decoder = new TextDecoder ( ) ;
336+ let buffer = "" ;
337+ let streamCompleted = false ;
331338
332- try {
333- const event = JSON . parse ( payload ) ;
334- if ( event . type === "reasoning-delta" ) {
335- if ( hasEmittedText ) {
336- startNewAssistantMessage ( ) ;
337- }
338- setReasoningStreaming ( widgetId , true ) ;
339- appendReasoningToMessage ( widgetId , currentMsgId , event . text ) ;
340- } else if ( event . type === "text-delta" ) {
341- setReasoningStreaming ( widgetId , false ) ;
342- hasEmittedText = true ;
343- fullText += event . text ;
344- updateAssistantMessage ( widgetId , currentMsgId , fullText ) ;
345- } else if ( event . type === "widget-file" ) {
346- if ( event . path && event . content ) {
347- setWidgetFile ( widgetId , event . path , event . content ) ;
348- }
349- } else if ( event . type === "widget-code" ) {
350- if ( event . code ) {
351- setWidgetCode ( widgetId , event . code ) ;
352- showAction ( "Building widget…" ) ;
353- setTimeout ( ( ) => bumpIframeVersion ( widgetId ) , 15000 ) ;
339+ while ( true ) {
340+ const { done, value } = await reader . read ( ) ;
341+ if ( done ) {
342+ streamCompleted = true ;
343+ break ;
344+ }
345+
346+ buffer += decoder . decode ( value , { stream : true } ) ;
347+ const parts = buffer . split ( "\n\n" ) ;
348+ buffer = parts . pop ( ) ?? "" ;
349+
350+ for ( const part of parts ) {
351+ const line = part . trim ( ) ;
352+ if ( ! line . startsWith ( "data:" ) ) continue ;
353+ const payload = line . slice ( 5 ) . trim ( ) ;
354+ if ( payload === "[DONE]" ) {
355+ streamCompleted = true ;
356+ continue ;
354357 }
355- } else if ( event . type === "tool-call" ) {
356- let action = "" ;
357- if ( event . toolName === "writeFile" ) {
358- const filePath = event . args ?. path ?? "" ;
359- action =
360- filePath === "src/App.tsx"
361- ? "Writing widget code"
362- : `Writing ${ filePath } ` ;
363- } else if ( event . toolName === "readFile" ) {
364- action = `Reading ${ event . args ?. path ?? "file" } ` ;
365- } else if ( event . toolName === "bash" ) {
366- const cmd = String ( event . args ?. command ?? "" ) ;
367- action = cmd . length > 40 ? `Running: ${ cmd . slice ( 0 , 40 ) } …` : `Running: ${ cmd } ` ;
368- } else if ( event . toolName === "listDashboardWidgets" ) {
369- action = "Checking dashboard widgets" ;
370- } else if ( event . toolName === "readWidgetCode" ) {
371- action = `Reading ${ event . args ?. targetWidgetId ?? "sibling" } code` ;
372- } else if ( event . toolName === "web_search" ) {
373- action = event . args ?. query
374- ? `Searching "${ event . args . query } "`
375- : "Searching the web" ;
376- } else {
377- action = `Using ${ event . toolName } ` ;
358+
359+ try {
360+ const event = JSON . parse ( payload ) ;
361+ if ( event . type === "reasoning-delta" ) {
362+ if ( hasEmittedText ) {
363+ startNewAssistantMessage ( ) ;
364+ }
365+ setReasoningStreaming ( widgetId , true ) ;
366+ appendReasoningToMessage ( widgetId , currentMsgId , event . text ) ;
367+ } else if ( event . type === "text-delta" ) {
368+ setReasoningStreaming ( widgetId , false ) ;
369+ hasEmittedText = true ;
370+ fullText += event . text ;
371+ updateAssistantMessage ( widgetId , currentMsgId , fullText ) ;
372+ } else if ( event . type === "widget-file" ) {
373+ if ( event . path && event . content ) {
374+ setWidgetFile ( widgetId , event . path , event . content ) ;
375+ }
376+ } else if ( event . type === "widget-code" ) {
377+ if ( event . code ) {
378+ setWidgetCode ( widgetId , event . code ) ;
379+ showAction ( "Building widget…" ) ;
380+ setTimeout ( ( ) => bumpIframeVersion ( widgetId ) , 15000 ) ;
381+ }
382+ } else if ( event . type === "tool-call" ) {
383+ let action = "" ;
384+ if ( event . toolName === "writeFile" ) {
385+ const filePath = event . args ?. path ?? "" ;
386+ action =
387+ filePath === "src/App.tsx"
388+ ? "Writing widget code"
389+ : `Writing ${ filePath } ` ;
390+ } else if ( event . toolName === "readFile" ) {
391+ action = `Reading ${ event . args ?. path ?? "file" } ` ;
392+ } else if ( event . toolName === "bash" ) {
393+ const cmd = String ( event . args ?. command ?? "" ) ;
394+ action = cmd . length > 40 ? `Running: ${ cmd . slice ( 0 , 40 ) } …` : `Running: ${ cmd } ` ;
395+ } else if ( event . toolName === "listDashboardWidgets" ) {
396+ action = "Checking dashboard widgets" ;
397+ } else if ( event . toolName === "readWidgetCode" ) {
398+ action = `Reading ${ event . args ?. targetWidgetId ?? "sibling" } code` ;
399+ } else if ( event . toolName === "web_search" ) {
400+ action = event . args ?. query
401+ ? `Searching "${ event . args . query } "`
402+ : "Searching the web" ;
403+ } else {
404+ action = `Using ${ event . toolName } ` ;
405+ }
406+ if ( action ) showAction ( action ) ;
407+ } else if ( event . type === "tool-result" ) {
408+ clearActionWithMinimumVisibility ( ) ;
409+ } else if ( event . type === "abort" ) {
410+ updateAssistantMessage (
411+ widgetId ,
412+ currentMsgId ,
413+ fullText || "[Interrupted]"
414+ ) ;
415+ } else if ( event . type === "error" ) {
416+ updateAssistantMessage (
417+ widgetId ,
418+ currentMsgId ,
419+ `Error: ${ event . error } `
420+ ) ;
421+ }
422+ } catch {
423+ // skip malformed chunks
378424 }
379- if ( action ) showAction ( action ) ;
380- } else if ( event . type === "tool-result" ) {
381- clearActionWithMinimumVisibility ( ) ;
382- } else if ( event . type === "abort" ) {
383- updateAssistantMessage (
384- widgetId ,
385- currentMsgId ,
386- fullText || "[Interrupted]"
387- ) ;
388- } else if ( event . type === "error" ) {
389- updateAssistantMessage (
390- widgetId ,
391- currentMsgId ,
392- `Error: ${ event . error } `
393- ) ;
394425 }
395- } catch {
396- // skip malformed chunks
426+ }
427+
428+ if ( streamCompleted ) return ;
429+ } catch ( err ) {
430+ if ( ( err as Error ) . name === "AbortError" ) {
431+ updateAssistantMessage ( widgetId , currentMsgId , fullText || "[Interrupted]" ) ;
432+ return ;
433+ }
434+ if ( ! isNetworkError ( err ) || attempt >= MAX_RETRIES ) {
435+ const friendly = isNetworkError ( err )
436+ ? "Connection lost — please check your network and try again."
437+ : `Error: ${ String ( err ) } ` ;
438+ updateAssistantMessage ( widgetId , currentMsgId , fullText ? `${ fullText } \n\n${ friendly } ` : friendly ) ;
439+ return ;
397440 }
398441 }
399442 }
400- } catch ( err ) {
401- if ( ( err as Error ) . name === "AbortError" ) {
402- updateAssistantMessage ( widgetId , currentMsgId , fullText || "[Interrupted]" ) ;
403- } else {
404- updateAssistantMessage ( widgetId , currentMsgId , `Error: ${ String ( err ) } ` ) ;
443+
444+ if ( ! controller . signal . aborted ) {
445+ updateAssistantMessage (
446+ widgetId ,
447+ currentMsgId ,
448+ fullText
449+ ? `${ fullText } \n\nConnection lost after multiple retries — please try again.`
450+ : "Connection lost after multiple retries — please try again." ,
451+ ) ;
405452 }
406453 } finally {
407454 abortControllers . delete ( widgetId ) ;
0 commit comments