dsh-smooth-stream 0.5.0 → 0.6.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.
- package/README.en.md +4 -1
- package/README.md +5 -1
- package/lib/client.js +572 -93
- package/lib/client.js.map +1 -1
- package/lib/index.js +219 -1
- package/package.json +1 -1
- package/src/client/LogarithmicFade.module.css +160 -0
- package/src/client/SmoothStreamCard.module.css +34 -0
- package/src/client/SmoothStreamCard.tsx +42 -0
- package/src/client/TypewriterAssistantNodeView.tsx +131 -18
- package/src/client/index.ts +4 -0
- package/src/client/locales.ts +25 -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 +8 -18
- package/src/client/turn-process-face.d.ts +27 -0
- package/src/client/useLogarithmicFade.ts +293 -0
- package/src/plugin.ts +39 -4
- package/src/settings-api.ts +4 -0
- package/src/settings-channel.ts +277 -0
- package/src/settings.ts +23 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import css from './LogarithmicFade.module.css'
|
|
3
|
+
|
|
4
|
+
export const FADE_DURATION_MS = 240
|
|
5
|
+
export const FADE_TAIL_SIZE = 24
|
|
6
|
+
export const FADE_MAX_TAIL_SIZE = 160
|
|
7
|
+
export const FADE_MIN_OPACITY = 0
|
|
8
|
+
const FADE_STEPS = 32
|
|
9
|
+
const PREFIX = 'dsh-smooth-stream-log-fade-'
|
|
10
|
+
const COLOR_PROPERTY = '--dsh-smooth-stream-fade-color'
|
|
11
|
+
// Lightning CSS scopes highlight identifiers as well as class names.
|
|
12
|
+
const highlightName = (index: number): string => css[`${PREFIX}${index}`] ?? `${PREFIX}${index}`
|
|
13
|
+
const EXCLUDED = 'pre,code,math,.katex,.katex-display,mjx-container,svg,script,style,textarea,input,button,select,[role="button"],[contenteditable],[hidden],[aria-hidden="true"],[aria-live]'
|
|
14
|
+
|
|
15
|
+
export function logarithmicOpacity(progress: number): number {
|
|
16
|
+
const p = Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 1
|
|
17
|
+
// Reverse the log easing: preserve translucency early, then settle to ink.
|
|
18
|
+
return FADE_MIN_OPACITY + (1 - FADE_MIN_OPACITY) * (1 - Math.log1p(5 * (1 - p)) / Math.log(6))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fadeTailSize(speedCps: number): number {
|
|
22
|
+
const speed = Number.isFinite(speedCps) ? Math.max(0, speedCps) : 0
|
|
23
|
+
return Math.min(FADE_MAX_TAIL_SIZE, Math.max(FADE_TAIL_SIZE, Math.ceil(speed * FADE_DURATION_MS / 1000)))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FadeCharacter {
|
|
27
|
+
start: number
|
|
28
|
+
end: number
|
|
29
|
+
born: number
|
|
30
|
+
range: Range
|
|
31
|
+
bucket: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Scheduler {
|
|
35
|
+
highlights: Highlight[]
|
|
36
|
+
clients: Set<LogarithmicFadeController>
|
|
37
|
+
pending: Set<LogarithmicFadeController>
|
|
38
|
+
frame: number
|
|
39
|
+
registry: HighlightRegistry
|
|
40
|
+
window: Window
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const schedulers = new WeakMap<Document, Scheduler>()
|
|
44
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
45
|
+
|
|
46
|
+
function schedule(scheduler: Scheduler): void {
|
|
47
|
+
if (scheduler.frame !== 0 || scheduler.pending.size === 0) return
|
|
48
|
+
scheduler.frame = scheduler.window.requestAnimationFrame((now) => {
|
|
49
|
+
scheduler.frame = 0
|
|
50
|
+
for (const client of scheduler.pending) {
|
|
51
|
+
if (!client.paint(now)) scheduler.pending.delete(client)
|
|
52
|
+
}
|
|
53
|
+
schedule(scheduler)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function schedulerFor(root: HTMLElement): Scheduler | null {
|
|
58
|
+
const doc = root.ownerDocument
|
|
59
|
+
const win = doc.defaultView
|
|
60
|
+
if (win === null) return null
|
|
61
|
+
// Use the root's realm, including when a renderer lives in another document.
|
|
62
|
+
const realm = win as Window & typeof globalThis
|
|
63
|
+
if (typeof realm.Highlight !== 'function' || !realm.CSS?.highlights
|
|
64
|
+
|| !realm.CSS.supports('color', 'color-mix(in srgb, currentColor 15%, transparent)')) return null
|
|
65
|
+
let scheduler = schedulers.get(doc)
|
|
66
|
+
if (scheduler === undefined) {
|
|
67
|
+
const highlights = Array.from({ length: FADE_STEPS }, () => new realm.Highlight())
|
|
68
|
+
for (const [index, highlight] of highlights.entries()) realm.CSS.highlights.set(highlightName(index), highlight)
|
|
69
|
+
scheduler = {
|
|
70
|
+
highlights,
|
|
71
|
+
clients: new Set(),
|
|
72
|
+
pending: new Set(),
|
|
73
|
+
frame: 0,
|
|
74
|
+
registry: realm.CSS.highlights,
|
|
75
|
+
window: win,
|
|
76
|
+
}
|
|
77
|
+
schedulers.set(doc, scheduler)
|
|
78
|
+
}
|
|
79
|
+
return scheduler
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Owns ranges only: React retains ownership of every element and Text node. */
|
|
83
|
+
export class LogarithmicFadeController {
|
|
84
|
+
private previous = ''
|
|
85
|
+
private characters: FadeCharacter[] = []
|
|
86
|
+
private colors = new Map<HTMLElement, { value: string, priority: string }>()
|
|
87
|
+
private enabled = false
|
|
88
|
+
private active = false
|
|
89
|
+
private speedCps = 100
|
|
90
|
+
private pausedAt: number | null = null
|
|
91
|
+
private disposed = false
|
|
92
|
+
private readonly observer: MutationObserver
|
|
93
|
+
|
|
94
|
+
private constructor(private readonly root: HTMLElement, private readonly scheduler: Scheduler) {
|
|
95
|
+
scheduler.clients.add(this)
|
|
96
|
+
const win = root.ownerDocument.defaultView as Window & typeof globalThis
|
|
97
|
+
this.observer = new win.MutationObserver(() => { this.reconcile() })
|
|
98
|
+
this.observer.observe(root, { subtree: true, childList: true, characterData: true })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static create(root: HTMLElement): LogarithmicFadeController | null {
|
|
102
|
+
const scheduler = schedulerFor(root)
|
|
103
|
+
return scheduler === null ? null : new LogarithmicFadeController(root, scheduler)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
update(enabled: boolean, active: boolean, speedCps = 100, paused = false): void {
|
|
107
|
+
const now = this.scheduler.window.performance.now()
|
|
108
|
+
if (paused && this.pausedAt === null) this.pausedAt = now
|
|
109
|
+
if (!paused && this.pausedAt !== null) {
|
|
110
|
+
const pauseDuration = now - this.pausedAt
|
|
111
|
+
for (const character of this.characters) character.born += pauseDuration
|
|
112
|
+
this.pausedAt = null
|
|
113
|
+
}
|
|
114
|
+
this.enabled = enabled
|
|
115
|
+
this.active = active
|
|
116
|
+
this.speedCps = speedCps
|
|
117
|
+
this.reconcile()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private clearRanges(): void {
|
|
121
|
+
for (const character of this.characters) {
|
|
122
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
123
|
+
}
|
|
124
|
+
this.characters = []
|
|
125
|
+
this.restoreColors()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private restoreColors(): void {
|
|
129
|
+
for (const [element, original] of this.colors) {
|
|
130
|
+
if (original.value === '') element.style.removeProperty(COLOR_PROPERTY)
|
|
131
|
+
else element.style.setProperty(COLOR_PROPERTY, original.value, original.priority)
|
|
132
|
+
}
|
|
133
|
+
this.colors.clear()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private preserveColor(element: HTMLElement): void {
|
|
137
|
+
if (this.colors.has(element)) return
|
|
138
|
+
const color = this.scheduler.window.getComputedStyle(element).color
|
|
139
|
+
this.colors.set(element, {
|
|
140
|
+
value: element.style.getPropertyValue(COLOR_PROPERTY),
|
|
141
|
+
priority: element.style.getPropertyPriority(COLOR_PROPERTY),
|
|
142
|
+
})
|
|
143
|
+
// An explicit source color prevents highlight inheritance from multiplying
|
|
144
|
+
// alpha through nested Markdown elements (root → paragraph → strong).
|
|
145
|
+
element.style.setProperty(COLOR_PROPERTY, color)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private reconcile(): void {
|
|
149
|
+
if (this.disposed) return
|
|
150
|
+
const text = this.root.textContent ?? ''
|
|
151
|
+
const previous = this.previous
|
|
152
|
+
this.previous = text
|
|
153
|
+
const old = this.characters
|
|
154
|
+
this.clearRanges()
|
|
155
|
+
if (!this.enabled) {
|
|
156
|
+
this.root.classList.remove(css.scope!)
|
|
157
|
+
this.scheduler.pending.delete(this)
|
|
158
|
+
this.stopIfIdle()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
this.root.classList.add(css.scope!)
|
|
162
|
+
const now = this.pausedAt ?? this.scheduler.window.performance.now()
|
|
163
|
+
// A parser rewrite must not replay already readable content. Retain only
|
|
164
|
+
// the unchanged prefix; future appends resume the effect normally.
|
|
165
|
+
let prefix = 0
|
|
166
|
+
while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
|
|
167
|
+
const appended = text.startsWith(previous)
|
|
168
|
+
const nodes: { node: Text, start: number, end: number, eligible: boolean }[] = []
|
|
169
|
+
const walker = this.root.ownerDocument.createTreeWalker(this.root, NodeFilter.SHOW_TEXT)
|
|
170
|
+
let offset = 0
|
|
171
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
172
|
+
const end = offset + (node.textContent?.length ?? 0)
|
|
173
|
+
nodes.push({ node: node as Text, start: offset, end, eligible: node.parentElement?.closest(EXCLUDED) === null })
|
|
174
|
+
offset = end
|
|
175
|
+
}
|
|
176
|
+
// containing() walks backwards by grapheme without segmenting the entire
|
|
177
|
+
// answer into an array. Offsets still refer to the original DOM text.
|
|
178
|
+
const segments = segmenter.segment(text)
|
|
179
|
+
// Keep existing characters alive when the engine resets its speed during
|
|
180
|
+
// completion. A shrinking window must not abruptly darken the old tail.
|
|
181
|
+
const tailSize = fadeTailSize(this.speedCps)
|
|
182
|
+
const oldestLiveStart = old.reduce((start, character) => (
|
|
183
|
+
now - character.born < FADE_DURATION_MS && character.end <= prefix
|
|
184
|
+
? Math.min(start, character.start)
|
|
185
|
+
: start
|
|
186
|
+
), Infinity)
|
|
187
|
+
let end = text.length
|
|
188
|
+
for (let count = 0; count < FADE_MAX_TAIL_SIZE && end > 0 && (count < tailSize || end > oldestLiveStart); count += 1) {
|
|
189
|
+
const segment = segments.containing(end - 1)
|
|
190
|
+
if (segment === undefined) break
|
|
191
|
+
const start = segment.index
|
|
192
|
+
const parts = nodes.filter(node => node.end > start && node.start < end)
|
|
193
|
+
const retained = old.find(character => character.start === start && character.end === end && end <= prefix)
|
|
194
|
+
const born = retained?.born ?? (this.active && appended && start >= previous.length ? now : null)
|
|
195
|
+
if (born !== null && now - born < FADE_DURATION_MS && segment.segment.trim() !== ''
|
|
196
|
+
&& parts.length > 0 && parts.every(part => part.eligible)) {
|
|
197
|
+
const first = parts[0]!
|
|
198
|
+
const last = parts[parts.length - 1]!
|
|
199
|
+
const range = this.root.ownerDocument.createRange()
|
|
200
|
+
range.setStart(first.node, start - first.start)
|
|
201
|
+
range.setEnd(last.node, end - last.start)
|
|
202
|
+
for (const part of parts) this.preserveColor(part.node.parentElement!)
|
|
203
|
+
this.characters.push({ start, end, born, range, bucket: -1 })
|
|
204
|
+
}
|
|
205
|
+
end = start
|
|
206
|
+
}
|
|
207
|
+
if (this.paint(now)) {
|
|
208
|
+
if (this.pausedAt === null) {
|
|
209
|
+
this.scheduler.pending.add(this)
|
|
210
|
+
schedule(this.scheduler)
|
|
211
|
+
} else {
|
|
212
|
+
this.scheduler.pending.delete(this)
|
|
213
|
+
this.stopIfIdle()
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
this.scheduler.pending.delete(this)
|
|
217
|
+
this.stopIfIdle()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
paint(now: number): boolean {
|
|
222
|
+
this.characters = this.characters.filter((character) => {
|
|
223
|
+
const progress = (now - character.born) / FADE_DURATION_MS
|
|
224
|
+
if (progress >= 1 || !this.root.isConnected || !this.root.contains(character.range.startContainer)) {
|
|
225
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
const bucket = Math.min(FADE_STEPS - 1, Math.round((logarithmicOpacity(progress) - FADE_MIN_OPACITY) / (1 - FADE_MIN_OPACITY) * (FADE_STEPS - 1)))
|
|
229
|
+
if (bucket !== character.bucket) {
|
|
230
|
+
this.scheduler.highlights[character.bucket]?.delete(character.range)
|
|
231
|
+
this.scheduler.highlights[bucket]!.add(character.range)
|
|
232
|
+
character.bucket = bucket
|
|
233
|
+
}
|
|
234
|
+
return true
|
|
235
|
+
})
|
|
236
|
+
if (this.characters.length === 0) this.restoreColors()
|
|
237
|
+
return this.characters.length > 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private stopIfIdle(): void {
|
|
241
|
+
if (this.scheduler.pending.size !== 0) return
|
|
242
|
+
this.scheduler.window.cancelAnimationFrame(this.scheduler.frame)
|
|
243
|
+
this.scheduler.frame = 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
dispose(): void {
|
|
247
|
+
if (this.disposed) return
|
|
248
|
+
this.disposed = true
|
|
249
|
+
this.observer.disconnect()
|
|
250
|
+
this.clearRanges()
|
|
251
|
+
this.root.classList.remove(css.scope!)
|
|
252
|
+
this.scheduler.pending.delete(this)
|
|
253
|
+
this.scheduler.clients.delete(this)
|
|
254
|
+
this.stopIfIdle()
|
|
255
|
+
if (this.scheduler.clients.size === 0) {
|
|
256
|
+
for (const [index, highlight] of this.scheduler.highlights.entries()) {
|
|
257
|
+
const name = highlightName(index)
|
|
258
|
+
if (this.scheduler.registry.get(name) === highlight) this.scheduler.registry.delete(name)
|
|
259
|
+
}
|
|
260
|
+
schedulers.delete(this.root.ownerDocument)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** active admits new characters; enabled=false also cancels completion linger. */
|
|
266
|
+
export function useLogarithmicFade(
|
|
267
|
+
rootRef: RefObject<HTMLElement | null>,
|
|
268
|
+
enabled: boolean,
|
|
269
|
+
active: boolean,
|
|
270
|
+
speedCpsRef?: { current: number },
|
|
271
|
+
paused = false,
|
|
272
|
+
): void {
|
|
273
|
+
const controller = useRef<LogarithmicFadeController | null>(null)
|
|
274
|
+
const committed = useRef(false)
|
|
275
|
+
useLayoutEffect(() => {
|
|
276
|
+
return () => {
|
|
277
|
+
controller.current?.dispose()
|
|
278
|
+
controller.current = null
|
|
279
|
+
}
|
|
280
|
+
}, [rootRef])
|
|
281
|
+
// Deliberately commit-driven, including Markdown updates with unchanged source.
|
|
282
|
+
useLayoutEffect(() => {
|
|
283
|
+
const root = rootRef.current
|
|
284
|
+
// Settled history allocates neither observers nor highlight buckets.
|
|
285
|
+
if (controller.current === null && root !== null && enabled && active) {
|
|
286
|
+
controller.current = LogarithmicFadeController.create(root)
|
|
287
|
+
// Enabling midway through a message must not replay readable text.
|
|
288
|
+
if (committed.current) controller.current?.update(false, false)
|
|
289
|
+
}
|
|
290
|
+
controller.current?.update(enabled, active, speedCpsRef?.current, paused)
|
|
291
|
+
committed.current = true
|
|
292
|
+
})
|
|
293
|
+
}
|
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,7 +57,13 @@ 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
|
+
motionPreference: Schema.union([
|
|
61
|
+
Schema.const('auto'),
|
|
62
|
+
Schema.const('force-smooth'),
|
|
63
|
+
Schema.const('force-reduced'),
|
|
64
|
+
] as const).default(DEFAULT_STREAM_SETTINGS.motionPreference),
|
|
59
65
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
66
|
+
logarithmicFade: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.logarithmicFade),
|
|
60
67
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
61
68
|
debugTuning: Schema.object({
|
|
62
69
|
revealScale: Schema.number().min(0.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
|
|
@@ -118,7 +125,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
118
125
|
writable: connectionCtx.settings.writable,
|
|
119
126
|
enabled: settings.enabled,
|
|
120
127
|
controlScroll: settings.controlScroll,
|
|
128
|
+
motionPreference: settings.motionPreference,
|
|
121
129
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
130
|
+
logarithmicFade: settings.logarithmicFade,
|
|
122
131
|
canUpgrade: installation.kind === 'npm',
|
|
123
132
|
}
|
|
124
133
|
}
|
|
@@ -185,10 +194,37 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
185
194
|
const next = payload as {
|
|
186
195
|
enabled: boolean
|
|
187
196
|
controlScroll: boolean
|
|
197
|
+
motionPreference?: unknown
|
|
188
198
|
thinkAutoExpand: boolean
|
|
199
|
+
logarithmicFade?: unknown
|
|
189
200
|
debugEnabled?: unknown
|
|
190
201
|
debugTuning?: unknown
|
|
191
202
|
}
|
|
203
|
+
if (
|
|
204
|
+
next.motionPreference !== undefined
|
|
205
|
+
&& next.motionPreference !== 'auto'
|
|
206
|
+
&& next.motionPreference !== 'force-smooth'
|
|
207
|
+
&& next.motionPreference !== 'force-reduced'
|
|
208
|
+
) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: {
|
|
212
|
+
code: 'settings-rejected',
|
|
213
|
+
message: 'motionPreference must be one of auto | force-smooth | force-reduced',
|
|
214
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (next.logarithmicFade !== undefined && typeof next.logarithmicFade !== 'boolean') {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
error: {
|
|
222
|
+
code: 'settings-rejected',
|
|
223
|
+
message: 'logarithmicFade must be a boolean',
|
|
224
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
192
228
|
const hasDebug = next.debugEnabled !== undefined || next.debugTuning !== undefined
|
|
193
229
|
if (hasDebug && (typeof next.debugEnabled !== 'boolean' || !validDebugTuning(next.debugTuning))) {
|
|
194
230
|
return {
|
|
@@ -203,7 +239,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
203
239
|
await scope.update({
|
|
204
240
|
enabled: next.enabled,
|
|
205
241
|
controlScroll: next.controlScroll,
|
|
242
|
+
...(next.motionPreference === undefined ? {} : { motionPreference: next.motionPreference }),
|
|
206
243
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
244
|
+
...(next.logarithmicFade === undefined ? {} : { logarithmicFade: next.logarithmicFade }),
|
|
207
245
|
...(hasDebug ? { debugEnabled: next.debugEnabled, debugTuning: next.debugTuning } : {}),
|
|
208
246
|
})
|
|
209
247
|
} catch {
|
|
@@ -285,10 +323,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
285
323
|
}
|
|
286
324
|
return { ok: false, error: { code: 'internal', message: `unknown smooth-stream endpoint ${JSON.stringify(endpoint)}`, details: {} } }
|
|
287
325
|
}
|
|
288
|
-
connectionCtx
|
|
289
|
-
() => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: 'loopback' }),
|
|
290
|
-
'dsh-smooth-stream: settings RPC',
|
|
291
|
-
)
|
|
326
|
+
registerSettingsChannel(connectionCtx, STREAM_SETTINGS_RPC_CHANNEL, handle)
|
|
292
327
|
})
|
|
293
328
|
})
|
|
294
329
|
}
|
package/src/settings-api.ts
CHANGED
|
@@ -27,8 +27,12 @@ export interface StreamSettingsView {
|
|
|
27
27
|
enabled: boolean
|
|
28
28
|
/** Whether smooth-stream also owns conversation bottom-follow. */
|
|
29
29
|
controlScroll: boolean
|
|
30
|
+
/** How the reveal honors the OS reduced-motion preference. */
|
|
31
|
+
motionPreference: import('./settings.ts').StreamMotionPreference
|
|
30
32
|
/** Current resolved preference. */
|
|
31
33
|
thinkAutoExpand: boolean
|
|
34
|
+
/** Whether new answer/thinking text fades into its original color. */
|
|
35
|
+
logarithmicFade: boolean
|
|
32
36
|
/** Whether a fixed npm update command is safe to offer. */
|
|
33
37
|
canUpgrade: boolean
|
|
34
38
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
const connection = ctx.get('connection') as ChannelConnection | undefined
|
|
80
|
+
return ctx.effect(() => {
|
|
81
|
+
const viaService = tryServiceChannel(connection, channel, handler)
|
|
82
|
+
if (viaService !== undefined) return viaService
|
|
83
|
+
return mountDirectRoute(ctx, connection, channel, handler)
|
|
84
|
+
}, `dsh-smooth-stream: ${channel} RPC channel`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Try the service-owned registration, which is the only path that also carries
|
|
89
|
+
* the kernel's own channel policy.
|
|
90
|
+
* @returns Disposer on success, `undefined` when the kernel cannot mount it.
|
|
91
|
+
*/
|
|
92
|
+
function tryServiceChannel(
|
|
93
|
+
connection: ChannelConnection | undefined,
|
|
94
|
+
channel: string,
|
|
95
|
+
handler: ConnectionRpcHandler,
|
|
96
|
+
): (() => void) | undefined {
|
|
97
|
+
const rpc = connection?.rpc
|
|
98
|
+
if (rpc === undefined || typeof rpc.handle !== 'function') return undefined
|
|
99
|
+
try {
|
|
100
|
+
const release = rpc.handle(channel, handler, { authority: 'loopback' })
|
|
101
|
+
if (typeof release !== 'function') return undefined
|
|
102
|
+
return () => { void (release as () => unknown)() }
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// A kernel that cannot resolve `webServer` inside the service throws here;
|
|
105
|
+
// the direct route below keeps the card reachable on that kernel.
|
|
106
|
+
console.warn(
|
|
107
|
+
`[dsh-smooth-stream] connection.rpc.handle() could not mount ${channel}; `
|
|
108
|
+
+ 'falling back to a directly registered route',
|
|
109
|
+
error,
|
|
110
|
+
)
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mount the channel prefix on the Web server with the connection fence intact.
|
|
117
|
+
* @throws when the kernel exposes neither the fence nor a Web server, so the
|
|
118
|
+
* failure is reported instead of silently leaving the card unreachable.
|
|
119
|
+
*/
|
|
120
|
+
function mountDirectRoute(
|
|
121
|
+
ctx: Context,
|
|
122
|
+
connection: ChannelConnection | undefined,
|
|
123
|
+
channel: string,
|
|
124
|
+
handler: ConnectionRpcHandler,
|
|
125
|
+
): () => void {
|
|
126
|
+
const webServer = ctx.get('webServer') as ChannelWebServer | undefined
|
|
127
|
+
const reject = connection?.requestRejection
|
|
128
|
+
if (webServer === undefined || typeof webServer.register !== 'function') {
|
|
129
|
+
throw new Error(`dsh-smooth-stream: webServer is unavailable, so ${channel} cannot be mounted`)
|
|
130
|
+
}
|
|
131
|
+
if (typeof reject !== 'function') {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`dsh-smooth-stream: connection.requestRejection is unavailable, so ${channel} `
|
|
134
|
+
+ 'cannot be mounted behind the connection trust fence',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
return webServer.register({
|
|
138
|
+
kind: 'prefix',
|
|
139
|
+
path: channel,
|
|
140
|
+
handler: async (req, res) => {
|
|
141
|
+
const rejection = reject.call(connection, req)
|
|
142
|
+
if (rejection !== undefined) {
|
|
143
|
+
res.writeHead(rejection)
|
|
144
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
await answer(req, res, channel, handler)
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Decode one request envelope, dispatch it, and write the decoded reply.
|
|
154
|
+
* Mirrors the Connection router's own carrier rules: method and endpoint must
|
|
155
|
+
* agree, the body must be JSON, and only endpoint failures are results while
|
|
156
|
+
* transport failures are statuses.
|
|
157
|
+
*/
|
|
158
|
+
async function answer(
|
|
159
|
+
req: Parameters<WebRoute['handler']>[0],
|
|
160
|
+
res: Parameters<WebRoute['handler']>[1],
|
|
161
|
+
channel: string,
|
|
162
|
+
handler: ConnectionRpcHandler,
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
const endpoint = endpointOf(channel, req.url)
|
|
165
|
+
if (req.method !== 'POST' || endpoint === undefined) {
|
|
166
|
+
res.writeHead(404)
|
|
167
|
+
res.end('not found')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
const mediaType = (req.headers['content-type'] ?? '').split(';', 1)[0]?.trim().toLowerCase()
|
|
171
|
+
if (mediaType !== 'application/json') {
|
|
172
|
+
res.writeHead(415)
|
|
173
|
+
res.end('content type must be application/json')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const body = await readBody(req)
|
|
177
|
+
if (body.kind === 'too-large') {
|
|
178
|
+
res.writeHead(413)
|
|
179
|
+
res.end('request body too large')
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (body.kind === 'invalid') {
|
|
183
|
+
res.writeHead(400)
|
|
184
|
+
res.end('body is not JSON')
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const envelope = requestEnvelope(body.value)
|
|
188
|
+
if (envelope === undefined) {
|
|
189
|
+
write(res, rpcIdOf(body.value), {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: {
|
|
192
|
+
code: 'gateway/bad-request',
|
|
193
|
+
message: 'invalid client-request message',
|
|
194
|
+
details: { issues: [] },
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (envelope.method !== endpoint) {
|
|
200
|
+
write(res, envelope.rpcId, {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: {
|
|
203
|
+
code: 'gateway/bad-request',
|
|
204
|
+
message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
|
205
|
+
details: { issues: [] },
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const controller = new AbortController()
|
|
211
|
+
res.on('close', () => { controller.abort() })
|
|
212
|
+
try {
|
|
213
|
+
write(res, envelope.rpcId, await handler(endpoint, envelope.payload, controller.signal))
|
|
214
|
+
} catch (error) {
|
|
215
|
+
res.writeHead(500)
|
|
216
|
+
res.end(`handler failure: ${String(error)}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve the channel-relative endpoint for one request path.
|
|
222
|
+
* @returns The endpoint, or `undefined` when the path is outside the channel
|
|
223
|
+
* or carries a segment the Connection router would refuse.
|
|
224
|
+
*/
|
|
225
|
+
function endpointOf(channel: string, rawUrl: string | undefined): string | undefined {
|
|
226
|
+
const pathname = new URL(rawUrl ?? '/', 'http://dsh.internal').pathname
|
|
227
|
+
if (!pathname.startsWith(`${channel}/`)) return undefined
|
|
228
|
+
const endpoint = pathname.slice(channel.length + 1)
|
|
229
|
+
const segments = endpoint.split('/')
|
|
230
|
+
if (segments.some(segment =>
|
|
231
|
+
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT.test(segment))) {
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
return endpoint
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Structure one decoded request, rejecting anything the caller could not have sent. */
|
|
238
|
+
function requestEnvelope(value: unknown): RequestEnvelope | undefined {
|
|
239
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
240
|
+
const record = value as Record<string, unknown>
|
|
241
|
+
if (record.type !== CLIENT_REQUEST) return undefined
|
|
242
|
+
if (typeof record.rpcId !== 'string' || typeof record.method !== 'string') return undefined
|
|
243
|
+
return { rpcId: record.rpcId, method: record.method, payload: record.payload }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Recover the correlation id of a malformed envelope so the caller can match it. */
|
|
247
|
+
function rpcIdOf(value: unknown): string {
|
|
248
|
+
const raw = (value as { rpcId?: unknown } | null)?.rpcId
|
|
249
|
+
return typeof raw === 'string' ? raw : 'invalid-request'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Write one decoded reply in the envelope the browser caller parses. */
|
|
253
|
+
function write(
|
|
254
|
+
res: Parameters<WebRoute['handler']>[1],
|
|
255
|
+
rpcId: string,
|
|
256
|
+
result: unknown,
|
|
257
|
+
): void {
|
|
258
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
259
|
+
res.end(JSON.stringify({ type: SERVER_RESPONSE, rpcId, result }))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Buffer one request body, refusing both malformed JSON and unbounded input. */
|
|
263
|
+
async function readBody(req: Parameters<WebRoute['handler']>[0]): Promise<BodyRead> {
|
|
264
|
+
const chunks: Buffer[] = []
|
|
265
|
+
let bytes = 0
|
|
266
|
+
for await (const chunk of req) {
|
|
267
|
+
const buffer = chunk as Buffer
|
|
268
|
+
bytes += buffer.length
|
|
269
|
+
if (bytes > MAX_REQUEST_BYTES) return { kind: 'too-large' }
|
|
270
|
+
chunks.push(buffer)
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
return { kind: 'value', value: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
274
|
+
} catch {
|
|
275
|
+
return { kind: 'invalid' }
|
|
276
|
+
}
|
|
277
|
+
}
|