diff --git a/package.json b/package.json index beefe8a9..2c067ca7 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,7 @@ "@types/react-dom": "^19.0.2", "@types/unist": "^3.0.3", "@types/uuid": "^10.0.0", - "@vitest/coverage-v8": "~1.2.2", + "@vitest/coverage-v8": "^3", "antd": "^5.23.0", "babel-plugin-antd-style": "^1.0.4", "clean-package": "^2.2.0", @@ -174,6 +174,7 @@ "husky": "^9.1.7", "jsdom": "^25.0.1", "lint-staged": "^15.3.0", + "mdast-util-to-markdown": "^2.1.2", "prettier": "^3.4.2", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -185,7 +186,7 @@ "typescript": "^5.7.3", "unist-util-is": "^6.0.0", "unist-util-visit": "^5.0.0", - "vitest": "~1.2.2" + "vitest": "^3.0.4" }, "peerDependencies": { "antd": "^5.23.0", diff --git a/src/Markdown/demos/chat.tsx b/src/Markdown/demos/chat.tsx index 95bb5a7e..eb7831fd 100644 --- a/src/Markdown/demos/chat.tsx +++ b/src/Markdown/demos/chat.tsx @@ -3,13 +3,17 @@ import { StoryBook, useControls, useCreateStore } from '@lobehub/ui/storybook'; import { content } from '@/Markdown/demos/data'; +const thinking = ` +this is a thinking + +`; export default () => { const store = useCreateStore(); const options: MarkdownProps | any = useControls( { children: { rows: true, - value: content, + value: thinking + '\n\n' + content, }, variant: { options: ['normal', 'chat'], diff --git a/src/Markdown/demos/thinking/content.ts b/src/Markdown/demos/thinking/content.ts new file mode 100644 index 00000000..548ac007 --- /dev/null +++ b/src/Markdown/demos/thinking/content.ts @@ -0,0 +1,82 @@ +export const fullThinking = ` + +嗯,用户问的是“9.8比9.11大吗?”我需要仔细分析这个问题。首先,可能有两种理解:一种是数值比较,另一种是日期比较。 + +首先看数值比较,9.8和9.11。如果用户指的是数字的话,9.8其实是9.80,而9.11是9.11。这时候需要比较小数点后的部分。0.8相当于0.80,而0.80比0.11大,所以9.8比9.11大。这种情况下答案是对的。 + +不过用户也有可能是在问日期,比如9月8日和9月11日哪个更晚。这时候9月11日在9月8日之后,所以9.11比9.8大,也就是更晚。这时候答案就相反了。 + +接下来要看用户的具体意图。问题中有“ma”这个字,可能是“吗”的拼音,所以用户可能在确认是否正确。另外,中文里有时候会用“大”来指日期更晚,比如“你的生日比我大”可能指月份或日期更晚。不过数值比较的话,通常不会用“大”而是直接比较大小。 + +不过根据常见的数学问题,可能用户是在问数值比较,所以正确答案是9.8确实比9.11大,因为0.8大于0.11。不过需要提醒用户注意小数位数的不同,可能容易混淆。比如有人可能会误以为9.8是9.08,而9.11是9.11,这时候比较的话9.11更大。但根据数学规则,小数点后的位数应该对齐,所以9.8等于9.80,确实大于9.11。 + +另外,需要确认用户是否了解小数的比较方法,特别是不同位数的小数如何比较。比如从高位到低位依次比较,十分位、百分位等。所以9.8的十分位是8,而9.11的十分位是1,所以直接得出9.8更大。 + +总结来说,如果问题中的9.8和9.11是数字,那么9.8更大;如果是日期,则9.11更大。但根据常规数学问题,应该回答数值比较的情况,即9.8更大,并解释小数比较的方法。同时需要指出可能的歧义,确保用户明确自己问题的类型。 + + + +通过观察 ollama 的运行日志,我发现 think 标签的内容也被传到了上下文中,这与官方文档提到的处理方式有所偏差,然后我就找到了另一个支持 ollama 的 webui 对这个问题的处理方式供参考。 +`; + +export const partialThinking = ` + +嗯,用户问的是“9.8比9.11大吗?”我需要仔细分析这个问题。首先,可能有两种理解:一种是数值比较,另一种是日期比较。 + +首先看数值比较,9.8和9.11。如果用户指的是数字的话,9.8其实是9.80,而9.11是9.11。这时候需要比较小数点后的部分。0.8相当于0.80,而0.80比0.11大,所以9.8比9.11大。这种情况下答案是对的。 + +不过用户也有可能是在问日期,比如9月8日和9月11日哪个更晚。这时候9月11日在9月8日之后,所以9.11比9.8大,也就是更晚。这时候答案就相反了。 + +接下来要看用户的具体意图。问题中有“ma”这个字,可能是“吗”的拼音,所以用户可能在确认是否正确。另外,中文里有时候会用“大”来指日期更晚,比如“你的生日比我大”可能指月份或日期更晚。不过数值比较的话,通常不会用“大”而是直接比较大小。 + +`; + +export const ollama = ` +嗯,为什么天空是蓝色的呢?这个问题听起来挺简单的,但其实背后可能有很多科学原理。让我仔细想想。 + +首先,我记得小时候老师告诉我们,天空是蓝色的,因为太阳发出的颜色透过大气层到达我们的眼睛。可是,这好像只是表面现象,背后的机制是什么呢? + +我想,空气是一种混合物,包含各种气体,比如氮气、氧气等等。这些气体在大气中流动,可能会影响光线。但是为什么透明的东西看起来会是某个颜色呢?比如说,水是透明的,但不是蓝色或其他颜色。 + +哦,对了,我好像听说过“散射”这个词。光从太阳那里发出后,在空气中传播时,会被各种分子和颗粒散射。这可能影响到了我们看到的颜色。 + +那是什么导致这种散射现象呢?是不是因为阳光含有不同颜色的光,而这些颜色在穿过大气层的时候发生了变化? + +对了,阳光其实是由多种颜色组成的,也就是白光包含了红、橙、黄、绿、蓝、靛、紫七种颜色。这是光谱的概念。当阳光穿过大气层时,不同波长的颜色传播的速度略有不同。 + +那么,为什么蓝色的光会被散射得更多呢?是不是因为蓝色光的波长短,所以更容易被大气中的分子和颗粒散射出去? + +对啊,我记得老师讲过,空气中的大部分物质对光线的吸收或反射并不是特别明显。但是蓝色光的频率更高,波长更短,这使得它更容易受到大气分子的影响。 + +特别是像臭氧这样的气体,它们在高空中浓度较高,能够有效地散射蓝色光。这样,当阳光到达地球表面时,红色和橙色的光因为波长得较长,更容易透过大气层,而蓝色的光则被散射到各个方向,包括我们的眼睛,所以我们看到天空是蓝色的。 + +不过,这好像解释得有点快,我是不是漏掉了什么?比如说,为什么其他颜色不会像蓝色那样明显呢? + +我想,这是因为我们的肉眼对蓝色更敏感,或者是因为散射的程度更大。实际上,阳光中的蓝色光在穿过大气层时被散射的比例更高,导致我们看到的天空呈现出那种明亮而宁静的颜色。 + +但是,如果地球没有大气层,情况会怎样呢?我记得如果大气层不存在,天空看起来应该是深灰色或黑色的。这是因为太阳的光线直接到达我们的眼睛,而不是通过空气中的分子发生改变。 + +所以,大气层对光的散射作用确实影响了天空的颜色。而蓝色光因为其波长较短,更容易被散射,所以我们看到天空是蓝色的。 + +不过,我是不是忽略了其他因素?比如说,地球自转的影响,或者太阳的位置? + +嗯,可能还有其他因素,但主要的原因应该还是大气层对蓝光的散射作用。这个解释听起来合理,但我需要确认一下是否正确。 + + +天空呈现蓝色主要是由于阳光穿过地球大气层时的散射现象。以下是详细解释: + +1. **阳光组成**:太阳发出的白光实际上包含了不同颜色的光谱,包括红、橙、黄、绿、蓝、靛、紫七种颜色。 + +2. **大气对光线的影响**: + - 大气层中的分子(如氮气和氧气)会吸收或散射部分光线。 + - 蓝色光(具有较短的波长)比其他颜色更容易被大气分子散射,而红色和橙色光由于波长较长,能够穿透大气层。 + +3. **臭氧的作用**:在高空中,臭氧对蓝色光有较强的吸收作用,进一步增强了蓝色光的散射。 + +4. **散射效果**:阳光中的蓝色光在穿过大气层时被散射到各个方向,包括我们的眼睛,因此我们看到天空是蓝色的。 + +5. **无大气情况**:如果地球没有大气层,阳光会直接到达地面,天空可能呈现深灰色或黑色。 + +总结来说,天空呈现蓝色主要是因为蓝色光能够穿透大气层并被散射到我们眼中。`; + +export const inlineMode = `inline`; diff --git a/src/Markdown/demos/thinking/index.tsx b/src/Markdown/demos/thinking/index.tsx new file mode 100644 index 00000000..9244057e --- /dev/null +++ b/src/Markdown/demos/thinking/index.tsx @@ -0,0 +1,35 @@ +import { Markdown } from '@lobehub/ui'; +import { Button } from 'antd'; +import { useTheme } from 'antd-style'; +import { PropsWithChildren, useState } from 'react'; +import { Flexbox } from 'react-layout-kit'; + +import { fullThinking, inlineMode, ollama, partialThinking } from './content'; +import { normalizeThinkTags, remarkCaptureThink } from './remarkPlugin'; + +const Think = ({ children }: PropsWithChildren) => { + const theme = useTheme(); + return ( +
+ here is a custom think comp: + {children as string} +
+ ); +}; + +export default () => { + const [displayContent, setContent] = useState(fullThinking); + return ( +
+ + + + + + + + {normalizeThinkTags(displayContent)} + +
+ ); +}; diff --git a/src/Markdown/demos/thinking/remarkPlugin.ts b/src/Markdown/demos/thinking/remarkPlugin.ts new file mode 100644 index 00000000..e74a6201 --- /dev/null +++ b/src/Markdown/demos/thinking/remarkPlugin.ts @@ -0,0 +1,72 @@ +import { toMarkdown } from 'mdast-util-to-markdown'; +import { SKIP, visit } from 'unist-util-visit'; + +// 预处理函数:确保 think 标签前后有两个换行符 +export const normalizeThinkTags = (markdown: string) => { + return ( + markdown + // 确保 标签前后有两个换行符 + .replaceAll(/([^\n])\s*/g, '$1\n\n') + .replaceAll(/\s*([^\n])/g, '\n\n$1') + // 确保 标签前后有两个换行符 + .replaceAll(/([^\n])\s*<\/think>/g, '$1\n\n') + .replaceAll(/<\/think>\s*([^\n])/g, '\n\n$1') + // 处理可能产生的多余换行符 + .replaceAll(/\n{3,}/g, '\n\n') + ); +}; + +export const remarkCaptureThink = () => { + return (tree: any) => { + visit(tree, 'html', (node, index, parent) => { + if (node.value === '') { + const startIndex = index as number; + let endIndex = startIndex + 1; + let hasCloseTag = false; + + // 查找闭合标签 + while (endIndex < parent.children.length) { + const sibling = parent.children[endIndex]; + if (sibling.type === 'html' && sibling.value === '') { + hasCloseTag = true; + break; + } + endIndex++; + } + + // 计算需要删除的节点范围 + const deleteCount = hasCloseTag + ? endIndex - startIndex + 1 + : parent.children.length - startIndex; + + // 提取内容节点 + const contentNodes = parent.children.slice( + startIndex + 1, + hasCloseTag ? endIndex : undefined, + ); + + // 转换为 Markdown 字符串 + const content = contentNodes + .map((n: any) => toMarkdown(n)) + .join('\n\n') + .trim(); + + // 创建自定义节点 + const thinkNode = { + data: { + hChildren: [{ type: 'text', value: content }], + hName: 'think', + }, + position: node.position, + type: 'thinkBlock', + }; + + // 替换原始节点 + parent.children.splice(startIndex, deleteCount, thinkNode); + + // 跳过已处理的节点 + return [SKIP, startIndex + 1]; + } + }); + }; +}; diff --git a/src/Markdown/index.md b/src/Markdown/index.md index 17835c1b..074a6330 100644 --- a/src/Markdown/index.md +++ b/src/Markdown/index.md @@ -25,6 +25,10 @@ description: Markdown is a React component used to render markdown text. It supp +## Think + + + ## Custom Plugins diff --git a/src/Markdown/index.tsx b/src/Markdown/index.tsx index 60051b80..ab1a9fff 100644 --- a/src/Markdown/index.tsx +++ b/src/Markdown/index.tsx @@ -1,7 +1,7 @@ 'use client'; import type { AnchorProps } from 'antd'; -import { CSSProperties, ReactNode, memo, useMemo } from 'react'; +import { CSSProperties, FC, ReactNode, memo, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import { Components } from 'react-markdown/lib'; import rehypeKatex from 'rehype-katex'; @@ -39,7 +39,7 @@ export interface MarkdownProps extends TypographyProps { pre?: Partial; video?: Partial; }; - components?: Components; + components?: Components & Record; customRender?: (dom: ReactNode, context: { text: string }) => ReactNode; enableImageGallery?: boolean; enableLatex?: boolean; @@ -48,6 +48,7 @@ export interface MarkdownProps extends TypographyProps { onDoubleClick?: () => void; rehypePlugins?: Pluggable[]; remarkPlugins?: Pluggable[]; + remarkPluginsAhead?: Pluggable[]; style?: CSSProperties; variant?: 'normal' | 'chat'; } @@ -71,6 +72,7 @@ const Markdown = memo( lineHeight, rehypePlugins, remarkPlugins, + remarkPluginsAhead, components = {}, customRender, ...rest @@ -150,15 +152,20 @@ const Markdown = memo( ); const innerRemarkPlugins = Array.isArray(remarkPlugins) ? remarkPlugins : [remarkPlugins]; + const innerRemarkPluginsAhead = Array.isArray(remarkPluginsAhead) + ? remarkPluginsAhead + : [remarkPluginsAhead]; + const memoRemarkPlugins = useMemo( () => [ + ...innerRemarkPluginsAhead, remarkGfm, enableLatex && remarkMath, isChatMode && remarkBreaks, ...innerRemarkPlugins, ].filter(Boolean) as any, - [isChatMode, enableLatex, ...innerRemarkPlugins], + [isChatMode, enableLatex, ...innerRemarkPluginsAhead, ...innerRemarkPlugins], ); const defaultDOM = ( @@ -198,14 +205,14 @@ const Markdown = memo( : defaultDOM; return ( -
{markdownContent} -
+ ); }, ); diff --git a/src/Markdown/markdown.style.ts b/src/Markdown/markdown.style.ts index c154c802..69a2952f 100644 --- a/src/Markdown/markdown.style.ts +++ b/src/Markdown/markdown.style.ts @@ -51,10 +51,9 @@ export const useStyles = createStyles( margin-inline: 0; padding-block: 0; padding-inline: 1em; + border-inline-start: solid 4px ${token.colorBorder}; color: ${token.colorTextSecondary}; - - border-inline-start: solid 4px ${token.colorBorder}; } `, code: css` @@ -64,6 +63,8 @@ export const useStyles = createStyles( margin-inline: 0.25em; padding-block: 0.2em; padding-inline: 0.4em; + border: 1px solid var(--lobe-markdown-border-color); + border-radius: 0.25em; font-family: ${token.fontFamilyCode}; font-size: 0.875em; @@ -72,8 +73,6 @@ export const useStyles = createStyles( white-space: break-spaces; background: ${token.colorFillSecondary}; - border: 1px solid var(--lobe-markdown-border-color); - border-radius: 0.25em; } `, details: css` @@ -81,9 +80,9 @@ export const useStyles = createStyles( margin-block: calc(var(--lobe-markdown-margin-multiple) * 1em); padding-block: 0.75em; padding-inline: 1em; + border-radius: calc(var(--lobe-markdown-border-radius) * 1px); background: ${token.colorFillTertiary}; - border-radius: calc(var(--lobe-markdown-border-radius) * 1px); box-shadow: 0 0 0 1px var(--lobe-markdown-border-color); summary { @@ -103,12 +102,11 @@ export const useStyles = createStyles( width: 0.4em; height: 0.4em; - - font-family: ${token.fontFamily}; - border-block-end: 1.5px solid ${token.colorTextSecondary}; border-inline-end: 1.5px solid ${token.colorTextSecondary}; + font-family: ${token.fontFamily}; + transition: transform 200ms ${token.motionEaseOut}; } } @@ -174,7 +172,6 @@ export const useStyles = createStyles( hr: css` hr { margin-block: calc(var(--lobe-markdown-margin-multiple) * 1.5em); - border-color: ${token.colorBorderSecondary}; border-style: dashed; border-width: 1px; @@ -206,6 +203,8 @@ export const useStyles = createStyles( margin-inline: 0.25em; padding-block: 0.2em; padding-inline: 0.4em; + border: 1px solid ${token.colorBorderSecondary}; + border-radius: 0.25em; font-family: ${token.fontFamily}; font-size: 0.875em; @@ -214,8 +213,6 @@ export const useStyles = createStyles( text-align: center; background: ${token.colorBgLayout}; - border: 1px solid ${token.colorBorderSecondary}; - border-radius: 0.25em; } `, list: css` @@ -263,28 +260,32 @@ export const useStyles = createStyles( `, p: css` p { - /* stylelint-disable declaration-block-no-redundant-longhand-properties */ - margin-block-start: calc(var(--lobe-markdown-margin-multiple) * 1em); - margin-block-end: calc(var(--lobe-markdown-margin-multiple) * 1em); - /* stylelint-enable declaration-block-no-redundant-longhand-properties */ + margin-block: 4px; line-height: var(--lobe-markdown-line-height); letter-spacing: 0.02em; } + + p:not(:first-child) { + margin-block-start: calc(var(--lobe-markdown-margin-multiple) * 1em); + } + + p:not(:last-child) { + margin-block-end: calc(var(--lobe-markdown-margin-multiple) * 1em); + } `, pre: css` pre, [data-code-type='highlighter'] { - white-space: break-spaces; border: none; + white-space: break-spaces; > code { padding: 0 !important; + border: none !important; font-family: ${token.fontFamilyCode}; font-size: 0.875em; line-height: 1.6; - - border: none !important; } } `, @@ -305,6 +306,7 @@ export const useStyles = createStyles( width: max-content; max-width: 100%; margin-block: calc(var(--lobe-markdown-margin-multiple) * 1em); + border-radius: calc(var(--lobe-markdown-border-radius) * 1px); text-align: start; text-indent: initial; @@ -313,7 +315,6 @@ export const useStyles = createStyles( overflow-wrap: break-word; background: ${token.colorFillQuaternary}; - border-radius: calc(var(--lobe-markdown-border-radius) * 1px); box-shadow: 0 0 0 1px var(--lobe-markdown-border-color); code { diff --git a/src/Markdown/style.ts b/src/Markdown/style.ts index 7e207543..158d81e8 100644 --- a/src/Markdown/style.ts +++ b/src/Markdown/style.ts @@ -19,13 +19,6 @@ export const useStyles = createStyles( --lobe-markdown-line-height: ${lineHeight}; --lobe-markdown-border-radius: ${token.borderRadius}; - margin-block: ${marginMultiple * -0.75}em; - - /* 解决只有一个子节点时高度坍缩的问题 */ - :first-child:not(:has(*)) { - margin-block: 0; - } - ol, ul { li { @@ -86,13 +79,13 @@ export const useStyles = createStyles( 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}; - border: 1px solid ${token.colorBorderSecondary}; - border-radius: 0.25em; } section.footnotes { @@ -121,13 +114,12 @@ export const useStyles = createStyles( 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; - border: 1px solid ${token.colorBorderSecondary}; - border-radius: 0.25em; - &::before { content: counter(list-item); counter-increment: list-item; diff --git a/src/mdx/mdxComponents/Pre.tsx b/src/mdx/mdxComponents/Pre.tsx index 70f4ad06..a59d42df 100644 --- a/src/mdx/mdxComponents/Pre.tsx +++ b/src/mdx/mdxComponents/Pre.tsx @@ -78,6 +78,7 @@ export const PreMermaid: FC = ({ children, className, style, + type, ...rest }) => { const { styles, cx } = useStyles(); @@ -88,7 +89,7 @@ export const PreMermaid: FC = ({ copyButtonSize={{ blockSize: 28, fontSize: 16 }} fullFeatured={fullFeatured} style={style} - type="block" + type={type || 'pure'} {...rest} > {children}