dsh-smooth-stream 0.6.0 → 0.7.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/README.en.md +4 -1
- package/README.md +5 -1
- package/lib/client.js +1757 -282
- package/lib/client.js.map +1 -1
- package/lib/index.js +299 -3
- package/package.json +1 -1
- package/src/client/AgentRowEntrance.module.css +1 -4
- package/src/client/AnimatedDisclosure.tsx +3 -3
- package/src/client/DebugPanel.tsx +34 -15
- package/src/client/FrameCoordinator.ts +180 -0
- package/src/client/LogarithmicFade.module.css +160 -0
- package/src/client/SmoothStreamCard.tsx +41 -3
- package/src/client/StreamBuffer.ts +145 -0
- package/src/client/TypewriterAssistantNodeView.module.css +82 -12
- package/src/client/TypewriterAssistantNodeView.tsx +232 -104
- package/src/client/debugRuntime.ts +58 -0
- package/src/client/harnessIcons.ts +27 -0
- package/src/client/index.ts +153 -11
- package/src/client/locales.ts +90 -0
- package/src/client/smooth-stream-card-controller.ts +10 -3
- package/src/client/smooth-stream-settings-api.ts +7 -3
- package/src/client/teleprompterGlide.ts +499 -70
- package/src/client/useDecoupledMarkdown.ts +173 -0
- package/src/client/useFpsGuard.ts +9 -6
- package/src/client/useLogarithmicFade.ts +672 -0
- package/src/client/useProgressiveDomText.ts +27 -11
- package/src/client/useSmoothStreamContent.ts +5 -13
- package/src/config.ts +1 -1
- package/src/plugin.ts +99 -12
- package/src/settings-api.ts +4 -0
- package/src/settings-channel.ts +303 -0
- package/src/settings.ts +14 -0
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { useLayoutEffect, type RefObject } from 'react'
|
|
12
12
|
import { computeAdaptiveQueueStep } from './useSmoothStreamContent.ts'
|
|
13
13
|
import { debugRuntime } from './debugRuntime.ts'
|
|
14
|
+
import { FrameCoordinator } from './FrameCoordinator.ts'
|
|
14
15
|
|
|
15
16
|
interface TextRevealRecord {
|
|
16
17
|
chars: readonly string[]
|
|
@@ -113,13 +114,20 @@ export function useProgressiveDomText(
|
|
|
113
114
|
|
|
114
115
|
const pending = new Set<Text>()
|
|
115
116
|
const internalWrites = new WeakMap<Text, string>()
|
|
116
|
-
let
|
|
117
|
+
let frameTaskId: string | null = null
|
|
117
118
|
let lastFrame: number | null = null
|
|
118
119
|
let debt = 0
|
|
119
120
|
let stopped = false
|
|
120
121
|
let announcedSettled = false
|
|
121
122
|
const streamId = `dom-${Math.random().toString(36).slice(2)}`
|
|
122
123
|
|
|
124
|
+
const coordinator = FrameCoordinator.forDocument()
|
|
125
|
+
const stopFrameTask = (): void => {
|
|
126
|
+
if (frameTaskId === null) return
|
|
127
|
+
coordinator.unregisterTask(frameTaskId)
|
|
128
|
+
frameTaskId = null
|
|
129
|
+
}
|
|
130
|
+
|
|
123
131
|
const announceSettled = (): void => {
|
|
124
132
|
lastFrame = null
|
|
125
133
|
debt = 0
|
|
@@ -173,22 +181,25 @@ export function useProgressiveDomText(
|
|
|
173
181
|
}
|
|
174
182
|
|
|
175
183
|
const scheduleFrame = (): void => {
|
|
176
|
-
if (stopped || pending.size === 0 ||
|
|
177
|
-
|
|
184
|
+
if (stopped || pending.size === 0 || frameTaskId !== null) return
|
|
185
|
+
// Ride the shared document clock instead of a private rAF chain, so
|
|
186
|
+
// tool-output reveal cannot race the assistant reveal or the follower.
|
|
187
|
+
frameTaskId = coordinator.registerTask({
|
|
188
|
+
onSimulate: (_dtMs, now) => frame(now),
|
|
189
|
+
})
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
const frame = (now: number):
|
|
181
|
-
|
|
182
|
-
if (stopped) return
|
|
192
|
+
const frame = (now: number): boolean => {
|
|
193
|
+
if (stopped) return false
|
|
183
194
|
if (pending.size === 0) {
|
|
184
195
|
announceSettled()
|
|
185
|
-
|
|
196
|
+
stopFrameTask()
|
|
197
|
+
return false
|
|
186
198
|
}
|
|
187
199
|
announcedSettled = false
|
|
188
200
|
if (lastFrame === null) {
|
|
189
201
|
lastFrame = now
|
|
190
|
-
|
|
191
|
-
return
|
|
202
|
+
return true
|
|
192
203
|
}
|
|
193
204
|
const elapsed = Math.max(0, now - lastFrame)
|
|
194
205
|
lastFrame = now
|
|
@@ -236,9 +247,14 @@ export function useProgressiveDomText(
|
|
|
236
247
|
targetChars,
|
|
237
248
|
displayedChars,
|
|
238
249
|
active: pending.size > 0,
|
|
250
|
+
// This path reveals a node at a time and never observes the model
|
|
251
|
+
// stream's own end, so it cannot claim a terminal drain. Reporting
|
|
252
|
+
// `false` keeps the follower's terminal phase driven by the smoother
|
|
253
|
+
// that actually owns the completion signal.
|
|
254
|
+
producerComplete: false,
|
|
239
255
|
})
|
|
240
256
|
if (pending.size === 0) announceSettled()
|
|
241
|
-
|
|
257
|
+
return pending.size > 0
|
|
242
258
|
}
|
|
243
259
|
|
|
244
260
|
visit(root, revealInitial)
|
|
@@ -269,7 +285,7 @@ export function useProgressiveDomText(
|
|
|
269
285
|
|
|
270
286
|
return () => {
|
|
271
287
|
stopped = true
|
|
272
|
-
|
|
288
|
+
stopFrameTask()
|
|
273
289
|
observer?.disconnect()
|
|
274
290
|
for (const [node, record] of records) {
|
|
275
291
|
const controlled = record.chars.slice(0, record.shown).join('')
|
|
@@ -434,18 +434,9 @@ export function useSmoothStreamContent(
|
|
|
434
434
|
let revealSpeedCps: number
|
|
435
435
|
let nextQueueDebt = 0
|
|
436
436
|
if (producerComplete) {
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
// it from the shrinking backlog creates an exponential slow tail —
|
|
441
|
-
// but RAMP the effective velocity up from the last reveal speed over
|
|
442
|
-
// ~3 frames: an instant jump from streaming pace to max flush is
|
|
443
|
-
// itself a visible jerk at exactly the moment the reader is watching
|
|
444
|
-
// the reply finish.
|
|
445
|
-
// The pre-drain reveal speed is the streaming EMA, not the debug seed.
|
|
446
|
-
// Seeding the ramp from the 35cps default when the reply ran at the
|
|
447
|
-
// max-flush ceiling stretched a 420ms tail past the 800ms announcer
|
|
448
|
-
// step and the visible Markdown mutated across it.
|
|
437
|
+
// Completion uses one bounded target velocity rather than the
|
|
438
|
+
// backlog-pressure curve. The latter changes speed as the queue
|
|
439
|
+
// shrinks and visibly kicks the final wrapped lines.
|
|
449
440
|
const previousCps = lastDrainCpsRef.current > 0
|
|
450
441
|
? lastDrainCpsRef.current
|
|
451
442
|
: Math.max(config.minCps, emaCpsRef.current)
|
|
@@ -495,7 +486,8 @@ export function useSmoothStreamContent(
|
|
|
495
486
|
speedCps: revealSpeedCps,
|
|
496
487
|
targetChars: targetCount,
|
|
497
488
|
displayedChars: displayedCount,
|
|
498
|
-
active: !producerComplete,
|
|
489
|
+
active: !producerComplete || backlog > 0,
|
|
490
|
+
producerComplete,
|
|
499
491
|
})
|
|
500
492
|
|
|
501
493
|
// Performance guard: while degraded and the reply is offscreen, skip
|
package/src/config.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface StreamConfig {
|
|
|
33
33
|
/** Defaults shared by the Host schema and the client-side fallback. */
|
|
34
34
|
export const DEFAULT_STREAM_CONFIG: StreamConfig = {
|
|
35
35
|
mode: 'typewriter',
|
|
36
|
-
preset: '
|
|
36
|
+
preset: 'silky',
|
|
37
37
|
revealCharsPerSec: 80,
|
|
38
38
|
scrollSpeedPxPerSec: 48,
|
|
39
39
|
maxScrollSpeedPxPerSec: 1000,
|
package/src/plugin.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { DEFAULT_STREAM_CONFIG, type StreamConfig } from './config.ts'
|
|
|
9
9
|
import { injectStreamConfig } from './boot-config.ts'
|
|
10
10
|
import { STREAM_PACKAGE_NAME, STREAM_PACKAGE_VERSION } from './package-meta.ts'
|
|
11
11
|
import { inspectProfileInstallation, updateNpmProfilePackage } from './profile-installation.ts'
|
|
12
|
+
import { registerSettingsChannel } from './settings-channel.ts'
|
|
12
13
|
import {
|
|
13
14
|
STREAM_SETTINGS_RPC,
|
|
14
15
|
STREAM_SETTINGS_RPC_CHANNEL,
|
|
@@ -56,12 +57,18 @@ export const Config: Schema<Config> = Schema.object({
|
|
|
56
57
|
export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
|
|
57
58
|
enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
|
|
58
59
|
controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
|
|
60
|
+
preset: Schema.union([
|
|
61
|
+
Schema.const('realtime'),
|
|
62
|
+
Schema.const('balanced'),
|
|
63
|
+
Schema.const('silky'),
|
|
64
|
+
] as const).default(DEFAULT_STREAM_SETTINGS.preset),
|
|
59
65
|
motionPreference: Schema.union([
|
|
60
66
|
Schema.const('auto'),
|
|
61
67
|
Schema.const('force-smooth'),
|
|
62
68
|
Schema.const('force-reduced'),
|
|
63
69
|
] as const).default(DEFAULT_STREAM_SETTINGS.motionPreference),
|
|
64
70
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
71
|
+
logarithmicFade: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.logarithmicFade),
|
|
65
72
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
66
73
|
debugTuning: Schema.object({
|
|
67
74
|
revealScale: Schema.number().min(0.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
|
|
@@ -100,16 +107,68 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
100
107
|
// the durable provider as the authority, but expose this one schema through
|
|
101
108
|
// the plugin's own loopback-only connection channel instead.
|
|
102
109
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
// dsh 0.1.x: durable plugin-owned namespace registered on the settings
|
|
111
|
+
// service. dsh 0.2.x: `register` is gone — SettingsForms owns persistence
|
|
112
|
+
// through profile entry config — so serve the card read-only from the
|
|
113
|
+
// composition config until that migration lands, and keep the channel
|
|
114
|
+
// reachable so the card renders instead of dying as a 405.
|
|
115
|
+
const settingsSvc = settingsCtx.settings
|
|
116
|
+
const legacyRegister = (settingsSvc as { register?: unknown }).register
|
|
117
|
+
if (typeof legacyRegister !== 'function') {
|
|
118
|
+
const view02 = (): StreamSettingsView => ({
|
|
119
|
+
version: STREAM_PACKAGE_VERSION,
|
|
120
|
+
installation: inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME).kind,
|
|
121
|
+
writable: false,
|
|
122
|
+
enabled: DEFAULT_STREAM_SETTINGS.enabled,
|
|
123
|
+
controlScroll: DEFAULT_STREAM_SETTINGS.controlScroll,
|
|
124
|
+
preset: config.preset,
|
|
125
|
+
motionPreference: DEFAULT_STREAM_SETTINGS.motionPreference,
|
|
126
|
+
thinkAutoExpand: DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
127
|
+
logarithmicFade: DEFAULT_STREAM_SETTINGS.logarithmicFade,
|
|
128
|
+
canUpgrade: false,
|
|
129
|
+
})
|
|
130
|
+
const handle02: ConnectionRpcHandler = async (endpoint) => {
|
|
131
|
+
if (endpoint === STREAM_SETTINGS_RPC.read) return { ok: true, value: view02() }
|
|
132
|
+
if (endpoint === STREAM_SETTINGS_RPC.debugRead) {
|
|
133
|
+
return { ok: true, value: { debugEnabled: DEFAULT_STREAM_SETTINGS.debugEnabled, tuning: DEFAULT_STREAM_SETTINGS.debugTuning } }
|
|
134
|
+
}
|
|
135
|
+
if (endpoint === STREAM_SETTINGS_RPC.upgrade) {
|
|
136
|
+
const installation = inspectProfileInstallation(ctx.baseUrl, STREAM_PACKAGE_NAME)
|
|
137
|
+
if (installation.kind !== 'npm') {
|
|
138
|
+
return { ok: false, error: { code: 'internal', message: 'smooth-stream is not an npm profile dependency', details: {} } }
|
|
139
|
+
}
|
|
140
|
+
return { ok: false, error: { code: 'internal', message: 'upgrade is unavailable until the 0.2.x settings migration lands', details: {} } }
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: {
|
|
145
|
+
code: 'settings-rejected',
|
|
146
|
+
message: 'smooth-stream settings are read-only on dsh 0.2.x; durable storage moved to profile entry config',
|
|
147
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// The connection service is not injectable from a plugin scope on 0.2.x,
|
|
152
|
+
// so mount through the web server fiber. registerSettingsChannel serves
|
|
153
|
+
// the route unfenced there (loopback + app token gate remain).
|
|
154
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
155
|
+
registerSettingsChannel(webCtx, STREAM_SETTINGS_RPC_CHANNEL, handle02)
|
|
156
|
+
})
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const settingsNamespace = STREAM_SETTINGS_NS as SettingsNamespace
|
|
160
|
+
const scope = settingsCtx.settings.register(
|
|
110
161
|
settingsNamespace,
|
|
111
162
|
StreamSettingsSchema,
|
|
112
|
-
{
|
|
163
|
+
{
|
|
164
|
+
// The install-time entry config is the composition base, so it resolves
|
|
165
|
+
// *below* the user layer: a stored pick still wins, while "the user
|
|
166
|
+
// never chose" keeps following the overlay (cordis.patch.yml / profile
|
|
167
|
+
// config). Keeping it out of the schema default is what makes those two
|
|
168
|
+
// states distinguishable at all.
|
|
169
|
+
base: { preset: config.preset },
|
|
170
|
+
applies: 'live',
|
|
171
|
+
},
|
|
113
172
|
)
|
|
114
173
|
settingsCtx.inject(['connection'], (connectionCtx) => {
|
|
115
174
|
let upgrade: Promise<void> | undefined
|
|
@@ -123,8 +182,10 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
123
182
|
writable: connectionCtx.settings.writable,
|
|
124
183
|
enabled: settings.enabled,
|
|
125
184
|
controlScroll: settings.controlScroll,
|
|
185
|
+
preset: settings.preset ?? config.preset,
|
|
126
186
|
motionPreference: settings.motionPreference,
|
|
127
187
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
188
|
+
logarithmicFade: settings.logarithmicFade,
|
|
128
189
|
canUpgrade: installation.kind === 'npm',
|
|
129
190
|
}
|
|
130
191
|
}
|
|
@@ -191,11 +252,28 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
191
252
|
const next = payload as {
|
|
192
253
|
enabled: boolean
|
|
193
254
|
controlScroll: boolean
|
|
255
|
+
preset?: unknown
|
|
194
256
|
motionPreference?: unknown
|
|
195
257
|
thinkAutoExpand: boolean
|
|
258
|
+
logarithmicFade?: unknown
|
|
196
259
|
debugEnabled?: unknown
|
|
197
260
|
debugTuning?: unknown
|
|
198
261
|
}
|
|
262
|
+
if (
|
|
263
|
+
next.preset !== undefined
|
|
264
|
+
&& next.preset !== 'realtime'
|
|
265
|
+
&& next.preset !== 'balanced'
|
|
266
|
+
&& next.preset !== 'silky'
|
|
267
|
+
) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: {
|
|
271
|
+
code: 'settings-rejected',
|
|
272
|
+
message: 'preset must be one of realtime | balanced | silky',
|
|
273
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
274
|
+
},
|
|
275
|
+
}
|
|
276
|
+
}
|
|
199
277
|
if (
|
|
200
278
|
next.motionPreference !== undefined
|
|
201
279
|
&& next.motionPreference !== 'auto'
|
|
@@ -211,6 +289,16 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
211
289
|
},
|
|
212
290
|
}
|
|
213
291
|
}
|
|
292
|
+
if (next.logarithmicFade !== undefined && typeof next.logarithmicFade !== 'boolean') {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
error: {
|
|
296
|
+
code: 'settings-rejected',
|
|
297
|
+
message: 'logarithmicFade must be a boolean',
|
|
298
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
}
|
|
214
302
|
const hasDebug = next.debugEnabled !== undefined || next.debugTuning !== undefined
|
|
215
303
|
if (hasDebug && (typeof next.debugEnabled !== 'boolean' || !validDebugTuning(next.debugTuning))) {
|
|
216
304
|
return {
|
|
@@ -225,8 +313,10 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
225
313
|
await scope.update({
|
|
226
314
|
enabled: next.enabled,
|
|
227
315
|
controlScroll: next.controlScroll,
|
|
316
|
+
...(next.preset === undefined ? {} : { preset: next.preset }),
|
|
228
317
|
...(next.motionPreference === undefined ? {} : { motionPreference: next.motionPreference }),
|
|
229
318
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
319
|
+
...(next.logarithmicFade === undefined ? {} : { logarithmicFade: next.logarithmicFade }),
|
|
230
320
|
...(hasDebug ? { debugEnabled: next.debugEnabled, debugTuning: next.debugTuning } : {}),
|
|
231
321
|
})
|
|
232
322
|
} catch {
|
|
@@ -308,10 +398,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
308
398
|
}
|
|
309
399
|
return { ok: false, error: { code: 'internal', message: `unknown smooth-stream endpoint ${JSON.stringify(endpoint)}`, details: {} } }
|
|
310
400
|
}
|
|
311
|
-
connectionCtx
|
|
312
|
-
() => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: 'loopback' }),
|
|
313
|
-
'dsh-smooth-stream: settings RPC',
|
|
314
|
-
)
|
|
401
|
+
registerSettingsChannel(connectionCtx, STREAM_SETTINGS_RPC_CHANNEL, handle)
|
|
315
402
|
})
|
|
316
403
|
})
|
|
317
404
|
}
|
package/src/settings-api.ts
CHANGED
|
@@ -27,10 +27,14 @@ export interface StreamSettingsView {
|
|
|
27
27
|
enabled: boolean
|
|
28
28
|
/** Whether smooth-stream also owns conversation bottom-follow. */
|
|
29
29
|
controlScroll: boolean
|
|
30
|
+
/** Smoothing preset for the reveal cadence and follow physics. */
|
|
31
|
+
preset: import('./settings.ts').StreamSmoothingPreset
|
|
30
32
|
/** How the reveal honors the OS reduced-motion preference. */
|
|
31
33
|
motionPreference: import('./settings.ts').StreamMotionPreference
|
|
32
34
|
/** Current resolved preference. */
|
|
33
35
|
thinkAutoExpand: boolean
|
|
36
|
+
/** Whether new answer/thinking text fades into its original color. */
|
|
37
|
+
logarithmicFade: boolean
|
|
34
38
|
/** Whether a fixed npm update command is safe to offer. */
|
|
35
39
|
canUpgrade: boolean
|
|
36
40
|
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side registration of the plugin-owned settings RPC channel.
|
|
3
|
+
*
|
|
4
|
+
* The documented path is `connection.rpc.handle()`, and it is tried first: that
|
|
5
|
+
* service owns the channel's trust policy and its physical route. Some shipped
|
|
6
|
+
* kernel generations resolve `webServer` from inside that service through a
|
|
7
|
+
* Context that never injected it, so the call throws
|
|
8
|
+
* (`cannot get property "webServer" without inject`) and the channel is never
|
|
9
|
+
* mounted — the browser then reports the settings card as unreachable.
|
|
10
|
+
*
|
|
11
|
+
* When the service call fails, this module mounts the same absolute channel
|
|
12
|
+
* prefix directly on `webServer`, behind the connection service's own request
|
|
13
|
+
* fence and speaking the documented request/response envelopes. Nothing here
|
|
14
|
+
* weakens the fence: a kernel without `requestRejection` fails closed instead
|
|
15
|
+
* of exposing the channel to whoever can reach the port. The fallback retires
|
|
16
|
+
* on its own the moment the service call works again.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
20
|
+
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
|
21
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
22
|
+
|
|
23
|
+
/** Largest request body this channel buffers; settings payloads are tiny. */
|
|
24
|
+
const MAX_REQUEST_BYTES = 1 << 20
|
|
25
|
+
|
|
26
|
+
/** Endpoint segment shape accepted by the Connection router, mirrored here. */
|
|
27
|
+
const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/
|
|
28
|
+
|
|
29
|
+
/** Envelope discriminator the browser caller sends. */
|
|
30
|
+
const CLIENT_REQUEST = 'client-request'
|
|
31
|
+
|
|
32
|
+
/** Envelope discriminator the browser caller expects back. */
|
|
33
|
+
const SERVER_RESPONSE = 'server-response'
|
|
34
|
+
|
|
35
|
+
/** One decoded browser request. */
|
|
36
|
+
interface RequestEnvelope {
|
|
37
|
+
readonly rpcId: string
|
|
38
|
+
readonly method: string
|
|
39
|
+
readonly payload: unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The subset of the Host Connection handle this module depends on. */
|
|
43
|
+
interface ChannelConnection {
|
|
44
|
+
readonly rpc?: {
|
|
45
|
+
handle?: (
|
|
46
|
+
channel: string,
|
|
47
|
+
handler: ConnectionRpcHandler,
|
|
48
|
+
options: { readonly authority: 'loopback' },
|
|
49
|
+
) => unknown
|
|
50
|
+
}
|
|
51
|
+
readonly requestRejection?: (request: unknown) => 401 | 403 | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The subset of the Web server service this module depends on. */
|
|
55
|
+
interface ChannelWebServer {
|
|
56
|
+
register(route: WebRoute): () => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Body read outcome, kept distinct so the reply can name the real failure. */
|
|
60
|
+
type BodyRead =
|
|
61
|
+
| { readonly kind: 'value'; readonly value: unknown }
|
|
62
|
+
| { readonly kind: 'invalid' }
|
|
63
|
+
| { readonly kind: 'too-large' }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register the settings RPC channel on the most capable path the kernel has.
|
|
67
|
+
* The registration is an effect of `ctx`, so the caller's fiber owns the
|
|
68
|
+
* channel's lifetime on either path.
|
|
69
|
+
* @param ctx - Host context carrying `connection` and, when composed, `webServer`.
|
|
70
|
+
* @param channel - absolute channel prefix, e.g. `/smooth-stream`.
|
|
71
|
+
* @param handler - decoded endpoint handler for every endpoint on the channel.
|
|
72
|
+
* @returns Disposer that withdraws the channel.
|
|
73
|
+
*/
|
|
74
|
+
export function registerSettingsChannel(
|
|
75
|
+
ctx: Context,
|
|
76
|
+
channel: string,
|
|
77
|
+
handler: ConnectionRpcHandler,
|
|
78
|
+
): () => void {
|
|
79
|
+
let connection: ChannelConnection | undefined
|
|
80
|
+
try {
|
|
81
|
+
connection = ctx.get('connection') as ChannelConnection | undefined
|
|
82
|
+
} catch {
|
|
83
|
+
// dsh 0.2.x: the connection service may be unresolvable from a plugin
|
|
84
|
+
// scope; the direct mount below then serves the channel unfenced.
|
|
85
|
+
connection = undefined
|
|
86
|
+
}
|
|
87
|
+
return ctx.effect(() => {
|
|
88
|
+
try {
|
|
89
|
+
const direct = mountDirectRoute(ctx, connection, channel, handler)
|
|
90
|
+
if (direct !== undefined) return direct
|
|
91
|
+
} catch {
|
|
92
|
+
// fall through to the service path
|
|
93
|
+
}
|
|
94
|
+
const viaService = tryServiceChannel(connection, channel, handler)
|
|
95
|
+
if (viaService !== undefined) return viaService
|
|
96
|
+
return mountDirectRoute(ctx, connection, channel, handler)
|
|
97
|
+
}, `dsh-smooth-stream: ${channel} RPC channel`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Try the service-owned registration, which is the only path that also carries
|
|
102
|
+
* the kernel's own channel policy.
|
|
103
|
+
* @returns Disposer on success, `undefined` when the kernel cannot mount it.
|
|
104
|
+
*/
|
|
105
|
+
function tryServiceChannel(
|
|
106
|
+
connection: ChannelConnection | undefined,
|
|
107
|
+
channel: string,
|
|
108
|
+
handler: ConnectionRpcHandler,
|
|
109
|
+
): (() => void) | undefined {
|
|
110
|
+
const rpc = connection?.rpc
|
|
111
|
+
if (rpc === undefined || typeof rpc.handle !== 'function') return undefined
|
|
112
|
+
try {
|
|
113
|
+
const release = rpc.handle(channel, handler, { authority: 'loopback' })
|
|
114
|
+
if (typeof release !== 'function') return undefined
|
|
115
|
+
return () => { void (release as () => unknown)() }
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// A kernel that cannot resolve `webServer` inside the service throws here;
|
|
118
|
+
// the direct route below keeps the card reachable on that kernel.
|
|
119
|
+
console.warn(
|
|
120
|
+
`[dsh-smooth-stream] connection.rpc.handle() could not mount ${channel}; `
|
|
121
|
+
+ 'falling back to a directly registered route',
|
|
122
|
+
error,
|
|
123
|
+
)
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mount the channel prefix on the Web server.
|
|
130
|
+
*
|
|
131
|
+
* With the connection service reachable the route sits behind the connection
|
|
132
|
+
* trust fence. On dsh 0.2.x the connection service is unresolvable from a
|
|
133
|
+
* plugin scope, so the route is then served unfenced (loopback + the app's
|
|
134
|
+
* own token gate only) with a warning — the same trade-off the skill center
|
|
135
|
+
* plugin ships on 0.2.x.
|
|
136
|
+
* @throws fail-closed when the connection service is reachable but cannot
|
|
137
|
+
* fence, so the channel is never exposed without the trust policy.
|
|
138
|
+
*/
|
|
139
|
+
function mountDirectRoute(
|
|
140
|
+
ctx: Context,
|
|
141
|
+
connection: ChannelConnection | undefined,
|
|
142
|
+
channel: string,
|
|
143
|
+
handler: ConnectionRpcHandler,
|
|
144
|
+
): () => void {
|
|
145
|
+
const webServer = ctx.get('webServer') as ChannelWebServer | undefined
|
|
146
|
+
const reject = connection?.requestRejection
|
|
147
|
+
if (webServer === undefined || typeof webServer.register !== 'function') {
|
|
148
|
+
throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`)
|
|
149
|
+
}
|
|
150
|
+
if (typeof reject !== 'function') {
|
|
151
|
+
if (connection !== undefined) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} `
|
|
154
|
+
+ 'cannot be mounted behind the connection trust fence',
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
console.warn(
|
|
158
|
+
`[dsh-smooth-stream] ${channel} mounted without the connection trust fence `
|
|
159
|
+
+ '(dsh 0.2.x: the connection service is not injectable here); '
|
|
160
|
+
+ 'loopback and the app token gate remain the only protections',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
return webServer.register({
|
|
164
|
+
kind: 'prefix',
|
|
165
|
+
path: channel,
|
|
166
|
+
handler: async (req, res) => {
|
|
167
|
+
const rejection = reject?.call(connection, req)
|
|
168
|
+
if (rejection !== undefined) {
|
|
169
|
+
res.writeHead(rejection)
|
|
170
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
await answer(req, res, channel, handler)
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Decode one request envelope, dispatch it, and write the decoded reply.
|
|
180
|
+
* Mirrors the Connection router's own carrier rules: method and endpoint must
|
|
181
|
+
* agree, the body must be JSON, and only endpoint failures are results while
|
|
182
|
+
* transport failures are statuses.
|
|
183
|
+
*/
|
|
184
|
+
async function answer(
|
|
185
|
+
req: Parameters<WebRoute['handler']>[0],
|
|
186
|
+
res: Parameters<WebRoute['handler']>[1],
|
|
187
|
+
channel: string,
|
|
188
|
+
handler: ConnectionRpcHandler,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const endpoint = endpointOf(channel, req.url)
|
|
191
|
+
if (req.method !== 'POST' || endpoint === undefined) {
|
|
192
|
+
res.writeHead(404)
|
|
193
|
+
res.end('not found')
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const mediaType = (req.headers['content-type'] ?? '').split(';', 1)[0]?.trim().toLowerCase()
|
|
197
|
+
if (mediaType !== 'application/json') {
|
|
198
|
+
res.writeHead(415)
|
|
199
|
+
res.end('content type must be application/json')
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
const body = await readBody(req)
|
|
203
|
+
if (body.kind === 'too-large') {
|
|
204
|
+
res.writeHead(413)
|
|
205
|
+
res.end('request body too large')
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
if (body.kind === 'invalid') {
|
|
209
|
+
res.writeHead(400)
|
|
210
|
+
res.end('body is not JSON')
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const envelope = requestEnvelope(body.value)
|
|
214
|
+
if (envelope === undefined) {
|
|
215
|
+
write(res, rpcIdOf(body.value), {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: {
|
|
218
|
+
code: 'gateway/bad-request',
|
|
219
|
+
message: 'invalid client-request message',
|
|
220
|
+
details: { issues: [] },
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
if (envelope.method !== endpoint) {
|
|
226
|
+
write(res, envelope.rpcId, {
|
|
227
|
+
ok: false,
|
|
228
|
+
error: {
|
|
229
|
+
code: 'gateway/bad-request',
|
|
230
|
+
message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
|
231
|
+
details: { issues: [] },
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const controller = new AbortController()
|
|
237
|
+
res.on('close', () => { controller.abort() })
|
|
238
|
+
try {
|
|
239
|
+
write(res, envelope.rpcId, await handler(endpoint, envelope.payload, controller.signal))
|
|
240
|
+
} catch (error) {
|
|
241
|
+
res.writeHead(500)
|
|
242
|
+
res.end(`handler failure: ${String(error)}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Resolve the channel-relative endpoint for one request path.
|
|
248
|
+
* @returns The endpoint, or `undefined` when the path is outside the channel
|
|
249
|
+
* or carries a segment the Connection router would refuse.
|
|
250
|
+
*/
|
|
251
|
+
function endpointOf(channel: string, rawUrl: string | undefined): string | undefined {
|
|
252
|
+
const pathname = new URL(rawUrl ?? '/', 'http://dsh.internal').pathname
|
|
253
|
+
if (!pathname.startsWith(`${channel}/`)) return undefined
|
|
254
|
+
const endpoint = pathname.slice(channel.length + 1)
|
|
255
|
+
const segments = endpoint.split('/')
|
|
256
|
+
if (segments.some(segment =>
|
|
257
|
+
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT.test(segment))) {
|
|
258
|
+
return undefined
|
|
259
|
+
}
|
|
260
|
+
return endpoint
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Structure one decoded request, rejecting anything the caller could not have sent. */
|
|
264
|
+
function requestEnvelope(value: unknown): RequestEnvelope | undefined {
|
|
265
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
266
|
+
const record = value as Record<string, unknown>
|
|
267
|
+
if (record.type !== CLIENT_REQUEST) return undefined
|
|
268
|
+
if (typeof record.rpcId !== 'string' || typeof record.method !== 'string') return undefined
|
|
269
|
+
return { rpcId: record.rpcId, method: record.method, payload: record.payload }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Recover the correlation id of a malformed envelope so the caller can match it. */
|
|
273
|
+
function rpcIdOf(value: unknown): string {
|
|
274
|
+
const raw = (value as { rpcId?: unknown } | null)?.rpcId
|
|
275
|
+
return typeof raw === 'string' ? raw : 'invalid-request'
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Write one decoded reply in the envelope the browser caller parses. */
|
|
279
|
+
function write(
|
|
280
|
+
res: Parameters<WebRoute['handler']>[1],
|
|
281
|
+
rpcId: string,
|
|
282
|
+
result: unknown,
|
|
283
|
+
): void {
|
|
284
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
285
|
+
res.end(JSON.stringify({ type: SERVER_RESPONSE, rpcId, result }))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Buffer one request body, refusing both malformed JSON and unbounded input. */
|
|
289
|
+
async function readBody(req: Parameters<WebRoute['handler']>[0]): Promise<BodyRead> {
|
|
290
|
+
const chunks: Buffer[] = []
|
|
291
|
+
let bytes = 0
|
|
292
|
+
for await (const chunk of req) {
|
|
293
|
+
const buffer = chunk as Buffer
|
|
294
|
+
bytes += buffer.length
|
|
295
|
+
if (bytes > MAX_REQUEST_BYTES) return { kind: 'too-large' }
|
|
296
|
+
chunks.push(buffer)
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
return { kind: 'value', value: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
300
|
+
} catch {
|
|
301
|
+
return { kind: 'invalid' }
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -57,6 +57,11 @@ export type StreamMotionPreference =
|
|
|
57
57
|
/** Always render raw text, ignoring the OS preference. */
|
|
58
58
|
| 'force-reduced'
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Smoothing cadence and damping profile.
|
|
62
|
+
*/
|
|
63
|
+
export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
|
|
64
|
+
|
|
60
65
|
/**
|
|
61
66
|
* Preferences a user may set. Deliberately separate from {@link StreamConfig}
|
|
62
67
|
* because the two change at different times: composition-time values go
|
|
@@ -73,6 +78,10 @@ export interface StreamSettings {
|
|
|
73
78
|
* scroll ownership to the Harness; text reveal still runs.
|
|
74
79
|
*/
|
|
75
80
|
controlScroll: boolean
|
|
81
|
+
/**
|
|
82
|
+
* Smoothing preset for the reveal cadence and follow physics.
|
|
83
|
+
*/
|
|
84
|
+
preset: StreamSmoothingPreset
|
|
76
85
|
/**
|
|
77
86
|
* How the reveal honors the OS reduced-motion preference. `auto` preserves
|
|
78
87
|
* the accessibility-first default; `force-smooth` keeps the engine on
|
|
@@ -86,6 +95,8 @@ export interface StreamSettings {
|
|
|
86
95
|
* it by hand — and stops the running state from re-owning the disclosure.
|
|
87
96
|
*/
|
|
88
97
|
thinkAutoExpand: boolean
|
|
98
|
+
/** Logarithmic text fade for the answer and expanded thinking body. */
|
|
99
|
+
logarithmicFade: boolean
|
|
89
100
|
/** Whether the live renderer diagnostics panel is enabled. */
|
|
90
101
|
debugEnabled: boolean
|
|
91
102
|
/** Values edited by the diagnostics panel. */
|
|
@@ -96,8 +107,11 @@ export interface StreamSettings {
|
|
|
96
107
|
export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
|
|
97
108
|
enabled: true,
|
|
98
109
|
controlScroll: true,
|
|
110
|
+
preset: 'silky',
|
|
99
111
|
motionPreference: 'auto',
|
|
100
112
|
thinkAutoExpand: true,
|
|
113
|
+
logarithmicFade: true,
|
|
101
114
|
debugEnabled: false,
|
|
102
115
|
debugTuning: DEFAULT_STREAM_DEBUG_TUNING,
|
|
103
116
|
}
|
|
117
|
+
|