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-merge": "^3.0.2",
|
||||||
"tailwind-scrollbar": "^4.0.1",
|
"tailwind-scrollbar": "^4.0.1",
|
||||||
"typography": "^0.16.24",
|
"typography": "^0.16.24",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"zustand": "^5.0.3",
|
"zustand": "^5.0.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwind-scrollbar": "^4.0.1",
|
"tailwind-scrollbar": "^4.0.1",
|
||||||
"typography": "^0.16.24",
|
"typography": "^0.16.24",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import rehypeReact from 'rehype-react'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
import mermaid from 'mermaid'
|
import mermaid from 'mermaid'
|
||||||
|
import { remarkFootnotes } from '@/utils/remarkFootnotes'
|
||||||
|
|
||||||
|
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||||
|
|
@ -127,14 +128,14 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
|
||||||
</div>
|
</div>
|
||||||
{/* Show thinking content when expanded and content exists, even during thinking process */}
|
{/* Show thinking content when expanded and content exists, even during thinking process */}
|
||||||
{isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (
|
{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 && (
|
{isThinking && (
|
||||||
<div className="mb-2 text-xs text-gray-400 dark:text-gray-500 italic">
|
<div className="mb-2 text-xs text-gray-400 dark:text-gray-500 italic">
|
||||||
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
|
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]}
|
||||||
rehypePlugins={[
|
rehypePlugins={[
|
||||||
rehypeRaw,
|
rehypeRaw,
|
||||||
...(katexPlugin ? [[katexPlugin, { errorColor: theme === 'dark' ? '#ef4444' : '#dc2626', throwOnError: false, displayMode: false }] as any] : []),
|
...(katexPlugin ? [[katexPlugin, { errorColor: theme === 'dark' ? '#ef4444' : '#dc2626', throwOnError: false, displayMode: false }] as any] : []),
|
||||||
|
|
@ -153,8 +154,12 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
|
||||||
{finalDisplayContent && (
|
{finalDisplayContent && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ReactMarkdown
|
<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"
|
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 ${
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
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={[
|
rehypePlugins={[
|
||||||
rehypeRaw,
|
rehypeRaw,
|
||||||
...(katexPlugin ? [[
|
...(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',
|
'rehype-raw',
|
||||||
'remark-gfm',
|
'remark-gfm',
|
||||||
'remark-math',
|
'remark-math',
|
||||||
'react-syntax-highlighter'
|
'react-syntax-highlighter',
|
||||||
|
'unist-util-visit'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Ensure consistent chunk naming format
|
// Ensure consistent chunk naming format
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue