feat(consult): type out streamed text at a capped pace and write the tail out on settle instead of one frame (T3, BUG-1075)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
86d99115c6
commit
3f8b817261
@@ -7,39 +7,62 @@
|
||||
* happens per animation frame. Answer and thinking text are released at a
|
||||
* steady per-frame pace so a burst of chunks reads as flowing text instead of
|
||||
* a jump, while a large backlog (reconnect, slow tab) catches up in roughly a
|
||||
* dozen frames.
|
||||
* dozen frames. When the stream ends, what is still unreleased is written out
|
||||
* at the same pace within a short budget instead of appearing in one frame
|
||||
* (BUG-1075); only a stop, a failure or a hidden document releases at once.
|
||||
*
|
||||
* Pure release arithmetic lives in exported functions so the policy is
|
||||
* testable without a DOM; scheduling is injectable for the same reason.
|
||||
*/
|
||||
|
||||
export const STREAM_RELEASE_MIN_CHARS = 2;
|
||||
/**
|
||||
* Typing pace: at most this many characters per frame while the backlog is
|
||||
* ordinary (BUG-1075). Four a frame is about 240 characters a second at 60 fps,
|
||||
* above what a model produces on average, so the backlog does not grow; a
|
||||
* server-held lump (the 160-character answer release, a whole short follow-up)
|
||||
* reads as writing instead of appearing at once.
|
||||
*/
|
||||
export const STREAM_RELEASE_MAX_CHARS = 4;
|
||||
export const STREAM_RELEASE_CATCHUP_DIVISOR = 12;
|
||||
/** A backlog above this (reconnect, slow tab) catches up in about twelve frames instead of typing it out. */
|
||||
export const STREAM_RELEASE_CATCHUP_CHARS = 600;
|
||||
/** A paced settle writes out what is left within this many frames (1.5 s at 60 fps). */
|
||||
export const STREAM_SETTLE_MAX_FRAMES = 90;
|
||||
export const STREAM_HIDDEN_FLUSH_MS = 250;
|
||||
|
||||
/**
|
||||
* Characters to reveal on one frame. `backlogChars` is how much was waiting
|
||||
* when the newest text arrived: dividing that by twelve clears any burst in
|
||||
* about twelve frames, while the two-character floor keeps a slow model from
|
||||
* reading as stalled. Callers without a backlog figure pass the pending count.
|
||||
* when the newest text arrived. An ordinary backlog is typed out at up to
|
||||
* STREAM_RELEASE_MAX_CHARS a frame, with a two-character floor so a slow model
|
||||
* never reads as stalled; a backlog above STREAM_RELEASE_CATCHUP_CHARS is
|
||||
* divided by twelve so any burst clears in about twelve frames. Callers
|
||||
* without a backlog figure pass the pending count.
|
||||
*/
|
||||
export function streamReleaseCount(pendingChars: number, backlogChars = pendingChars): number {
|
||||
if (pendingChars <= 0) return 0;
|
||||
return Math.min(
|
||||
pendingChars,
|
||||
Math.max(STREAM_RELEASE_MIN_CHARS, Math.ceil(backlogChars / STREAM_RELEASE_CATCHUP_DIVISOR)),
|
||||
);
|
||||
const catchUp = Math.ceil(backlogChars / STREAM_RELEASE_CATCHUP_DIVISOR);
|
||||
const perFrame = backlogChars > STREAM_RELEASE_CATCHUP_CHARS
|
||||
? catchUp
|
||||
: Math.min(STREAM_RELEASE_MAX_CHARS, catchUp);
|
||||
return Math.min(pendingChars, Math.max(STREAM_RELEASE_MIN_CHARS, perFrame));
|
||||
}
|
||||
|
||||
/** Characters per frame that write out `pendingChars` within the settle budget, never slower than the typing pace. */
|
||||
export function settleReleaseCount(pendingChars: number): number {
|
||||
if (pendingChars <= 0) return 0;
|
||||
return Math.max(STREAM_RELEASE_MAX_CHARS, Math.ceil(pendingChars / STREAM_SETTLE_MAX_FRAMES));
|
||||
}
|
||||
|
||||
/** Advance a released prefix toward its target by one frame's worth of text. */
|
||||
export function advanceStreamRelease(released: string, target: string, backlogChars?: number): string {
|
||||
export function advanceStreamRelease(released: string, target: string, backlogChars?: number, perFrame?: number): string {
|
||||
if (!target.startsWith(released)) {
|
||||
// The target was replaced rather than extended: restart from its head.
|
||||
return target.slice(0, streamReleaseCount(target.length, backlogChars ?? target.length));
|
||||
return target.slice(0, perFrame ?? streamReleaseCount(target.length, backlogChars ?? target.length));
|
||||
}
|
||||
const pending = target.length - released.length;
|
||||
if (pending <= 0) return target;
|
||||
return target.slice(0, released.length + streamReleaseCount(pending, backlogChars ?? pending));
|
||||
return target.slice(0, released.length + (perFrame ?? streamReleaseCount(pending, backlogChars ?? pending)));
|
||||
}
|
||||
|
||||
export type StreamFrameSnapshot<Meta> = Readonly<{
|
||||
@@ -70,8 +93,15 @@ export type StreamFrameBuffer<Meta> = Readonly<{
|
||||
setMeta: (next: Meta | ((current: Meta) => Meta)) => void;
|
||||
/** Publish meta-only changes (timeline rows, activity) on the next frame. */
|
||||
touch: () => void;
|
||||
/** Release everything received and flush synchronously. */
|
||||
settle: () => void;
|
||||
/**
|
||||
* The stream has ended. By default what is still unreleased is written out
|
||||
* at the typing pace within STREAM_SETTLE_MAX_FRAMES, and the last frame
|
||||
* flushes with `settled: true`; the promise resolves on that frame. With
|
||||
* `immediate` (stop, failure, disconnect) everything is released in one
|
||||
* synchronous flush, as before BUG-1075. A hidden document always releases
|
||||
* at once.
|
||||
*/
|
||||
settle: (options?: Readonly<{ immediate?: boolean }>) => Promise<void>;
|
||||
/** Drop everything, including scheduled work, without flushing. */
|
||||
reset: (meta?: Meta) => void;
|
||||
dispose: () => void;
|
||||
@@ -107,6 +137,10 @@ export function createStreamFrameBuffer<Meta>(
|
||||
let disposed = false;
|
||||
let frameHandle: number | null = null;
|
||||
let timeoutHandle: number | null = null;
|
||||
// A paced settle in flight: its per-frame count, and who to tell when the
|
||||
// last frame has flushed. dispose() during it defers until that frame.
|
||||
let settling: { perFrame: number; resolve: () => void } | null = null;
|
||||
let disposeWhenSettled = false;
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (frameHandle !== null) {
|
||||
@@ -128,6 +162,16 @@ export function createStreamFrameBuffer<Meta>(
|
||||
});
|
||||
};
|
||||
|
||||
const finishSettle = () => {
|
||||
const done = settling;
|
||||
settling = null;
|
||||
if (disposeWhenSettled) {
|
||||
disposeWhenSettled = false;
|
||||
disposed = true;
|
||||
}
|
||||
done?.resolve();
|
||||
};
|
||||
|
||||
const step = () => {
|
||||
frameHandle = null;
|
||||
timeoutHandle = null;
|
||||
@@ -135,6 +179,9 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (scheduler.hidden()) {
|
||||
releasedAnswer = targetAnswer;
|
||||
releasedThinking = targetThinking;
|
||||
} else if (settling) {
|
||||
releasedAnswer = advanceStreamRelease(releasedAnswer, targetAnswer, answerBacklog, settling.perFrame);
|
||||
releasedThinking = targetThinking;
|
||||
} else {
|
||||
releasedAnswer = advanceStreamRelease(releasedAnswer, targetAnswer, answerBacklog);
|
||||
releasedThinking = advanceStreamRelease(releasedThinking, targetThinking, thinkingBacklog);
|
||||
@@ -143,7 +190,11 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (releasedThinking === targetThinking) thinkingBacklog = 0;
|
||||
const caughtUp = releasedAnswer === targetAnswer && releasedThinking === targetThinking;
|
||||
emit(caughtUp);
|
||||
if (!caughtUp) schedule();
|
||||
if (!caughtUp) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
if (settling) finishSettle();
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
@@ -177,17 +228,38 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (disposed) return;
|
||||
schedule();
|
||||
},
|
||||
settle() {
|
||||
if (disposed) return;
|
||||
cancelScheduled();
|
||||
releasedAnswer = targetAnswer;
|
||||
releasedThinking = targetThinking;
|
||||
answerBacklog = 0;
|
||||
thinkingBacklog = 0;
|
||||
emit(true);
|
||||
settle(settleOptions) {
|
||||
if (disposed) return Promise.resolve();
|
||||
if (settling) {
|
||||
// A second settle while one is writing out: an immediate one takes
|
||||
// over and flushes now; a paced one just waits for the first.
|
||||
if (!settleOptions?.immediate) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const previous = settling!.resolve;
|
||||
settling!.resolve = () => { previous(); resolve(); };
|
||||
});
|
||||
}
|
||||
cancelScheduled();
|
||||
}
|
||||
const pending = pendingChars(releasedAnswer, targetAnswer);
|
||||
if (settleOptions?.immediate || scheduler.hidden() || pending <= 0) {
|
||||
cancelScheduled();
|
||||
releasedAnswer = targetAnswer;
|
||||
releasedThinking = targetThinking;
|
||||
answerBacklog = 0;
|
||||
thinkingBacklog = 0;
|
||||
emit(true);
|
||||
if (settling) finishSettle();
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
settling = { perFrame: settleReleaseCount(pending), resolve };
|
||||
schedule();
|
||||
});
|
||||
},
|
||||
reset(nextMeta) {
|
||||
cancelScheduled();
|
||||
if (settling) finishSettle();
|
||||
targetAnswer = "";
|
||||
targetThinking = "";
|
||||
releasedAnswer = "";
|
||||
@@ -197,6 +269,11 @@ export function createStreamFrameBuffer<Meta>(
|
||||
if (nextMeta !== undefined) meta = nextMeta;
|
||||
},
|
||||
dispose() {
|
||||
if (settling) {
|
||||
// Let the paced settle write out its last frames; it disposes itself.
|
||||
disposeWhenSettled = true;
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
cancelScheduled();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user