Add footnotes support to markdown rendering in chat messages

• Add unist-util-visit dependency
• Create remarkFootnotes plugin
• Style footnotes with CSS classes
• Support footnote refs and definitions
• Update Vite config for new dependency
This commit is contained in:
yangdx 2025-09-25 01:42:16 +08:00
parent 09bdf2c028
commit d1b3661a87
5 changed files with 129 additions and 5 deletions

View file

@ -59,6 +59,7 @@
"tailwind-merge": "^3.0.2",
"tailwind-scrollbar": "^4.0.1",
"typography": "^0.16.24",
"unist-util-visit": "^5.0.0",
"zustand": "^5.0.3",
},
"devDependencies": {

View file

@ -68,6 +68,7 @@
"tailwind-merge": "^3.0.2",
"tailwind-scrollbar": "^4.0.1",
"typography": "^0.16.24",
"unist-util-visit": "^5.0.0",
"zustand": "^5.0.3"
},
"devDependencies": {

View file

@ -9,6 +9,7 @@ import rehypeReact from 'rehype-react'
import rehypeRaw from 'rehype-raw'
import remarkMath from 'remark-math'
import mermaid from 'mermaid'
import { remarkFootnotes } from '@/utils/remarkFootnotes'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
@ -127,14 +128,14 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
</div>
{/* Show thinking content when expanded and content exists, even during thinking process */}
{isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (
<div className="mt-2 pl-4 border-l-2 border-primary/20 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2 [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500">
<div className="mt-2 pl-4 border-l-2 border-primary/20 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2 [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-6 [&_.footnotes]:pt-3 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes_ol]:text-xs [&_.footnotes_li]:my-0.5 [&_a[href^='#fn']]:text-primary [&_a[href^='#fn']]:no-underline [&_a[href^='#fn']]:hover:underline [&_a[href^='#fnref']]:text-primary [&_a[href^='#fnref']]:no-underline [&_a[href^='#fnref']]:hover:underline">
{isThinking && (
<div className="mb-2 text-xs text-gray-400 dark:text-gray-500 italic">
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
</div>
)}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
rehypePlugins={[
rehypeRaw,
...(katexPlugin ? [[katexPlugin, { errorColor: theme === 'dark' ? '#ef4444' : '#dc2626', throwOnError: false, displayMode: false }] as any] : []),
@ -153,8 +154,12 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
{finalDisplayContent && (
<div className="relative">
<ReactMarkdown
className="prose dark:prose-invert max-w-none text-sm break-words prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 [&_.katex]:text-current [&_.katex-display]:my-4 [&_.katex-display]:overflow-x-auto [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500"
remarkPlugins={[remarkGfm, remarkMath]}
className={`prose dark:prose-invert max-w-none text-sm break-words prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 [&_.katex]:text-current [&_.katex-display]:my-4 [&_.katex-display]:overflow-x-auto [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-8 [&_.footnotes]:pt-4 [&_.footnotes]:border-t [&_.footnotes_ol]:text-sm [&_.footnotes_li]:my-1 ${
message.role === 'user'
? '[&_.footnotes]:border-primary-foreground/30 [&_a[href^="#fn"]]:text-primary-foreground [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary-foreground [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
: '[&_.footnotes]:border-border [&_a[href^="#fn"]]:text-primary [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline'
}`}
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
rehypePlugins={[
rehypeRaw,
...(katexPlugin ? [[

View file

@ -0,0 +1,116 @@
import { visit } from 'unist-util-visit'
import type { Plugin } from 'unified'
import type { Root, Text, Paragraph, Html } from 'mdast'
// Simple footnote plugin for remark
export const remarkFootnotes: Plugin<[], Root> = () => {
return (tree: Root) => {
const footnoteDefinitions = new Map<string, string>()
// First pass: collect footnote definitions and remove them
const nodesToRemove: Array<{ parent: any; index: number }> = []
visit(tree, 'paragraph', (node: Paragraph, index, parent) => {
if (!parent || typeof index !== 'number') return
// Check if this paragraph contains only a footnote definition
if (node.children.length === 1 && node.children[0].type === 'text') {
const text = (node.children[0] as Text).value
const match = text.match(/^\[\^([^\]]+)\]:\s*(.+)$/)
if (match) {
const [, id, content] = match
footnoteDefinitions.set(id, content.trim())
nodesToRemove.push({ parent, index })
return
}
}
})
// Remove footnote definition paragraphs
nodesToRemove.reverse().forEach(({ parent, index }) => {
parent.children.splice(index, 1)
})
// Second pass: find footnote references and replace them
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
const text = node.value
const footnoteRegex = /\[\^([^\]]+)\]/g
let match
const replacements: any[] = []
let lastIndex = 0
while ((match = footnoteRegex.exec(text)) !== null) {
const [fullMatch, id] = match
const startIndex = match.index!
// Add text before footnote
if (startIndex > lastIndex) {
replacements.push({
type: 'text',
value: text.slice(lastIndex, startIndex)
})
}
// Add footnote reference as HTML
replacements.push({
type: 'html',
value: `<sup><a href="#fn-${id}" id="fnref-${id}" class="footnote-ref">${id}</a></sup>`
})
lastIndex = startIndex + fullMatch.length
}
// Add remaining text
if (lastIndex < text.length) {
replacements.push({
type: 'text',
value: text.slice(lastIndex)
})
}
// Replace the text node if we found footnotes
if (replacements.length > 1) {
parent.children.splice(index, 1, ...replacements)
}
})
// Third pass: add footnotes section at the end if we have definitions
if (footnoteDefinitions.size > 0) {
const footnotesList: any[] = []
footnoteDefinitions.forEach((content, id) => {
footnotesList.push({
type: 'listItem',
children: [{
type: 'paragraph',
children: [{
type: 'html',
value: `<span id="fn-${id}">${content} <a href="#fnref-${id}" class="footnote-backref">↩</a></span>`
}]
}]
})
})
// Add footnotes section
tree.children.push({
type: 'html',
value: '<div class="footnotes">'
} as Html)
tree.children.push({
type: 'list',
ordered: true,
start: 1,
spread: false,
children: footnotesList
})
tree.children.push({
type: 'html',
value: '</div>'
} as Html)
}
}
}

View file

@ -47,7 +47,8 @@ export default defineConfig({
'rehype-raw',
'remark-gfm',
'remark-math',
'react-syntax-highlighter'
'react-syntax-highlighter',
'unist-util-visit'
]
},
// Ensure consistent chunk naming format