@@ -281,6 +281,99 @@ export function StructuredOutputRenderer({ output, schema }) {
281281 )
282282}
283283
284+ // ---------------------------------------------------------------------------
285+ // Smart message renderer — detects JSON blocks and renders them structurally
286+ // ---------------------------------------------------------------------------
287+
288+ const JSON_FENCE_RE = / ` ` ` j s o n \s * \n ? ( [ \s \S ] * ?) \n ? ` ` ` / gi
289+
290+ /**
291+ * Split a markdown string into alternating segments of prose and parsed JSON.
292+ * Pure calculation — no side effects.
293+ *
294+ * @param {string } content - Raw assistant message (markdown with optional JSON fences)
295+ * @returns {{ type: 'markdown'|'json', value: string|object }[] }
296+ */
297+ function splitMarkdownAndJson ( content ) {
298+ if ( ! content || typeof content !== 'string' ) return [ ]
299+
300+ // Try parsing the entire content as JSON first (no fences)
301+ const trimmed = content . trim ( )
302+ try {
303+ const parsed = JSON . parse ( trimmed )
304+ if ( typeof parsed === 'object' && parsed !== null ) {
305+ return [ { type : 'json' , value : parsed } ]
306+ }
307+ } catch { /* not pure JSON */ }
308+
309+ const segments = [ ]
310+ let lastIndex = 0
311+ let match
312+
313+ // Reset regex state
314+ JSON_FENCE_RE . lastIndex = 0
315+
316+ while ( ( match = JSON_FENCE_RE . exec ( content ) ) !== null ) {
317+ // Add markdown before this JSON block
318+ const before = content . slice ( lastIndex , match . index ) . trim ( )
319+ if ( before ) segments . push ( { type : 'markdown' , value : before } )
320+
321+ // Parse JSON block
322+ try {
323+ const parsed = JSON . parse ( match [ 1 ] . trim ( ) )
324+ if ( typeof parsed === 'object' && parsed !== null ) {
325+ segments . push ( { type : 'json' , value : parsed } )
326+ } else {
327+ // Primitive JSON value — render as markdown
328+ segments . push ( { type : 'markdown' , value : match [ 0 ] } )
329+ }
330+ } catch {
331+ // Invalid JSON — keep as markdown code block
332+ segments . push ( { type : 'markdown' , value : match [ 0 ] } )
333+ }
334+
335+ lastIndex = match . index + match [ 0 ] . length
336+ }
337+
338+ // Add remaining markdown after last JSON block
339+ const remaining = content . slice ( lastIndex ) . trim ( )
340+ if ( remaining ) segments . push ( { type : 'markdown' , value : remaining } )
341+
342+ return segments
343+ }
344+
345+ /**
346+ * Smart message renderer for assistant chat messages.
347+ * Detects JSON blocks in markdown and renders them with structured widgets
348+ * (tables, stat cards, badge lists) instead of raw <pre><code> blocks.
349+ * Falls back to plain MarkdownWidget when no JSON is detected.
350+ */
351+ export function SmartMessageRenderer ( { content, schema } ) {
352+ const styles = useStyles ( )
353+ const segments = splitMarkdownAndJson ( content )
354+
355+ // No JSON found — fast path: plain markdown
356+ if ( segments . length === 0 ) {
357+ return < MarkdownWidget content = { content } />
358+ }
359+ if ( segments . length === 1 && segments [ 0 ] . type === 'markdown' ) {
360+ return < MarkdownWidget content = { segments [ 0 ] . value } />
361+ }
362+
363+ return (
364+ < div className = { styles . widgetContainer } >
365+ { segments . map ( ( seg , i ) => {
366+ if ( seg . type === 'markdown' ) {
367+ return < MarkdownWidget key = { i } content = { seg . value } />
368+ }
369+ return (
370+ < StructuredOutputRenderer key = { i } output = { seg . value } schema = { schema } />
371+ )
372+ } ) }
373+ </ div >
374+ )
375+ }
376+
284377/**
285378 * Build props for a specific widget from value and schema property.
286379 * Pure calculation.
0 commit comments