{
+ setCurrent(e.target.value);
+ }}
+ options={[
+ { label: '普通case', value: 'general' },
+ { label: '带 thinking', value: 'withThinking' },
+ ]}
+ value={current}
+ />
+
+
+ ({ title: item, url: item }))}
+ />
+
+ (
+
+
+
+
+ ),
+ }}
+ remarkPlugins={[remarkCaptureThink]}
+ variant={'chat'}
+ >
+ {normalizeThinkTags(state.markdown)}
+
+ >
+ );
+};
diff --git a/src/Markdown/index.md b/src/Markdown/index.md
index 074a6330..9b713870 100644
--- a/src/Markdown/index.md
+++ b/src/Markdown/index.md
@@ -23,11 +23,15 @@ description: Markdown is a React component used to render markdown text. It supp
## Custom Highlight
-
+
+
+## Citations
+
+
## Think
-
+
## Custom Plugins
diff --git a/src/Markdown/index.tsx b/src/Markdown/index.tsx
index ab1a9fff..2b5a64d3 100644
--- a/src/Markdown/index.tsx
+++ b/src/Markdown/index.tsx
@@ -17,19 +17,23 @@ import { type MermaidProps } from '@/Mermaid';
import Image, { type ImageProps } from '@/mdx/mdxComponents/Image';
import Link from '@/mdx/mdxComponents/Link';
import { type PreProps } from '@/mdx/mdxComponents/Pre';
+import Section from '@/mdx/mdxComponents/Section';
import Video, { type VideoProps } from '@/mdx/mdxComponents/Video';
import type { AProps } from '@/types';
+import { CitationItem } from '@/types/citation';
import { CodeFullFeatured, CodeLite } from './CodeBlock';
import type { TypographyProps } from './Typography';
import { useStyles as useMarkdownStyles } from './markdown.style';
-import { rehypeKatexDir } from './rehypePlugin';
+import { rehypeFootnoteLinks, remarkCustomFootnotes } from './plugins/footnote';
+import { rehypeKatexDir } from './plugins/katexDir';
import { useStyles } from './style';
-import { escapeBrackets, escapeMhchem, fixMarkdownBold } from './utils';
+import { escapeBrackets, escapeMhchem, fixMarkdownBold, transformCitations } from './utils';
export interface MarkdownProps extends TypographyProps {
allowHtml?: boolean;
children: string;
+ citations?: CitationItem[];
className?: string;
componentProps?: {
a?: Partial;
@@ -49,6 +53,7 @@ export interface MarkdownProps extends TypographyProps {
rehypePlugins?: Pluggable[];
remarkPlugins?: Pluggable[];
remarkPluginsAhead?: Pluggable[];
+ showFootnotes?: boolean;
style?: CSSProperties;
variant?: 'normal' | 'chat';
}
@@ -68,6 +73,7 @@ const Markdown = memo(
fontSize,
headerMultiple,
marginMultiple,
+ showFootnotes,
variant = 'normal',
lineHeight,
rehypePlugins,
@@ -75,6 +81,7 @@ const Markdown = memo(
remarkPluginsAhead,
components = {},
customRender,
+ citations,
...rest
}) => {
const { cx, styles } = useStyles({
@@ -89,12 +96,15 @@ const Markdown = memo(
const escapedContent = useMemo(() => {
if (!enableLatex) return fixMarkdownBold(children);
- return fixMarkdownBold(escapeMhchem(escapeBrackets(children)));
+ return transformCitations(
+ fixMarkdownBold(escapeMhchem(escapeBrackets(children))),
+ citations?.length,
+ );
}, [children, enableLatex]);
const memoComponents: Components = useMemo(
() => ({
- a: (props: any) => ,
+ a: (props: any) => ,
img: enableImageGallery
? (props: any) => (
(
{...componentProps?.pre}
/>
),
+ section: (props: any) => ,
video: (props: any) => ,
...components,
}),
@@ -135,6 +146,8 @@ const Markdown = memo(
enableImageGallery,
enableMermaid,
fullFeaturedCodeBlock,
+ ...(citations || []),
+ showFootnotes,
],
) as Components;
@@ -146,6 +159,7 @@ const Markdown = memo(
allowHtml && rehypeRaw,
enableLatex && rehypeKatex,
enableLatex && rehypeKatexDir,
+ rehypeFootnoteLinks,
...innerRehypePlugins,
].filter(Boolean) as any,
[allowHtml, enableLatex, ...innerRehypePlugins],
@@ -161,6 +175,7 @@ const Markdown = memo(
[
...innerRemarkPluginsAhead,
remarkGfm,
+ remarkCustomFootnotes,
enableLatex && remarkMath,
isChatMode && remarkBreaks,
...innerRemarkPlugins,
diff --git a/src/Markdown/plugins/footnote.ts b/src/Markdown/plugins/footnote.ts
new file mode 100644
index 00000000..24d6948d
--- /dev/null
+++ b/src/Markdown/plugins/footnote.ts
@@ -0,0 +1,73 @@
+import { Node } from 'unist';
+import { SKIP, visit } from 'unist-util-visit';
+
+interface FootnoteLink {
+ alt?: string;
+ title?: string;
+ url: string;
+}
+
+// eslint-disable-next-line unicorn/consistent-function-scoping
+export const remarkCustomFootnotes = () => (tree: any, file: any) => {
+ const footnoteLinks = new Map();
+
+ visit(tree, 'footnoteDefinition', (node) => {
+ let linkData: FootnoteLink | null = null;
+
+ // 查找第一个link类型的子节点
+ visit(node, 'link', (linkNode) => {
+ if (linkData) return SKIP; // 只取第一个链接
+
+ // 提取链接文本
+ const textNode = linkNode.children.find((n: Node) => n.type === 'text');
+
+ linkData = {
+ alt: textNode?.value || '',
+ title: textNode?.value || '',
+ url: linkNode.url, // 或者根据需求处理
+ };
+
+ return SKIP; // 找到后停止遍历
+ });
+
+ if (linkData) {
+ footnoteLinks.set(node.identifier, linkData);
+ }
+ });
+
+ // 将数据存入文件上下文
+ file.data.footnoteLinks = Object.fromEntries(footnoteLinks);
+};
+
+// eslint-disable-next-line unicorn/consistent-function-scoping
+export const rehypeFootnoteLinks = () => (tree: any, file: any) => {
+ const linksData: Record = file.data.footnoteLinks || {};
+
+ visit(tree, 'element', (node) => {
+ if (node.tagName === 'section' && node.properties.className?.includes('footnotes')) {
+ // 转换数据格式为数组(按identifier排序)
+ const sortedLinks = Object.entries(linksData)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([id, data]) => ({ id, ...data }));
+ // 注入数据属性
+ node.properties['data-footnote-links'] = JSON.stringify(sortedLinks);
+ }
+
+ if (node.tagName === 'sup') {
+ const link = node.children.find((n: any) => n.tagName === 'a');
+
+ if (link) {
+ // a node example
+ // {
+ // "href": "#user-content-fn-3",
+ // "id": "user-content-fnref-3-2",
+ // "dataFootnoteRef": true,
+ // }
+ const linkRefIndex = link.properties?.id?.replace(/^user-content-fnref-/, '')[0];
+
+ if (linkRefIndex !== undefined)
+ link.properties['data-link'] = JSON.stringify(linksData[linkRefIndex]);
+ }
+ }
+ });
+};
diff --git a/src/Markdown/rehypePlugin.ts b/src/Markdown/plugins/katexDir.ts
similarity index 100%
rename from src/Markdown/rehypePlugin.ts
rename to src/Markdown/plugins/katexDir.ts
index 304f558b..f2cf637f 100644
--- a/src/Markdown/rehypePlugin.ts
+++ b/src/Markdown/plugins/katexDir.ts
@@ -1,8 +1,8 @@
-// katex-directive
-// 给 class="katex" 的节点加上 dir="ltr" 属性
import type { Node } from 'unist';
import { visit } from 'unist-util-visit';
+// katex-directive
+// 给 class="katex" 的节点加上 dir="ltr" 属性
// eslint-disable-next-line unicorn/consistent-function-scoping
export const rehypeKatexDir = () => (tree: Node) => {
visit(tree, 'element', (node: any) => {
diff --git a/src/Markdown/style.ts b/src/Markdown/style.ts
index 158d81e8..73c69e04 100644
--- a/src/Markdown/style.ts
+++ b/src/Markdown/style.ts
@@ -76,73 +76,7 @@ export const useStyles = createStyles(
}
sup:has(a[aria-describedby='footnote-label']) {
- margin-inline: 0.2em;
- padding-block: 0.05em;
- padding-inline: 0.4em;
- border: 1px solid ${token.colorBorderSecondary};
- border-radius: 0.25em;
-
- font-size: 0.75em;
vertical-align: super !important;
-
- background: ${token.colorFillTertiary};
- }
-
- section.footnotes {
- padding-block: 1em;
- font-size: 0.875em;
- color: ${token.colorTextSecondary};
-
- ol {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5em;
-
- margin: 0;
- padding: 0;
-
- list-style-type: none;
- }
-
- ol li {
- position: relative;
-
- overflow: hidden;
- display: flex;
- flex-direction: row;
-
- margin: 0 !important;
- padding-block: 0 !important;
- padding-inline: 0 0.4em !important;
- border: 1px solid ${token.colorBorderSecondary};
- border-radius: 0.25em;
-
- text-overflow: ellipsis;
- white-space: nowrap;
-
- &::before {
- content: counter(list-item);
- counter-increment: list-item;
-
- display: block;
-
- margin-inline-end: 0.4em;
- padding-inline: 0.6em;
-
- background: ${token.colorFillSecondary};
- }
-
- p,
- a {
- overflow: hidden;
-
- margin: 0 !important;
- padding: 0 !important;
-
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
}
`,
};
diff --git a/src/Markdown/utils.ts b/src/Markdown/utils.ts
index acd9da52..ebb5e024 100644
--- a/src/Markdown/utils.ts
+++ b/src/Markdown/utils.ts
@@ -62,3 +62,16 @@ export function fixMarkdownBold(text: string): string {
}
return result;
}
+
+export const transformCitations = (rawContent: string, length: number = 40) => {
+ // 生成动态正则表达式模式
+ const idx = Array.from({ length })
+ .fill('')
+ .map((_, index) => index + 1);
+
+ const pattern = new RegExp(`\\[(${idx.join('|')})\\]`, 'g');
+
+ return rawContent
+ .replaceAll(pattern, (match, id) => `[#citation-${id}](citation-${id})`)
+ .replaceAll('][', '] [');
+};
diff --git a/src/SearchResultCard/index.tsx b/src/SearchResultCard/index.tsx
new file mode 100644
index 00000000..c95526ba
--- /dev/null
+++ b/src/SearchResultCard/index.tsx
@@ -0,0 +1,89 @@
+import { Typography } from 'antd';
+import { createStyles } from 'antd-style';
+import { memo, useMemo } from 'react';
+import { Flexbox } from 'react-layout-kit';
+
+const useStyles = createStyles(({ css, token }) => ({
+ container: css`
+ cursor: pointer;
+
+ min-width: 160px;
+ max-width: 160px;
+ height: 100%;
+ padding: 8px;
+ border-radius: 8px;
+
+ font-size: 12px;
+ color: initial;
+
+ background: ${token.colorFillQuaternary};
+
+ &:hover {
+ background: ${token.colorFillTertiary};
+ }
+ `,
+ title: css`
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+
+ text-overflow: ellipsis;
+ `,
+ url: css`
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+
+ text-overflow: ellipsis;
+ `,
+}));
+
+interface SearchResultCardProps {
+ alt?: string;
+ title?: string;
+ url: string;
+}
+
+const SearchResultCard = memo(({ url, title, alt }) => {
+ const { styles } = useStyles();
+
+ const [displayTitle, domain, host] = useMemo(() => {
+ try {
+ const urlObj = new URL(url);
+ const domain = urlObj.hostname.replace('www.', '');
+ const hostForUrl = urlObj.host;
+
+ let displayTitle = title;
+ if (title === url) {
+ displayTitle = hostForUrl + urlObj.pathname;
+ }
+
+ return [displayTitle, domain, hostForUrl];
+ } catch {
+ return [title, url, url];
+ }
+ }, [url, title]);
+
+ return (
+
+
+ {displayTitle}
+
+
+
+ {domain}
+
+
+
+
+ );
+});
+
+export default SearchResultCard;
diff --git a/src/SearchResultCards/index.tsx b/src/SearchResultCards/index.tsx
new file mode 100644
index 00000000..ae23c6fd
--- /dev/null
+++ b/src/SearchResultCards/index.tsx
@@ -0,0 +1,31 @@
+import { memo } from 'react';
+import { Flexbox } from 'react-layout-kit';
+
+import SearchResultCard from '@/SearchResultCard';
+
+export interface SearchResultItem {
+ alt?: string;
+ summary?: string;
+ title?: string;
+ url: string;
+}
+
+export interface SearchResultCardsProps {
+ dataSource: string[] | SearchResultItem[];
+}
+
+const SearchResultCards = memo(({ dataSource }) => {
+ return (
+
+ {dataSource.map((link) =>
+ typeof link === 'string' ? (
+
+ ) : (
+
+ ),
+ )}
+
+ );
+});
+
+export default SearchResultCards;
diff --git a/src/components.ts b/src/components.ts
index 94710f00..b7212f2b 100644
--- a/src/components.ts
+++ b/src/components.ts
@@ -88,6 +88,7 @@ export {
export { default as Mermaid, type MermaidProps } from './Mermaid';
export { default as Modal, type ModalProps } from './Modal';
export { default as SearchBar, type SearchBarProps } from './SearchBar';
+export { default as SearchResultCards, type SearchResultCardsProps } from './SearchResultCards';
export {
default as SelectWithImg,
type SelectWithImgOptionItem,
diff --git a/src/mdx/mdxComponents/Citation/PopoverPanel.tsx b/src/mdx/mdxComponents/Citation/PopoverPanel.tsx
new file mode 100644
index 00000000..168de4a8
--- /dev/null
+++ b/src/mdx/mdxComponents/Citation/PopoverPanel.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { Popover } from 'antd';
+import { createStyles } from 'antd-style';
+import { ArrowRightIcon } from 'lucide-react';
+import { ReactNode, memo, useMemo } from 'react';
+import { Flexbox } from 'react-layout-kit';
+
+import Icon from '@/Icon';
+
+const useStyles = createStyles(({ css, token }) => ({
+ link: css`
+ cursor: pointer;
+ color: ${token.colorTextSecondary};
+
+ :hover {
+ color: ${token.colorText};
+ }
+ `,
+ url: css`
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+
+ text-overflow: ellipsis;
+ `,
+}));
+
+interface PopoverPanelProps {
+ alt?: string;
+ children?: ReactNode;
+ title?: string;
+ url?: string;
+ usePopover?: boolean;
+}
+
+const PopoverPanel = memo(({ children, usePopover, title, alt, url }: PopoverPanelProps) => {
+ const { styles } = useStyles();
+
+ const [displayTitle, domain, host] = useMemo(() => {
+ try {
+ const urlObj = new URL(url!);
+ const hostForUrl = urlObj.host;
+
+ let displayTitle = title;
+
+ if (title === url) {
+ displayTitle = '';
+ }
+
+ let domain = urlObj.hostname.replace('www.', '');
+ if (!displayTitle) domain = url!;
+
+ return [displayTitle, domain, hostForUrl];
+ } catch {
+ return [title, url, url];
+ }
+ }, [url, title]);
+
+ return usePopover && url ? (
+
+ {
+ window.open(url, '_blank');
+ }}
+ >
+
+
+ {domain}
+
+
+
+ {displayTitle}
+
+ }
+ trigger={'hover'}
+ >
+ {children}
+
+ ) : (
+ children
+ );
+});
+
+export default PopoverPanel;
diff --git a/src/mdx/mdxComponents/Citation/index.tsx b/src/mdx/mdxComponents/Citation/index.tsx
new file mode 100644
index 00000000..f9e9a32f
--- /dev/null
+++ b/src/mdx/mdxComponents/Citation/index.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { createStyles } from 'antd-style';
+import { isEmpty } from 'lodash-es';
+import { ReactNode, memo } from 'react';
+
+import PopoverPanel from '@/mdx/mdxComponents/Citation/PopoverPanel';
+import { CitationItem } from '@/types/citation';
+
+const useStyles = createStyles(({ css, token }) => ({
+ container: css`
+ display: inline-flex;
+ line-height: var(--lobe-markdown-line-height);
+ vertical-align: baseline;
+
+ a {
+ color: inherit;
+ }
+ `,
+ content: css`
+ width: 16px;
+ height: 16px;
+ margin-inline: 2px;
+ border-radius: 4px;
+
+ font-family: ${token.fontFamilyCode};
+ font-size: 10px;
+ color: ${token.colorTextSecondary} !important;
+ text-align: center;
+ vertical-align: top;
+
+ background: ${token.colorFillSecondary};
+
+ transition: all 100ms ${token.motionEaseOut};
+ `,
+ hover: css`
+ cursor: pointer;
+
+ :hover {
+ color: ${token.colorBgSpotlight} !important;
+ background: ${token.colorPrimary};
+ }
+ `,
+ supContainer: css`
+ vertical-align: super;
+ `,
+}));
+
+interface CitationProps {
+ children?: ReactNode;
+ citationDetail?: CitationItem;
+ citations?: string[];
+ href?: string;
+ id: string;
+ inSup?: boolean;
+}
+
+const Citation = memo(({ children, href, inSup, id, citationDetail }: CitationProps) => {
+ const { styles, cx } = useStyles();
+ const usePopover = !isEmpty(citationDetail);
+ const url = citationDetail?.url || href;
+
+ // [^1] 格式类型
+ if (inSup) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {url ? (
+
+ {children}
+
+ ) : (
+ {children}
+ )}
+
+
+ );
+});
+
+export default Citation;
diff --git a/src/mdx/mdxComponents/Footnotes.tsx b/src/mdx/mdxComponents/Footnotes.tsx
new file mode 100644
index 00000000..809326e8
--- /dev/null
+++ b/src/mdx/mdxComponents/Footnotes.tsx
@@ -0,0 +1,92 @@
+import { createStyles } from 'antd-style';
+import { ReactNode, useMemo } from 'react';
+
+import SearchResultCards from '@/SearchResultCards';
+
+const useStyles = createStyles(({ css, token }) => ({
+ fallback: css`
+ padding-block: 1em;
+ font-size: 0.875em;
+ color: ${token.colorTextSecondary};
+
+ ol {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+
+ margin: 0;
+ padding: 0;
+
+ list-style-type: none;
+ }
+
+ ol li {
+ position: relative;
+
+ overflow: hidden;
+ display: flex;
+ flex-direction: row;
+
+ margin: 0 !important;
+ padding-block: 0 !important;
+ padding-inline: 0 0.4em !important;
+ border: 1px solid ${token.colorBorderSecondary};
+ border-radius: 0.25em;
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &::before {
+ content: counter(list-item);
+ counter-increment: list-item;
+
+ display: block;
+
+ margin-inline-end: 0.4em;
+ padding-inline: 0.6em;
+
+ background: ${token.colorFillSecondary};
+ }
+
+ p,
+ a {
+ overflow: hidden;
+
+ margin: 0 !important;
+ padding: 0 !important;
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ `,
+}));
+
+interface FootnotesProps {
+ 'children': ReactNode;
+ 'data-footnote-links'?: string;
+ 'data-footnotes'?: boolean;
+}
+
+const Footnotes = ({ children, ...res }: FootnotesProps) => {
+ const { styles, cx } = useStyles();
+
+ const links = useMemo(() => {
+ try {
+ return JSON.parse(res['data-footnote-links'] || '');
+ } catch (error) {
+ console.error(error);
+ console.log(res);
+ return [];
+ }
+ }, [res['data-footnote-links']]);
+
+ const isError = links.length === 0;
+ return (
+
+ {isError ? children : }
+
+ );
+};
+
+export default Footnotes;
diff --git a/src/mdx/mdxComponents/Link.tsx b/src/mdx/mdxComponents/Link.tsx
index 62471f6a..97101b17 100644
--- a/src/mdx/mdxComponents/Link.tsx
+++ b/src/mdx/mdxComponents/Link.tsx
@@ -2,11 +2,47 @@ import { FC } from 'react';
import A from '@/A';
import { AProps } from '@/types';
+import { CitationItem } from '@/types/citation';
+import { safeParseJSON } from '@/utils/safeParseJSON';
-export type LinkProps = AProps;
+import Citation from './Citation';
+
+export interface LinkProps extends AProps {
+ 'aria-describedby'?: string;
+ 'citations'?: CitationItem[];
+ 'data-footnote-ref'?: boolean;
+ 'data-link'?: string;
+ 'id'?: string;
+ 'node': any;
+}
+
+const Link: FC = ({ href, target, citations, ...rest }) => {
+ // [^1] 格式类型
+ if (rest['data-footnote-ref']) {
+ return (
+
+ {rest.children}
+
+ );
+ }
+
+ // [1] 格式类型,搭配 citations 注入
+ const match = href?.match(/citation-(\d+)/);
+
+ if (match) {
+ const index = Number.parseInt(match[1]) - 1;
+
+ const detail = citations?.[index];
+
+ return (
+
+ {match[1]}
+
+ );
+ }
-const Link: FC = ({ href, target, ...rest }) => {
const isNewWindow = href?.startsWith('http');
+
return ;
};
diff --git a/src/mdx/mdxComponents/Section.tsx b/src/mdx/mdxComponents/Section.tsx
new file mode 100644
index 00000000..dd3e6143
--- /dev/null
+++ b/src/mdx/mdxComponents/Section.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from 'react';
+
+import Footnotes from '@/mdx/mdxComponents/Footnotes';
+
+interface SectionProps {
+ 'children': ReactNode;
+ 'data-footnotes'?: boolean;
+ 'showFootnotes'?: boolean;
+}
+
+const Section = (props: SectionProps) => {
+ // 说明是脚注
+ if (props['data-footnotes']) {
+ return ;
+ }
+
+ return ;
+};
+
+export default Section;
diff --git a/src/types/citation.ts b/src/types/citation.ts
new file mode 100644
index 00000000..382fcdec
--- /dev/null
+++ b/src/types/citation.ts
@@ -0,0 +1,6 @@
+export interface CitationItem {
+ alt?: string;
+ summary?: string;
+ title?: string;
+ url: string;
+}
diff --git a/src/utils/safeParseJSON.ts b/src/utils/safeParseJSON.ts
new file mode 100644
index 00000000..66e78034
--- /dev/null
+++ b/src/utils/safeParseJSON.ts
@@ -0,0 +1,12 @@
+export const safeParseJSON = >(text?: string) => {
+ if (typeof text !== 'string') return undefined;
+
+ let json: T;
+ try {
+ json = JSON.parse(text);
+ } catch {
+ return undefined;
+ }
+
+ return json;
+};