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:
parent
09bdf2c028
commit
d1b3661a87
5 changed files with 129 additions and 5 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 ? [[
|
||||
|
|
|
|||
116
lightrag_webui/src/utils/remarkFootnotes.ts
Normal file
116
lightrag_webui/src/utils/remarkFootnotes.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue