@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
298 lines (297 loc) • 10.8 kB
JavaScript
"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