dsh-smooth-stream 0.1.0
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/LICENSE +21 -0
- package/README.md +88 -0
- package/README.zh.md +88 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1288 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +73 -0
- package/package.json +135 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +85 -0
- package/src/client/FollowHost.tsx +21 -0
- package/src/client/TypewriterAssistantNodeView.module.css +240 -0
- package/src/client/TypewriterAssistantNodeView.tsx +319 -0
- package/src/client/TypewriterToolNodeView.tsx +54 -0
- package/src/client/index.ts +122 -0
- package/src/client/teleprompterGlide.ts +426 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/client/useSmoothStreamContent.ts +468 -0
- package/src/config.ts +50 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/plugin.ts +54 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream-smoothing reveal hook.
|
|
3
|
+
*
|
|
4
|
+
* Buffers the model's chunked text and reveals it at a cadence that tracks
|
|
5
|
+
* the observed arrival rate, so a long reply never dumps whole paragraphs at
|
|
6
|
+
* once and a fast stream never stutters. Port of lobe-ui's smoother: EMA
|
|
7
|
+
* arrival cps + chunk size, backlog pressure, commit-interval widening with
|
|
8
|
+
* tail length, and a flush-speed settle drain once the input idles. The
|
|
9
|
+
* reveal decision is the pure {@link computeRevealStep} for unit tests.
|
|
10
|
+
*
|
|
11
|
+
* `shouldHoldBack` is the performance guard's veto: while it returns true the
|
|
12
|
+
* loop keeps measuring but skips the DOM commit, so an offscreen reply never
|
|
13
|
+
* competes with visible frames when the frame rate is degraded.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
17
|
+
|
|
18
|
+
export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
|
|
19
|
+
|
|
20
|
+
export interface StreamSmoothingPresetConfig {
|
|
21
|
+
readonly activeInputWindowMs: number
|
|
22
|
+
readonly defaultCps: number
|
|
23
|
+
readonly emaAlpha: number
|
|
24
|
+
readonly flushCps: number
|
|
25
|
+
readonly largeAppendChars: number
|
|
26
|
+
readonly maxActiveCps: number
|
|
27
|
+
readonly maxCps: number
|
|
28
|
+
readonly maxFlushCps: number
|
|
29
|
+
readonly minCommitIntervalMs: number
|
|
30
|
+
readonly minCps: number
|
|
31
|
+
readonly settleAfterMs: number
|
|
32
|
+
readonly settleDrainMaxMs: number
|
|
33
|
+
readonly settleDrainMinMs: number
|
|
34
|
+
readonly targetBufferMs: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetConfig> = {
|
|
38
|
+
balanced: {
|
|
39
|
+
activeInputWindowMs: 220,
|
|
40
|
+
defaultCps: 80,
|
|
41
|
+
emaAlpha: 0.35,
|
|
42
|
+
flushCps: 180,
|
|
43
|
+
largeAppendChars: 120,
|
|
44
|
+
maxActiveCps: 360,
|
|
45
|
+
maxCps: 240,
|
|
46
|
+
maxFlushCps: 480,
|
|
47
|
+
minCommitIntervalMs: 16,
|
|
48
|
+
minCps: 24,
|
|
49
|
+
settleAfterMs: 280,
|
|
50
|
+
settleDrainMaxMs: 420,
|
|
51
|
+
settleDrainMinMs: 120,
|
|
52
|
+
targetBufferMs: 40,
|
|
53
|
+
},
|
|
54
|
+
realtime: {
|
|
55
|
+
activeInputWindowMs: 140,
|
|
56
|
+
defaultCps: 120,
|
|
57
|
+
emaAlpha: 0.45,
|
|
58
|
+
flushCps: 240,
|
|
59
|
+
largeAppendChars: 180,
|
|
60
|
+
maxActiveCps: 480,
|
|
61
|
+
maxCps: 320,
|
|
62
|
+
maxFlushCps: 640,
|
|
63
|
+
minCommitIntervalMs: 16,
|
|
64
|
+
minCps: 32,
|
|
65
|
+
settleAfterMs: 200,
|
|
66
|
+
settleDrainMaxMs: 280,
|
|
67
|
+
settleDrainMinMs: 100,
|
|
68
|
+
targetBufferMs: 24,
|
|
69
|
+
},
|
|
70
|
+
silky: {
|
|
71
|
+
activeInputWindowMs: 280,
|
|
72
|
+
defaultCps: 64,
|
|
73
|
+
emaAlpha: 0.28,
|
|
74
|
+
flushCps: 140,
|
|
75
|
+
largeAppendChars: 100,
|
|
76
|
+
maxActiveCps: 280,
|
|
77
|
+
maxCps: 180,
|
|
78
|
+
maxFlushCps: 400,
|
|
79
|
+
minCommitIntervalMs: 16,
|
|
80
|
+
minCps: 20,
|
|
81
|
+
settleAfterMs: 360,
|
|
82
|
+
settleDrainMaxMs: 520,
|
|
83
|
+
settleDrainMinMs: 160,
|
|
84
|
+
targetBufferMs: 56,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const MAX_COMMIT_INTERVAL_MS = 16
|
|
89
|
+
/** Demo queue divisor: each frame reveals about `backlog / 8` characters. */
|
|
90
|
+
export const QUEUE_REVEAL_DIVISOR = 8
|
|
91
|
+
const QUEUE_FRAME_MS = 16.67
|
|
92
|
+
/** Hard cap on how far display may trail a live stream, in characters. */
|
|
93
|
+
export const LIVE_LAG_CHAR_CEILING = 32
|
|
94
|
+
const CATCHUP_SECONDS = 0.15
|
|
95
|
+
|
|
96
|
+
export const clamp = (value: number, min: number, max: number): number => {
|
|
97
|
+
return Math.min(max, Math.max(min, value))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Demo per-frame reveal: drain `backlog / 8` characters, at least 1, scaled
|
|
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.
|
|
104
|
+
* @param backlog - Unrevealed characters.
|
|
105
|
+
* @param dtMs - Frame delta in ms.
|
|
106
|
+
* @returns Characters to reveal this frame.
|
|
107
|
+
*/
|
|
108
|
+
export function computeQueueReveal(backlog: number, dtMs: number): number {
|
|
109
|
+
if (backlog <= 0 || dtMs <= 0) return 0
|
|
110
|
+
return Math.min(backlog, Math.max(1, Math.ceil((backlog / QUEUE_REVEAL_DIVISOR) * (dtMs / QUEUE_FRAME_MS))))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Counts user-perceived characters (code points), not UTF-16 units. */
|
|
114
|
+
export const countChars = (text: string): number => {
|
|
115
|
+
let count = 0
|
|
116
|
+
for (const char of text) {
|
|
117
|
+
void char
|
|
118
|
+
count += 1
|
|
119
|
+
}
|
|
120
|
+
return count
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drain rate for backlog beyond the tail-lag ceiling: the displayed text may
|
|
125
|
+
* trail the stream by at most `backlogCharCeiling` characters for at most
|
|
126
|
+
* `backlogSecondCeiling` seconds. The constants are a smoothness invariant
|
|
127
|
+
* (bounded lag is the reason the reveal never stalls mid-reply), so they are
|
|
128
|
+
* not config fields; the preset multiplier is how a deployment tunes them.
|
|
129
|
+
*/
|
|
130
|
+
export const BACKLOG_CHAR_CEILING = 300
|
|
131
|
+
export const BACKLOG_SECOND_CEILING = 2
|
|
132
|
+
|
|
133
|
+
export interface SettleDrainInput {
|
|
134
|
+
readonly backlog: number
|
|
135
|
+
readonly inputActive: boolean
|
|
136
|
+
readonly settling: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Pure settle-drain decision shared by the frame loop and its tests. */
|
|
140
|
+
export function computeSettleDrain(config: StreamSmoothingPresetConfig, input: SettleDrainInput): number {
|
|
141
|
+
if (input.inputActive || !input.settling) return 0
|
|
142
|
+
// A settled stream must drain fast enough to finish the reply promptly:
|
|
143
|
+
// lag beyond the ceiling drains within the second ceiling.
|
|
144
|
+
const overflow = Math.max(0, input.backlog - BACKLOG_CHAR_CEILING)
|
|
145
|
+
const overflowCps = (overflow * 1000) / BACKLOG_SECOND_CEILING
|
|
146
|
+
const drainTargetMs = clamp(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs)
|
|
147
|
+
const settleCps = (input.backlog * 1000) / drainTargetMs
|
|
148
|
+
return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Drain rate multiplier once the input ends: leftover backlog reveals at
|
|
153
|
+
* this multiple of the steady rate, so the end never drags.
|
|
154
|
+
*/
|
|
155
|
+
export const SETTLE_DRAIN_MULTIPLIER = 1.8
|
|
156
|
+
|
|
157
|
+
export interface RevealStepInput {
|
|
158
|
+
readonly backlog: number
|
|
159
|
+
readonly chunkSizeEma: number
|
|
160
|
+
readonly arrivalCpsEma: number
|
|
161
|
+
readonly emaCps: number
|
|
162
|
+
readonly inputActive: boolean
|
|
163
|
+
readonly settling: boolean
|
|
164
|
+
/** Fixed reveal rate; overrides the arrival-tracking cadence entirely. */
|
|
165
|
+
readonly steadyCps?: number | undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface RevealStepResult {
|
|
169
|
+
readonly revealChars: number
|
|
170
|
+
readonly targetLagChars: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pure per-frame reveal decision shared by the loop and its tests. */
|
|
174
|
+
export function computeRevealStep(config: StreamSmoothingPresetConfig, input: RevealStepInput, dtSeconds: number): RevealStepResult {
|
|
175
|
+
// Track the faster of the two EMAs and do not clamp to the old low maxCps
|
|
176
|
+
// ceiling — that cap is why a fast model left hundreds of chars unrevealed.
|
|
177
|
+
const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma)
|
|
178
|
+
const baseCps = clamp(trackedCps, config.minCps, config.maxFlushCps)
|
|
179
|
+
const targetLagChars = input.inputActive
|
|
180
|
+
? Math.max(2, Math.round((baseCps * config.targetBufferMs) / 1000))
|
|
181
|
+
: 0
|
|
182
|
+
|
|
183
|
+
let currentCps: number
|
|
184
|
+
if (input.steadyCps !== undefined) {
|
|
185
|
+
currentCps = input.inputActive || input.settling
|
|
186
|
+
? clamp(
|
|
187
|
+
input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER),
|
|
188
|
+
config.minCps,
|
|
189
|
+
config.maxFlushCps,
|
|
190
|
+
)
|
|
191
|
+
: 0
|
|
192
|
+
} else if (input.inputActive) {
|
|
193
|
+
const overflow = Math.max(0, input.backlog - LIVE_LAG_CHAR_CEILING)
|
|
194
|
+
const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0
|
|
195
|
+
currentCps = clamp(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps)
|
|
196
|
+
} else if (input.settling) {
|
|
197
|
+
currentCps = computeSettleDrain(config, input)
|
|
198
|
+
} else {
|
|
199
|
+
const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * 0.8)
|
|
200
|
+
currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const minRevealChars = input.inputActive ? 1 : 2
|
|
204
|
+
return { revealChars: Math.max(minRevealChars, Math.round(currentCps * dtSeconds)), targetLagChars }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface UseSmoothStreamContentOptions {
|
|
208
|
+
enabled?: boolean
|
|
209
|
+
preset?: StreamSmoothingPreset
|
|
210
|
+
/** Performance guard veto: while true, reveal commits are held back. */
|
|
211
|
+
shouldHoldBack?: (() => boolean) | undefined
|
|
212
|
+
/**
|
|
213
|
+
* Fixed reveal rate in chars/s. When set, the reveal runs at this steady
|
|
214
|
+
* pace while the input streams (instead of tracking the arrival rate) and
|
|
215
|
+
* drains the leftover backlog at {@link SETTLE_DRAIN_MULTIPLIER} once the
|
|
216
|
+
* input ends.
|
|
217
|
+
*/
|
|
218
|
+
steadyCps?: number | undefined
|
|
219
|
+
/**
|
|
220
|
+
* Seed for the arrival-rate EMA. When omitted, the preset's `defaultCps`
|
|
221
|
+
* is used. The live rate then tracks observed arrival; this is not a cap.
|
|
222
|
+
*/
|
|
223
|
+
defaultCps?: number | undefined
|
|
224
|
+
/** Written each commit with the live arrival-rate EMA for the follow lerp. */
|
|
225
|
+
speedCpsRef?: { current: number } | undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Smooth a chunked content stream into a reveal-paced display string.
|
|
230
|
+
*
|
|
231
|
+
* @param content - The full accumulated input so far.
|
|
232
|
+
* @param options - Preset, guard, and steady-rate wiring.
|
|
233
|
+
* @returns The displayed content, revealed at the smoothed cadence.
|
|
234
|
+
*/
|
|
235
|
+
export function useSmoothStreamContent(
|
|
236
|
+
content: string,
|
|
237
|
+
{ enabled = true, preset = 'balanced', shouldHoldBack, steadyCps, defaultCps, speedCpsRef }: UseSmoothStreamContentOptions = {},
|
|
238
|
+
): string {
|
|
239
|
+
const config = PRESET_CONFIG[preset]
|
|
240
|
+
const seedCps = defaultCps ?? config.defaultCps
|
|
241
|
+
const [displayedContent, setDisplayedContent] = useState(content)
|
|
242
|
+
|
|
243
|
+
const displayedContentRef = useRef(content)
|
|
244
|
+
const displayedCountRef = useRef(countChars(content))
|
|
245
|
+
const targetContentRef = useRef(content)
|
|
246
|
+
const targetCharsRef = useRef([...content])
|
|
247
|
+
const targetCountRef = useRef(countChars(content))
|
|
248
|
+
|
|
249
|
+
const emaCpsRef = useRef(seedCps)
|
|
250
|
+
const lastInputTsRef = useRef(0)
|
|
251
|
+
const lastInputCountRef = useRef(countChars(content))
|
|
252
|
+
const chunkSizeEmaRef = useRef(1)
|
|
253
|
+
const arrivalCpsEmaRef = useRef(seedCps)
|
|
254
|
+
|
|
255
|
+
const rafRef = useRef<number | null>(null)
|
|
256
|
+
const lastFrameTsRef = useRef<number | null>(null)
|
|
257
|
+
const wakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
258
|
+
const holdBackRef = useRef(shouldHoldBack)
|
|
259
|
+
const speedOutRef = useRef(speedCpsRef)
|
|
260
|
+
speedOutRef.current = speedCpsRef
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
holdBackRef.current = shouldHoldBack
|
|
264
|
+
}, [shouldHoldBack])
|
|
265
|
+
|
|
266
|
+
const clearWakeTimer = useCallback(() => {
|
|
267
|
+
if (wakeTimerRef.current !== null) {
|
|
268
|
+
clearTimeout(wakeTimerRef.current)
|
|
269
|
+
wakeTimerRef.current = null
|
|
270
|
+
}
|
|
271
|
+
}, [])
|
|
272
|
+
|
|
273
|
+
const stopFrameLoop = useCallback(() => {
|
|
274
|
+
if (rafRef.current !== null) {
|
|
275
|
+
cancelAnimationFrame(rafRef.current)
|
|
276
|
+
rafRef.current = null
|
|
277
|
+
}
|
|
278
|
+
lastFrameTsRef.current = null
|
|
279
|
+
}, [])
|
|
280
|
+
|
|
281
|
+
const stopScheduling = useCallback(() => {
|
|
282
|
+
stopFrameLoop()
|
|
283
|
+
clearWakeTimer()
|
|
284
|
+
}, [clearWakeTimer, stopFrameLoop])
|
|
285
|
+
|
|
286
|
+
const startFrameLoopRef = useRef<() => void>(() => {})
|
|
287
|
+
|
|
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
|
+
const syncImmediate = useCallback(
|
|
303
|
+
(nextContent: string) => {
|
|
304
|
+
stopScheduling()
|
|
305
|
+
const chars = [...nextContent]
|
|
306
|
+
const now = performance.now()
|
|
307
|
+
targetContentRef.current = nextContent
|
|
308
|
+
targetCharsRef.current = chars
|
|
309
|
+
targetCountRef.current = chars.length
|
|
310
|
+
displayedContentRef.current = nextContent
|
|
311
|
+
displayedCountRef.current = chars.length
|
|
312
|
+
setDisplayedContent(nextContent)
|
|
313
|
+
emaCpsRef.current = seedCps
|
|
314
|
+
chunkSizeEmaRef.current = 1
|
|
315
|
+
arrivalCpsEmaRef.current = seedCps
|
|
316
|
+
lastInputTsRef.current = now
|
|
317
|
+
lastInputCountRef.current = chars.length
|
|
318
|
+
},
|
|
319
|
+
[seedCps, stopScheduling],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
const startFrameLoop = useCallback(() => {
|
|
323
|
+
clearWakeTimer()
|
|
324
|
+
if (rafRef.current !== null) return
|
|
325
|
+
|
|
326
|
+
const tick = () => {
|
|
327
|
+
const targetCount = targetCountRef.current
|
|
328
|
+
const displayedCount = displayedCountRef.current
|
|
329
|
+
const backlog = targetCount - displayedCount
|
|
330
|
+
|
|
331
|
+
if (backlog <= 0) {
|
|
332
|
+
stopFrameLoop()
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const now = performance.now()
|
|
337
|
+
if (lastFrameTsRef.current === null) {
|
|
338
|
+
lastFrameTsRef.current = now
|
|
339
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs)
|
|
344
|
+
const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current)
|
|
345
|
+
if (frameIntervalMs < commitIntervalMs) {
|
|
346
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const dtSeconds = Math.max(0.001, Math.min(frameIntervalMs / 1000, 0.12))
|
|
351
|
+
lastFrameTsRef.current = now
|
|
352
|
+
|
|
353
|
+
const idleMs = now - lastInputTsRef.current
|
|
354
|
+
const inputActive = idleMs <= config.activeInputWindowMs
|
|
355
|
+
const settling = !inputActive && idleMs >= config.settleAfterMs
|
|
356
|
+
|
|
357
|
+
let revealChars: number
|
|
358
|
+
if (steadyCps !== undefined) {
|
|
359
|
+
const step = computeRevealStep(
|
|
360
|
+
config,
|
|
361
|
+
{
|
|
362
|
+
backlog,
|
|
363
|
+
chunkSizeEma: chunkSizeEmaRef.current,
|
|
364
|
+
arrivalCpsEma: arrivalCpsEmaRef.current,
|
|
365
|
+
emaCps: emaCpsRef.current,
|
|
366
|
+
inputActive,
|
|
367
|
+
settling,
|
|
368
|
+
steadyCps,
|
|
369
|
+
},
|
|
370
|
+
dtSeconds,
|
|
371
|
+
)
|
|
372
|
+
revealChars = Math.min(step.revealChars, backlog)
|
|
373
|
+
} else {
|
|
374
|
+
// Same rAF queue drain as the silky markdown demo.
|
|
375
|
+
revealChars = computeQueueReveal(backlog, frameIntervalMs)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Performance guard: while degraded and the reply is offscreen, skip
|
|
379
|
+
// the DOM commit — the backlog keeps accumulating and flushes when the
|
|
380
|
+
// guard clears or the reply scrolls into view.
|
|
381
|
+
if (holdBackRef.current?.() === true) {
|
|
382
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const speedOut = speedOutRef.current
|
|
387
|
+
if (speedOut !== undefined && frameIntervalMs > 0) {
|
|
388
|
+
const instantCps = (revealChars * 1000) / frameIntervalMs
|
|
389
|
+
speedOut.current = speedOut.current * 0.92 + instantCps * 0.08
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const nextCount = displayedCount + revealChars
|
|
393
|
+
const segment = targetCharsRef.current.slice(displayedCount, nextCount).join('')
|
|
394
|
+
if (segment) {
|
|
395
|
+
const nextDisplayed = displayedContentRef.current + segment
|
|
396
|
+
displayedContentRef.current = nextDisplayed
|
|
397
|
+
displayedCountRef.current = nextCount
|
|
398
|
+
setDisplayedContent(nextDisplayed)
|
|
399
|
+
} else {
|
|
400
|
+
displayedContentRef.current = targetContentRef.current
|
|
401
|
+
displayedCountRef.current = targetCount
|
|
402
|
+
setDisplayedContent(targetContentRef.current)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
409
|
+
}, [config, scheduleFrameWake, stopFrameLoop, clearWakeTimer, steadyCps])
|
|
410
|
+
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
startFrameLoopRef.current = startFrameLoop
|
|
413
|
+
}, [startFrameLoop])
|
|
414
|
+
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
if (!enabled) {
|
|
417
|
+
syncImmediate(content)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const prevTargetContent = targetContentRef.current
|
|
422
|
+
if (content === prevTargetContent) return
|
|
423
|
+
|
|
424
|
+
const now = performance.now()
|
|
425
|
+
const appendOnly = content.startsWith(prevTargetContent)
|
|
426
|
+
|
|
427
|
+
if (!appendOnly) {
|
|
428
|
+
syncImmediate(content)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const appended = content.slice(prevTargetContent.length)
|
|
433
|
+
const appendedChars = [...appended]
|
|
434
|
+
const appendedCount = appendedChars.length
|
|
435
|
+
|
|
436
|
+
targetContentRef.current = content
|
|
437
|
+
targetCharsRef.current.push(...appendedChars)
|
|
438
|
+
targetCountRef.current += appendedCount
|
|
439
|
+
|
|
440
|
+
const hadSample = lastInputTsRef.current > 0
|
|
441
|
+
const deltaChars = targetCountRef.current - lastInputCountRef.current
|
|
442
|
+
const deltaMs = Math.max(1, now - lastInputTsRef.current)
|
|
443
|
+
|
|
444
|
+
// Skip the first sample: lastInputTs starts at 0, so the interval would
|
|
445
|
+
// be "time since page load" (or 1ms under fake timers) and poison the EMA.
|
|
446
|
+
if (hadSample && deltaChars > 0) {
|
|
447
|
+
const instantCps = (deltaChars * 1000) / deltaMs
|
|
448
|
+
const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 3)
|
|
449
|
+
const chunkEmaAlpha = 0.45
|
|
450
|
+
chunkSizeEmaRef.current = chunkSizeEmaRef.current * (1 - chunkEmaAlpha) + appendedCount * chunkEmaAlpha
|
|
451
|
+
arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * (1 - chunkEmaAlpha) + normalizedInstantCps * chunkEmaAlpha
|
|
452
|
+
emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + normalizedInstantCps * config.emaAlpha
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
lastInputTsRef.current = now
|
|
456
|
+
lastInputCountRef.current = targetCountRef.current
|
|
457
|
+
|
|
458
|
+
startFrameLoop()
|
|
459
|
+
}, [content, enabled, config, startFrameLoop, syncImmediate])
|
|
460
|
+
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
return () => {
|
|
463
|
+
stopScheduling()
|
|
464
|
+
}
|
|
465
|
+
}, [stopScheduling])
|
|
466
|
+
|
|
467
|
+
return displayedContent
|
|
468
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plugin configuration contract. The Host half defines the
|
|
3
|
+
* Schemastery schema over this shape (defaults live here so both halves stay
|
|
4
|
+
* symmetric); the client half falls back to the same defaults when the Host
|
|
5
|
+
* boot-config bridge is absent (client-only composition).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { StreamSmoothingPreset } from './client/useSmoothStreamContent.ts'
|
|
9
|
+
|
|
10
|
+
/** Streaming render direction of the assistant node view. */
|
|
11
|
+
export type StreamMode = 'typewriter' | 'teleprompter'
|
|
12
|
+
|
|
13
|
+
/** Plugin configuration validated by the Host schema and bridged to the browser half. */
|
|
14
|
+
export interface StreamConfig {
|
|
15
|
+
/** Render direction: horizontal per-character reveal or uniform upward glide. */
|
|
16
|
+
readonly mode: StreamMode
|
|
17
|
+
/** Smoothing preset for the reveal cadence. */
|
|
18
|
+
readonly preset: StreamSmoothingPreset
|
|
19
|
+
/**
|
|
20
|
+
* Unused at runtime; kept so existing overlays load. Live reveal tracks
|
|
21
|
+
* observed arrival and ignores this seed.
|
|
22
|
+
*/
|
|
23
|
+
readonly revealCharsPerSec: number
|
|
24
|
+
/**
|
|
25
|
+
* Unused at runtime; kept so existing overlays load. Follow is a
|
|
26
|
+
* smooth-damp, not a cruise speed.
|
|
27
|
+
*/
|
|
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
|
+
*/
|
|
33
|
+
readonly maxScrollSpeedPxPerSec: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Defaults shared by the Host schema and the client-side fallback. */
|
|
37
|
+
export const DEFAULT_STREAM_CONFIG: StreamConfig = {
|
|
38
|
+
mode: 'typewriter',
|
|
39
|
+
preset: 'balanced',
|
|
40
|
+
revealCharsPerSec: 80,
|
|
41
|
+
scrollSpeedPxPerSec: 48,
|
|
42
|
+
maxScrollSpeedPxPerSec: 1000,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Window global the Host writes into the served index HTML. The browser boot
|
|
47
|
+
* graph carries no per-entry config, so this inline script is the only
|
|
48
|
+
* Host-to-client configuration channel for a composed web plugin.
|
|
49
|
+
*/
|
|
50
|
+
export const STREAM_BOOT_GLOBAL = '__DSH_SMOOTH_STREAM_CONFIG__'
|
package/src/index.ts
ADDED
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
3
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
4
|
+
import { DEFAULT_STREAM_CONFIG, type StreamConfig } from './config.ts'
|
|
5
|
+
import { injectStreamConfig } from './boot-config.ts'
|
|
6
|
+
|
|
7
|
+
/** Display name shown by the Host loader while the plugin is mounted. */
|
|
8
|
+
export const name = 'dsh-smooth-stream'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plugin configuration accepted from the overlay's `config` section. Cordis
|
|
12
|
+
* validates the value against this schema at load and fills omitted fields
|
|
13
|
+
* from the shared defaults, so an invalid value fails the load loudly.
|
|
14
|
+
*/
|
|
15
|
+
export interface Config extends StreamConfig {}
|
|
16
|
+
|
|
17
|
+
export const Config: Schema<Config> = Schema.object({
|
|
18
|
+
mode: Schema.union(['typewriter', 'teleprompter'] as const).default(DEFAULT_STREAM_CONFIG.mode),
|
|
19
|
+
preset: Schema.union(['realtime', 'balanced', 'silky'] as const).default(DEFAULT_STREAM_CONFIG.preset),
|
|
20
|
+
revealCharsPerSec: Schema.number()
|
|
21
|
+
.min(5)
|
|
22
|
+
.max(200)
|
|
23
|
+
.default(DEFAULT_STREAM_CONFIG.revealCharsPerSec),
|
|
24
|
+
scrollSpeedPxPerSec: Schema.number()
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(200)
|
|
27
|
+
.default(DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec),
|
|
28
|
+
maxScrollSpeedPxPerSec: Schema.number()
|
|
29
|
+
.min(1)
|
|
30
|
+
.max(2000)
|
|
31
|
+
.default(DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Host half: log the resolved configuration and bridge it to the browser
|
|
36
|
+
* half. The web boot graph carries no per-entry config, so the validated
|
|
37
|
+
* value is injected into every served index response as a boot global the
|
|
38
|
+
* client entry reads at apply time.
|
|
39
|
+
* @param ctx - Host context carrying the web server service when composed.
|
|
40
|
+
* @param config - Schema-validated configuration with defaults filled.
|
|
41
|
+
*/
|
|
42
|
+
export function apply(ctx: Context, config: Config): void {
|
|
43
|
+
console.log(
|
|
44
|
+
`[dsh-smooth-stream] plugin loaded! mode=${config.mode} preset=${config.preset} `
|
|
45
|
+
+ `seed=${config.revealCharsPerSec}cps scroll=${config.scrollSpeedPxPerSec}px/s `
|
|
46
|
+
+ `maxScroll=${config.maxScrollSpeedPxPerSec}px/s`,
|
|
47
|
+
)
|
|
48
|
+
ctx.inject(['webServer'], (httpCtx) => {
|
|
49
|
+
httpCtx.effect(
|
|
50
|
+
() => httpCtx.webServer.tapIndex(html => injectStreamConfig(html, config)),
|
|
51
|
+
'dsh-smooth-stream: boot config bridge',
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
}
|