Components
Unified Markdown
1/27/2026, 1:17:38 PM modified by MarvinAn MD component that supports streaming rendering.
Note: This component is a work in progress and is not yet ready for production.
Install
Preview
'use client';import { Fragment, type ReactNode, useMemo, useRef } from 'react';import processor, { createProcessor } from './processor';const stableProcessor = createProcessor({ streaming: false });/** * 寻找安全的分割点 * 规则:最后一个双换行符 \n\n,且确保不在代码块或数学公式块中 */function findSafeSplitPoint(content: string): number { const regex = /\n\n/g; let match: RegExpExecArray | null; let lastMatchIndex = -1; while (true) { match = regex.exec(content); if (match === null) break; const index = match.index; const prefix = content.slice(0, index); // 检查是否在代码块中 (```) const codeBlockCount = (prefix.match(/```/g) || []).length; if (codeBlockCount % 2 !== 0) continue; // 检查是否在数学公式块中 ($$) const mathBlockCount = (prefix.match(/\$\$/g) || []).length; if (mathBlockCount % 2 !== 0) continue; lastMatchIndex = index + match[0].length; } return lastMatchIndex;}export interface IncrementalMarkdownOptions { /** * 是否还有下一个 chunk */ hasNextChunk?: boolean;}export function useIncrementalMarkdown(content: string, options: IncrementalMarkdownOptions = {}) { const { hasNextChunk = false } = options; const lastSplitPointRef = useRef<number>(-1); const cachedStableResult = useRef<ReactNode>([]); return useMemo(() => { const splitPoint = !hasNextChunk ? content.length : findSafeSplitPoint(content); if (splitPoint !== lastSplitPointRef.current) { lastSplitPointRef.current = splitPoint; cachedStableResult.current = stableProcessor.processSync(content.slice(0, splitPoint)).result as ReactNode; } const tailContent = splitPoint === -1 ? content : content.slice(splitPoint); const tail = tailContent ? (processor.processSync(tailContent).result as ReactNode) : undefined; const result = ( <Fragment key="incremental-markdown-root"> <Fragment key="stable-part">{cachedStableResult.current}</Fragment> <Fragment key="tail-part">{tail}</Fragment> </Fragment> ); return result; }, [content, hasNextChunk]);}import { Fragment } from "react";import * as prod from "react/jsx-runtime";import rehypeHighlight from "rehype-highlight";import rehypeKatex from "rehype-katex";import rehypeReact from "rehype-react";import remarkGfm from "remark-gfm";import remarkMath from "remark-math";import remarkParse from "remark-parse";import remarkRehype from "remark-rehype";import { unified } from "unified";import { visit } from "unist-util-visit";import components from "./components";function rehypeStreamSplitter() { return (tree: any) => { // 1. 寻找最后一个包含文本的节点 let lastTextNodeParent: any = null; let lastTextNodeIndex = -1; // 深度优先遍历,找到最后一个文本节点 visit(tree, "text", (node, index, parent) => { if (node.value && node.value.trim() !== "") { lastTextNodeParent = parent; lastTextNodeIndex = index!; } }); // 2. 如果找到了,执行拆分逻辑 if (lastTextNodeParent && lastTextNodeIndex !== -1) { const originalNode = lastTextNodeParent.children[lastTextNodeIndex]; const textContent = originalNode.value; // 将纯文本节点替换为一系列 span 节点 const charNodes = textContent .split("") .map((char: string, i: number) => ({ type: "element", tagName: "span", properties: { className: ["streaming-char"], // 这里的 Key 很重要,帮助 React 识别增量 dataCharIndex: i, style: "display: inline-block; white-space: pre;", }, children: [{ type: "text", value: char }], })); // 用拆分后的节点替换原有的文本节点 lastTextNodeParent.children.splice(lastTextNodeIndex, 1, ...charNodes); } };}export interface ProcessorOptions { streaming?: boolean;}export const createProcessor = (options: ProcessorOptions = {}) => { const p = unified() .use(remarkParse) .use(remarkGfm) .use(remarkMath) .use(remarkRehype) .use(rehypeKatex) .use(rehypeHighlight, { detect: true, ignoreMissing: true }); if (options.streaming) { p.use(rehypeStreamSplitter); } return p.use(rehypeReact, { ...prod, Fragment, components: components, });};const defaultProcessor = createProcessor({ streaming: true });export default defaultProcessor;'use client';import { cn } from '@/lib/utils';import { useIncrementalMarkdown } from './use-incremental-markdown';import 'katex/dist/katex.min.css';import 'highlight.js/styles/github-dark.css';export function UnifiedMarkdown({ content, className, hasNextChunk = false,}: { content: string; className?: string; hasNextChunk?: boolean;}) { const contentComponent = useIncrementalMarkdown(content, { hasNextChunk, }); return ( <div className={cn( 'prose prose-slate dark:prose-invert max-w-none', 'streaming-content', // 触发 CSS 中的 :last-child 动画 className, )} > {contentComponent} </div> );}'use client';import { useRef, useState } from 'react';import { Button } from '@/components/ui/button';import { SSEMessageGenerator } from '~/registry/utils/stream';import { UnifiedMarkdown } from './index';const longContent = `# About This ProjectWelcome to **MyAI Portal** \`\`\`javascriptconst a = 1;console.log(a);\`\`\`---## 🚀 Features- **Markdown Rendering** with support for tables, math formulas, and syntax highlighting.- **Next.js 14** + **React 19** + **TypeScript** for modern web development.- **Tailwind CSS** & **shadcn/ui** for elegant interfaces.- **Internationalization**: Easily switch languages.---> “The best software is built by a community.”Check out the [GitHub repo](https://github.com/myaiportal/myai) and join us!- [x] Markdown parsing- [ ] AI-powered chat---## 📚 Example Table| Name | Role | Active || ------------ | ------------- | ------ || Alice | Frontend | ✅ || Bob | Backend | ✅ || Charlie | DevOps | ❌ |---## 🧮 Math SupportInline math like $E=mc^2$ and block math:$$\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}$$😊`;const shortContent = `Welcome to 😊 **MyAI Portal** I'm a chatbot that can answer your questions and help you with your tasks. 😊`;export default function Demo() { const [content, setContent] = useState<string>(''); const abortControllerRef = useRef<AbortController | null>(null); const [hasNextChunk, setHasNextChunk] = useState(false); const [useLongContent, setUseLongContent] = useState(false); const handleStart = async () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } const abortController = new AbortController(); abortControllerRef.current = abortController; setContent(''); setHasNextChunk(true); const c = useLongContent ? longContent : shortContent; // 模拟 fetch 返回的 SSE 流 const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); for (let i = 0; i < c.length; i++) { if (abortController.signal.aborted) break; const char = c.at(i); // 模拟 SSE 格式: data: "char"\n\n const chunk = `data: ${JSON.stringify(char)}\n\n`; controller.enqueue(encoder.encode(chunk)); await new Promise((resolve) => setTimeout(resolve, 5)); } controller.close(); }, }); const startTime = performance.now(); try { console.log('started at ', startTime); const generator = SSEMessageGenerator(stream); for await (const message of generator) { console.log('message', message); if (abortController.signal.aborted) break; try { const char = JSON.parse(message); setContent((prev) => prev + char); } catch { // ignore } } console.log('cost time ', performance.now() - startTime); } finally { if (!abortController.signal.aborted) { setHasNextChunk(false); } } }; const handleReset = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); } setContent(''); setHasNextChunk(false); }; return ( <div className=""> <div className="flex gap-4"> <Button onClick={handleStart}>Start</Button> <Button onClick={handleReset}>Reset</Button> <Button variant={'secondary'} onClick={() => setUseLongContent(!useLongContent)}> Switch {useLongContent ? 'short' : 'long'} </Button> </div> <UnifiedMarkdown className="flex-1" content={content} hasNextChunk={hasNextChunk} /> </div> );}import { memo } from "react";import { cn } from "@/lib/utils";// 1. 使用 memo 封装自定义组件,防止因父组件重绘导致的无效 renderconst MemoizedStrong = memo(({ className, children, ...props }: any) => { return ( <strong className={cn("font-bold text-primary", className)} {...props}> {children} </strong> );});MemoizedStrong.displayName = "MemoizedStrong";const components = { strong: MemoizedStrong, // 你可以继续在此添加 p, li, code 等的 memo 版本};export default components;CSS
You need to add the following CSS to your project to make the streaming effect work.
/* globals.css */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 仅针对容器中最后一个块级元素及其子元素应用动画 */
.streaming-char {
animation: fadeIn 0.3s ease-out forwards;
}Git Commit History(1 commits)
fix: dep
Marvin
1月27日 13:17
b6e70fbd