Files
tutti/packages/agent/gui/shared/useStreamingVisibleText.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

151 lines
3.6 KiB
TypeScript

import {
startTransition,
useEffect,
useRef,
useState,
type MutableRefObject
} from "react";
export interface StreamingVisibleTextOptions {
enabled: boolean;
frameMs?: number;
maxCharsPerSecond?: number;
trailingFlushChars?: number;
}
const DEFAULT_FRAME_MS = 24;
const DEFAULT_MAX_CHARS_PER_SECOND = 6_000;
const DEFAULT_TRAILING_FLUSH_CHARS = 0;
export function useStreamingVisibleText(
sourceText: string,
options: StreamingVisibleTextOptions
): string {
const {
enabled,
frameMs = DEFAULT_FRAME_MS,
maxCharsPerSecond = DEFAULT_MAX_CHARS_PER_SECOND,
trailingFlushChars = DEFAULT_TRAILING_FLUSH_CHARS
} = options;
const [visibleText, setVisibleText] = useState(sourceText);
const sourceRef = useRef(sourceText);
const visibleRef = useRef(visibleText);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
visibleRef.current = visibleText;
}, [visibleText]);
useEffect(
() => () => {
clearStreamingVisibleTextTimer(timerRef);
},
[]
);
useEffect(() => {
sourceRef.current = sourceText;
if (!enabled) {
clearStreamingVisibleTextTimer(timerRef);
visibleRef.current = sourceText;
setVisibleText(sourceText);
return;
}
if (sourceText === visibleRef.current || timerRef.current !== null) {
return;
}
timerRef.current = setTimeout(
() => {
timerRef.current = null;
const nextVisibleText = advanceStreamingVisibleText({
visibleText: visibleRef.current,
sourceText: sourceRef.current,
frameMs,
maxCharsPerSecond,
trailingFlushChars
});
if (nextVisibleText === visibleRef.current) {
return;
}
visibleRef.current = nextVisibleText;
startTransition(() => {
setVisibleText(nextVisibleText);
});
},
Math.max(1, frameMs)
);
return undefined;
}, [
enabled,
frameMs,
maxCharsPerSecond,
sourceText,
trailingFlushChars,
visibleText
]);
return enabled ? visibleText : sourceText;
}
export function advanceStreamingVisibleText({
visibleText,
sourceText,
frameMs = DEFAULT_FRAME_MS,
maxCharsPerSecond = DEFAULT_MAX_CHARS_PER_SECOND,
trailingFlushChars = DEFAULT_TRAILING_FLUSH_CHARS
}: {
visibleText: string;
sourceText: string;
frameMs?: number;
maxCharsPerSecond?: number;
trailingFlushChars?: number;
}): string {
if (visibleText === sourceText) {
return visibleText;
}
const prefixLength = sourceText.startsWith(visibleText)
? visibleText.length
: commonPrefixLength(visibleText, sourceText);
const stablePrefix = sourceText.slice(0, prefixLength);
const remainingLength = sourceText.length - prefixLength;
if (remainingLength <= trailingFlushChars) {
return sourceText;
}
const charsPerFrame = Math.max(
1,
Math.ceil((Math.max(1, maxCharsPerSecond) * Math.max(1, frameMs)) / 1000)
);
return sourceText.slice(
0,
Math.min(sourceText.length, stablePrefix.length + charsPerFrame)
);
}
function commonPrefixLength(left: string, right: string): number {
const maxLength = Math.min(left.length, right.length);
for (let index = 0; index < maxLength; index += 1) {
if (left.charCodeAt(index) !== right.charCodeAt(index)) {
return index;
}
}
return maxLength;
}
function clearStreamingVisibleTextTimer(
timerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
): void {
if (timerRef.current === null) {
return;
}
clearTimeout(timerRef.current);
timerRef.current = null;
}