dsh-smooth-stream 0.3.4 → 0.4.1

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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Shared browser-side diagnostics state.
3
+ *
4
+ * The settings card owns persistence. This module is the live bridge used by
5
+ * the renderer and the chat-side panel: renderer loops publish measurements,
6
+ * while panel edits are staged through the settings-card controller.
7
+ */
8
+
9
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
10
+ import {
11
+ DEFAULT_STREAM_DEBUG_TUNING,
12
+ type StreamDebugTuning,
13
+ type StreamSettings,
14
+ } from '../settings.ts'
15
+
16
+ export interface DebugMetrics {
17
+ fps: number | null
18
+ frameMs: number | null
19
+ fpsDegraded: boolean
20
+ streamActive: boolean
21
+ streamBacklog: number
22
+ streamSpeedCps: number
23
+ streamTargetChars: number
24
+ streamDisplayedChars: number
25
+ followActive: boolean
26
+ followLagPx: number
27
+ followVelocityPxPerSec: number
28
+ followReservePx: number
29
+ followCapacityPx: number
30
+ followRevealScale: number
31
+ followFollowing: boolean
32
+ followConstrained: boolean
33
+ scrollTop: number | null
34
+ scrollHeight: number | null
35
+ clientHeight: number | null
36
+ lastUpdatedMs: number | null
37
+ }
38
+
39
+ export interface DebugRuntimeState {
40
+ available: boolean
41
+ enabled: boolean
42
+ writable: boolean
43
+ dirty: boolean
44
+ status: 'loading' | 'ready' | 'unavailable'
45
+ tuning: StreamDebugTuning
46
+ metrics: DebugMetrics
47
+ }
48
+
49
+ export type DebugSettingsPatch = Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>
50
+
51
+ export interface DebugPanelFace {
52
+ hooks: {
53
+ debugRuntime: SnapshotStore<DebugRuntimeState>
54
+ }
55
+ edit: (patch: DebugSettingsPatch) => void
56
+ save: () => void
57
+ discard: () => void
58
+ reset: () => void
59
+ }
60
+
61
+ interface DebugSettingsActions {
62
+ edit: (patch: DebugSettingsPatch) => void
63
+ save: () => void
64
+ discard: () => void
65
+ }
66
+
67
+ interface StreamMetric {
68
+ backlog: number
69
+ speedCps: number
70
+ targetChars: number
71
+ displayedChars: number
72
+ active: boolean
73
+ updatedAt: number
74
+ }
75
+
76
+ interface FollowMetric {
77
+ lagPx: number
78
+ velocityPxPerSec: number
79
+ reservePx: number
80
+ capacityPx: number
81
+ revealScale: number
82
+ following: boolean
83
+ constrained: boolean
84
+ scrollTop: number
85
+ scrollHeight: number
86
+ clientHeight: number
87
+ active: boolean
88
+ updatedAt: number
89
+ }
90
+
91
+ const EMPTY_METRICS: DebugMetrics = {
92
+ fps: null,
93
+ frameMs: null,
94
+ fpsDegraded: false,
95
+ streamActive: false,
96
+ streamBacklog: 0,
97
+ streamSpeedCps: 0,
98
+ streamTargetChars: 0,
99
+ streamDisplayedChars: 0,
100
+ followActive: false,
101
+ followLagPx: 0,
102
+ followVelocityPxPerSec: 0,
103
+ followReservePx: 0,
104
+ followCapacityPx: 0,
105
+ followRevealScale: 1,
106
+ followFollowing: false,
107
+ followConstrained: false,
108
+ scrollTop: null,
109
+ scrollHeight: null,
110
+ clientHeight: null,
111
+ lastUpdatedMs: null,
112
+ }
113
+
114
+ const INITIAL_STATE: DebugRuntimeState = {
115
+ available: false,
116
+ enabled: false,
117
+ writable: false,
118
+ dirty: false,
119
+ status: 'loading',
120
+ tuning: { ...DEFAULT_STREAM_DEBUG_TUNING },
121
+ metrics: EMPTY_METRICS,
122
+ }
123
+
124
+ const store = createSnapshotStore<DebugRuntimeState>(INITIAL_STATE)
125
+ const streamMetrics = new Map<string, StreamMetric>()
126
+ const followMetrics = new Map<HTMLElement, FollowMetric>()
127
+ let actions: DebugSettingsActions | undefined
128
+ let lastMetricPublish = 0
129
+
130
+ function resetMetrics(): void {
131
+ streamMetrics.clear()
132
+ followMetrics.clear()
133
+ lastMetricPublish = 0
134
+ }
135
+
136
+ function resetRuntime(): void {
137
+ actions = undefined
138
+ resetMetrics()
139
+ store.set({ ...INITIAL_STATE, metrics: EMPTY_METRICS })
140
+ }
141
+
142
+ function now(): number {
143
+ return typeof performance === 'undefined' ? Date.now() : performance.now()
144
+ }
145
+
146
+ function sameTuning(left: StreamDebugTuning, right: StreamDebugTuning): boolean {
147
+ return left.revealScale === right.revealScale
148
+ && left.queuePressure === right.queuePressure
149
+ && left.maxRevealCps === right.maxRevealCps
150
+ && left.springStiffness === right.springStiffness
151
+ && left.springDamping === right.springDamping
152
+ && left.springMass === right.springMass
153
+ && left.runwayPx === right.runwayPx
154
+ && left.reserveResponseMs === right.reserveResponseMs
155
+ && left.backpressureMinScale === right.backpressureMinScale
156
+ }
157
+
158
+ function currentMetrics(timestamp: number): DebugMetrics {
159
+ let stream: StreamMetric | undefined
160
+ for (const candidate of streamMetrics.values()) {
161
+ if (stream === undefined || candidate.updatedAt > stream.updatedAt) stream = candidate
162
+ }
163
+ let follow: FollowMetric | undefined
164
+ for (const candidate of followMetrics.values()) {
165
+ if (follow === undefined || candidate.updatedAt > follow.updatedAt) follow = candidate
166
+ }
167
+ return {
168
+ fps: store.getSnapshot().metrics.fps,
169
+ frameMs: store.getSnapshot().metrics.frameMs,
170
+ fpsDegraded: store.getSnapshot().metrics.fpsDegraded,
171
+ streamActive: stream?.active ?? false,
172
+ streamBacklog: stream?.backlog ?? 0,
173
+ streamSpeedCps: stream?.speedCps ?? 0,
174
+ streamTargetChars: stream?.targetChars ?? 0,
175
+ streamDisplayedChars: stream?.displayedChars ?? 0,
176
+ followActive: follow?.active ?? false,
177
+ followLagPx: follow?.lagPx ?? 0,
178
+ followVelocityPxPerSec: follow?.velocityPxPerSec ?? 0,
179
+ followReservePx: follow?.reservePx ?? 0,
180
+ followCapacityPx: follow?.capacityPx ?? 0,
181
+ followRevealScale: follow?.revealScale ?? 1,
182
+ followFollowing: follow?.following ?? false,
183
+ followConstrained: follow?.constrained ?? false,
184
+ scrollTop: follow?.scrollTop ?? null,
185
+ scrollHeight: follow?.scrollHeight ?? null,
186
+ clientHeight: follow?.clientHeight ?? null,
187
+ lastUpdatedMs: timestamp,
188
+ }
189
+ }
190
+
191
+ function publishMetrics(force = false): void {
192
+ const timestamp = now()
193
+ if (!force && timestamp - lastMetricPublish < 80) return
194
+ lastMetricPublish = timestamp
195
+ store.set({ ...store.getSnapshot(), metrics: currentMetrics(timestamp) })
196
+ }
197
+
198
+ export const debugRuntime = {
199
+ store,
200
+
201
+ getSnapshot(): DebugRuntimeState {
202
+ return store.getSnapshot()
203
+ },
204
+
205
+ subscribe(listener: () => void): () => void {
206
+ return store.subscribe(listener)
207
+ },
208
+
209
+ /** Whether hot-path instrumentation should do any work. */
210
+ isEnabled(): boolean {
211
+ return store.getSnapshot().enabled
212
+ },
213
+
214
+ tuning(): StreamDebugTuning {
215
+ return store.getSnapshot().tuning
216
+ },
217
+
218
+ /** Production values remain untouched until the user explicitly enables diagnostics. */
219
+ activeTuning(): StreamDebugTuning {
220
+ return store.getSnapshot().enabled ? store.getSnapshot().tuning : DEFAULT_STREAM_DEBUG_TUNING
221
+ },
222
+
223
+ bindSettings(nextActions: DebugSettingsActions | undefined): () => void {
224
+ actions = nextActions
225
+ return () => {
226
+ if (actions !== nextActions) return
227
+ resetRuntime()
228
+ }
229
+ },
230
+
231
+ syncSettings(input: {
232
+ available?: boolean
233
+ enabled: boolean
234
+ writable: boolean
235
+ dirty: boolean
236
+ status: DebugRuntimeState['status']
237
+ tuning?: StreamDebugTuning
238
+ }): void {
239
+ const current = store.getSnapshot()
240
+ // Callers predating the Host capability flag are local diagnostics tests
241
+ // and renderer probes; preserve their enabled behavior unless the wiring
242
+ // explicitly reports that the RPC is unavailable.
243
+ const available = input.available ?? true
244
+ const enabled = available && input.enabled
245
+ const availabilityChanged = current.available !== available
246
+ const enabledChanged = current.enabled !== enabled
247
+ if (availabilityChanged || enabledChanged) resetMetrics()
248
+ const tuning = input.tuning === undefined
249
+ ? current.tuning
250
+ : { ...DEFAULT_STREAM_DEBUG_TUNING, ...input.tuning }
251
+ if (
252
+ current.available === available
253
+ && current.enabled === enabled
254
+ && current.writable === input.writable
255
+ && current.dirty === input.dirty
256
+ && current.status === input.status
257
+ && sameTuning(current.tuning, tuning)
258
+ ) return
259
+ store.set({
260
+ ...current,
261
+ available,
262
+ enabled,
263
+ writable: input.writable,
264
+ dirty: input.dirty,
265
+ status: input.status,
266
+ tuning,
267
+ metrics: availabilityChanged || enabledChanged || !enabled ? EMPTY_METRICS : current.metrics,
268
+ })
269
+ },
270
+
271
+ edit(patch: DebugSettingsPatch): void {
272
+ const current = store.getSnapshot()
273
+ const enabled = patch.debugEnabled ?? current.enabled
274
+ const enabledChanged = current.enabled !== enabled
275
+ if (enabledChanged) resetMetrics()
276
+ const tuning = patch.debugTuning === undefined
277
+ ? current.tuning
278
+ : { ...current.tuning, ...patch.debugTuning }
279
+ store.set({
280
+ ...current,
281
+ enabled,
282
+ dirty: true,
283
+ tuning,
284
+ metrics: enabledChanged || !enabled ? EMPTY_METRICS : current.metrics,
285
+ })
286
+ actions?.edit({
287
+ ...(patch.debugEnabled === undefined ? {} : { debugEnabled: patch.debugEnabled }),
288
+ ...(patch.debugTuning === undefined ? {} : { debugTuning: tuning }),
289
+ })
290
+ },
291
+
292
+ save(): void {
293
+ actions?.save()
294
+ },
295
+
296
+ discard(): void {
297
+ actions?.discard()
298
+ },
299
+
300
+ reset(): void {
301
+ this.edit({ debugTuning: { ...DEFAULT_STREAM_DEBUG_TUNING } })
302
+ },
303
+
304
+ reportStream(id: string, metric: Omit<StreamMetric, 'updatedAt'> | null): void {
305
+ if (!this.isEnabled()) return
306
+ if (metric === null) streamMetrics.delete(id)
307
+ else streamMetrics.set(id, { ...metric, updatedAt: now() })
308
+ publishMetrics(metric === null)
309
+ },
310
+
311
+ reportFollow(port: HTMLElement, metric: Omit<FollowMetric, 'updatedAt'> | null): void {
312
+ if (!this.isEnabled()) return
313
+ if (metric === null) followMetrics.delete(port)
314
+ else followMetrics.set(port, { ...metric, updatedAt: now() })
315
+ publishMetrics(metric === null)
316
+ },
317
+
318
+ reportFps(fps: number, frameMs: number, degraded: boolean): void {
319
+ if (!this.isEnabled()) return
320
+ const current = store.getSnapshot()
321
+ store.set({
322
+ ...current,
323
+ metrics: { ...current.metrics, fps, frameMs, fpsDegraded: degraded, lastUpdatedMs: now() },
324
+ })
325
+ },
326
+
327
+ clearFps(): void {
328
+ if (!this.isEnabled()) return
329
+ const current = store.getSnapshot()
330
+ store.set({
331
+ ...current,
332
+ metrics: {
333
+ ...current.metrics,
334
+ fps: null,
335
+ frameMs: null,
336
+ fpsDegraded: false,
337
+ lastUpdatedMs: now(),
338
+ },
339
+ })
340
+ },
341
+
342
+ panelFace(): DebugPanelFace {
343
+ return {
344
+ hooks: { debugRuntime: store },
345
+ edit: patch => { this.edit(patch) },
346
+ save: () => { this.save() },
347
+ discard: () => { this.discard() },
348
+ reset: () => { this.reset() },
349
+ }
350
+ },
351
+
352
+ resetRuntime(): void {
353
+ resetRuntime()
354
+ },
355
+ }
@@ -7,13 +7,16 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client
7
7
  import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
