@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
229 lines (227 loc) • 8.3 kB
JavaScript
import { useCallback, useEffect, useRef, useState } from "react";
//#region src/Markdown/SyntaxMarkdown/useSmoothStreamContent.ts
const PRESET_CONFIG = {
balanced: {
activeInputWindowMs: 220,
defaultCps: 38,
emaAlpha: .2,
flushCps: 120,
largeAppendChars: 120,
maxActiveCps: 132,
maxCps: 72,
maxFlushCps: 280,
minCps: 18,
settleAfterMs: 360,
settleDrainMaxMs: 520,
settleDrainMinMs: 180,
targetBufferMs: 120
},
realtime: {
activeInputWindowMs: 140,
defaultCps: 50,
emaAlpha: .3,
flushCps: 170,
largeAppendChars: 180,
maxActiveCps: 180,
maxCps: 96,
maxFlushCps: 360,
minCps: 24,
settleAfterMs: 260,
settleDrainMaxMs: 360,
settleDrainMinMs: 140,
targetBufferMs: 40
},
silky: {
activeInputWindowMs: 320,
defaultCps: 28,
emaAlpha: .14,
flushCps: 96,
largeAppendChars: 100,
maxActiveCps: 102,
maxCps: 56,
maxFlushCps: 220,
minCps: 14,
settleAfterMs: 460,
settleDrainMaxMs: 680,
settleDrainMinMs: 240,
targetBufferMs: 170
}
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const countChars = (text) => {
return [...text].length;
};
const useSmoothStreamContent = (content, { enabled = true, preset = "balanced" } = {}) => {
const config = PRESET_CONFIG[preset];
const [displayedContent, setDisplayedContent] = useState(content);
const displayedContentRef = useRef(content);
const displayedCountRef = useRef(countChars(content));
const targetContentRef = useRef(content);
const targetCharsRef = useRef([...content]);
const targetCountRef = useRef(targetCharsRef.current.length);
const emaCpsRef = useRef(config.defaultCps);
const lastInputTsRef = useRef(0);
const lastInputCountRef = useRef(targetCountRef.current);
const chunkSizeEmaRef = useRef(1);
const arrivalCpsEmaRef = useRef(config.defaultCps);
const rafRef = useRef(null);
const lastFrameTsRef = useRef(null);
const stopFrameLoop = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
lastFrameTsRef.current = null;
}, []);
const syncImmediate = useCallback((nextContent) => {
stopFrameLoop();
const chars = [...nextContent];
const now = performance.now();
targetContentRef.current = nextContent;
targetCharsRef.current = chars;
targetCountRef.current = chars.length;
displayedContentRef.current = nextContent;
displayedCountRef.current = chars.length;
setDisplayedContent(nextContent);
emaCpsRef.current = config.defaultCps;
chunkSizeEmaRef.current = 1;
arrivalCpsEmaRef.current = config.defaultCps;
lastInputTsRef.current = now;
lastInputCountRef.current = chars.length;
}, [config.defaultCps, stopFrameLoop]);
const startFrameLoop = useCallback(() => {
if (rafRef.current !== null) return;
const tick = (ts) => {
if (lastFrameTsRef.current === null) {
lastFrameTsRef.current = ts;
rafRef.current = requestAnimationFrame(tick);
return;
}
const dtSeconds = Math.max(.001, Math.min((ts - lastFrameTsRef.current) / 1e3, .05));
lastFrameTsRef.current = ts;
const targetCount = targetCountRef.current;
const displayedCount = displayedCountRef.current;
const backlog = targetCount - displayedCount;
if (backlog <= 0) {
stopFrameLoop();
return;
}
const idleMs = performance.now() - lastInputTsRef.current;
const inputActive = idleMs <= config.activeInputWindowMs;
const settling = !inputActive && idleMs >= config.settleAfterMs;
const baseCps = clamp(emaCpsRef.current, config.minCps, config.maxCps);
const baseLagChars = Math.max(1, Math.round(baseCps * config.targetBufferMs / 1e3));
const lagUpperBound = Math.max(baseLagChars + 2, baseLagChars * 3);
const targetLagChars = inputActive ? Math.round(clamp(baseLagChars + chunkSizeEmaRef.current * .35, baseLagChars, lagUpperBound)) : 0;
const desiredDisplayed = Math.max(0, targetCount - targetLagChars);
let currentCps;
if (inputActive) {
const backlogPressure = targetLagChars > 0 ? backlog / targetLagChars : 1;
const chunkPressure = targetLagChars > 0 ? chunkSizeEmaRef.current / targetLagChars : 1;
const arrivalPressure = arrivalCpsEmaRef.current / Math.max(baseCps, 1);
const combinedPressure = clamp(backlogPressure * .6 + chunkPressure * .25 + arrivalPressure * .15, 1, 4.5);
const activeCap = clamp(config.maxActiveCps + chunkSizeEmaRef.current * 6, config.maxActiveCps, config.maxFlushCps);
currentCps = clamp(baseCps * combinedPressure, config.minCps, activeCap);
} else if (settling) {
const drainTargetMs = clamp(backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs);
currentCps = clamp(backlog * 1e3 / drainTargetMs, config.flushCps, config.maxFlushCps);
} else currentCps = clamp(Math.max(config.flushCps, baseCps * 1.8, arrivalCpsEmaRef.current * .8), config.flushCps, config.maxFlushCps);
const urgentBacklog = inputActive && targetLagChars > 0 && backlog > targetLagChars * 2.2;
const burstyInput = inputActive && chunkSizeEmaRef.current >= targetLagChars * .9;
const minRevealChars = inputActive ? urgentBacklog || burstyInput ? 2 : 1 : 2;
let revealChars = Math.max(minRevealChars, Math.round(currentCps * dtSeconds));
if (inputActive) {
const shortfall = desiredDisplayed - displayedCount;
if (shortfall <= 0) {
rafRef.current = requestAnimationFrame(tick);
return;
}
revealChars = Math.min(revealChars, shortfall, backlog);
} else revealChars = Math.min(revealChars, backlog);
const nextCount = displayedCount + revealChars;
const segment = targetCharsRef.current.slice(displayedCount, nextCount).join("");
if (segment) {
const nextDisplayed = displayedContentRef.current + segment;
displayedContentRef.current = nextDisplayed;
displayedCountRef.current = nextCount;
setDisplayedContent(nextDisplayed);
} else {
displayedContentRef.current = targetContentRef.current;
displayedCountRef.current = targetCount;
setDisplayedContent(targetContentRef.current);
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
}, [
config.activeInputWindowMs,
config.flushCps,
config.maxActiveCps,
config.maxCps,
config.maxFlushCps,
config.minCps,
config.settleAfterMs,
config.settleDrainMaxMs,
config.settleDrainMinMs,
config.targetBufferMs,
stopFrameLoop
]);
useEffect(() => {
if (!enabled) {
syncImmediate(content);
return;
}
const prevTargetContent = targetContentRef.current;
if (content === prevTargetContent) return;
const now = performance.now();
if (!content.startsWith(prevTargetContent)) {
syncImmediate(content);
return;
}
const appendedChars = [...content.slice(prevTargetContent.length)];
const appendedCount = appendedChars.length;
if (appendedCount > config.largeAppendChars) {
syncImmediate(content);
return;
}
targetContentRef.current = content;
targetCharsRef.current = [...targetCharsRef.current, ...appendedChars];
targetCountRef.current += appendedCount;
const deltaChars = targetCountRef.current - lastInputCountRef.current;
const deltaMs = Math.max(1, now - lastInputTsRef.current);
if (deltaChars > 0) {
const instantCps = deltaChars * 1e3 / deltaMs;
const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 2);
const chunkEmaAlpha = .35;
chunkSizeEmaRef.current = chunkSizeEmaRef.current * (1 - chunkEmaAlpha) + appendedCount * chunkEmaAlpha;
arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * (1 - chunkEmaAlpha) + normalizedInstantCps * chunkEmaAlpha;
const clampedCps = clamp(instantCps, config.minCps, config.maxActiveCps);
emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + clampedCps * config.emaAlpha;
}
lastInputTsRef.current = now;
lastInputCountRef.current = targetCountRef.current;
startFrameLoop();
}, [
config.emaAlpha,
config.largeAppendChars,
config.maxActiveCps,
config.maxCps,
config.maxFlushCps,
config.minCps,
content,
enabled,
startFrameLoop,
syncImmediate
]);
useEffect(() => {
return () => {
stopFrameLoop();
};
}, [stopFrameLoop]);
return displayedContent;
};
//#endregion
export { useSmoothStreamContent };
//# sourceMappingURL=useSmoothStreamContent.mjs.map