UNPKG

@lobehub/ui

Version:

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

308 lines (307 loc) 10.9 kB
"use client"; import { isDeepEqual } from "../../utils/isDeepEqual.mjs"; import { useStableValue } from "../../hooks/useStableValue.mjs"; 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 { getNow } from "../../utils/getNow.mjs"; import { rehypeStreamAnimated } from "../plugins/rehypeStreamAnimated.mjs"; import { useStreamdownProfiler } from "../streamProfiler/StreamdownProfilerProvider.mjs"; import { CachedMarkdown } from "./CachedMarkdown.mjs"; import { resolveBlockAnimationMeta } from "./streamAnimationMeta.mjs"; import { styles } from "./style.mjs"; import { countChars, 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 { marked } from "marked"; import remend from "remend"; //#region src/Markdown/SyntaxMarkdown/StreamdownRender.tsx 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 isDeepEqual(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(CachedMarkdown, { ...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 MIN_STREAM_CHAR_PACE_MS = 2; const MAX_REVEAL_GAP_MS = 160; const updateBlockAnimation = ({ blocks, charDelay, getBlockState, pluginsCache, renderNow, revealClock, runtimes }) => { const blockAnimationMeta = /* @__PURE__ */ new Map(); const alive = /* @__PURE__ */ new Set(); let revealedNewChars = false; for (const [index, block] of blocks.entries()) { alive.add(block.startOffset); const state = getBlockState(index); if (state === "queued") continue; let runtime = runtimes.get(block.startOffset); if (!runtime) { runtime = { births: [], charCount: 0, rawLength: -1, settled: false, styles: [] }; runtimes.set(block.startOffset, runtime); } if (runtime.rawLength !== block.content.length) { runtime.charCount = countChars(block.content); runtime.rawLength = block.content.length; } const blockCharCount = runtime.charCount; const births = runtime.births; if (births.length > blockCharCount) { births.length = blockCharCount; runtime.styles.length = blockCharCount; } if (births.length < blockCharCount) { const newChars = blockCharCount - births.length; let pace = charDelay; let cap = renderNow + 180; if (state === "streaming") { revealedNewChars = true; const gapMs = Math.min(Math.max(renderNow - revealClock.lastTs, 16), MAX_REVEAL_GAP_MS); pace = Math.min(charDelay, Math.max(gapMs / newChars, MIN_STREAM_CHAR_PACE_MS)); cap = renderNow + gapMs + 180; } for (let i = births.length; i < blockCharCount; i++) { const chained = (i > 0 ? births[i - 1] : renderNow - pace) + pace; births.push(Math.min(cap, Math.max(chained, renderNow))); } } let meta; if (runtime.settled) meta = { charDelay: runtime.charDelay ?? charDelay, settled: true }; else { meta = resolveBlockAnimationMeta({ currentCharDelay: charDelay, fadeDuration: 180, lastElapsedMs: renderNow - (births.length > 0 ? births.at(-1) ?? renderNow : renderNow), previousCharDelay: runtime.charDelay, state }); runtime.settled = meta.settled; } runtime.charDelay = meta.charDelay; blockAnimationMeta.set(block.startOffset, meta); } if (revealedNewChars) revealClock.lastTs = renderNow; for (const key of runtimes.keys()) if (!alive.has(key)) { runtimes.delete(key); pluginsCache.delete(key); } return blockAnimationMeta; }; const StreamdownBlocks = memo(({ content: smoothedContent, markdownOptions: rest }) => { const { streamAnimationGranularity = "char" } = useMarkdownContext(); const profiler = useStreamdownProfiler(); const components = useMarkdownComponents(); const baseRehypePlugins = useStablePlugins(useMarkdownRehypePlugins()); const remarkPlugins = useStablePlugins(useMarkdownRemarkPlugins()); const generatedId = useId(); 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 blockRuntimesRef = useRef(/* @__PURE__ */ new Map()); const blockPluginsRef = useRef(/* @__PURE__ */ new Map()); const revealClockRef = useRef({ lastTs: 0 }); const renderNow = getNow(); const animationStart = profiler ? getNow() : 0; const blockAnimationMeta = updateBlockAnimation({ blocks, charDelay, getBlockState, pluginsCache: blockPluginsRef.current, renderNow, revealClock: revealClockRef.current, runtimes: blockRuntimesRef.current }); const blockAnimationDurationMs = profiler ? getNow() - animationStart : 0; 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: blockAnimationDurationMs, itemCount: blocks.length, name: "block-births", textLength: processedContent.length }); }, [ blockAnimationDurationMs, blocks.length, processedContent.length, profiler ]); const resolveBlockPlugins = (startOffset, settled) => { if (settled) return baseRehypePlugins; const cache = blockPluginsRef.current; const entry = cache.get(startOffset); if (entry && entry.base === baseRehypePlugins && entry.granularity === streamAnimationGranularity) return entry.value; const runtime = blockRuntimesRef.current.get(startOffset); const value = [...baseRehypePlugins, [rehypeStreamAnimated, { fadeDuration: 180, granularity: streamAnimationGranularity, runtime }]]; cache.set(startOffset, { base: baseRehypePlugins, granularity: streamAnimationGranularity, value }); return value; }; 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) => { const animationMeta = blockAnimationMeta.get(block.startOffset); if (!animationMeta) return null; const plugins = resolveBlockPlugins(block.startOffset, animationMeta.settled); const key = `${generatedId}-${block.startOffset}`; 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: /* @__PURE__ */ jsx(StreamdownBlock, { ...rest, components, rehypePlugins: plugins, remarkPlugins, children: block.content }) }, key); }) }); if (!profiler) return content; return /* @__PURE__ */ jsx(Profiler, { id: "streamdown-root", onRender: handleRootRender, children: content }); }); StreamdownBlocks.displayName = "StreamdownBlocks"; const StreamdownRender = memo(({ children, ...rest }) => { const { streamSmoothingPreset = "balanced" } = useMarkdownContext(); const escapedContent = useMarkdownContent(children || ""); return /* @__PURE__ */ jsx(StreamdownBlocks, { content: useSmoothStreamContent(typeof escapedContent === "string" ? escapedContent : "", { preset: streamSmoothingPreset }), markdownOptions: useStableValue(rest) }); }); StreamdownRender.displayName = "StreamdownRender"; //#endregion export { StreamdownRender as default }; //# sourceMappingURL=StreamdownRender.mjs.map