8
8
  import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
9
9
  import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
10
+ import { AutoCollapseController } from './auto-collapse-controller.ts'
10
11
  import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
11
12
  import { SmoothStreamCard } from './SmoothStreamCard.tsx'
12
13
  import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
13
14
  import { createSmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
15
+ import { DebugPanel } from './DebugPanel.tsx'
16
+ import { debugRuntime } from './debugRuntime.ts'
14
17
  import { NS as SETTINGS_NS, en, zh } from './locales.ts'
15
18
  import { DEFAULT_STREAM_CONFIG, STREAM_BOOT_GLOBAL, type StreamConfig } from '../config.ts'
16
- import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
19
+ import { DEFAULT_STREAM_SETTINGS, STREAM_SETTINGS_NS, type StreamSettings } from '../settings.ts'
17
20
 
18
21
  /**
19
22
  * Cordis services required by the browser half. Only `slots` is load-bearing
@@ -116,15 +119,15 @@ function wrapAgentChatRows(ctx: ClientContext): () => void {
116
119
  }
117
120
 
118
121
  /**
119
- * A live preference cell read by `useSyncExternalStore`. It starts on the
120
- * shared default and, once the plugin-owned settings controller is attached,
121
- * tracks the resolved `thinkAutoExpand` value. A composition (or test) that
122
- * ships no settings surface keeps the cell on the default.
122
+ * A live settings cell shared by the renderer lifecycle and React views. It
123
+ * starts on the shared defaults and follows the plugin-owned controller once
124
+ * the optional settings services arrive.
123
125
  */
124
- class PreferenceCell {
126
+ class SettingsCell {
125
127
  private readonly listeners = new Set<() => void>()
126
128
  private card: SmoothStreamCardController | undefined
127
- private value = DEFAULT_STREAM_SETTINGS.thinkAutoExpand
129
+ private value: StreamSettings = DEFAULT_STREAM_SETTINGS
130
+ private pending = false
128
131
 
129
132
  /** Re-point the cell at the plugin-owned settings controller. */
130
133
  attach(card: SmoothStreamCardController): () => void {
@@ -139,18 +142,39 @@ class PreferenceCell {
139
142
  }
140
143
  }
141
144
 
142
- private read(): boolean {
143
- return this.card?.getSnapshot().thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand
145
+ private read(): StreamSettings {
146
+ const snapshot = this.card?.getSnapshot()
147
+ if (snapshot === undefined || snapshot.status !== 'ready') return this.value
148
+ return this.card?.values() ?? this.value
144
149
  }
145
150
 
146
151
  private refresh(): void {
147
152
  const next = this.read()
148
- if (next === this.value) return
153
+ const pending = this.card?.getSnapshot().status === 'loading'
154
+ if (
155
+ pending === this.pending
156
+ && next.enabled === this.value.enabled
157
+ && next.thinkAutoExpand === this.value.thinkAutoExpand
158
+ && next.autoCollapse === this.value.autoCollapse
159
+ && next.debugEnabled === this.value.debugEnabled
160
+ && next.debugTuning === this.value.debugTuning
161
+ ) return
162
+ this.pending = pending
149
163
  this.value = next
150
164
  for (const listener of this.listeners) listener()
151
165
  }
152
166
 
153
- readonly getSnapshot = (): boolean => this.value
167
+ /** False while an available settings service is resolving its authority. */
168
+ takeoverEnabled(): boolean {
169
+ return !this.pending && this.value.enabled
170
+ }
171
+
172
+ /** Whether finished turns should fold behind a summary row right now. */
173
+ autoCollapseActive(): boolean {
174
+ return !this.pending && this.value.autoCollapse
175
+ }
176
+
177
+ readonly getSnapshot = (): StreamSettings => this.value
154
178
 
155
179
  readonly subscribe = (listener: () => void): () => void => {
156
180
  this.listeners.add(listener)
@@ -171,7 +195,26 @@ class PreferenceCell {
171
195
  */
172
196
  export function apply(ctx: ClientContext): void {
173
197
  const config = readBootConfig()
174
- const preference = new PreferenceCell()
198
+ const settings = new SettingsCell()
199
+
200
+ // Auto-collapse works at the DOM level and is independent of the renderer
201
+ // takeover: it also folds finished turns when the built-in Harness renderer
202
+ // owns the conversation. The lifecycle rides the guaranteed `slots` service
203
+ // (this entry's own inject list) and follows the same live settings cell, so
204
+ // flipping the preference restores every folded row immediately.
205
+ ctx.inject(['slots'], () => {
206
+ const autoCollapse = new AutoCollapseController()
207
+ const syncAutoCollapse = (): void => {
208
+ if (settings.autoCollapseActive()) autoCollapse.start()
209
+ else autoCollapse.stop()
210
+ }
211
+ const unsubscribe = settings.subscribe(syncAutoCollapse)
212
+ syncAutoCollapse()
213
+ return () => {
214
+ unsubscribe()
215
+ autoCollapse.stop()
216
+ }
217
+ })
175
218
 
176
219
  // The card talks to the plugin-owned loopback RPC, so the core settings
177
220
  // namespace allowlist cannot make it disappear. The stream still applies
@@ -183,27 +226,57 @@ export function apply(ctx: ClientContext): void {
183
226
  // handle, so narrow through unknown to its client contract here.
184
227
  createSmoothStreamSettingsApi(settingsCtx.get('connection') as unknown as ConnectionHandle),
185
228
  )
186
- const detachPreference = preference.attach(card)
229
+ const detachSettings = settings.attach(card)
230
+ const syncDebug = (): void => {
231
+ const snapshot = card.getSnapshot()
232
+ debugRuntime.syncSettings({
233
+ available: snapshot.debugAvailable,
234
+ enabled: snapshot.debugEnabled,
235
+ writable: snapshot.writable && !snapshot.saving,
236
+ dirty: snapshot.dirty,
237
+ status: snapshot.status,
238
+ tuning: snapshot.debugTuning,
239
+ })
240
+ }
241
+ const detachBinding = debugRuntime.bindSettings({
242
+ edit: patch => { card.inject().edit(patch) },
243
+ save: () => { card.inject().save() },
244
+ discard: () => { card.inject().discard() },
245
+ })
246
+ const detachDebug = card.subscribe(syncDebug)
247
+ syncDebug()
187
248
  card.start()
188
249
  settingsCtx.effect(() => settingsCtx.locale.register(SETTINGS_NS, { zh, en }), 'dsh-smooth-stream: settings dictionaries')
189
250
  settingsCtx.slots.inject('settings.plugin.item', () => settingsCtx.slots.register({
190
251
  name: 'settings.plugin.item',
191
252
  id: 'smooth-stream',
253
+ key: STREAM_SETTINGS_NS,
192
254
  order: 30,
193
255
  locale: SETTINGS_NS,
194
256
  inject: () => card.inject(),
195
- }, SmoothStreamCard))
257
+ // Older Harness declares this slot as a list (`id`); newer Harness uses a
258
+ // keyed slot filtered by the Host settings namespace (`key`).
259
+ } as never, SmoothStreamCard))
260
+ settingsCtx.slots.inject('conversation.session.header.utilities', () => settingsCtx.slots.register({
261
+ name: 'conversation.session.header.utilities',
262
+ id: 'smooth-stream-debug',
263
+ order: 40,
264
+ locale: SETTINGS_NS,
265
+ inject: () => debugRuntime.panelFace(),
266
+ }, DebugPanel))
196
267
  return () => {
197
268
  card.stop()
198
- detachPreference()
269
+ detachDebug()
270
+ detachSettings()
271
+ detachBinding()
199
272
  }
200
273
  })
201
274
 
202
275
  const configured = function StreamConfiguredView(props: AssistantProps) {
203
- const thinkAutoExpand = useSyncExternalStore(
204
- preference.subscribe,
205
- preference.getSnapshot,
206
- preference.getSnapshot,
276
+ const preferences = useSyncExternalStore(
277
+ settings.subscribe,
278
+ settings.getSnapshot,
279
+ settings.getSnapshot,
207
280
  )
208
281
  return createElement(TypewriterAssistantNodeView, {
209
282
  ...props,
@@ -212,21 +285,39 @@ export function apply(ctx: ClientContext): void {
212
285
  revealCharsPerSec: config.revealCharsPerSec,
213
286
  scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
214
287
  maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
215
- thinkAutoExpand,
288
+ thinkAutoExpand: preferences.thinkAutoExpand,
216
289
  })
217
290
  }
218
291
  ctx.slots.inject('conversation.chat.node', () => {
219
- const unwrap = wrapAgentChatRows(ctx)
220
- const unshadow = ctx.slots.register({
221
- name: 'conversation.chat.node',
222
- key: 'assistant-step',
223
- priority: -100,
224
- locale: 'conversation',
225
- registrant: 'dsh-smooth-stream',
226
- }, configured)
292
+ let releaseTakeover: (() => void) | undefined
293
+
294
+ const syncTakeover = (): void => {
295
+ if (!settings.takeoverEnabled()) {
296
+ releaseTakeover?.()
297
+ releaseTakeover = undefined
298
+ return
299
+ }
300
+ if (releaseTakeover !== undefined) return
301
+ const unwrap = wrapAgentChatRows(ctx)
302
+ const unshadow = ctx.slots.register({
303
+ name: 'conversation.chat.node',
304
+ key: 'assistant-step',
305
+ priority: -100,
306
+ locale: 'conversation',
307
+ registrant: 'dsh-smooth-stream',
308
+ }, configured)
309
+ releaseTakeover = () => {
310
+ // Stop observing slot changes before the shadow entry is removed.
311
+ unwrap()
312
+ unshadow()
313
+ }
314
+ }
315
+
316
+ const unsubscribe = settings.subscribe(syncTakeover)
317
+ syncTakeover()
227
318
  return () => {
228
- unshadow()
229
- unwrap()
319
+ unsubscribe()
320
+ releaseTakeover?.()
230
321
  }
231
322
  })
232
323
  }