mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
fix(agents): pace streaming reasoning, cut only between graphemes, cap at 60 Hz
- Reasoning in view streams at the reply's pace instead of in bursts. - The painted end of a streaming text falls between graphemes (Intl.Segmenter): an emoji with joiners or a skin tone, a flag or a letter with combining marks no longer flashes in parts. A chunk ending mid-cluster holds that fragment back until the rest arrives. - The reveal commits at most at 60 Hz, so a 120 Hz display doesn't re-render the Markdown twice as often.
This commit is contained in:
@@ -1185,8 +1185,13 @@ function ThoughtEntry({
|
||||
working: boolean;
|
||||
onDiagramDrawn: () => void;
|
||||
}) {
|
||||
const { thinking } = useAgentSettings();
|
||||
const { thinking, smoothText } = useAgentSettings();
|
||||
const open = thinking === "expanded" || (thinking === "auto" && working);
|
||||
// Streaming reasoning in view is revealed at the reply's pace; folded, nothing is painted.
|
||||
const shown = useRevealedText(text, working && smoothText && open);
|
||||
useLayoutEffect(() => {
|
||||
if (working && open) onDiagramDrawn();
|
||||
}, [shown, working, open, onDiagramDrawn]);
|
||||
return (
|
||||
<div className={styles.process}>
|
||||
<Fold
|
||||
@@ -1206,7 +1211,7 @@ function ThoughtEntry({
|
||||
}
|
||||
>
|
||||
<div className={styles.thoughtBody}>
|
||||
<Markdown text={text} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
<Markdown text={shown} cwd={task.cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
</div>
|
||||
</Fold>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { REVEAL_HORIZON_MS, revealStep, useRevealedText } from "./reveal";
|
||||
import { REVEAL_HORIZON_MS, revealBoundary, revealStep, useRevealedText, withoutBrokenTail } from "./reveal";
|
||||
|
||||
describe("revealStep", () => {
|
||||
it("drains the backlog over the horizon, at least a character a frame", () => {
|
||||
@@ -13,6 +13,25 @@ describe("revealStep", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("revealBoundary", () => {
|
||||
it("cuts only between graphemes", () => {
|
||||
expect(revealBoundary("a👋b", 2)).toBe(1);
|
||||
expect(revealBoundary("ae\u0301", 2)).toBe(1);
|
||||
expect(revealBoundary("a🇯🇵", 3)).toBe(1);
|
||||
expect(revealBoundary("abc", 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutBrokenTail", () => {
|
||||
it("holds back a cluster's first half until the rest arrives", () => {
|
||||
expect(withoutBrokenTail("a\ud83d")).toBe("a");
|
||||
expect(withoutBrokenTail("a👨\u200d")).toBe("a👨");
|
||||
expect(withoutBrokenTail("a🇯")).toBe("a");
|
||||
expect(withoutBrokenTail("a🇯🇵")).toBe("a🇯🇵");
|
||||
expect(withoutBrokenTail("done")).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRevealedText", () => {
|
||||
let frames: FrameRequestCallback[] = [];
|
||||
let now = 0;
|
||||
@@ -48,13 +67,28 @@ describe("useRevealedText", () => {
|
||||
expect(result.current.length).toBe(102);
|
||||
});
|
||||
|
||||
it("never paints half an emoji", () => {
|
||||
it("never paints part of a grapheme", () => {
|
||||
const family = "👨👩👧";
|
||||
const { result, rerender } = renderHook(({ text }) => useRevealedText(text, true), {
|
||||
initialProps: { text: "a" },
|
||||
});
|
||||
rerender({ text: "a👋" });
|
||||
frame(1);
|
||||
expect(result.current).toBe("a👋");
|
||||
rerender({ text: `a${family}` });
|
||||
for (let i = 0; i < 20 && result.current !== `a${family}`; i++) {
|
||||
frame(16);
|
||||
expect([`a`, `a${family}`]).toContain(result.current);
|
||||
}
|
||||
expect(result.current).toBe(`a${family}`);
|
||||
});
|
||||
|
||||
it("commits at most at 60 Hz on a faster display", () => {
|
||||
const { result, rerender } = renderHook(({ text }) => useRevealedText(text, true), {
|
||||
initialProps: { text: "" },
|
||||
});
|
||||
rerender({ text: "x".repeat(1000) });
|
||||
frame(8);
|
||||
expect(result.current).toBe("");
|
||||
frame(8);
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows text whole once it stops streaming or is replaced", () => {
|
||||
|
||||
@@ -11,6 +11,11 @@ import { useEffect, useState } from "react";
|
||||
export const REVEAL_HORIZON_MS = 150;
|
||||
/** A frame stalled longer than this (a background tab) counts as this long. */
|
||||
const MAX_FRAME_MS = 250;
|
||||
/**
|
||||
* Commits at most at 60 Hz: a 120 Hz display would otherwise re-render the reply's Markdown
|
||||
* twice as often for no visible gain. The slack absorbs frame jitter on a 60 Hz display.
|
||||
*/
|
||||
const FRAME_MS = 1000 / 60 - 2;
|
||||
|
||||
/** Characters to reveal in a frame `elapsed` ms after the last, at least one while behind. */
|
||||
export function revealStep(backlog: number, elapsed: number, horizon = REVEAL_HORIZON_MS): number {
|
||||
@@ -20,10 +25,36 @@ export function revealStep(backlog: number, elapsed: number, horizon = REVEAL_HO
|
||||
return Math.min(backlog, Math.max(1, Math.ceil((backlog * ms) / horizon)));
|
||||
}
|
||||
|
||||
/** `count` moved past a lone UTF-16 high surrogate, so an emoji never paints half. */
|
||||
function boundary(text: string, count: number): number {
|
||||
const graphemes =
|
||||
typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null;
|
||||
|
||||
/**
|
||||
* `count` pulled back to the start of the grapheme it falls in, so a cluster (an emoji with
|
||||
* its joiners or skin tone, a flag, a letter with its combining marks) never paints in parts.
|
||||
* Only the painted end moves: the count still advances, so a long cluster can't stall.
|
||||
*/
|
||||
export function revealBoundary(text: string, count: number): number {
|
||||
if (count <= 0 || count >= text.length) return Math.max(0, Math.min(count, text.length));
|
||||
if (graphemes) return graphemes.segment(text).containing(count)?.index ?? count;
|
||||
const c = text.charCodeAt(count - 1);
|
||||
return c >= 0xd800 && c <= 0xdbff ? Math.min(count + 1, text.length) : count;
|
||||
return c >= 0xd800 && c <= 0xdbff ? count - 1 : count;
|
||||
}
|
||||
|
||||
/**
|
||||
* `text` without a trailing fragment that waits on the rest of its cluster: a chunk can end
|
||||
* mid-cluster. A lone high surrogate, a trailing zero-width joiner, an unpaired regional
|
||||
* indicator (half a flag).
|
||||
*/
|
||||
export function withoutBrokenTail(text: string): string {
|
||||
const last = text.charCodeAt(text.length - 1);
|
||||
if ((last >= 0xd800 && last <= 0xdbff) || last === 0x200d) return text.slice(0, -1);
|
||||
let flags = 0;
|
||||
for (let i = text.length; i >= 2; i -= 2) {
|
||||
const cp = text.codePointAt(i - 2)!;
|
||||
if (cp < 0x1f1e6 || cp > 0x1f1ff) break;
|
||||
flags++;
|
||||
}
|
||||
return flags % 2 === 1 ? text.slice(0, -2) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,15 +74,14 @@ export function useRevealedText(text: string, live: boolean): string {
|
||||
if (!behind) return;
|
||||
let last = performance.now();
|
||||
let frame = requestAnimationFrame(function tick(now) {
|
||||
const elapsed = now - last;
|
||||
last = now;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
count: boundary(s.text, s.count + revealStep(s.text.length - s.count, elapsed)),
|
||||
}));
|
||||
frame = requestAnimationFrame(tick);
|
||||
const elapsed = now - last;
|
||||
if (elapsed < FRAME_MS) return;
|
||||
last = now;
|
||||
setState((s) => ({ ...s, count: s.count + revealStep(s.text.length - s.count, elapsed) }));
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [behind]);
|
||||
return behind ? text.slice(0, count) : text;
|
||||
if (behind) return text.slice(0, revealBoundary(text, count));
|
||||
return live ? withoutBrokenTail(text) : text;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user