type FrameHandle = number;
type SmoothTextStreamOptions = {
emit: (content: string) => void;
finalize?: () => void;
now?: () => number;
scheduleFrame?: (callback: () => void) => FrameHandle;
cancelFrame?: (handle: FrameHandle) => void;
frameMs?: number;
fallbackFrameMs?: number;
targetLagMs?: number;
maxLagMs?: number;
minCharsPerFrame?: number;
maxCharsPerFrame?: number;
};
export type SmoothTextStreamSnapshot = {
targetLength: number;
renderedLength: number;
averageCharsPerSecond: number;
pendingChars: number;
isScheduled: boolean;
};
const DEFAULT_FRAME_MS = 33;
const DEFAULT_TARGET_LAG_MS = 360;
const DEFAULT_MAX_LAG_MS = 650;
const DEFAULT_MIN_CHARS_PER_FRAME = 1;
const DEFAULT_MAX_CHARS_PER_FRAME = 36;
const DEFAULT_AVERAGE_CHARS_PER_SECOND = 90;
const DEFAULT_FALLBACK_FRAME_MS = 80;
const RATE_ALPHA = 0.22;
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function smooth(previous: number, next: number): number {
return previous * (1 - RATE_ALPHA) + next * RATE_ALPHA;
}
function isPreferredBoundary(char: string): boolean {
return /[\s,.;:!?,。!?、;:)\])}]/.test(char);
}
function findBoundary(content: string, minLength: number, desiredLength: number, maxLength: number): number {
const safeMax = clamp(maxLength, minLength, content.length);
const safeDesired = clamp(desiredLength, minLength, safeMax);
const backwardLimit = Math.max(minLength, safeDesired - 12);
for (let index = safeDesired; index >= backwardLimit; index -= 1) {
if (isPreferredBoundary(content[index - 1] || '')) {
return index;
}
}
const forwardLimit = Math.min(safeMax, safeDesired + 12);
for (let index = safeDesired + 1; index <= forwardLimit; index += 1) {
if (isPreferredBoundary(content[index - 1] || '')) {
return index;
}
}
return safeDesired;
}
export class SmoothTextStream {
private targetContent = '';
private renderedContent = '';
private frame: FrameHandle | null = null;
private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
private lastChunkAtMs: number | null = null;
private lastFrameAtMs: number | null = null;
private averageCharsPerSecond = DEFAULT_AVERAGE_CHARS_PER_SECOND;
constructor(private readonly options: SmoothTextStreamOptions) {}
append(text: string): void {
if (!text) return;
const now = this.now();
if (this.lastChunkAtMs != null) {
const intervalSeconds = clamp((now - this.lastChunkAtMs) / 1000, 0.016, 1.5);
const currentRate = text.length / intervalSeconds;
this.averageCharsPerSecond = smooth(this.averageCharsPerSecond, currentRate);
}
this.lastChunkAtMs = now;
this.targetContent += text;
this.emitInitialContent();
this.schedulePump();
}
flush(finalize = false): void {
this.cancelScheduledFrame();
if (this.renderedContent !== this.targetContent) {
this.renderedContent = this.targetContent;
this.options.emit(this.renderedContent);
}
if (finalize) {
this.options.finalize?.();
this.targetContent = '';
this.renderedContent = '';
this.lastChunkAtMs = null;
this.lastFrameAtMs = null;
}
}
cancel(): void {
this.cancelScheduledFrame();
}
getSnapshot(): SmoothTextStreamSnapshot {
const pendingChars = this.targetContent.length - this.renderedContent.length;
return {
targetLength: this.targetContent.length,
renderedLength: this.renderedContent.length,
averageCharsPerSecond: this.averageCharsPerSecond,
pendingChars,
isScheduled: this.frame != null,
};
}
private now(): number {
return this.options.now?.() ?? performance.now();
}
private scheduleFrame(callback: () => void): FrameHandle {
if (this.options.scheduleFrame) {
return this.options.scheduleFrame(callback);
}
return window.requestAnimationFrame(callback);
}
private cancelFrame(handle: FrameHandle): void {
if (this.options.cancelFrame) {
this.options.cancelFrame(handle);
return;
}
window.cancelAnimationFrame(handle);
}
private cancelScheduledFrame(): void {
if (this.frame != null) {
this.cancelFrame(this.frame);
this.frame = null;
}
this.cancelFallbackTimer();
}
private cancelFallbackTimer(): void {
if (this.fallbackTimer == null) return;
clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
}
private schedulePump(): void {
if (this.frame != null) return;
this.frame = this.scheduleFrame(() => this.pump());
this.scheduleFallbackPump();
}
private scheduleFallbackPump(): void {
if (this.options.scheduleFrame || this.fallbackTimer != null || typeof window === 'undefined') {
return;
}
this.fallbackTimer = window.setTimeout(() => {
this.fallbackTimer = null;
if (this.frame == null) return;
this.cancelFrame(this.frame);
this.frame = null;
this.pump();
}, this.options.fallbackFrameMs ?? DEFAULT_FALLBACK_FRAME_MS);
}
private emitInitialContent(): void {
if (this.renderedContent.length > 0 || this.targetContent.length === 0) {
return;
}
const charsToRender = this.getCharsForFrame(this.targetContent.length);
const minNextLength = Math.min(this.targetContent.length, this.minCharsPerFrame);
const maxNextLength = Math.min(this.targetContent.length, this.maxCharsPerFrame);
const nextLength = findBoundary(
this.targetContent,
minNextLength,
charsToRender,
maxNextLength,
);
this.renderedContent = this.targetContent.slice(0, nextLength);
this.options.emit(this.renderedContent);
}
private pump(): void {
this.frame = null;
this.cancelFallbackTimer();
const now = this.now();
if (this.lastFrameAtMs != null && now - this.lastFrameAtMs < this.frameMs * 0.8) {
this.schedulePump();
return;
}
this.lastFrameAtMs = now;
const remaining = this.targetContent.length - this.renderedContent.length;
if (remaining <= 0) return;
const charsToRender = this.getCharsForFrame(remaining);
const minNextLength = this.renderedContent.length + charsToRender;
const maxNextLength = Math.min(
this.targetContent.length,
this.renderedContent.length + this.maxCharsPerFrame,
);
const nextLength = findBoundary(
this.targetContent,
this.renderedContent.length + this.minCharsPerFrame,
minNextLength,
maxNextLength,
);
this.renderedContent = this.targetContent.slice(0, nextLength);
this.options.emit(this.renderedContent);
if (this.renderedContent.length < this.targetContent.length) {
this.schedulePump();
}
}
private get frameMs(): number {
return this.options.frameMs ?? DEFAULT_FRAME_MS;
}
private get targetLagMs(): number {
return this.options.targetLagMs ?? DEFAULT_TARGET_LAG_MS;
}
private get maxLagMs(): number {
return Math.max(this.targetLagMs, this.options.maxLagMs ?? DEFAULT_MAX_LAG_MS);
}
private get minCharsPerFrame(): number {
return this.options.minCharsPerFrame ?? DEFAULT_MIN_CHARS_PER_FRAME;
}
private get maxCharsPerFrame(): number {
return this.options.maxCharsPerFrame ?? DEFAULT_MAX_CHARS_PER_FRAME;
}
private getCharsForFrame(remaining: number): number {
const targetPendingChars = Math.max(
this.minCharsPerFrame,
Math.round(this.averageCharsPerSecond * (this.targetLagMs / 1000)),
);
const maxPendingChars = Math.max(
targetPendingChars,
Math.round(this.averageCharsPerSecond * (this.maxLagMs / 1000)),
);
const baseChars = Math.ceil(this.averageCharsPerSecond * (this.frameMs / 1000));
const excessChars = Math.max(0, remaining - targetPendingChars);
const catchUpChars = remaining > maxPendingChars ? Math.ceil((remaining - maxPendingChars) / 4) : 0;
const desired = Math.max(this.minCharsPerFrame, baseChars, Math.ceil(excessChars / 10), catchUpChars);
return clamp(desired, this.minCharsPerFrame, Math.min(this.maxCharsPerFrame, remaining));
}
}