mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[eric] streaming: the reveal commits every frame and its lag tracks the arrival interval; the CLI hands text over in 90-char lumps every 580 ms and 60 ms commits read as words popping
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6253429050
commit
0001dfe9af
@@ -0,0 +1,30 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { lagForGap, nextGapEma } from './useSmoothText';
|
||||
|
||||
// The CLI hands text over in ~90-char lumps every ~580 ms; a fixed 0.35 s lag drained each lump and
|
||||
// then idled, and a 60 ms commit painted 12 characters at a time, which together read as "chunky".
|
||||
// The lag now tracks the arrival interval so the reveal always has the next lump in hand, and the
|
||||
// reveal commits every frame (3 characters a frame, measured free on a 9,000-char reply).
|
||||
|
||||
test('a lumpy lane gets a lag longer than its interval; a fine lane keeps the floor', () => {
|
||||
assert.equal(lagForGap(0.58), 0.725);
|
||||
assert.equal(lagForGap(0.05), 0.3);
|
||||
assert.equal(lagForGap(null), 0.3);
|
||||
assert.equal(lagForGap(5), 0.9);
|
||||
});
|
||||
|
||||
test('the interval estimate seeds on the first gap and eases toward later ones', () => {
|
||||
const first = nextGapEma(null, 0.6);
|
||||
assert.equal(first, 0.6);
|
||||
const second = nextGapEma(first, 0.2);
|
||||
assert.ok(second < 0.6 && second > 0.2, String(second));
|
||||
});
|
||||
|
||||
test('the reveal commits every frame, not in 60 ms word-sized steps', () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/bubbles/useSmoothText.ts'), 'utf8');
|
||||
const m = src.match(/const COMMIT_MS = (\d+);/);
|
||||
assert.ok(m && Number(m[1]) <= 17, `COMMIT_MS is ${m && m[1]}`);
|
||||
});
|
||||
@@ -40,7 +40,7 @@ test('the reveal still advances, it just advances on commits', () => {
|
||||
test('the typed feel is kept rather than dropped', () => {
|
||||
// hermes renders streamed text with a memoised parse, a caret, and no pacing at all. That is the
|
||||
// same conclusion one step further; the velocity model is what makes this read like typing.
|
||||
for (const knob of ['TARGET_LAG_S', 'RATE_SMOOTH_S', 'MAX_CPS']) {
|
||||
for (const knob of ['LAG_MIN_S', 'lagForGap', 'RATE_SMOOTH_S', 'MAX_CPS']) {
|
||||
assert.ok(smooth.includes(knob), `${knob} is part of the feel this exists for`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,13 +34,32 @@ import { useEffect, useRef, useState } from 'react';
|
||||
* further; keeping the velocity model preserves the typed feel this was built for.
|
||||
*/
|
||||
|
||||
const TARGET_LAG_S = 0.35; // stay this far behind = the buffer that prevents stalls
|
||||
// The lag is the buffer that prevents stalls, so it has to be at least one arrival interval: the
|
||||
// CLI hands us text in ~90-char lumps every ~580 ms (measured 2026-09-03 at the SDK boundary, while
|
||||
// the router underneath streamed every 30-90 ms), and a fixed 0.35 s drained each lump and then sat
|
||||
// idle for the rest of the gap, which read as burst, pause, burst. Fine-grained lanes keep the floor.
|
||||
const LAG_MIN_S = 0.3;
|
||||
const LAG_MAX_S = 0.9;
|
||||
const LAG_GAP_RATIO = 1.25;
|
||||
const GAP_EMA = 0.3;
|
||||
|
||||
export function lagForGap(gapEmaS: number | null): number {
|
||||
if (gapEmaS == null || !Number.isFinite(gapEmaS) || gapEmaS <= 0) return LAG_MIN_S;
|
||||
return Math.min(LAG_MAX_S, Math.max(LAG_MIN_S, gapEmaS * LAG_GAP_RATIO));
|
||||
}
|
||||
|
||||
/** EMA of the interval between text arrivals; the first arrival seeds it. */
|
||||
export function nextGapEma(prev: number | null, gapS: number): number {
|
||||
if (prev == null) return gapS;
|
||||
return prev + (gapS - prev) * GAP_EMA;
|
||||
}
|
||||
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
|
||||
// 60ms ~= 17fps, which still reads as typing. It was 150ms when a per-frame DOM write hid the
|
||||
// commit cadence; with React the only writer, that cadence IS the reveal, and 150 looked stepped.
|
||||
const COMMIT_MS = 60;
|
||||
// Every frame. At 60 ms the eye got 12 characters every 67 ms, which reads as words popping (Eric:
|
||||
// "chunky"); at 16 ms it gets 3 per frame. Measured 2026-09-03 on a 9,000-char reply: frame time
|
||||
// p50/p90/p99 17/17/18 ms in both arms, 8 vs 2 frames over 25 ms across 49 s, so a commit per frame is free.
|
||||
const COMMIT_MS = 16;
|
||||
|
||||
export function useSmoothText(
|
||||
target: string,
|
||||
@@ -58,6 +77,8 @@ export function useSmoothText(
|
||||
const lastRef = useRef<number>(0); // last frame timestamp
|
||||
const committedRef = useRef<number>(committedLen);
|
||||
const lastCommitAtRef = useRef<number>(0);
|
||||
const lastArrivalRef = useRef<number>(0);
|
||||
const gapEmaRef = useRef<number | null>(null);
|
||||
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const tickRef = useRef<((now: number) => void) | null>(null);
|
||||
@@ -78,7 +99,7 @@ export function useSmoothText(
|
||||
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 desired = backlog / lagForGap(gapEmaRef.current); // 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;
|
||||
@@ -121,6 +142,10 @@ export function useSmoothText(
|
||||
// persistent-loop comment above warns about); it only restarts one that parked itself at idle.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Every growth is an arrival; the interval between them sizes the lag above.
|
||||
const now = performance.now();
|
||||
if (lastArrivalRef.current) gapEmaRef.current = nextGapEma(gapEmaRef.current, (now - lastArrivalRef.current) / 1000);
|
||||
lastArrivalRef.current = now;
|
||||
if (rafRef.current === null && tickRef.current && posRef.current < target.length) {
|
||||
lastRef.current = 0;
|
||||
rafRef.current = requestAnimationFrame(tickRef.current);
|
||||
@@ -132,6 +157,8 @@ export function useSmoothText(
|
||||
if (posRef.current > target.length) {
|
||||
posRef.current = enabled ? 0 : target.length;
|
||||
cpsRef.current = 0;
|
||||
lastArrivalRef.current = 0;
|
||||
gapEmaRef.current = null;
|
||||
lastRef.current = 0;
|
||||
committedRef.current = enabled ? 0 : target.length;
|
||||
setCommittedLen(enabled ? 0 : target.length);
|
||||
|
||||
Reference in New Issue
Block a user