@@ -92,6 +92,86 @@ function parseMermaidXyChart(code: string): ParsedGraph | null {
9292 }
9393}
9494
95+ function parseMermaidPieChart ( code : string ) : ParsedGraph | null {
96+ if ( ! / ^ \s * p i e (?: \s + s h o w D a t a ) ? \s * $ / im. test ( code ) ) return null
97+
98+ const labels : string [ ] = [ ]
99+ const values : number [ ] = [ ]
100+ const dataLineRe = / ^ \s * (?: " ( [ ^ " ] + ) " | ' ( [ ^ ' ] + ) ' | ( [ ^ : ] + ?) ) \s * : \s * ( - ? \d + (?: \. \d + ) ? ) \s * $ /
101+
102+ for ( const line of code . split ( "\n" ) ) {
103+ const match = line . match ( dataLineRe )
104+ if ( ! match ) continue
105+ const label = ( match [ 1 ] ?? match [ 2 ] ?? match [ 3 ] ?? "" ) . trim ( )
106+ const value = Number ( match [ 4 ] )
107+ if ( ! label || ! Number . isFinite ( value ) || value < 0 ) continue
108+ labels . push ( label )
109+ values . push ( value )
110+ }
111+
112+ if ( labels . length === 0 || values . every ( ( value ) => value === 0 ) ) return null
113+ return {
114+ type : "pie" ,
115+ labels,
116+ series : [ { name : "Value" , values } ] ,
117+ }
118+ }
119+
120+ function parseMermaidGraph ( code : string ) : ParsedGraph | null {
121+ return parseMermaidXyChart ( code ) ?? parseMermaidPieChart ( code )
122+ }
123+
124+ const OPENAI_CITATION_OPEN = "\uE200cite\uE202"
125+ const OPENAI_CITATION_CLOSE = "\uE201"
126+
127+ function parseOpenAICitationToken ( text : string , from : number ) : { length : number ; ids : number [ ] } | null {
128+ if ( ! text . startsWith ( OPENAI_CITATION_OPEN , from ) ) return null
129+ const payloadStart = from + OPENAI_CITATION_OPEN . length
130+ const end = text . indexOf ( OPENAI_CITATION_CLOSE , payloadStart )
131+ if ( end === - 1 ) return null
132+
133+ const payload = text . slice ( payloadStart , end )
134+ const ids : number [ ] = [ ]
135+ const seen = new Set < number > ( )
136+ const matches = payload . matchAll ( / t u r n \d + s e a r c h ( \d + ) / gi)
137+ for ( const match of matches ) {
138+ const raw = Number ( match [ 1 ] )
139+ if ( ! Number . isFinite ( raw ) ) continue
140+ const oneBased = raw + 1
141+ if ( seen . has ( oneBased ) ) continue
142+ seen . add ( oneBased )
143+ ids . push ( oneBased )
144+ }
145+
146+ return { length : end + OPENAI_CITATION_CLOSE . length - from , ids }
147+ }
148+
149+ function parseLooseMermaidChartBlock ( lines : string [ ] , startLine : number ) : { graph : ParsedGraph ; endLine : number ; snippet : string } | null {
150+ const start = lines [ startLine ] ?. trim ( ) ?? ""
151+ const maybeMermaidStart = / ^ x y c h a r t - b e t a \b / i. test ( start ) || / ^ p i e (?: \s + s h o w D a t a ) ? $ / i. test ( start )
152+ if ( ! maybeMermaidStart ) return null
153+
154+ const collected : string [ ] = [ lines [ startLine ] ]
155+ let endLine = startLine
156+ for ( let i = startLine + 1 ; i < lines . length ; i ++ ) {
157+ const t = lines [ i ] . trim ( )
158+ if ( ! t ) break
159+ if ( / ^ # { 1 , 6 } \s / . test ( t ) ) break
160+ if ( / ^ ` ` ` / . test ( t ) ) break
161+ if ( / ^ [ - * ] \s + / . test ( t ) ) break
162+ if ( / ^ \d + [ . ) ] \s + / . test ( t ) ) break
163+ if ( parseGraphTag ( t ) ) break
164+ if ( parseTableLine ( lines [ i ] ) ) break
165+ collected . push ( lines [ i ] )
166+ endLine = i
167+ }
168+
169+ const snippet = collected . join ( "\n" )
170+ const graph = parseMermaidGraph ( snippet )
171+ if ( ! graph ) return null
172+ return { graph, endLine, snippet }
173+ }
174+
95175function findNextIndex ( haystack : string , from : number , needle : string ) : number {
96176 const idx = haystack . indexOf ( needle , from )
97177 return idx >= 0 ? idx : Number . POSITIVE_INFINITY
@@ -244,6 +324,7 @@ function renderInline(text: string, variant: Variant, COLORS: ThemeColors, VS: R
244324 let key = 0
245325
246326 while ( cursor < text . length ) {
327+ const nextOpenAICitation = findNextIndex ( text , cursor , OPENAI_CITATION_OPEN )
247328 const nextCode = findNextIndex ( text , cursor , "`" )
248329 const nextBoldItalic = allowBold ? findNextIndex ( text , cursor , "***" ) : Number . POSITIVE_INFINITY
249330 const nextBold = allowBold ? findNextIndex ( text , cursor , "**" ) : Number . POSITIVE_INFINITY
@@ -264,6 +345,7 @@ function renderInline(text: string, variant: Variant, COLORS: ThemeColors, VS: R
264345 nextDollar ,
265346 nextImgLink ,
266347 nextLink ,
348+ nextOpenAICitation ,
267349 )
268350 if ( ! Number . isFinite ( next ) ) {
269351 const rest = text . slice ( cursor )
@@ -276,6 +358,68 @@ function renderInline(text: string, variant: Variant, COLORS: ThemeColors, VS: R
276358 cursor = next
277359 }
278360
361+ // OpenAI web-search citation token: \uE200cite\uE202turn0searchX...\uE201
362+ if ( cursor === nextOpenAICitation ) {
363+ const token = parseOpenAICitationToken ( text , cursor )
364+ if ( token ) {
365+ const citationText = token . ids . length ? `[${ token . ids . join ( "," ) } ]` : "[source]"
366+ const isSingle = token . ids . length === 1
367+ const targetId = isSingle ? `ref-${ token . ids [ 0 ] } ` : "references"
368+ const canScroll = token . ids . length > 0
369+
370+ if ( canScroll ) {
371+ nodes . push (
372+ < button
373+ key = { `cite-openai-${ key ++ } ` }
374+ type = "button"
375+ onClick = { ( ) => document . getElementById ( targetId ) ?. scrollIntoView ( { behavior : "smooth" , block : "start" } ) }
376+ style = { {
377+ font : "inherit" ,
378+ fontSize : "0.7em" ,
379+ color : COLORS . accent ,
380+ marginLeft : "3px" ,
381+ padding : "1px 6px" ,
382+ border : `1px solid ${ COLORS . borderLight } ` ,
383+ borderRadius : "999px" ,
384+ background : "rgba(196,117,0,0.14)" ,
385+ cursor : "pointer" ,
386+ verticalAlign : "super" ,
387+ textDecoration : "none" ,
388+ lineHeight : 1.2 ,
389+ } }
390+ title = "Jump to references"
391+ >
392+ { citationText }
393+ </ button > ,
394+ )
395+ } else {
396+ nodes . push (
397+ < span
398+ key = { `cite-openai-${ key ++ } ` }
399+ style = { {
400+ fontSize : "0.7em" ,
401+ color : COLORS . accent ,
402+ marginLeft : "3px" ,
403+ padding : "1px 6px" ,
404+ border : `1px solid ${ COLORS . borderLight } ` ,
405+ borderRadius : "999px" ,
406+ background : "rgba(196,117,0,0.14)" ,
407+ verticalAlign : "super" ,
408+ lineHeight : 1.2 ,
409+ } }
410+ >
411+ { citationText }
412+ </ span > ,
413+ )
414+ }
415+ cursor += token . length
416+ continue
417+ }
418+ nodes . push ( < span key = { `t-${ key ++ } ` } > { text [ cursor ] } </ span > )
419+ cursor += 1
420+ continue
421+ }
422+
279423 //  — markdown image
280424 if ( cursor === nextImgLink ) {
281425 const altEnd = text . indexOf ( "]" , cursor + 2 )
@@ -958,6 +1102,50 @@ export function Markdownish({
9581102
9591103 if ( parseGraphTag ( trimmed ) ) continue
9601104
1105+ const looseMermaid = parseLooseMermaidChartBlock ( lines , i )
1106+ if ( looseMermaid ) {
1107+ i = looseMermaid . endLine
1108+ const graphActions = effectiveSourceBlockId
1109+ ? resolveBlockActions ( "graph" , { sourceBlockId : effectiveSourceBlockId } , {
1110+ chart : looseMermaid . graph ,
1111+ markdownSnippet : looseMermaid . snippet ,
1112+ } )
1113+ : [ ]
1114+ const graphAnalyze = graphActions . find ( ( action ) => action . request . actionType === "analyze" )
1115+ const graphConvertActions = graphActions . filter ( ( action ) => action . request . actionType === "convert" )
1116+ elements . push (
1117+ < GraphBlock
1118+ key = { `loose-mermaid-graph-${ i } ` }
1119+ chart = { looseMermaid . graph }
1120+ variant = { variant }
1121+ COLORS = { COLORS }
1122+ onAnalyze = {
1123+ graphAnalyze && onBlockAction
1124+ ? ( ) => onBlockAction ( { ...graphAnalyze . request , sourceBlockId : effectiveSourceBlockId } )
1125+ : undefined
1126+ }
1127+ onConvertType = {
1128+ graphConvertActions . length > 0 && onBlockAction
1129+ ? ( targetType ) =>
1130+ ( ( ) => {
1131+ const matched =
1132+ graphConvertActions . find ( ( action ) => action . request . targetGraphType === targetType ) ||
1133+ graphConvertActions [ 0 ]
1134+ onBlockAction ( {
1135+ ...matched . request ,
1136+ sourceBlockId : effectiveSourceBlockId ,
1137+ } )
1138+ } ) ( )
1139+ : undefined
1140+ }
1141+ /> ,
1142+ )
1143+ continue
1144+ }
1145+
1146+ // Ignore orphan fence delimiters in malformed pasted markdown.
1147+ if ( trimmed === "```" ) continue
1148+
9611149 // Markdown image on its own line: 
9621150 const imgLineMatch = trimmed . match ( / ^ ! \[ ( [ ^ \] ] * ) \] \( ( [ ^ ) ] + ) \) $ / )
9631151 if ( imgLineMatch ) {
@@ -1093,7 +1281,7 @@ export function Markdownish({
10931281 const code = collected . join ( "\n" )
10941282 const lowerLang = lang . toLowerCase ( )
10951283 if ( lowerLang === "mermaid" ) {
1096- const mermaidGraph = parseMermaidXyChart ( code )
1284+ const mermaidGraph = parseMermaidGraph ( code )
10971285 if ( mermaidGraph ) {
10981286 const graphSnippet = `\`\`\`mermaid\n${ code } \n\`\`\``
10991287 const graphActions = effectiveSourceBlockId
0 commit comments