Skip to content

Commit aea7f56

Browse files
committed
Fix citation token and chart rendering in markdown parser
1 parent 85726ae commit aea7f56

1 file changed

Lines changed: 189 additions & 1 deletion

File tree

frontend/src/components/ui/RichText.tsx

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,86 @@ function parseMermaidXyChart(code: string): ParsedGraph | null {
9292
}
9393
}
9494

95+
function parseMermaidPieChart(code: string): ParsedGraph | null {
96+
if (!/^\s*pie(?:\s+showData)?\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(/turn\d+search(\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 = /^xychart-beta\b/i.test(start) || /^pie(?:\s+showData)?$/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+
95175
function 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
// ![alt](url) — 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: ![alt](url)
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

Comments
 (0)