Merge pull request #2145 from danielaskdd/footnode

Feature: Add footnotes support to markdown rendering in chat messages
This commit is contained in:
Daniel.y 2025-09-25 01:52:27 +08:00 committed by GitHub
commit 2645ad5587
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 149 additions and 25 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
import{j as o,Y as ld,O as fg,k as dg,u as ad,Z as mg,c as hg,l as gg,g as pg,S as yg,T as vg,n as bg,m as nd,o as Sg,p as Tg,$ as ud,a0 as id,a1 as cd,a2 as xg}from"./ui-vendor-CeCm8EER.js";import{d as Ag,h as Dg,r as E,u as sd,H as Ng,i as Eg,j as kf}from"./react-vendor-DEwriMA6.js";import{N as we,c as Ve,ae as od,u as qt,M as st,af as rd,ag as fd,I as us,B as Cn,D as Mg,l as zg,m as Cg,n as Og,o as _g,ah as jg,ai as Rg,aj as Ug,ak as Lg,al as Bt,am as dd,an as ss,ao as is,a1 as Hg,a2 as Bg,a3 as qg,a4 as Gg,ap as Yg,aq as Xg,ar as md,as as wg,at as hd,au as Vg,av as gd,d as Qg,R as Kg,V as Zg,g as En,aw as kg,ax as Jg,ay as Fg}from"./feature-graph-CGlNNV88.js";import{S as Jf,a as Ff,b as Pf,c as $f,e as rl,D as Pg}from"./feature-documents-BOF9chpi.js";import{R as $g}from"./feature-retrieval-B0uK1e0s.js";import{i as cs}from"./utils-vendor-BysuhMZA.js";import"./graph-vendor-B-X5JegA.js";import"./mermaid-vendor-DEhmckNu.js";import"./markdown-vendor-a_AXlAX2.js";(function(){const y=document.createElement("link").relList;if(y&&y.supports&&y.supports("modulepreload"))return;for(const N of document.querySelectorAll('link[rel="modulepreload"]'))d(N);new MutationObserver(N=>{for(const _ of N)if(_.type==="childList")for(const L of _.addedNodes)L.tagName==="LINK"&&L.rel==="modulepreload"&&d(L)}).observe(document,{childList:!0,subtree:!0});function x(N){const _={};return N.integrity&&(_.integrity=N.integrity),N.referrerPolicy&&(_.referrerPolicy=N.referrerPolicy),N.crossOrigin==="use-credentials"?_.credentials="include":N.crossOrigin==="anonymous"?_.credentials="omit":_.credentials="same-origin",_}function d(N){if(N.ep)return;N.ep=!0;const _=x(N);fetch(N.href,_)}})();var ls={exports:{}},Mn={},as={exports:{}},ns={};/**
import{j as o,Y as ld,O as fg,k as dg,u as ad,Z as mg,c as hg,l as gg,g as pg,S as yg,T as vg,n as bg,m as nd,o as Sg,p as Tg,$ as ud,a0 as id,a1 as cd,a2 as xg}from"./ui-vendor-CeCm8EER.js";import{d as Ag,h as Dg,r as E,u as sd,H as Ng,i as Eg,j as kf}from"./react-vendor-DEwriMA6.js";import{N as we,c as Ve,ae as od,u as qt,M as st,af as rd,ag as fd,I as us,B as Cn,D as Mg,l as zg,m as Cg,n as Og,o as _g,ah as jg,ai as Rg,aj as Ug,ak as Lg,al as Bt,am as dd,an as ss,ao as is,a1 as Hg,a2 as Bg,a3 as qg,a4 as Gg,ap as Yg,aq as Xg,ar as md,as as wg,at as hd,au as Vg,av as gd,d as Qg,R as Kg,V as Zg,g as En,aw as kg,ax as Jg,ay as Fg}from"./feature-graph-CGlNNV88.js";import{S as Jf,a as Ff,b as Pf,c as $f,e as rl,D as Pg}from"./feature-documents-BOF9chpi.js";import{R as $g}from"./feature-retrieval-BCjqAd-A.js";import{i as cs}from"./utils-vendor-BysuhMZA.js";import"./graph-vendor-B-X5JegA.js";import"./mermaid-vendor-DEhmckNu.js";import"./markdown-vendor-Dv0NSOeH.js";(function(){const y=document.createElement("link").relList;if(y&&y.supports&&y.supports("modulepreload"))return;for(const N of document.querySelectorAll('link[rel="modulepreload"]'))d(N);new MutationObserver(N=>{for(const _ of N)if(_.type==="childList")for(const L of _.addedNodes)L.tagName==="LINK"&&L.rel==="modulepreload"&&d(L)}).observe(document,{childList:!0,subtree:!0});function x(N){const _={};return N.integrity&&(_.integrity=N.integrity),N.referrerPolicy&&(_.referrerPolicy=N.referrerPolicy),N.crossOrigin==="use-credentials"?_.credentials="include":N.crossOrigin==="anonymous"?_.credentials="omit":_.credentials="same-origin",_}function d(N){if(N.ep)return;N.ep=!0;const _=x(N);fetch(N.href,_)}})();var ls={exports:{}},Mn={},as={exports:{}},ns={};/**
* @license React
* scheduler.production.js
*

File diff suppressed because one or more lines are too long

View file

@ -8,7 +8,7 @@
<link rel="icon" type="image/png" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lightrag</title>
<script type="module" crossorigin src="/webui/assets/index-C5wxCqtI.js"></script>
<script type="module" crossorigin src="/webui/assets/index-WVaad7ie.js"></script>
<link rel="modulepreload" crossorigin href="/webui/assets/react-vendor-DEwriMA6.js">
<link rel="modulepreload" crossorigin href="/webui/assets/ui-vendor-CeCm8EER.js">
<link rel="modulepreload" crossorigin href="/webui/assets/graph-vendor-B-X5JegA.js">
@ -16,10 +16,10 @@
<link rel="modulepreload" crossorigin href="/webui/assets/feature-graph-CGlNNV88.js">
<link rel="modulepreload" crossorigin href="/webui/assets/feature-documents-BOF9chpi.js">
<link rel="modulepreload" crossorigin href="/webui/assets/mermaid-vendor-DEhmckNu.js">
<link rel="modulepreload" crossorigin href="/webui/assets/markdown-vendor-a_AXlAX2.js">
<link rel="modulepreload" crossorigin href="/webui/assets/feature-retrieval-B0uK1e0s.js">
<link rel="modulepreload" crossorigin href="/webui/assets/markdown-vendor-Dv0NSOeH.js">
<link rel="modulepreload" crossorigin href="/webui/assets/feature-retrieval-BCjqAd-A.js">
<link rel="stylesheet" crossorigin href="/webui/assets/feature-graph-BipNuM18.css">
<link rel="stylesheet" crossorigin href="/webui/assets/index-BDkGEMA8.css">
<link rel="stylesheet" crossorigin href="/webui/assets/index-XaGZCpwe.css">
</head>
<body>
<div id="root"></div>

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