dsh-smooth-stream 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +12 -10
- package/README.md +12 -10
- package/lib/client.js +1110 -349
- package/lib/client.js.map +1 -1
- package/package.json +3 -1
- package/src/client/FollowHost.tsx +49 -4
- package/src/client/TypewriterAssistantNodeView.module.css +1 -0
- package/src/client/TypewriterAssistantNodeView.tsx +148 -18
- package/src/client/TypewriterToolNodeView.tsx +183 -8
- package/src/client/index.ts +25 -8
- package/src/client/teleprompterGlide.ts +753 -233
- package/src/client/useProgressiveDomText.ts +206 -0
- package/src/client/useSmoothStreamContent.ts +139 -73
- package/src/config.ts +3 -6
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progressive text reveal for opaque Agent renderers.
|
|
3
|
+
*
|
|
4
|
+
* Slot renderers own arbitrary React trees, so the generic integration cannot
|
|
5
|
+
* clone or classify their business components. This hook leaves that tree and
|
|
6
|
+
* all of its event handlers in place, and only paces visible Text node data
|
|
7
|
+
* while the row belongs to the live Agent turn. No clip, mask, overlay, or
|
|
8
|
+
* duplicate accessibility tree is introduced.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useLayoutEffect, type RefObject } from 'react'
|
|
12
|
+
import { computeAdaptiveQueueStep } from './useSmoothStreamContent.ts'
|
|
13
|
+
|
|
14
|
+
interface TextRevealRecord {
|
|
15
|
+
chars: readonly string[]
|
|
16
|
+
full: string
|
|
17
|
+
shown: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SKIP_TEXT_SELECTOR = [
|
|
21
|
+
'[aria-hidden="true"]',
|
|
22
|
+
'[aria-live]',
|
|
23
|
+
'[contenteditable="true"]',
|
|
24
|
+
'script',
|
|
25
|
+
'style',
|
|
26
|
+
'textarea',
|
|
27
|
+
].join(',')
|
|
28
|
+
|
|
29
|
+
function revealable(node: Text, root: HTMLElement): boolean {
|
|
30
|
+
if (node.data.trim() === '') return false
|
|
31
|
+
const parent = node.parentElement
|
|
32
|
+
return parent !== null
|
|
33
|
+
&& root.contains(parent)
|
|
34
|
+
&& parent.closest(SKIP_TEXT_SELECTOR) === null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function commonPrefix(left: readonly string[], right: readonly string[]): number {
|
|
38
|
+
const limit = Math.min(left.length, right.length)
|
|
39
|
+
let index = 0
|
|
40
|
+
while (index < limit && left[index] === right[index]) index += 1
|
|
41
|
+
return index
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pace text inside a renderer whose React component is intentionally opaque.
|
|
46
|
+
* Initial content is revealed only for a genuinely new row; later mutations
|
|
47
|
+
* stay paced until `enabled` becomes false, at which point the full renderer
|
|
48
|
+
* content is restored synchronously before paint.
|
|
49
|
+
*/
|
|
50
|
+
export function useProgressiveDomText(
|
|
51
|
+
rootRef: RefObject<HTMLElement | null>,
|
|
52
|
+
enabled: boolean,
|
|
53
|
+
revealInitial: boolean,
|
|
54
|
+
speedCpsRef: { current: number },
|
|
55
|
+
onSettled?: (() => void) | undefined,
|
|
56
|
+
): void {
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (!enabled) return
|
|
59
|
+
const root = rootRef.current
|
|
60
|
+
if (root === null || typeof document === 'undefined') return
|
|
61
|
+
|
|
62
|
+
const records = new Map<Text, TextRevealRecord>()
|
|
63
|
+
const pending = new Set<Text>()
|
|
64
|
+
const internalWrites = new WeakMap<Text, string>()
|
|
65
|
+
let rafId = 0
|
|
66
|
+
let lastFrame: number | null = null
|
|
67
|
+
let debt = 0
|
|
68
|
+
let stopped = false
|
|
69
|
+
let announcedSettled = false
|
|
70
|
+
|
|
71
|
+
const announceSettled = (): void => {
|
|
72
|
+
lastFrame = null
|
|
73
|
+
debt = 0
|
|
74
|
+
speedCpsRef.current = 35
|
|
75
|
+
if (announcedSettled) return
|
|
76
|
+
announcedSettled = true
|
|
77
|
+
onSettled?.()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const write = (node: Text, value: string): void => {
|
|
81
|
+
if (node.data === value) return
|
|
82
|
+
internalWrites.set(node, value)
|
|
83
|
+
node.data = value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const enqueue = (node: Text, full: string, preserve: TextRevealRecord | undefined): void => {
|
|
87
|
+
if (!revealable(node, root)) return
|
|
88
|
+
const chars = [...full]
|
|
89
|
+
const preserved = preserve === undefined
|
|
90
|
+
? 0
|
|
91
|
+
: Math.min(preserve.shown, commonPrefix(preserve.chars, chars))
|
|
92
|
+
const record = { chars, full, shown: preserved }
|
|
93
|
+
records.set(node, record)
|
|
94
|
+
if (preserved < chars.length) {
|
|
95
|
+
pending.add(node)
|
|
96
|
+
announcedSettled = false
|
|
97
|
+
} else {
|
|
98
|
+
pending.delete(node)
|
|
99
|
+
}
|
|
100
|
+
write(node, chars.slice(0, preserved).join(''))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const visit = (from: Node, reveal: boolean): void => {
|
|
104
|
+
if (from.nodeType === Node.TEXT_NODE) {
|
|
105
|
+
const text = from as Text
|
|
106
|
+
if (reveal) enqueue(text, text.data, records.get(text))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT)
|
|
110
|
+
let current = walker.nextNode()
|
|
111
|
+
while (current !== null) {
|
|
112
|
+
const text = current as Text
|
|
113
|
+
if (reveal) enqueue(text, text.data, records.get(text))
|
|
114
|
+
current = walker.nextNode()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const scheduleFrame = (): void => {
|
|
119
|
+
if (stopped || pending.size === 0 || rafId !== 0) return
|
|
120
|
+
rafId = requestAnimationFrame(frame)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const frame = (now: number): void => {
|
|
124
|
+
rafId = 0
|
|
125
|
+
if (stopped) return
|
|
126
|
+
if (pending.size === 0) {
|
|
127
|
+
announceSettled()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
announcedSettled = false
|
|
131
|
+
if (lastFrame === null) {
|
|
132
|
+
lastFrame = now
|
|
133
|
+
scheduleFrame()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const elapsed = Math.max(0, now - lastFrame)
|
|
137
|
+
lastFrame = now
|
|
138
|
+
let backlog = 0
|
|
139
|
+
for (const node of pending) {
|
|
140
|
+
const record = records.get(node)
|
|
141
|
+
if (record !== undefined) backlog += record.chars.length - record.shown
|
|
142
|
+
}
|
|
143
|
+
const step = computeAdaptiveQueueStep(backlog, elapsed, debt)
|
|
144
|
+
debt = step.debt
|
|
145
|
+
speedCpsRef.current = step.speedCps
|
|
146
|
+
let remaining = step.revealChars
|
|
147
|
+
for (const node of [...pending]) {
|
|
148
|
+
if (remaining <= 0) break
|
|
149
|
+
const record = records.get(node)
|
|
150
|
+
if (record === undefined || !node.isConnected) {
|
|
151
|
+
pending.delete(node)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
const amount = Math.min(remaining, record.chars.length - record.shown)
|
|
155
|
+
const shown = record.shown + amount
|
|
156
|
+
const next = { ...record, shown }
|
|
157
|
+
records.set(node, next)
|
|
158
|
+
write(node, next.chars.slice(0, shown).join(''))
|
|
159
|
+
remaining -= amount
|
|
160
|
+
if (shown >= next.chars.length) pending.delete(node)
|
|
161
|
+
}
|
|
162
|
+
if (pending.size === 0) announceSettled()
|
|
163
|
+
else scheduleFrame()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (revealInitial) visit(root, true)
|
|
167
|
+
|
|
168
|
+
const observer = typeof MutationObserver === 'undefined'
|
|
169
|
+
? null
|
|
170
|
+
: new MutationObserver((mutations) => {
|
|
171
|
+
for (const mutation of mutations) {
|
|
172
|
+
if (mutation.type === 'characterData') {
|
|
173
|
+
const node = mutation.target as Text
|
|
174
|
+
const expected = internalWrites.get(node)
|
|
175
|
+
if (expected === node.data) {
|
|
176
|
+
internalWrites.delete(node)
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
enqueue(node, node.data, records.get(node))
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
for (const added of mutation.addedNodes) visit(added, true)
|
|
183
|
+
}
|
|
184
|
+
if (pending.size === 0) announceSettled()
|
|
185
|
+
else scheduleFrame()
|
|
186
|
+
})
|
|
187
|
+
observer?.observe(root, { childList: true, characterData: true, subtree: true })
|
|
188
|
+
if (pending.size === 0) announceSettled()
|
|
189
|
+
else scheduleFrame()
|
|
190
|
+
|
|
191
|
+
return () => {
|
|
192
|
+
stopped = true
|
|
193
|
+
cancelAnimationFrame(rafId)
|
|
194
|
+
observer?.disconnect()
|
|
195
|
+
for (const [node, record] of records) {
|
|
196
|
+
const controlled = record.chars.slice(0, record.shown).join('')
|
|
197
|
+
// React may have committed a terminal replacement in the same mutation
|
|
198
|
+
// phase that disables this hook. Never overwrite that newer renderer
|
|
199
|
+
// value with the previous controlled source during layout cleanup.
|
|
200
|
+
if (node.isConnected && node.data === controlled) write(node, record.full)
|
|
201
|
+
}
|
|
202
|
+
pending.clear()
|
|
203
|
+
speedCpsRef.current = 35
|
|
204
|
+
}
|
|
205
|
+
}, [enabled, onSettled, revealInitial, rootRef, speedCpsRef])
|
|
206
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* competes with visible frames when the frame rate is degraded.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
16
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
17
17
|
|
|
18
18
|
export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
|
|
19
19
|
|
|
@@ -26,7 +26,6 @@ export interface StreamSmoothingPresetConfig {
|
|
|
26
26
|
readonly maxActiveCps: number
|
|
27
27
|
readonly maxCps: number
|
|
28
28
|
readonly maxFlushCps: number
|
|
29
|
-
readonly minCommitIntervalMs: number
|
|
30
29
|
readonly minCps: number
|
|
31
30
|
readonly settleAfterMs: number
|
|
32
31
|
readonly settleDrainMaxMs: number
|
|
@@ -44,7 +43,6 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
|
|
|
44
43
|
maxActiveCps: 360,
|
|
45
44
|
maxCps: 240,
|
|
46
45
|
maxFlushCps: 480,
|
|
47
|
-
minCommitIntervalMs: 16,
|
|
48
46
|
minCps: 24,
|
|
49
47
|
settleAfterMs: 280,
|
|
50
48
|
settleDrainMaxMs: 420,
|
|
@@ -60,7 +58,6 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
|
|
|
60
58
|
maxActiveCps: 480,
|
|
61
59
|
maxCps: 320,
|
|
62
60
|
maxFlushCps: 640,
|
|
63
|
-
minCommitIntervalMs: 16,
|
|
64
61
|
minCps: 32,
|
|
65
62
|
settleAfterMs: 200,
|
|
66
63
|
settleDrainMaxMs: 280,
|
|
@@ -76,7 +73,6 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
|
|
|
76
73
|
maxActiveCps: 280,
|
|
77
74
|
maxCps: 180,
|
|
78
75
|
maxFlushCps: 400,
|
|
79
|
-
minCommitIntervalMs: 16,
|
|
80
76
|
minCps: 20,
|
|
81
77
|
settleAfterMs: 360,
|
|
82
78
|
settleDrainMaxMs: 520,
|
|
@@ -85,10 +81,11 @@ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetC
|
|
|
85
81
|
},
|
|
86
82
|
}
|
|
87
83
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
export const
|
|
91
|
-
const
|
|
84
|
+
/** Pressure curve from the reference stream renderer. */
|
|
85
|
+
export const QUEUE_BASE_SPEED_CPS = 90
|
|
86
|
+
export const QUEUE_ACCEL_EXPONENT = 1.25
|
|
87
|
+
export const QUEUE_PRESSURE_FACTOR = 0.85
|
|
88
|
+
export const QUEUE_MAX_SPEED_CPS = 600
|
|
92
89
|
/** Hard cap on how far display may trail a live stream, in characters. */
|
|
93
90
|
export const LIVE_LAG_CHAR_CEILING = 32
|
|
94
91
|
const CATCHUP_SECONDS = 0.15
|
|
@@ -98,16 +95,38 @@ export const clamp = (value: number, min: number, max: number): number => {
|
|
|
98
95
|
}
|
|
99
96
|
|
|
100
97
|
/**
|
|
101
|
-
*
|
|
102
|
-
* by frame time. A small queue types one glyph per frame; a burst raises the
|
|
103
|
-
* step so the display does not fall behind.
|
|
98
|
+
* Reference pressure-buffer reveal for a frame that starts without debt.
|
|
104
99
|
* @param backlog - Unrevealed characters.
|
|
105
100
|
* @param dtMs - Frame delta in ms.
|
|
106
101
|
* @returns Characters to reveal this frame.
|
|
107
102
|
*/
|
|
108
103
|
export function computeQueueReveal(backlog: number, dtMs: number): number {
|
|
109
104
|
if (backlog <= 0 || dtMs <= 0) return 0
|
|
110
|
-
return
|
|
105
|
+
return computeAdaptiveQueueStep(backlog, dtMs, 0).revealChars
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AdaptiveQueueStep {
|
|
109
|
+
readonly revealChars: number
|
|
110
|
+
readonly debt: number
|
|
111
|
+
readonly speedCps: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Float-debt queue integration from `ultimate_stream_physics_scroller.html`. */
|
|
115
|
+
export function computeAdaptiveQueueStep(
|
|
116
|
+
backlog: number,
|
|
117
|
+
dtMs: number,
|
|
118
|
+
debt: number,
|
|
119
|
+
revealScale = 1,
|
|
120
|
+
): AdaptiveQueueStep {
|
|
121
|
+
if (backlog <= 0 || dtMs <= 0) return { revealChars: 0, debt: 0, speedCps: 0 }
|
|
122
|
+
const speedCps = Math.min(
|
|
123
|
+
QUEUE_MAX_SPEED_CPS,
|
|
124
|
+
QUEUE_BASE_SPEED_CPS + Math.pow(backlog, QUEUE_ACCEL_EXPONENT) * QUEUE_PRESSURE_FACTOR,
|
|
125
|
+
)
|
|
126
|
+
const effectiveScale = clamp(revealScale, 0.05, 1)
|
|
127
|
+
const accumulated = Math.max(0, debt) + speedCps * effectiveScale * (dtMs / 1000)
|
|
128
|
+
const revealChars = Math.min(backlog, Math.floor(accumulated))
|
|
129
|
+
return { revealChars, debt: revealChars >= backlog ? 0 : accumulated - revealChars, speedCps }
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
/** Counts user-perceived characters (code points), not UTF-16 units. */
|
|
@@ -148,6 +167,20 @@ export function computeSettleDrain(config: StreamSmoothingPresetConfig, input: S
|
|
|
148
167
|
return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps)
|
|
149
168
|
}
|
|
150
169
|
|
|
170
|
+
/** Fixed velocity that closes a producer-complete queue within its deadline. */
|
|
171
|
+
export function computeCompletionDrain(
|
|
172
|
+
config: StreamSmoothingPresetConfig,
|
|
173
|
+
backlog: number,
|
|
174
|
+
): number {
|
|
175
|
+
if (backlog <= 0) return 0
|
|
176
|
+
const drainTargetMs = clamp(backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs)
|
|
177
|
+
const deadlineCps = (backlog * 1000) / drainTargetMs
|
|
178
|
+
return Math.max(
|
|
179
|
+
deadlineCps,
|
|
180
|
+
computeSettleDrain(config, { backlog, inputActive: false, settling: true }),
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
151
184
|
/**
|
|
152
185
|
* Drain rate multiplier once the input ends: leftover backlog reveals at
|
|
153
186
|
* this multiple of the steady rate, so the end never drags.
|
|
@@ -206,14 +239,16 @@ export function computeRevealStep(config: StreamSmoothingPresetConfig, input: Re
|
|
|
206
239
|
|
|
207
240
|
export interface UseSmoothStreamContentOptions {
|
|
208
241
|
enabled?: boolean
|
|
242
|
+
/** The producer has completed; drain queued text without live backpressure. */
|
|
243
|
+
inputComplete?: boolean
|
|
209
244
|
preset?: StreamSmoothingPreset
|
|
210
245
|
/** Performance guard veto: while true, reveal commits are held back. */
|
|
211
246
|
shouldHoldBack?: (() => boolean) | undefined
|
|
212
247
|
/**
|
|
213
248
|
* Fixed reveal rate in chars/s. When set, the reveal runs at this steady
|
|
214
249
|
* pace while the input streams (instead of tracking the arrival rate) and
|
|
215
|
-
* drains
|
|
216
|
-
*
|
|
250
|
+
* drains an inferred idle backlog at {@link SETTLE_DRAIN_MULTIPLIER}.
|
|
251
|
+
* Explicit producer completion still uses the bounded completion drain.
|
|
217
252
|
*/
|
|
218
253
|
steadyCps?: number | undefined
|
|
219
254
|
/**
|
|
@@ -223,6 +258,8 @@ export interface UseSmoothStreamContentOptions {
|
|
|
223
258
|
defaultCps?: number | undefined
|
|
224
259
|
/** Written each commit with the live arrival-rate EMA for the follow lerp. */
|
|
225
260
|
speedCpsRef?: { current: number } | undefined
|
|
261
|
+
/** Live multiplier from the follow spring when safe visual lag is filling. */
|
|
262
|
+
revealScaleRef?: { current: number } | undefined
|
|
226
263
|
}
|
|
227
264
|
|
|
228
265
|
/**
|
|
@@ -234,42 +271,53 @@ export interface UseSmoothStreamContentOptions {
|
|
|
234
271
|
*/
|
|
235
272
|
export function useSmoothStreamContent(
|
|
236
273
|
content: string,
|
|
237
|
-
{
|
|
274
|
+
{
|
|
275
|
+
enabled = true,
|
|
276
|
+
inputComplete = false,
|
|
277
|
+
preset = 'balanced',
|
|
278
|
+
shouldHoldBack,
|
|
279
|
+
steadyCps,
|
|
280
|
+
defaultCps,
|
|
281
|
+
speedCpsRef,
|
|
282
|
+
revealScaleRef,
|
|
283
|
+
}: UseSmoothStreamContentOptions = {},
|
|
238
284
|
): string {
|
|
239
285
|
const config = PRESET_CONFIG[preset]
|
|
240
286
|
const seedCps = defaultCps ?? config.defaultCps
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
const
|
|
287
|
+
// A fast provider can fill the first Host render with a large batch. Start
|
|
288
|
+
// an enabled stream behind that batch so it still enters the reveal queue;
|
|
289
|
+
// disabled (settled/history) content must remain immediate.
|
|
290
|
+
const initialContent = enabled ? '' : content
|
|
291
|
+
const [displayedContent, setDisplayedContent] = useState(initialContent)
|
|
292
|
+
|
|
293
|
+
const displayedContentRef = useRef(initialContent)
|
|
294
|
+
const displayedCountRef = useRef(countChars(initialContent))
|
|
295
|
+
const targetContentRef = useRef(initialContent)
|
|
296
|
+
const targetCharsRef = useRef([...initialContent])
|
|
297
|
+
const targetCountRef = useRef(countChars(initialContent))
|
|
248
298
|
|
|
249
299
|
const emaCpsRef = useRef(seedCps)
|
|
250
300
|
const lastInputTsRef = useRef(0)
|
|
251
|
-
const lastInputCountRef = useRef(countChars(
|
|
301
|
+
const lastInputCountRef = useRef(countChars(initialContent))
|
|
252
302
|
const chunkSizeEmaRef = useRef(1)
|
|
253
303
|
const arrivalCpsEmaRef = useRef(seedCps)
|
|
254
304
|
|
|
255
305
|
const rafRef = useRef<number | null>(null)
|
|
256
306
|
const lastFrameTsRef = useRef<number | null>(null)
|
|
257
|
-
const
|
|
307
|
+
const queueDebtRef = useRef(0)
|
|
308
|
+
const settleCpsRef = useRef<number | null>(null)
|
|
258
309
|
const holdBackRef = useRef(shouldHoldBack)
|
|
259
310
|
const speedOutRef = useRef(speedCpsRef)
|
|
260
311
|
speedOutRef.current = speedCpsRef
|
|
312
|
+
const revealScaleOutRef = useRef(revealScaleRef)
|
|
313
|
+
revealScaleOutRef.current = revealScaleRef
|
|
314
|
+
const inputCompleteRef = useRef(inputComplete)
|
|
315
|
+
inputCompleteRef.current = inputComplete
|
|
261
316
|
|
|
262
317
|
useEffect(() => {
|
|
263
318
|
holdBackRef.current = shouldHoldBack
|
|
264
319
|
}, [shouldHoldBack])
|
|
265
320
|
|
|
266
|
-
const clearWakeTimer = useCallback(() => {
|
|
267
|
-
if (wakeTimerRef.current !== null) {
|
|
268
|
-
clearTimeout(wakeTimerRef.current)
|
|
269
|
-
wakeTimerRef.current = null
|
|
270
|
-
}
|
|
271
|
-
}, [])
|
|
272
|
-
|
|
273
321
|
const stopFrameLoop = useCallback(() => {
|
|
274
322
|
if (rafRef.current !== null) {
|
|
275
323
|
cancelAnimationFrame(rafRef.current)
|
|
@@ -278,30 +326,11 @@ export function useSmoothStreamContent(
|
|
|
278
326
|
lastFrameTsRef.current = null
|
|
279
327
|
}, [])
|
|
280
328
|
|
|
281
|
-
const stopScheduling = useCallback(() => {
|
|
282
|
-
stopFrameLoop()
|
|
283
|
-
clearWakeTimer()
|
|
284
|
-
}, [clearWakeTimer, stopFrameLoop])
|
|
285
|
-
|
|
286
329
|
const startFrameLoopRef = useRef<() => void>(() => {})
|
|
287
330
|
|
|
288
|
-
const scheduleFrameWake = useCallback(
|
|
289
|
-
(delayMs: number) => {
|
|
290
|
-
clearWakeTimer()
|
|
291
|
-
wakeTimerRef.current = setTimeout(
|
|
292
|
-
() => {
|
|
293
|
-
wakeTimerRef.current = null
|
|
294
|
-
startFrameLoopRef.current()
|
|
295
|
-
},
|
|
296
|
-
Math.max(1, Math.ceil(delayMs)),
|
|
297
|
-
)
|
|
298
|
-
},
|
|
299
|
-
[clearWakeTimer],
|
|
300
|
-
)
|
|
301
|
-
|
|
302
331
|
const syncImmediate = useCallback(
|
|
303
332
|
(nextContent: string) => {
|
|
304
|
-
|
|
333
|
+
stopFrameLoop()
|
|
305
334
|
const chars = [...nextContent]
|
|
306
335
|
const now = performance.now()
|
|
307
336
|
targetContentRef.current = nextContent
|
|
@@ -309,6 +338,10 @@ export function useSmoothStreamContent(
|
|
|
309
338
|
targetCountRef.current = chars.length
|
|
310
339
|
displayedContentRef.current = nextContent
|
|
311
340
|
displayedCountRef.current = chars.length
|
|
341
|
+
queueDebtRef.current = 0
|
|
342
|
+
settleCpsRef.current = null
|
|
343
|
+
const speedOut = speedOutRef.current
|
|
344
|
+
if (speedOut !== undefined) speedOut.current = seedCps
|
|
312
345
|
setDisplayedContent(nextContent)
|
|
313
346
|
emaCpsRef.current = seedCps
|
|
314
347
|
chunkSizeEmaRef.current = 1
|
|
@@ -316,46 +349,66 @@ export function useSmoothStreamContent(
|
|
|
316
349
|
lastInputTsRef.current = now
|
|
317
350
|
lastInputCountRef.current = chars.length
|
|
318
351
|
},
|
|
319
|
-
[seedCps,
|
|
352
|
+
[seedCps, stopFrameLoop],
|
|
320
353
|
)
|
|
321
354
|
|
|
355
|
+
// Producer completion is authoritative. Publish the accumulated source in
|
|
356
|
+
// the same commit instead of leaving a visible typewriter tail after the
|
|
357
|
+
// Agent has already stopped; the conversation follower still glides any
|
|
358
|
+
// resulting layout height before paint.
|
|
359
|
+
useLayoutEffect(() => {
|
|
360
|
+
if (!enabled || !inputComplete || displayedContentRef.current === content) return
|
|
361
|
+
syncImmediate(content)
|
|
362
|
+
}, [content, enabled, inputComplete, syncImmediate])
|
|
363
|
+
|
|
322
364
|
const startFrameLoop = useCallback(() => {
|
|
323
|
-
clearWakeTimer()
|
|
324
365
|
if (rafRef.current !== null) return
|
|
325
366
|
|
|
326
|
-
const tick = () => {
|
|
367
|
+
const tick = (now: number) => {
|
|
327
368
|
const targetCount = targetCountRef.current
|
|
328
369
|
const displayedCount = displayedCountRef.current
|
|
329
370
|
const backlog = targetCount - displayedCount
|
|
330
371
|
|
|
331
372
|
if (backlog <= 0) {
|
|
373
|
+
queueDebtRef.current = 0
|
|
374
|
+
settleCpsRef.current = null
|
|
375
|
+
const speedOut = speedOutRef.current
|
|
376
|
+
if (speedOut !== undefined) speedOut.current = seedCps
|
|
332
377
|
stopFrameLoop()
|
|
333
378
|
return
|
|
334
379
|
}
|
|
335
380
|
|
|
336
|
-
const now = performance.now()
|
|
337
381
|
if (lastFrameTsRef.current === null) {
|
|
338
382
|
lastFrameTsRef.current = now
|
|
339
383
|
rafRef.current = requestAnimationFrame(tick)
|
|
340
384
|
return
|
|
341
385
|
}
|
|
342
386
|
|
|
343
|
-
const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs)
|
|
344
387
|
const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current)
|
|
345
|
-
if (frameIntervalMs < commitIntervalMs) {
|
|
346
|
-
rafRef.current = requestAnimationFrame(tick)
|
|
347
|
-
return
|
|
348
|
-
}
|
|
349
|
-
|
|
350
388
|
const dtSeconds = Math.max(0.001, Math.min(frameIntervalMs / 1000, 0.12))
|
|
351
389
|
lastFrameTsRef.current = now
|
|
352
390
|
|
|
353
391
|
const idleMs = now - lastInputTsRef.current
|
|
354
|
-
const
|
|
355
|
-
const
|
|
392
|
+
const producerComplete = inputCompleteRef.current
|
|
393
|
+
const inputActive = !producerComplete && idleMs <= config.activeInputWindowMs
|
|
394
|
+
const settling = producerComplete || (!inputActive && idleMs >= config.settleAfterMs)
|
|
395
|
+
if (!producerComplete) settleCpsRef.current = null
|
|
356
396
|
|
|
357
397
|
let revealChars: number
|
|
358
|
-
|
|
398
|
+
let revealSpeedCps: number
|
|
399
|
+
let nextQueueDebt = 0
|
|
400
|
+
if (producerComplete) {
|
|
401
|
+
// Backpressure protects live layout. Once input has ended, retaining
|
|
402
|
+
// that scale only makes a completed response keep typing onscreen.
|
|
403
|
+
// Keep one drain velocity for the whole completion tail. Recomputing
|
|
404
|
+
// it from the shrinking backlog creates an exponential slow tail.
|
|
405
|
+
const settleCps = settleCpsRef.current ?? computeCompletionDrain(config, backlog)
|
|
406
|
+
settleCpsRef.current = settleCps
|
|
407
|
+
const accumulated = Math.max(0, queueDebtRef.current) + settleCps * dtSeconds
|
|
408
|
+
revealChars = Math.min(backlog, Math.floor(accumulated))
|
|
409
|
+
revealSpeedCps = settleCps
|
|
410
|
+
nextQueueDebt = revealChars >= backlog ? 0 : accumulated - revealChars
|
|
411
|
+
} else if (steadyCps !== undefined) {
|
|
359
412
|
const step = computeRevealStep(
|
|
360
413
|
config,
|
|
361
414
|
{
|
|
@@ -370,9 +423,17 @@ export function useSmoothStreamContent(
|
|
|
370
423
|
dtSeconds,
|
|
371
424
|
)
|
|
372
425
|
revealChars = Math.min(step.revealChars, backlog)
|
|
426
|
+
revealSpeedCps = frameIntervalMs > 0 ? (revealChars * 1000) / frameIntervalMs : 0
|
|
373
427
|
} else {
|
|
374
|
-
|
|
375
|
-
|
|
428
|
+
const step = computeAdaptiveQueueStep(
|
|
429
|
+
backlog,
|
|
430
|
+
frameIntervalMs,
|
|
431
|
+
queueDebtRef.current,
|
|
432
|
+
revealScaleOutRef.current?.current ?? 1,
|
|
433
|
+
)
|
|
434
|
+
revealChars = step.revealChars
|
|
435
|
+
revealSpeedCps = step.speedCps
|
|
436
|
+
nextQueueDebt = step.debt
|
|
376
437
|
}
|
|
377
438
|
|
|
378
439
|
// Performance guard: while degraded and the reply is offscreen, skip
|
|
@@ -383,10 +444,14 @@ export function useSmoothStreamContent(
|
|
|
383
444
|
return
|
|
384
445
|
}
|
|
385
446
|
|
|
447
|
+
queueDebtRef.current = nextQueueDebt
|
|
448
|
+
|
|
386
449
|
const speedOut = speedOutRef.current
|
|
387
|
-
if (speedOut !== undefined
|
|
388
|
-
|
|
389
|
-
|
|
450
|
+
if (speedOut !== undefined) speedOut.current = revealSpeedCps
|
|
451
|
+
|
|
452
|
+
if (revealChars <= 0) {
|
|
453
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
454
|
+
return
|
|
390
455
|
}
|
|
391
456
|
|
|
392
457
|
const nextCount = displayedCount + revealChars
|
|
@@ -406,7 +471,7 @@ export function useSmoothStreamContent(
|
|
|
406
471
|
}
|
|
407
472
|
|
|
408
473
|
rafRef.current = requestAnimationFrame(tick)
|
|
409
|
-
}, [config,
|
|
474
|
+
}, [config, stopFrameLoop, steadyCps])
|
|
410
475
|
|
|
411
476
|
useEffect(() => {
|
|
412
477
|
startFrameLoopRef.current = startFrameLoop
|
|
@@ -436,6 +501,7 @@ export function useSmoothStreamContent(
|
|
|
436
501
|
targetContentRef.current = content
|
|
437
502
|
targetCharsRef.current.push(...appendedChars)
|
|
438
503
|
targetCountRef.current += appendedCount
|
|
504
|
+
settleCpsRef.current = null
|
|
439
505
|
|
|
440
506
|
const hadSample = lastInputTsRef.current > 0
|
|
441
507
|
const deltaChars = targetCountRef.current - lastInputCountRef.current
|
|
@@ -460,9 +526,9 @@ export function useSmoothStreamContent(
|
|
|
460
526
|
|
|
461
527
|
useEffect(() => {
|
|
462
528
|
return () => {
|
|
463
|
-
|
|
529
|
+
stopFrameLoop()
|
|
464
530
|
}
|
|
465
|
-
}, [
|
|
531
|
+
}, [stopFrameLoop])
|
|
466
532
|
|
|
467
533
|
return displayedContent
|
|
468
534
|
}
|
package/src/config.ts
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { StreamSmoothingPreset } from './client/useSmoothStreamContent.ts'
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/** Legacy render-mode value retained so existing overlays remain valid. */
|
|
11
11
|
export type StreamMode = 'typewriter' | 'teleprompter'
|
|
12
12
|
|
|
13
13
|
/** Plugin configuration validated by the Host schema and bridged to the browser half. */
|
|
14
14
|
export interface StreamConfig {
|
|
15
|
-
/**
|
|
15
|
+
/** Compatibility field; both values use the current adaptive reveal engine. */
|
|
16
16
|
readonly mode: StreamMode
|
|
17
17
|
/** Smoothing preset for the reveal cadence. */
|
|
18
18
|
readonly preset: StreamSmoothingPreset
|
|
@@ -26,10 +26,7 @@ export interface StreamConfig {
|
|
|
26
26
|
* smooth-damp, not a cruise speed.
|
|
27
27
|
*/
|
|
28
28
|
readonly scrollSpeedPxPerSec: number
|
|
29
|
-
/**
|
|
30
|
-
* Follow velocity ceiling in CSS pixels per second, so a huge first lag
|
|
31
|
-
* does not teleport. Ordinary wraps stay well below this.
|
|
32
|
-
*/
|
|
29
|
+
/** Unused at runtime; retained so existing overlays continue to load. */
|
|
33
30
|
readonly maxScrollSpeedPxPerSec: number
|
|
34
31
|
}
|
|
35
32
|
|