UNPKG

@lobehub/ui

Version:

Lobe UI is an open-source UI component library for building AIGC web apps

298 lines (297 loc) 10.8 kB
"use client"; import { useMarkdownContext } from "../components/MarkdownProvider.mjs"; import { useMarkdownComponents } from "../../hooks/useMarkdown/useMarkdownComponents.mjs"; import { useMarkdownContent } from "../../hooks/useMarkdown/useMarkdownContent.mjs"; import { useMarkdownRehypePlugins } from "../../hooks/useMarkdown/useMarkdownRehypePlugins.mjs"; import { useMarkdownRemarkPlugins } from "../../hooks/useMarkdown/useMarkdownRemarkPlugins.mjs"; import { rehypeStreamAnimated } from "../plugins/rehypeStreamAnimated.mjs"; import { useStreamdownProfiler } from "../streamProfiler/StreamdownProfilerProvider.mjs"; import { resolveBlockAnimationMeta } from "./streamAnimationMeta.mjs"; import { styles } from "./style.mjs"; import { useSmoothStreamContent } from "./useSmoothStreamContent.mjs"; import { useStreamQueue } from "./useStreamQueue.mjs"; import { Profiler, createElement, memo, useCallback, useEffect, useId, useMemo, useRef } from "react"; import { jsx } from "react/jsx-runtime"; import Markdown from "react-markdown"; import { marked } from "marked"; import remend from "remend"; //#region src/Markdown/SyntaxMarkdown/StreamdownRender.tsx const STREAM_FADE_DURATION = 280; const REVEALED_STREAM_PLUGIN = [rehypeStreamAnimated, { revealed: true }]; function countChars(text) { return [...text].length; } function getNow() { return typeof performance === "undefined" ? Date.now() : performance.now(); } const isRecord = (value) => typeof value === "object" && value !== null; const isDeepEqualValue = (a, b) => { if (a === b) return true; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b)) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (!isDeepEqualValue(a[i], b[i])) return false; return true; } if (!isRecord(a) || !isRecord(b)) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) if (!isDeepEqualValue(a[key], b[key])) return false; return true; }; const isSamePlugin = (prevPlugin, nextPlugin) => { const prevTuple = Array.isArray(prevPlugin) ? prevPlugin : [prevPlugin]; const nextTuple = Array.isArray(nextPlugin) ? nextPlugin : [nextPlugin]; if (prevTuple.length !== nextTuple.length) return false; if (prevTuple[0] !== nextTuple[0]) return false; return isDeepEqualValue(prevTuple.slice(1), nextTuple.slice(1)); }; const isSamePlugins = (prevPlugins, nextPlugins) => { if (prevPlugins === nextPlugins) return true; if (!prevPlugins || !nextPlugins) return !prevPlugins && !nextPlugins; if (prevPlugins.length !== nextPlugins.length) return false; for (let i = 0; i < prevPlugins.length; i++) if (!isSamePlugin(prevPlugins[i], nextPlugins[i])) return false; return true; }; const useStablePlugins = (plugins) => { const stableRef = useRef(plugins); if (!isSamePlugins(stableRef.current, plugins)) stableRef.current = plugins; return stableRef.current; }; const StreamdownBlock = memo(({ children, ...rest }) => { return /* @__PURE__ */ jsx(Markdown, { ...rest, children }); }, (prevProps, nextProps) => prevProps.children === nextProps.children && prevProps.components === nextProps.components && isSamePlugins(prevProps.rehypePlugins, nextProps.rehypePlugins) && isSamePlugins(prevProps.remarkPlugins, nextProps.remarkPlugins)); StreamdownBlock.displayName = "StreamdownBlock"; const StreamdownRender = memo(({ children, ...rest }) => { const { streamSmoothingPreset = "balanced" } = useMarkdownContext(); const profiler = useStreamdownProfiler(); const escapedContent = useMarkdownContent(children || ""); const components = useMarkdownComponents(); const baseRehypePlugins = useStablePlugins(useMarkdownRehypePlugins()); const remarkPlugins = useStablePlugins(useMarkdownRemarkPlugins()); const generatedId = useId(); const smoothedContent = useSmoothStreamContent(typeof escapedContent === "string" ? escapedContent : "", { preset: streamSmoothingPreset }); const processedContentResult = useMemo(() => { const start = profiler ? getNow() : 0; const value = remend(smoothedContent); return { durationMs: profiler ? getNow() - start : 0, value }; }, [profiler, smoothedContent]); const processedContent = processedContentResult.value; const blocksResult = useMemo(() => { const start = profiler ? getNow() : 0; const tokens = marked.lexer(processedContent); let offset = 0; const value = tokens.map((token) => { const block = { content: token.raw, startOffset: offset }; offset += token.raw.length; return block; }); return { durationMs: profiler ? getNow() - start : 0, value }; }, [processedContent, profiler]); const blocks = blocksResult.value; const { getBlockState, charDelay } = useStreamQueue(blocks); const prevBlockCharCountRef = useRef(/* @__PURE__ */ new Map()); const blockCharDelayRef = useRef(/* @__PURE__ */ new Map()); const blockTimelineRef = useRef(/* @__PURE__ */ new Map()); const lastRenderTsRef = useRef(null); const renderTs = getNow(); const frameDt = lastRenderTsRef.current === null ? 0 : Math.max(0, Math.min(renderTs - lastRenderTsRef.current, 120)); const timelineResult = useMemo(() => { const start = profiler ? getNow() : 0; const next = /* @__PURE__ */ new Map(); const prevTimeline = blockTimelineRef.current; const prevCharCounts = prevBlockCharCountRef.current; for (const block of blocks) { const blockCharCount = countChars(block.content); const prevCharCount = prevCharCounts.get(block.startOffset) ?? 0; const prevElapsed = prevTimeline.get(block.startOffset); const latestCharStart = Math.max(0, (blockCharCount - 1) * charDelay); if (prevElapsed === void 0 || blockCharCount < prevCharCount) { next.set(block.startOffset, latestCharStart); continue; } const elapsedByTime = prevElapsed + frameDt; const minElapsed = Math.max(0, latestCharStart - charDelay * 2); next.set(block.startOffset, Math.max(elapsedByTime, minElapsed)); } return { durationMs: profiler ? getNow() - start : 0, value: next }; }, [ blocks, charDelay, frameDt, profiler ]); const timelineForRender = timelineResult.value; useEffect(() => { if (!profiler) return; profiler.recordCalculation({ durationMs: processedContentResult.durationMs, name: "content-normalize", textLength: processedContent.length }); }, [ processedContent.length, processedContentResult.durationMs, profiler ]); useEffect(() => { if (!profiler) return; profiler.recordCalculation({ durationMs: blocksResult.durationMs, itemCount: blocks.length, name: "block-lex", textLength: processedContent.length }); }, [ blocks.length, blocksResult.durationMs, processedContent.length, profiler ]); useEffect(() => { if (!profiler) return; profiler.recordCalculation({ durationMs: timelineResult.durationMs, itemCount: blocks.length, name: "block-timeline", textLength: processedContent.length }); }, [ blocks.length, processedContent.length, profiler, timelineResult.durationMs ]); const blockAnimationMetaResult = useMemo(() => { const nextBlockCharDelay = /* @__PURE__ */ new Map(); const blockAnimationMeta = /* @__PURE__ */ new Map(); for (const [index, block] of blocks.entries()) { const state = getBlockState(index); const timelineElapsedMs = timelineForRender.get(block.startOffset) ?? 0; const animationMeta = resolveBlockAnimationMeta({ blockCharCount: countChars(block.content), currentCharDelay: charDelay, fadeDuration: STREAM_FADE_DURATION, previousCharDelay: blockCharDelayRef.current.get(block.startOffset), state, timelineElapsedMs }); nextBlockCharDelay.set(block.startOffset, animationMeta.charDelay); blockAnimationMeta.set(block.startOffset, animationMeta); } return { blockAnimationMeta, blockCharDelay: nextBlockCharDelay }; }, [ blocks, charDelay, getBlockState, timelineForRender ]); useEffect(() => { const nextCharCount = /* @__PURE__ */ new Map(); for (const block of blocks) nextCharCount.set(block.startOffset, countChars(block.content)); blockCharDelayRef.current = blockAnimationMetaResult.blockCharDelay; prevBlockCharCountRef.current = nextCharCount; blockTimelineRef.current = timelineForRender; lastRenderTsRef.current = getNow(); }, [ blockAnimationMetaResult.blockCharDelay, blocks, timelineForRender ]); const handleRootRender = useCallback((_, phase, actualDuration, baseDuration) => { profiler?.recordRootCommit({ actualDuration, baseDuration, blockCount: blocks.length, phase, textLength: processedContent.length }); }, [ blocks.length, processedContent.length, profiler ]); const handleBlockRender = useCallback((id, phase, actualDuration, baseDuration) => { if (!profiler) return; const [, indexText, offsetText] = id.split(":"); const blockIndex = Number(indexText); if (!Number.isFinite(blockIndex)) return; const block = blocks[blockIndex]; if (!block) return; profiler.recordBlockCommit({ actualDuration, baseDuration, blockChars: countChars(block.content), blockIndex, blockKey: offsetText ?? String(block.startOffset), phase, state: getBlockState(blockIndex) }); }, [ blocks, getBlockState, profiler ]); const content = /* @__PURE__ */ jsx("div", { className: styles.animated, children: blocks.map((block, index) => { if (getBlockState(index) === "queued") return null; const animationMeta = blockAnimationMetaResult.blockAnimationMeta.get(block.startOffset); if (!animationMeta) return null; const plugins = animationMeta.settled ? [...baseRehypePlugins, REVEALED_STREAM_PLUGIN] : [...baseRehypePlugins, [rehypeStreamAnimated, { charDelay: animationMeta.charDelay, fadeDuration: STREAM_FADE_DURATION, timelineElapsedMs: animationMeta.timelineElapsedMs }]]; const key = `${generatedId}-${block.startOffset}`; const blockNode = /* @__PURE__ */ jsx(StreamdownBlock, { ...rest, components, rehypePlugins: plugins, remarkPlugins, children: block.content }); if (!profiler) return /* @__PURE__ */ createElement(StreamdownBlock, { ...rest, components, key, rehypePlugins: plugins, remarkPlugins }, block.content); return /* @__PURE__ */ jsx(Profiler, { id: `streamdown-block:${index}:${block.startOffset}`, onRender: handleBlockRender, children: blockNode }, key); }) }); if (!profiler) return content; return /* @__PURE__ */ jsx(Profiler, { id: "streamdown-root", onRender: handleRootRender, children: content }); }); StreamdownRender.displayName = "StreamdownRender"; //#endregion export { StreamdownRender as default }; //# sourceMappingURL=StreamdownRender.mjs.map