mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[eric] chat: rewrite stream reveal as a buffered constant-velocity controller to kill the pump-pump stutter
This commit is contained in:
@@ -6,26 +6,28 @@ import { useEffect, useRef, useState } from 'react';
|
||||
* claude.ai does, so generated text reads like it's being typed rather than
|
||||
* dumped in clumps.
|
||||
*
|
||||
* Zero dependencies. Zero added TTFT: the first characters reveal on the very next
|
||||
* animation frame after the first delta (same frame budget as painting it directly).
|
||||
* The reveal rate is ADAPTIVE — it accelerates as the backlog grows, so display
|
||||
* never falls meaningfully behind the model and never reads as laggy. The rAF loop
|
||||
* runs ONLY while there's a backlog to drain and parks itself at zero cost once
|
||||
* caught up, so it adds no idle-frame churn.
|
||||
* Why the old "reveal backlog/4, floor 3 chars/frame" version felt like
|
||||
* "pump pump pump": that floor (~180 chars/sec) is FASTER than a model
|
||||
* generates (~90 chars/sec), so the display kept sprinting to catch up, then
|
||||
* FROZE waiting for the next token. Freeze-sprint-freeze at token frequency is
|
||||
* the choppiness.
|
||||
*
|
||||
* This version is a buffered constant-velocity controller:
|
||||
* - It deliberately stays ~TARGET_LAG seconds BEHIND the latest text, so there
|
||||
* is always a buffer to reveal and it never runs dry between tokens.
|
||||
* - Reveal is TIME-based (chars = rate * elapsed), so it's frame-rate
|
||||
* independent and survives a dropped frame without a visible jump.
|
||||
* - The reveal RATE is EMA-smoothed, so a burst ramps the speed up gently and
|
||||
* a lull ramps it down gently; the rate never steps, so the flow never pulses.
|
||||
* The rAF loop runs only while there's a backlog and parks at zero cost once
|
||||
* caught up. Zero added TTFT: the first characters still reveal in-render on the
|
||||
* very first frame content exists.
|
||||
*/
|
||||
|
||||
/** Pure pacing step (exported for testing): chars to reveal this frame. */
|
||||
export function smoothStep(shown: number, full: number): number {
|
||||
if (shown >= full) return full;
|
||||
const backlog = full - shown;
|
||||
// Floor of 3 chars/frame (~180 chars/sec at 60fps) for a calm typing feel,
|
||||
// and drain ~1/4 of any backlog on top of that so bursts catch up fast. The
|
||||
// /4 keeps mid-stream lag small (a few words at most), so when the live bubble
|
||||
// hands off to the final message at stream end there's no visible jump. Never
|
||||
// overshoots `full`.
|
||||
const step = Math.max(3, Math.ceil(backlog / 4));
|
||||
return Math.min(full, shown + step);
|
||||
}
|
||||
const TARGET_LAG_S = 0.35; // stay this far behind = the buffer that prevents stalls
|
||||
const RATE_SMOOTH_S = 0.25; // how fast the reveal speed eases toward its target
|
||||
const MAX_CPS = 1000; // cap so a huge paste/burst still reveals smoothly, not instantly
|
||||
const MAX_DT_S = 0.05; // clamp elapsed after a frame drop / tab switch so we don't leap
|
||||
|
||||
export function useSmoothText(target: string, enabled: boolean): string {
|
||||
const [shownLen, setShownLen] = useState(enabled ? 0 : target.length);
|
||||
@@ -33,40 +35,69 @@ export function useSmoothText(target: string, enabled: boolean): string {
|
||||
const targetRef = useRef(target);
|
||||
targetRef.current = target;
|
||||
|
||||
// Controller state lives in refs so the rAF loop reads the latest without the
|
||||
// effect re-subscribing every character.
|
||||
const posRef = useRef<number>(enabled ? 0 : target.length); // float reveal position
|
||||
const cpsRef = useRef<number>(0); // current reveal speed
|
||||
const lastRef = useRef<number>(0); // last frame timestamp
|
||||
const shownRef = useRef<number>(shownLen);
|
||||
shownRef.current = shownLen;
|
||||
|
||||
// ONE persistent loop, keyed only on `enabled`. It must NOT restart per token:
|
||||
// an effect that depends on target.length tears the rAF down and rebuilds it on
|
||||
// every delta, and that churn is what stalls the reveal. So the loop runs every
|
||||
// frame for the life of the stream, reads the latest text from a ref, and just
|
||||
// advances by 0 when it happens to be caught up (cheap, no stall, no parking).
|
||||
useEffect(() => {
|
||||
// Disabled (historical message, or smoothing turned off): show all, stop loop.
|
||||
if (!enabled) {
|
||||
if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||
posRef.current = targetRef.current.length;
|
||||
setShownLen(targetRef.current.length);
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
rafRef.current = null;
|
||||
setShownLen((cur) => {
|
||||
const next = smoothStep(cur, targetRef.current.length);
|
||||
if (next < targetRef.current.length) rafRef.current = requestAnimationFrame(tick);
|
||||
return next;
|
||||
});
|
||||
|
||||
const tick = (now: number) => {
|
||||
const full = targetRef.current.length;
|
||||
const dtRaw = lastRef.current ? (now - lastRef.current) / 1000 : 0.016;
|
||||
lastRef.current = now;
|
||||
const dt = dtRaw > MAX_DT_S ? MAX_DT_S : dtRaw;
|
||||
|
||||
const backlog = Math.max(0, full - posRef.current);
|
||||
const desired = backlog / TARGET_LAG_S; // speed that holds the lag steady (0 when caught up)
|
||||
const k = Math.min(1, dt / RATE_SMOOTH_S);
|
||||
let cps = cpsRef.current + (desired - cpsRef.current) * k; // EMA-smooth the speed itself, both up and down
|
||||
if (cps > MAX_CPS) cps = MAX_CPS;
|
||||
if (cps < 0) cps = 0;
|
||||
cpsRef.current = cps;
|
||||
|
||||
if (backlog > 0) {
|
||||
posRef.current = Math.min(full, posRef.current + cps * dt);
|
||||
const nextLen = Math.floor(posRef.current);
|
||||
if (nextLen !== shownRef.current) setShownLen(nextLen);
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick); // keep running for the whole stream
|
||||
};
|
||||
// Start a drain only if we're behind and no loop is already running.
|
||||
if (rafRef.current == null && shownLen < target.length) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
lastRef.current = 0;
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
|
||||
};
|
||||
}, [enabled, target.length, shownLen]);
|
||||
}, [enabled]);
|
||||
|
||||
// Target shrank (new turn / reset / branch switch): re-sync so we don't slice
|
||||
// past the end of a shorter string.
|
||||
// past the end of a shorter string and so a fresh turn starts from zero.
|
||||
useEffect(() => {
|
||||
if (shownLen > target.length) setShownLen(enabled ? 0 : target.length);
|
||||
}, [target.length, shownLen, enabled]);
|
||||
if (posRef.current > target.length) {
|
||||
posRef.current = enabled ? 0 : target.length;
|
||||
cpsRef.current = 0;
|
||||
lastRef.current = 0;
|
||||
setShownLen(enabled ? 0 : target.length);
|
||||
}
|
||||
}, [target.length, enabled]);
|
||||
|
||||
// ZERO added TTFT: on the very first frame content exists (shownLen still 0),
|
||||
// reveal the floor immediately in-render instead of waiting a frame for the rAF
|
||||
// tick. Pure derivation, no extra render — so first visible text lands on the
|
||||
// exact same frame it would have without smoothing. State catches up next frame.
|
||||
// ZERO added TTFT: on the very first frame content exists, reveal a few chars
|
||||
// in-render instead of waiting a frame for the first rAF tick.
|
||||
if (!enabled) return target;
|
||||
const effectiveShown = (shownLen === 0 && target.length > 0)
|
||||
? Math.min(3, target.length)
|
||||
|
||||
Reference in New Issue
Block a user