@jcy2387/dsh-conversation-ui 0.1.0-rc.12
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.en.md +123 -0
- package/README.md +123 -0
- package/conversation-ui-off.yml +3 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +3672 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +302 -0
- package/package.json +182 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +89 -0
- package/src/client/ConversationCard.module.css +253 -0
- package/src/client/ConversationCard.tsx +122 -0
- package/src/client/DeliverablesCard.module.css +187 -0
- package/src/client/DeliverablesCard.tsx +142 -0
- package/src/client/FollowHost.tsx +25 -0
- package/src/client/ToolActivityGroup.tsx +341 -0
- package/src/client/TurnPreludeUserNodeView.tsx +258 -0
- package/src/client/TurnProcessMember.tsx +92 -0
- package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
- package/src/client/TypewriterAssistantNodeView.tsx +390 -0
- package/src/client/TypewriterToolNodeView.tsx +916 -0
- package/src/client/conversation-ui-card-controller.ts +162 -0
- package/src/client/conversation-ui-settings-api.ts +65 -0
- package/src/client/deliverables.ts +263 -0
- package/src/client/index.ts +242 -0
- package/src/client/locales.ts +74 -0
- package/src/client/teleprompterGlide.ts +590 -0
- package/src/client/turnElapsed.ts +27 -0
- package/src/client/turnProcessFold.ts +224 -0
- package/src/client/useConversationContent.ts +471 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/config.ts +44 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/package-meta.ts +39 -0
- package/src/plugin.ts +155 -0
- package/src/profile-installation.ts +121 -0
- package/src/settings-api.ts +33 -0
- package/src/settings.ts +30 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface TurnFoldAddress {
|
|
4
|
+
readonly sessionId: string
|
|
5
|
+
readonly turn: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface TurnFoldEntry {
|
|
9
|
+
collapsed: boolean
|
|
10
|
+
completed: boolean
|
|
11
|
+
label: string | undefined
|
|
12
|
+
readonly boundaries: Map<string, number>
|
|
13
|
+
readonly members: Map<string, number>
|
|
14
|
+
readonly listeners: Set<() => void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TurnProcessFoldState {
|
|
18
|
+
readonly collapsed: boolean
|
|
19
|
+
readonly completed: boolean
|
|
20
|
+
readonly boundary: boolean
|
|
21
|
+
readonly controls: string
|
|
22
|
+
readonly hasMembers: boolean
|
|
23
|
+
readonly label: string | undefined
|
|
24
|
+
readonly toggle: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const entries = new Map<string, TurnFoldEntry>()
|
|
28
|
+
|
|
29
|
+
function addressKey(address: TurnFoldAddress): string {
|
|
30
|
+
return `${address.sessionId}\u0000${address.turn}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function entryFor(key: string): TurnFoldEntry {
|
|
34
|
+
const current = entries.get(key)
|
|
35
|
+
if (current !== undefined) return current
|
|
36
|
+
const created: TurnFoldEntry = {
|
|
37
|
+
collapsed: false,
|
|
38
|
+
completed: false,
|
|
39
|
+
label: undefined,
|
|
40
|
+
boundaries: new Map(),
|
|
41
|
+
members: new Map(),
|
|
42
|
+
listeners: new Set(),
|
|
43
|
+
}
|
|
44
|
+
entries.set(key, created)
|
|
45
|
+
return created
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function notify(entry: TurnFoldEntry): void {
|
|
49
|
+
for (const listener of entry.listeners) listener()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function discardUnused(key: string, entry: TurnFoldEntry): void {
|
|
53
|
+
if (entry.listeners.size === 0 && entry.boundaries.size === 0 && entry.members.size === 0) entries.delete(key)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function subscribe(key: string, listener: () => void): () => void {
|
|
57
|
+
const entry = entryFor(key)
|
|
58
|
+
entry.listeners.add(listener)
|
|
59
|
+
return () => {
|
|
60
|
+
entry.listeners.delete(listener)
|
|
61
|
+
discardUnused(key, entry)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function orderedMembers(entry: TurnFoldEntry): readonly string[] {
|
|
66
|
+
return [...entry.members]
|
|
67
|
+
.sort(([leftId, leftOrder], [rightId, rightOrder]) => leftOrder - rightOrder || leftId.localeCompare(rightId))
|
|
68
|
+
.map(([id]) => id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function orderedBoundaries(entry: TurnFoldEntry): readonly string[] {
|
|
72
|
+
return [...entry.boundaries]
|
|
73
|
+
.sort(([leftId, leftOrder], [rightId, rightOrder]) => leftOrder - rightOrder || leftId.localeCompare(rightId))
|
|
74
|
+
.map(([id]) => id)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function complete(key: string, label: string): void {
|
|
78
|
+
const entry = entryFor(key)
|
|
79
|
+
if (entry.completed) {
|
|
80
|
+
if (entry.label === label) return
|
|
81
|
+
entry.label = label
|
|
82
|
+
notify(entry)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
entry.completed = true
|
|
86
|
+
entry.label = label
|
|
87
|
+
entry.collapsed = true
|
|
88
|
+
notify(entry)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function registerMember(key: string, memberId: string, memberOrder: number): () => void {
|
|
92
|
+
const entry = entryFor(key)
|
|
93
|
+
entry.members.set(memberId, memberOrder)
|
|
94
|
+
notify(entry)
|
|
95
|
+
return () => {
|
|
96
|
+
entry.members.delete(memberId)
|
|
97
|
+
notify(entry)
|
|
98
|
+
discardUnused(key, entry)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function registerBoundary(key: string, boundaryId: string, boundaryOrder: number): () => void {
|
|
103
|
+
const entry = entryFor(key)
|
|
104
|
+
entry.boundaries.set(boundaryId, boundaryOrder)
|
|
105
|
+
notify(entry)
|
|
106
|
+
return () => {
|
|
107
|
+
entry.boundaries.delete(boundaryId)
|
|
108
|
+
notify(entry)
|
|
109
|
+
discardUnused(key, entry)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hash(value: string): string {
|
|
114
|
+
let result = 2_166_136_261
|
|
115
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
116
|
+
result ^= value.charCodeAt(index)
|
|
117
|
+
result = Math.imul(result, 16_777_619)
|
|
118
|
+
}
|
|
119
|
+
return (result >>> 0).toString(36)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Stable DOM id for one flat Chat node controlled by a Turn disclosure. */
|
|
123
|
+
export function turnProcessMemberId(address: TurnFoldAddress, nodeKey: string): string {
|
|
124
|
+
return `dsh-turn-process-${address.turn}-${hash(`${address.sessionId}\u0000${nodeKey}`)}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Stable DOM/store identity for the user-anchored Turn disclosure. */
|
|
128
|
+
export function turnProcessBoundaryId(sessionId: string, nodeKey: string): string {
|
|
129
|
+
return `dsh-turn-boundary-${hash(`${sessionId}\u0000${nodeKey}`)}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Share one collapse state across flat Chat siblings belonging to the same
|
|
134
|
+
* Session Turn. A successful final node marks the Turn complete; process
|
|
135
|
+
* members contribute their DOM ids to the disclosure's `aria-controls` list.
|
|
136
|
+
*/
|
|
137
|
+
export function useTurnProcessFold(
|
|
138
|
+
address: TurnFoldAddress | undefined,
|
|
139
|
+
options: {
|
|
140
|
+
readonly boundaryId?: string | undefined
|
|
141
|
+
readonly boundaryOrder?: number | undefined
|
|
142
|
+
readonly completedLabel?: string | undefined
|
|
143
|
+
readonly memberId?: string | undefined
|
|
144
|
+
readonly memberOrder?: number | undefined
|
|
145
|
+
} = {},
|
|
146
|
+
): TurnProcessFoldState {
|
|
147
|
+
const key = address === undefined ? undefined : addressKey(address)
|
|
148
|
+
const subscribeToKey = useCallback((listener: () => void) => (
|
|
149
|
+
key === undefined ? () => {} : subscribe(key, listener)
|
|
150
|
+
), [key])
|
|
151
|
+
const collapsed = useSyncExternalStore(
|
|
152
|
+
subscribeToKey,
|
|
153
|
+
() => key === undefined ? false : entries.get(key)?.collapsed ?? false,
|
|
154
|
+
() => false,
|
|
155
|
+
)
|
|
156
|
+
const completed = useSyncExternalStore(
|
|
157
|
+
subscribeToKey,
|
|
158
|
+
() => key === undefined ? false : entries.get(key)?.completed ?? false,
|
|
159
|
+
() => false,
|
|
160
|
+
)
|
|
161
|
+
const controls = useSyncExternalStore(
|
|
162
|
+
subscribeToKey,
|
|
163
|
+
() => {
|
|
164
|
+
if (key === undefined) return ''
|
|
165
|
+
const entry = entries.get(key)
|
|
166
|
+
return entry === undefined ? '' : orderedMembers(entry).join(' ')
|
|
167
|
+
},
|
|
168
|
+
() => '',
|
|
169
|
+
)
|
|
170
|
+
const boundaryId = useSyncExternalStore(
|
|
171
|
+
subscribeToKey,
|
|
172
|
+
() => {
|
|
173
|
+
if (key === undefined) return undefined
|
|
174
|
+
const entry = entries.get(key)
|
|
175
|
+
if (entry === undefined) return undefined
|
|
176
|
+
return orderedBoundaries(entry)[0] ?? orderedMembers(entry)[0]
|
|
177
|
+
},
|
|
178
|
+
() => undefined,
|
|
179
|
+
)
|
|
180
|
+
const hasMembers = useSyncExternalStore(
|
|
181
|
+
subscribeToKey,
|
|
182
|
+
() => key !== undefined && (entries.get(key)?.members.size ?? 0) > 0,
|
|
183
|
+
() => false,
|
|
184
|
+
)
|
|
185
|
+
const label = useSyncExternalStore(
|
|
186
|
+
subscribeToKey,
|
|
187
|
+
() => key === undefined ? undefined : entries.get(key)?.label,
|
|
188
|
+
() => undefined,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
useLayoutEffect(() => {
|
|
192
|
+
if (key === undefined || options.boundaryId === undefined || options.boundaryOrder === undefined) return
|
|
193
|
+
return registerBoundary(key, options.boundaryId, options.boundaryOrder)
|
|
194
|
+
}, [key, options.boundaryId, options.boundaryOrder])
|
|
195
|
+
|
|
196
|
+
useLayoutEffect(() => {
|
|
197
|
+
if (key === undefined || options.memberId === undefined || options.memberOrder === undefined) return
|
|
198
|
+
return registerMember(key, options.memberId, options.memberOrder)
|
|
199
|
+
}, [key, options.memberId, options.memberOrder])
|
|
200
|
+
|
|
201
|
+
useLayoutEffect(() => {
|
|
202
|
+
if (key === undefined || options.completedLabel === undefined) return
|
|
203
|
+
complete(key, options.completedLabel)
|
|
204
|
+
}, [key, options.completedLabel])
|
|
205
|
+
|
|
206
|
+
const toggle = useCallback(() => {
|
|
207
|
+
if (key === undefined) return
|
|
208
|
+
const entry = entryFor(key)
|
|
209
|
+
if (!entry.completed || entry.members.size === 0) return
|
|
210
|
+
entry.collapsed = !entry.collapsed
|
|
211
|
+
notify(entry)
|
|
212
|
+
}, [key])
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
collapsed,
|
|
216
|
+
completed,
|
|
217
|
+
boundary: (options.boundaryId !== undefined && boundaryId === options.boundaryId)
|
|
218
|
+
|| (options.memberId !== undefined && boundaryId === options.memberId),
|
|
219
|
+
controls,
|
|
220
|
+
hasMembers,
|
|
221
|
+
label,
|
|
222
|
+
toggle,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
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 ConversationSmoothingPreset = 'realtime' | 'balanced' | 'silky'
|
|
19
|
+
|
|
20
|
+
export interface ConversationSmoothingPresetConfig {
|
|
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<ConversationSmoothingPreset, ConversationSmoothingPresetConfig> = {
|
|
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
|
+
const graphemeSegmenter = typeof Intl.Segmenter === 'function'
|
|
114
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
115
|
+
: undefined
|
|
116
|
+
|
|
117
|
+
/** Split text into user-perceived characters without tearing emoji or combining marks. */
|
|
118
|
+
export function splitGraphemes(text: string): string[] {
|
|
119
|
+
if (graphemeSegmenter === undefined) return [...text]
|
|
120
|
+
return [...graphemeSegmenter.segment(text)].map(item => item.segment)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Counts user-perceived characters, not UTF-16 units or Unicode code points. */
|
|
124
|
+
export const countChars = (text: string): number => splitGraphemes(text).length
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Drain rate for backlog beyond the tail-lag ceiling: the displayed text may
|
|
128
|
+
* trail the stream by at most `backlogCharCeiling` characters for at most
|
|
129
|
+
* `backlogSecondCeiling` seconds. The constants are a smoothness invariant
|
|
130
|
+
* (bounded lag is the reason the reveal never stalls mid-reply), so they are
|
|
131
|
+
* not config fields; the preset multiplier is how a deployment tunes them.
|
|
132
|
+
*/
|
|
133
|
+
export const BACKLOG_CHAR_CEILING = 300
|
|
134
|
+
export const BACKLOG_SECOND_CEILING = 2
|
|
135
|
+
|
|
136
|
+
export interface SettleDrainInput {
|
|
137
|
+
readonly backlog: number
|
|
138
|
+
readonly inputActive: boolean
|
|
139
|
+
readonly settling: boolean
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Pure settle-drain decision shared by the frame loop and its tests. */
|
|
143
|
+
export function computeSettleDrain(config: ConversationSmoothingPresetConfig, input: SettleDrainInput): number {
|
|
144
|
+
if (input.inputActive || !input.settling) return 0
|
|
145
|
+
// A settled stream must drain fast enough to finish the reply promptly:
|
|
146
|
+
// lag beyond the ceiling drains within the second ceiling.
|
|
147
|
+
const overflow = Math.max(0, input.backlog - BACKLOG_CHAR_CEILING)
|
|
148
|
+
const overflowCps = (overflow * 1000) / BACKLOG_SECOND_CEILING
|
|
149
|
+
const drainTargetMs = clamp(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs)
|
|
150
|
+
const settleCps = (input.backlog * 1000) / drainTargetMs
|
|
151
|
+
return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Drain rate multiplier once the input ends: leftover backlog reveals at
|
|
156
|
+
* this multiple of the steady rate, so the end never drags.
|
|
157
|
+
*/
|
|
158
|
+
export const SETTLE_DRAIN_MULTIPLIER = 1.8
|
|
159
|
+
|
|
160
|
+
export interface RevealStepInput {
|
|
161
|
+
readonly backlog: number
|
|
162
|
+
readonly chunkSizeEma: number
|
|
163
|
+
readonly arrivalCpsEma: number
|
|
164
|
+
readonly emaCps: number
|
|
165
|
+
readonly inputActive: boolean
|
|
166
|
+
readonly settling: boolean
|
|
167
|
+
/** Fixed reveal rate; overrides the arrival-tracking cadence entirely. */
|
|
168
|
+
readonly steadyCps?: number | undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface RevealStepResult {
|
|
172
|
+
readonly revealChars: number
|
|
173
|
+
readonly targetLagChars: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Pure per-frame reveal decision shared by the loop and its tests. */
|
|
177
|
+
export function computeRevealStep(config: ConversationSmoothingPresetConfig, input: RevealStepInput, dtSeconds: number): RevealStepResult {
|
|
178
|
+
// Track the faster of the two EMAs and do not clamp to the old low maxCps
|
|
179
|
+
// ceiling — that cap is why a fast model left hundreds of chars unrevealed.
|
|
180
|
+
const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma)
|
|
181
|
+
const baseCps = clamp(trackedCps, config.minCps, config.maxFlushCps)
|
|
182
|
+
const targetLagChars = input.inputActive
|
|
183
|
+
? Math.max(2, Math.round((baseCps * config.targetBufferMs) / 1000))
|
|
184
|
+
: 0
|
|
185
|
+
|
|
186
|
+
let currentCps: number
|
|
187
|
+
if (input.steadyCps !== undefined) {
|
|
188
|
+
currentCps = input.inputActive || input.settling
|
|
189
|
+
? clamp(
|
|
190
|
+
input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER),
|
|
191
|
+
config.minCps,
|
|
192
|
+
config.maxFlushCps,
|
|
193
|
+
)
|
|
194
|
+
: 0
|
|
195
|
+
} else if (input.inputActive) {
|
|
196
|
+
const overflow = Math.max(0, input.backlog - LIVE_LAG_CHAR_CEILING)
|
|
197
|
+
const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0
|
|
198
|
+
currentCps = clamp(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps)
|
|
199
|
+
} else if (input.settling) {
|
|
200
|
+
currentCps = computeSettleDrain(config, input)
|
|
201
|
+
} else {
|
|
202
|
+
const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * 0.8)
|
|
203
|
+
currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const minRevealChars = input.inputActive ? 1 : 2
|
|
207
|
+
return { revealChars: Math.max(minRevealChars, Math.round(currentCps * dtSeconds)), targetLagChars }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface UseConversationContentOptions {
|
|
211
|
+
enabled?: boolean
|
|
212
|
+
preset?: ConversationSmoothingPreset
|
|
213
|
+
/** Performance guard veto: while true, reveal commits are held back. */
|
|
214
|
+
shouldHoldBack?: (() => boolean) | undefined
|
|
215
|
+
/**
|
|
216
|
+
* Fixed reveal rate in chars/s. When set, the reveal runs at this steady
|
|
217
|
+
* pace while the input streams (instead of tracking the arrival rate) and
|
|
218
|
+
* drains the leftover backlog at {@link SETTLE_DRAIN_MULTIPLIER} once the
|
|
219
|
+
* input ends.
|
|
220
|
+
*/
|
|
221
|
+
steadyCps?: number | undefined
|
|
222
|
+
/**
|
|
223
|
+
* Seed for the arrival-rate EMA. When omitted, the preset's `defaultCps`
|
|
224
|
+
* is used. The live rate then tracks observed arrival; this is not a cap.
|
|
225
|
+
*/
|
|
226
|
+
defaultCps?: number | undefined
|
|
227
|
+
/** Written each commit with the live arrival-rate EMA for the follow lerp. */
|
|
228
|
+
speedCpsRef?: { current: number } | undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Smooth a chunked content stream into a reveal-paced display string.
|
|
233
|
+
*
|
|
234
|
+
* @param content - The full accumulated input so far.
|
|
235
|
+
* @param options - Preset, guard, and steady-rate wiring.
|
|
236
|
+
* @returns The displayed content, revealed at the smoothed cadence.
|
|
237
|
+
*/
|
|
238
|
+
export function useConversationContent(
|
|
239
|
+
content: string,
|
|
240
|
+
{ enabled = true, preset = 'balanced', shouldHoldBack, steadyCps, defaultCps, speedCpsRef }: UseConversationContentOptions = {},
|
|
241
|
+
): string {
|
|
242
|
+
const config = PRESET_CONFIG[preset]
|
|
243
|
+
const seedCps = defaultCps ?? config.defaultCps
|
|
244
|
+
const [displayedContent, setDisplayedContent] = useState(content)
|
|
245
|
+
|
|
246
|
+
const displayedContentRef = useRef(content)
|
|
247
|
+
const displayedCountRef = useRef(countChars(content))
|
|
248
|
+
const targetContentRef = useRef(content)
|
|
249
|
+
const targetCharsRef = useRef(splitGraphemes(content))
|
|
250
|
+
const targetCountRef = useRef(countChars(content))
|
|
251
|
+
|
|
252
|
+
const emaCpsRef = useRef(seedCps)
|
|
253
|
+
const lastInputTsRef = useRef(0)
|
|
254
|
+
const lastInputCountRef = useRef(countChars(content))
|
|
255
|
+
const chunkSizeEmaRef = useRef(1)
|
|
256
|
+
const arrivalCpsEmaRef = useRef(seedCps)
|
|
257
|
+
|
|
258
|
+
const rafRef = useRef<number | null>(null)
|
|
259
|
+
const lastFrameTsRef = useRef<number | null>(null)
|
|
260
|
+
const wakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
261
|
+
const holdBackRef = useRef(shouldHoldBack)
|
|
262
|
+
const speedOutRef = useRef(speedCpsRef)
|
|
263
|
+
speedOutRef.current = speedCpsRef
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
holdBackRef.current = shouldHoldBack
|
|
267
|
+
}, [shouldHoldBack])
|
|
268
|
+
|
|
269
|
+
const clearWakeTimer = useCallback(() => {
|
|
270
|
+
if (wakeTimerRef.current !== null) {
|
|
271
|
+
clearTimeout(wakeTimerRef.current)
|
|
272
|
+
wakeTimerRef.current = null
|
|
273
|
+
}
|
|
274
|
+
}, [])
|
|
275
|
+
|
|
276
|
+
const stopFrameLoop = useCallback(() => {
|
|
277
|
+
if (rafRef.current !== null) {
|
|
278
|
+
cancelAnimationFrame(rafRef.current)
|
|
279
|
+
rafRef.current = null
|
|
280
|
+
}
|
|
281
|
+
lastFrameTsRef.current = null
|
|
282
|
+
}, [])
|
|
283
|
+
|
|
284
|
+
const stopScheduling = useCallback(() => {
|
|
285
|
+
stopFrameLoop()
|
|
286
|
+
clearWakeTimer()
|
|
287
|
+
}, [clearWakeTimer, stopFrameLoop])
|
|
288
|
+
|
|
289
|
+
const startFrameLoopRef = useRef<() => void>(() => {})
|
|
290
|
+
|
|
291
|
+
const scheduleFrameWake = useCallback(
|
|
292
|
+
(delayMs: number) => {
|
|
293
|
+
clearWakeTimer()
|
|
294
|
+
wakeTimerRef.current = setTimeout(
|
|
295
|
+
() => {
|
|
296
|
+
wakeTimerRef.current = null
|
|
297
|
+
startFrameLoopRef.current()
|
|
298
|
+
},
|
|
299
|
+
Math.max(1, Math.ceil(delayMs)),
|
|
300
|
+
)
|
|
301
|
+
},
|
|
302
|
+
[clearWakeTimer],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const syncImmediate = useCallback(
|
|
306
|
+
(nextContent: string) => {
|
|
307
|
+
stopScheduling()
|
|
308
|
+
const chars = splitGraphemes(nextContent)
|
|
309
|
+
const now = performance.now()
|
|
310
|
+
targetContentRef.current = nextContent
|
|
311
|
+
targetCharsRef.current = chars
|
|
312
|
+
targetCountRef.current = chars.length
|
|
313
|
+
displayedContentRef.current = nextContent
|
|
314
|
+
displayedCountRef.current = chars.length
|
|
315
|
+
setDisplayedContent(nextContent)
|
|
316
|
+
emaCpsRef.current = seedCps
|
|
317
|
+
chunkSizeEmaRef.current = 1
|
|
318
|
+
arrivalCpsEmaRef.current = seedCps
|
|
319
|
+
lastInputTsRef.current = now
|
|
320
|
+
lastInputCountRef.current = chars.length
|
|
321
|
+
},
|
|
322
|
+
[seedCps, stopScheduling],
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
const startFrameLoop = useCallback(() => {
|
|
326
|
+
clearWakeTimer()
|
|
327
|
+
if (rafRef.current !== null) return
|
|
328
|
+
|
|
329
|
+
const tick = () => {
|
|
330
|
+
const targetCount = targetCountRef.current
|
|
331
|
+
const displayedCount = displayedCountRef.current
|
|
332
|
+
const backlog = targetCount - displayedCount
|
|
333
|
+
|
|
334
|
+
if (backlog <= 0) {
|
|
335
|
+
stopFrameLoop()
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const now = performance.now()
|
|
340
|
+
if (lastFrameTsRef.current === null) {
|
|
341
|
+
lastFrameTsRef.current = now
|
|
342
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs)
|
|
347
|
+
const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current)
|
|
348
|
+
if (frameIntervalMs < commitIntervalMs) {
|
|
349
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const dtSeconds = Math.max(0.001, Math.min(frameIntervalMs / 1000, 0.12))
|
|
354
|
+
lastFrameTsRef.current = now
|
|
355
|
+
|
|
356
|
+
const idleMs = now - lastInputTsRef.current
|
|
357
|
+
const inputActive = idleMs <= config.activeInputWindowMs
|
|
358
|
+
const settling = !inputActive && idleMs >= config.settleAfterMs
|
|
359
|
+
|
|
360
|
+
let revealChars: number
|
|
361
|
+
if (steadyCps !== undefined) {
|
|
362
|
+
const step = computeRevealStep(
|
|
363
|
+
config,
|
|
364
|
+
{
|
|
365
|
+
backlog,
|
|
366
|
+
chunkSizeEma: chunkSizeEmaRef.current,
|
|
367
|
+
arrivalCpsEma: arrivalCpsEmaRef.current,
|
|
368
|
+
emaCps: emaCpsRef.current,
|
|
369
|
+
inputActive,
|
|
370
|
+
settling,
|
|
371
|
+
steadyCps,
|
|
372
|
+
},
|
|
373
|
+
dtSeconds,
|
|
374
|
+
)
|
|
375
|
+
revealChars = Math.min(step.revealChars, backlog)
|
|
376
|
+
} else {
|
|
377
|
+
// Same rAF queue drain as the silky markdown demo.
|
|
378
|
+
revealChars = computeQueueReveal(backlog, frameIntervalMs)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Performance guard: while degraded and the reply is offscreen, skip
|
|
382
|
+
// the DOM commit — the backlog keeps accumulating and flushes when the
|
|
383
|
+
// guard clears or the reply scrolls into view.
|
|
384
|
+
if (holdBackRef.current?.() === true) {
|
|
385
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const speedOut = speedOutRef.current
|
|
390
|
+
if (speedOut !== undefined && frameIntervalMs > 0) {
|
|
391
|
+
const instantCps = (revealChars * 1000) / frameIntervalMs
|
|
392
|
+
speedOut.current = speedOut.current * 0.92 + instantCps * 0.08
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const nextCount = displayedCount + revealChars
|
|
396
|
+
const segment = targetCharsRef.current.slice(displayedCount, nextCount).join('')
|
|
397
|
+
if (segment) {
|
|
398
|
+
const nextDisplayed = displayedContentRef.current + segment
|
|
399
|
+
displayedContentRef.current = nextDisplayed
|
|
400
|
+
displayedCountRef.current = nextCount
|
|
401
|
+
setDisplayedContent(nextDisplayed)
|
|
402
|
+
} else {
|
|
403
|
+
displayedContentRef.current = targetContentRef.current
|
|
404
|
+
displayedCountRef.current = targetCount
|
|
405
|
+
setDisplayedContent(targetContentRef.current)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
rafRef.current = requestAnimationFrame(tick)
|
|
412
|
+
}, [config, scheduleFrameWake, stopFrameLoop, clearWakeTimer, steadyCps])
|
|
413
|
+
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
startFrameLoopRef.current = startFrameLoop
|
|
416
|
+
}, [startFrameLoop])
|
|
417
|
+
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
if (!enabled) {
|
|
420
|
+
syncImmediate(content)
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const prevTargetContent = targetContentRef.current
|
|
425
|
+
if (content === prevTargetContent) return
|
|
426
|
+
|
|
427
|
+
const now = performance.now()
|
|
428
|
+
const appendOnly = content.startsWith(prevTargetContent)
|
|
429
|
+
|
|
430
|
+
if (!appendOnly) {
|
|
431
|
+
syncImmediate(content)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const appended = content.slice(prevTargetContent.length)
|
|
436
|
+
const appendedChars = splitGraphemes(appended)
|
|
437
|
+
const appendedCount = appendedChars.length
|
|
438
|
+
|
|
439
|
+
targetContentRef.current = content
|
|
440
|
+
targetCharsRef.current.push(...appendedChars)
|
|
441
|
+
targetCountRef.current += appendedCount
|
|
442
|
+
|
|
443
|
+
const hadSample = lastInputTsRef.current > 0
|
|
444
|
+
const deltaChars = targetCountRef.current - lastInputCountRef.current
|
|
445
|
+
const deltaMs = Math.max(1, now - lastInputTsRef.current)
|
|
446
|
+
|
|
447
|
+
// Skip the first sample: lastInputTs starts at 0, so the interval would
|
|
448
|
+
// be "time since page load" (or 1ms under fake timers) and poison the EMA.
|
|
449
|
+
if (hadSample && deltaChars > 0) {
|
|
450
|
+
const instantCps = (deltaChars * 1000) / deltaMs
|
|
451
|
+
const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 3)
|
|
452
|
+
const chunkEmaAlpha = 0.45
|
|
453
|
+
chunkSizeEmaRef.current = chunkSizeEmaRef.current * (1 - chunkEmaAlpha) + appendedCount * chunkEmaAlpha
|
|
454
|
+
arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * (1 - chunkEmaAlpha) + normalizedInstantCps * chunkEmaAlpha
|
|
455
|
+
emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + normalizedInstantCps * config.emaAlpha
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
lastInputTsRef.current = now
|
|
459
|
+
lastInputCountRef.current = targetCountRef.current
|
|
460
|
+
|
|
461
|
+
startFrameLoop()
|
|
462
|
+
}, [content, enabled, config, startFrameLoop, syncImmediate])
|
|
463
|
+
|
|
464
|
+
useEffect(() => {
|
|
465
|
+
return () => {
|
|
466
|
+
stopScheduling()
|
|
467
|
+
}
|
|
468
|
+
}, [stopScheduling])
|
|
469
|
+
|
|
470
|
+
return displayedContent
|
|
471
|
+
}
|