From d1b3661a872251213e2b3b6b7af1be466bea99d2 Mon Sep 17 00:00:00 2001 From: yangdx Date: Thu, 25 Sep 2025 01:42:16 +0800 Subject: [PATCH] Add footnotes support to markdown rendering in chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Add unist-util-visit dependency • Create remarkFootnotes plugin • Style footnotes with CSS classes • Support footnote refs and definitions • Update Vite config for new dependency --- lightrag_webui/bun.lock | 1 + lightrag_webui/package.json | 1 + .../src/components/retrieval/ChatMessage.tsx | 13 +- lightrag_webui/src/utils/remarkFootnotes.ts | 116 ++++++++++++++++++ lightrag_webui/vite.config.ts | 3 +- 5 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 lightrag_webui/src/utils/remarkFootnotes.ts diff --git a/lightrag_webui/bun.lock b/lightrag_webui/bun.lock index f3b96dff..2ba1883c 100644 --- a/lightrag_webui/bun.lock +++ b/lightrag_webui/bun.lock @@ -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": { diff --git a/lightrag_webui/package.json b/lightrag_webui/package.json index d4a91616..01d1af79 100644 --- a/lightrag_webui/package.json +++ b/lightrag_webui/package.json @@ -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": { diff --git a/lightrag_webui/src/components/retrieval/ChatMessage.tsx b/lightrag_webui/src/components/retrieval/ChatMessage.tsx index 1863c1e4..7ac208ef 100644 --- a/lightrag_webui/src/components/retrieval/ChatMessage.tsx +++ b/lightrag_webui/src/components/retrieval/ChatMessage.tsx @@ -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 }) => { // {/* Show thinking content when expanded and content exists, even during thinking process */} {isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && ( -
+
{isThinking && (
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
)} { // {finalDisplayContent && (
= () => { + return (tree: Root) => { + const footnoteDefinitions = new Map() + + // 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: `${id}` + }) + + 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: `${content} ` + }] + }] + }) + }) + + // Add footnotes section + tree.children.push({ + type: 'html', + value: '
' + } as Html) + + tree.children.push({ + type: 'list', + ordered: true, + start: 1, + spread: false, + children: footnotesList + }) + + tree.children.push({ + type: 'html', + value: '
' + } as Html) + } + } +} diff --git a/lightrag_webui/vite.config.ts b/lightrag_webui/vite.config.ts index 13ac4ed1..ebacddd2 100644 --- a/lightrag_webui/vite.config.ts +++ b/lightrag_webui/vite.config.ts @@ -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