dsh-smooth-stream 0.1.0 → 0.3.2
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 +103 -0
- package/README.md +62 -47
- package/lib/client.js +608 -75
- package/lib/client.js.map +1 -1
- package/lib/index.js +229 -0
- package/package.json +25 -3
- package/src/client/SmoothStreamCard.module.css +253 -0
- package/src/client/SmoothStreamCard.tsx +122 -0
- package/src/client/TypewriterAssistantNodeView.module.css +2 -2
- package/src/client/TypewriterAssistantNodeView.tsx +26 -11
- package/src/client/index.ts +96 -3
- package/src/client/locales.ts +74 -0
- package/src/client/smooth-stream-card-controller.ts +162 -0
- package/src/client/smooth-stream-settings-api.ts +65 -0
- package/src/client/teleprompterGlide.ts +71 -41
- package/src/package-meta.ts +39 -0
- package/src/plugin.ts +101 -0
- package/src/profile-installation.ts +121 -0
- package/src/settings-api.ts +33 -0
- package/src/settings.ts +30 -0
- package/README.zh.md +0 -88
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Browser adapter for the Host-owned smooth-stream settings RPC. */
|
|
2
|
+
|
|
3
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
4
|
+
import {
|
|
5
|
+
STREAM_SETTINGS_RPC,
|
|
6
|
+
STREAM_SETTINGS_RPC_CHANNEL,
|
|
7
|
+
type StreamSettingsView,
|
|
8
|
+
type StreamUpgradeView,
|
|
9
|
+
} from '../settings-api.ts'
|
|
10
|
+
|
|
11
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
13
|
+
? value as Record<string, unknown>
|
|
14
|
+
: undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function settingsView(value: unknown): StreamSettingsView {
|
|
18
|
+
const data = record(value)
|
|
19
|
+
if (data === undefined
|
|
20
|
+
|| typeof data.version !== 'string'
|
|
21
|
+
|| !['npm', 'development', 'unmanaged'].includes(data.installation as string)
|
|
22
|
+
|| typeof data.writable !== 'boolean'
|
|
23
|
+
|| typeof data.thinkAutoExpand !== 'boolean'
|
|
24
|
+
|| typeof data.canUpgrade !== 'boolean') {
|
|
25
|
+
throw new Error('dsh-smooth-stream: malformed settings response')
|
|
26
|
+
}
|
|
27
|
+
return data as unknown as StreamSettingsView
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function upgradeView(value: unknown): StreamUpgradeView {
|
|
31
|
+
const data = record(value)
|
|
32
|
+
if (data?.restartRequired !== true) throw new Error('dsh-smooth-stream: malformed update response')
|
|
33
|
+
return { restartRequired: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>): unknown {
|
|
37
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
38
|
+
return result.value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Narrow client contract consumed by the staged settings-card controller. */
|
|
42
|
+
export interface SmoothStreamSettingsApi {
|
|
43
|
+
read(): Promise<StreamSettingsView>
|
|
44
|
+
write(thinkAutoExpand: boolean): Promise<StreamSettingsView>
|
|
45
|
+
upgrade(): Promise<StreamUpgradeView>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the typed facade over the generic Connection RPC service. */
|
|
49
|
+
export function createSmoothStreamSettingsApi(connection: ConnectionHandle): SmoothStreamSettingsApi {
|
|
50
|
+
return {
|
|
51
|
+
async read(): Promise<StreamSettingsView> {
|
|
52
|
+
return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
|
|
53
|
+
},
|
|
54
|
+
async write(thinkAutoExpand: boolean): Promise<StreamSettingsView> {
|
|
55
|
+
return settingsView(accepted(await connection.rpc.call(
|
|
56
|
+
STREAM_SETTINGS_RPC_CHANNEL,
|
|
57
|
+
STREAM_SETTINGS_RPC.write,
|
|
58
|
+
{ thinkAutoExpand },
|
|
59
|
+
)))
|
|
60
|
+
},
|
|
61
|
+
async upgrade(): Promise<StreamUpgradeView> {
|
|
62
|
+
return upgradeView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.upgrade, {})))
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -14,17 +14,15 @@
|
|
|
14
14
|
* - while the port has scroll room, writes the interpolated lag as
|
|
15
15
|
* `translate3d` on `[data-chat-transcript]` (message rows only), so the
|
|
16
16
|
* turn-status chrome stays pinned at the floor;
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* - clips only the part of the translated surface that crosses the
|
|
18
|
+
* turn-status line, preserving the normal gap above that line;
|
|
19
|
+
* - leaves `[data-chat-turn-status]` in normal flow at every height, so its
|
|
20
|
+
* layout gap cannot be consumed by an interpolated transform.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `FOLLOW_THRESHOLD`, releasing its native snap-follow instead of fighting
|
|
27
|
-
* the gesture for the port.
|
|
22
|
+
* A real reader unpin first writes the effective visual top (`engine - lag`)
|
|
23
|
+
* into `scrollTop` before clearing the transform, so the frame stays
|
|
24
|
+
* continuous. Lifecycle completion is different: without a reader gesture,
|
|
25
|
+
* the last owner settles at the floor and preserves bottom-follow.
|
|
28
26
|
*
|
|
29
27
|
* Unpin is a real gesture (wheel / touch / pointer / key) that leaves the
|
|
30
28
|
* floor; a `scrollTop` delta from our own write must not release the pin.
|
|
@@ -136,35 +134,54 @@ function hasShiftSurface(port: HTMLElement): boolean {
|
|
|
136
134
|
return shiftRootOf(port) !== null || shiftRowsOf(port).length > 0
|
|
137
135
|
}
|
|
138
136
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
/** Cached natural gap from a shifted surface's bottom to the status line. */
|
|
138
|
+
const statusGapCache = new WeakMap<HTMLElement, { chrome: HTMLElement; px: number }>()
|
|
139
|
+
|
|
140
|
+
function currentShiftOf(element: HTMLElement): number {
|
|
141
|
+
return Number(/translate3d\(0, ([\d.]+)px, 0\)/.exec(element.style.transform)?.[1] ?? 0)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function statusGapBelow(surface: HTMLElement, chrome: HTMLElement): number {
|
|
145
|
+
const cached = statusGapCache.get(surface)
|
|
146
|
+
if (cached?.chrome === chrome) return cached.px
|
|
147
|
+
// getBoundingClientRect includes our current transform. Remove it to recover
|
|
148
|
+
// the layout bottom, then keep the existing gap available to the reveal.
|
|
149
|
+
const naturalBottom = surface.getBoundingClientRect().bottom - currentShiftOf(surface)
|
|
150
|
+
const px = Math.max(0, chrome.getBoundingClientRect().top - naturalBottom)
|
|
151
|
+
statusGapCache.set(surface, { chrome, px })
|
|
152
|
+
return px
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setShift(element: HTMLElement, px: number, paintAllowance?: number): void {
|
|
156
|
+
if (Math.abs(px) > 0.01) {
|
|
157
|
+
element.style.transform = `translate3d(0, ${px}px, 0)`
|
|
143
158
|
element.style.willChange = 'transform'
|
|
159
|
+
const bottomClip = paintAllowance === undefined ? 0 : Math.max(0, px - paintAllowance)
|
|
160
|
+
element.style.clipPath = bottomClip > 0.01 ? `inset(0 0 ${bottomClip}px 0)` : ''
|
|
144
161
|
} else {
|
|
145
162
|
element.style.transform = ''
|
|
146
163
|
element.style.willChange = ''
|
|
164
|
+
element.style.clipPath = ''
|
|
147
165
|
}
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
/**
|
|
151
|
-
* The running-turn label.
|
|
152
|
-
*
|
|
153
|
-
* expressed as a negative translate on this sibling: the label descends
|
|
154
|
-
* smoothly with each wrap instead of hopping down a line at a time.
|
|
169
|
+
* The running-turn label. It stays in normal flow so the host column's gap
|
|
170
|
+
* always separates it from the transcript.
|
|
155
171
|
*/
|
|
156
|
-
function
|
|
157
|
-
return port.querySelector<HTMLElement>(
|
|
172
|
+
function statusChromeOf(port: HTMLElement): HTMLElement | null {
|
|
173
|
+
return port.querySelector<HTMLElement>(
|
|
174
|
+
'[data-chat-turn-status], [data-chat-flow] > [role="status"]',
|
|
175
|
+
)
|
|
158
176
|
}
|
|
159
177
|
|
|
160
178
|
/**
|
|
161
|
-
* Render the single smoothed extent `animatedH`.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* shift surface the engine itself carries the interpolated top.
|
|
179
|
+
* Render the single smoothed extent `animatedH`. Once the port has scroll
|
|
180
|
+
* room the engine is pinned at the floor and the lag rides
|
|
181
|
+
* `[data-chat-transcript]` (or each message row when the host has no
|
|
182
|
+
* transcript box). Before that, message rows and status chrome remain in
|
|
183
|
+
* normal flow. Without any shift surface the engine carries the interpolated
|
|
184
|
+
* top itself.
|
|
168
185
|
*/
|
|
169
186
|
function applyVisual(port: HTMLElement, animatedH: number): void {
|
|
170
187
|
const contentHeight = Math.max(0, port.scrollHeight)
|
|
@@ -173,19 +190,19 @@ function applyVisual(port: HTMLElement, animatedH: number): void {
|
|
|
173
190
|
const lag = contentHeight - extent
|
|
174
191
|
port.style.overflowAnchor = 'none'
|
|
175
192
|
port.style.scrollBehavior = 'auto'
|
|
193
|
+
const chrome = statusChromeOf(port)
|
|
194
|
+
if (chrome !== null) setShift(chrome, 0)
|
|
176
195
|
const shiftRoot = shiftRootOf(port)
|
|
177
196
|
if (shiftRoot !== null) {
|
|
178
197
|
if (floor <= 0) {
|
|
179
198
|
port.scrollTop = 0
|
|
180
199
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
181
|
-
const chrome = chromeShiftOf(port)
|
|
182
|
-
if (chrome !== null) setShift(chrome, lag, true)
|
|
183
200
|
setShift(shiftRoot, 0)
|
|
184
201
|
return
|
|
185
202
|
}
|
|
186
203
|
port.scrollTop = floor
|
|
187
204
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
188
|
-
setShift(shiftRoot, lag)
|
|
205
|
+
setShift(shiftRoot, lag, chrome === null ? undefined : statusGapBelow(shiftRoot, chrome))
|
|
189
206
|
return
|
|
190
207
|
}
|
|
191
208
|
const rows = shiftRowsOf(port)
|
|
@@ -193,14 +210,16 @@ function applyVisual(port: HTMLElement, animatedH: number): void {
|
|
|
193
210
|
if (floor <= 0) {
|
|
194
211
|
port.scrollTop = 0
|
|
195
212
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
196
|
-
const chrome = chromeShiftOf(port)
|
|
197
|
-
if (chrome !== null) setShift(chrome, lag, true)
|
|
198
213
|
for (const row of rows) setShift(row, 0)
|
|
199
214
|
return
|
|
200
215
|
}
|
|
201
216
|
port.scrollTop = floor
|
|
202
217
|
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
203
|
-
|
|
218
|
+
const last = rows.length - 1
|
|
219
|
+
for (const [index, row] of rows.entries()) {
|
|
220
|
+
const allowance = index === last && chrome !== null ? statusGapBelow(row, chrome) : undefined
|
|
221
|
+
setShift(row, lag, allowance)
|
|
222
|
+
}
|
|
204
223
|
return
|
|
205
224
|
}
|
|
206
225
|
port.scrollTop = Math.min(floor, Math.max(0, extent))
|
|
@@ -213,21 +232,32 @@ function clearVisual(port: HTMLElement): void {
|
|
|
213
232
|
port.style.scrollBehavior = ''
|
|
214
233
|
const shiftRoot = shiftRootOf(port)
|
|
215
234
|
if (shiftRoot !== null) {
|
|
235
|
+
statusGapCache.delete(shiftRoot)
|
|
216
236
|
shiftRoot.style.transform = ''
|
|
217
237
|
shiftRoot.style.willChange = ''
|
|
238
|
+
shiftRoot.style.clipPath = ''
|
|
218
239
|
} else {
|
|
219
240
|
for (const row of shiftRowsOf(port)) {
|
|
241
|
+
statusGapCache.delete(row)
|
|
220
242
|
row.style.transform = ''
|
|
221
243
|
row.style.willChange = ''
|
|
244
|
+
row.style.clipPath = ''
|
|
222
245
|
}
|
|
223
246
|
}
|
|
224
|
-
const chrome =
|
|
247
|
+
const chrome = statusChromeOf(port)
|
|
225
248
|
if (chrome !== null) {
|
|
226
249
|
chrome.style.transform = ''
|
|
227
250
|
chrome.style.willChange = ''
|
|
251
|
+
chrome.style.clipPath = ''
|
|
228
252
|
}
|
|
229
253
|
}
|
|
230
254
|
|
|
255
|
+
function settleAtFloor(port: HTMLElement): void {
|
|
256
|
+
const floor = Math.max(0, port.scrollHeight - port.clientHeight)
|
|
257
|
+
port.scrollTop = floor
|
|
258
|
+
port.setAttribute(FOLLOW_OWNED_ATTR, String(port.scrollTop))
|
|
259
|
+
}
|
|
260
|
+
|
|
231
261
|
/** Live follow hosts per conversation port so one unmount does not clear another. */
|
|
232
262
|
const followOwners = new WeakMap<HTMLElement, number>()
|
|
233
263
|
|
|
@@ -412,15 +442,15 @@ export function useConversationFollow(
|
|
|
412
442
|
const root = rootRef.current
|
|
413
443
|
const host = root?.closest<HTMLElement>('[data-conversation-scroll]') ?? port
|
|
414
444
|
if (host === null) return
|
|
415
|
-
if (following) {
|
|
416
|
-
// Same handback as an unpin: land on the effective visual top rather
|
|
417
|
-
// than the floor, so the owner change (stream close, unmount) does
|
|
418
|
-
// not snap the flow up by the remaining lag.
|
|
419
|
-
handBackVisual(host)
|
|
420
|
-
}
|
|
421
445
|
const remaining = holding === host ? releaseFollow(host) : (followOwners.get(host) ?? 0)
|
|
422
446
|
holding = null
|
|
423
|
-
if (remaining === 0)
|
|
447
|
+
if (remaining === 0) {
|
|
448
|
+
if (following) {
|
|
449
|
+
if (interacting && awayPx >= FOLLOW_UNPIN_GESTURE_PX) handBackVisual(host)
|
|
450
|
+
else settleAtFloor(host)
|
|
451
|
+
}
|
|
452
|
+
clearVisual(host)
|
|
453
|
+
}
|
|
424
454
|
}
|
|
425
455
|
}, [active, rootRef, speedCpsRef])
|
|
426
456
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Host-only package metadata. The package manifest remains the source of truth. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
interface PackageManifest {
|
|
8
|
+
name?: unknown
|
|
9
|
+
version?: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function packageManifestPath(): string {
|
|
13
|
+
// Production loads this Node entry from its installed file. Vite's test
|
|
14
|
+
// transform instead supplies an http URL, where the project manifest is the
|
|
15
|
+
// closest equivalent source of truth.
|
|
16
|
+
try {
|
|
17
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
18
|
+
} catch {
|
|
19
|
+
// Fall through to the project root when a browser-oriented test transform
|
|
20
|
+
// has replaced import.meta.url with a non-file URL.
|
|
21
|
+
}
|
|
22
|
+
return join(process.cwd(), 'package.json')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const manifest = JSON.parse(readFileSync(packageManifestPath(), 'utf8')) as PackageManifest
|
|
26
|
+
|
|
27
|
+
function readRequiredString(field: keyof PackageManifest): string {
|
|
28
|
+
const value = manifest[field]
|
|
29
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
30
|
+
throw new Error(`dsh-smooth-stream: package.json must contain a non-empty ${field}`)
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** npm package name, read from this plugin's manifest. */
|
|
36
|
+
export const STREAM_PACKAGE_NAME = readRequiredString('name')
|
|
37
|
+
|
|
38
|
+
/** Version of the code currently loaded by the Host, read from its manifest. */
|
|
39
|
+
export const STREAM_PACKAGE_VERSION = readRequiredString('version')
|
package/src/plugin.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis'
|
|
2
2
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
3
|
+
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
|
4
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
3
5
|
import Schema from '@deepseek-ai/schemastery'
|
|
4
6
|
import { DEFAULT_STREAM_CONFIG, type StreamConfig } from './config.ts'
|
|
5
7
|
import { injectStreamConfig } from './boot-config.ts'
|
|
8
|
+
import { STREAM_PACKAGE_NAME, STREAM_PACKAGE_VERSION } from './package-meta.ts'
|
|
9
|
+
import { inspectProfileInstallation, updateNpmProfilePackage } from './profile-installation.ts'
|
|
10
|
+
import { STREAM_SETTINGS_RPC, STREAM_SETTINGS_RPC_CHANNEL, type StreamSettingsView } from './settings-api.ts'
|
|
11
|
+
import { DEFAULT_STREAM_SETTINGS, STREAM_SETTINGS_NS, type StreamSettings } from './settings.ts'
|
|
6
12
|
|
|
7
13
|
/** Display name shown by the Host loader while the plugin is mounted. */
|
|
8
14
|
export const name = 'dsh-smooth-stream'
|
|
@@ -31,6 +37,14 @@ export const Config: Schema<Config> = Schema.object({
|
|
|
31
37
|
.default(DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec),
|
|
32
38
|
})
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Schema of the user-owned settings section. The Host keeps it in the durable
|
|
42
|
+
* settings provider while the browser edits it through the plugin RPC below.
|
|
43
|
+
*/
|
|
44
|
+
export const StreamSettingsSchema: Schema<StreamSettings> = Schema.object({
|
|
45
|
+
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
46
|
+
})
|
|
47
|
+
|
|
34
48
|
/**
|
|
35
49
|
* Host half: log the resolved configuration and bridge it to the browser
|
|
36
50
|
* half. The web boot graph carries no per-entry config, so the validated
|
|
@@ -51,4 +65,91 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
51
65
|
'dsh-smooth-stream: boot config bridge',
|
|
52
66
|
)
|
|
53
67
|
})
|
|
68
|
+
// The core settings RPC deliberately filters third-party namespaces. Keep
|
|
69
|
+
// the durable provider as the authority, but expose this one schema through
|
|
70
|
+
// the plugin's own loopback-only connection channel instead.
|
|
71
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
72
|
+
const scope = settingsCtx.settings.register(
|
|
73
|
+
settingsNamespace(STREAM_SETTINGS_NS),
|
|
74
|
+
StreamSettingsSchema,
|
|
75
|
+
{ applies: 'live' },
|
|
76
|
+
)
|
|
77
|
+
settingsCtx.inject(['connection'], (connectionCtx) => {
|
|
78
|
+
let upgrade: Promise<void> | undefined
|
|
79
|
+
|
|
80
|
+
const view = (): StreamSettingsView => {
|
|
81
|
+
const installation = inspectProfileInstallation(connectionCtx.baseUrl, STREAM_PACKAGE_NAME)
|
|
82
|
+
return {
|
|
83
|
+
version: STREAM_PACKAGE_VERSION,
|
|
84
|
+
installation: installation.kind,
|
|
85
|
+
writable: connectionCtx.settings.writable,
|
|
86
|
+
thinkAutoExpand: scope.get().thinkAutoExpand,
|
|
87
|
+
canUpgrade: installation.kind === 'npm',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handle: ConnectionRpcHandler = async (endpoint, payload) => {
|
|
92
|
+
if (endpoint === STREAM_SETTINGS_RPC.read) return { ok: true, value: view() }
|
|
93
|
+
if (endpoint === STREAM_SETTINGS_RPC.write) {
|
|
94
|
+
if (typeof payload !== 'object' || payload === null
|
|
95
|
+
|| typeof (payload as { thinkAutoExpand?: unknown }).thinkAutoExpand !== 'boolean') {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: {
|
|
99
|
+
code: 'settings-rejected',
|
|
100
|
+
message: 'thinkAutoExpand must be a boolean',
|
|
101
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!connectionCtx.settings.writable) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: {
|
|
109
|
+
code: 'settings-rejected',
|
|
110
|
+
message: 'smooth-stream settings are read-only',
|
|
111
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await scope.update({ thinkAutoExpand: (payload as { thinkAutoExpand: boolean }).thinkAutoExpand })
|
|
117
|
+
} catch {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: {
|
|
121
|
+
code: 'settings-rejected',
|
|
122
|
+
message: 'smooth-stream settings update failed',
|
|
123
|
+
details: { ns: STREAM_SETTINGS_NS },
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, value: view() }
|
|
128
|
+
}
|
|
129
|
+
if (endpoint === STREAM_SETTINGS_RPC.upgrade) {
|
|
130
|
+
const installation = inspectProfileInstallation(connectionCtx.baseUrl, STREAM_PACKAGE_NAME)
|
|
131
|
+
if (installation.kind !== 'npm') {
|
|
132
|
+
return { ok: false, error: { code: 'internal', message: 'smooth-stream is not an npm profile dependency', details: {} } }
|
|
133
|
+
}
|
|
134
|
+
if (upgrade !== undefined) {
|
|
135
|
+
return { ok: false, error: { code: 'internal', message: 'smooth-stream update is already running', details: {} } }
|
|
136
|
+
}
|
|
137
|
+
upgrade = updateNpmProfilePackage(installation.profileDir, STREAM_PACKAGE_NAME)
|
|
138
|
+
try {
|
|
139
|
+
await upgrade
|
|
140
|
+
} catch {
|
|
141
|
+
return { ok: false, error: { code: 'internal', message: 'smooth-stream update failed', details: {} } }
|
|
142
|
+
} finally {
|
|
143
|
+
upgrade = undefined
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, value: { restartRequired: true } }
|
|
146
|
+
}
|
|
147
|
+
return { ok: false, error: { code: 'internal', message: `unknown smooth-stream endpoint ${JSON.stringify(endpoint)}`, details: {} } }
|
|
148
|
+
}
|
|
149
|
+
connectionCtx.effect(
|
|
150
|
+
() => connectionCtx.connection.rpc.handle(STREAM_SETTINGS_RPC_CHANNEL, handle, { authority: 'loopback' }),
|
|
151
|
+
'dsh-smooth-stream: settings RPC',
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
54
155
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Host-only profile source inspection and fixed npm update runner. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { basename, isAbsolute, join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/** A profile installation that can be updated through a fixed pnpm invocation. */
|
|
9
|
+
export interface NpmProfileInstallation {
|
|
10
|
+
kind: 'npm'
|
|
11
|
+
profileDir: string
|
|
12
|
+
profileName: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A local source is deliberately not eligible for one-click npm updates. */
|
|
16
|
+
export interface DevelopmentProfileInstallation {
|
|
17
|
+
kind: 'development'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The active context is not a recognized profile dependency. */
|
|
21
|
+
export interface UnmanagedProfileInstallation {
|
|
22
|
+
kind: 'unmanaged'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Installation source visible to the plugin's Host RPC. */
|
|
26
|
+
export type ProfileInstallation = NpmProfileInstallation | DevelopmentProfileInstallation | UnmanagedProfileInstallation
|
|
27
|
+
|
|
28
|
+
interface ProfileManifest {
|
|
29
|
+
dependencies?: unknown
|
|
30
|
+
dsh?: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
35
|
+
? value as Record<string, unknown>
|
|
36
|
+
: undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function profileDirectory(baseUrl: string | undefined): string | undefined {
|
|
40
|
+
if (baseUrl === undefined) return undefined
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(baseUrl)
|
|
43
|
+
return url.protocol === 'file:' ? fileURLToPath(url) : undefined
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasBundle(manifest: ProfileManifest, packageName: string): boolean {
|
|
50
|
+
const dsh = record(manifest.dsh)
|
|
51
|
+
const profile = record(dsh?.profile)
|
|
52
|
+
const bundles = profile?.bundles
|
|
53
|
+
return Array.isArray(bundles) && bundles.includes(packageName)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isLocalSpecifier(specifier: string): boolean {
|
|
57
|
+
return specifier.startsWith('link:')
|
|
58
|
+
|| specifier.startsWith('file:')
|
|
59
|
+
|| specifier.startsWith('.')
|
|
60
|
+
|| isAbsolute(specifier)
|
|
61
|
+
|| /^[A-Za-z]:[\\/]/.test(specifier)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRegistrySpecifier(specifier: string): boolean {
|
|
65
|
+
// A registry range/tag contains neither a scheme nor a path. Treat every
|
|
66
|
+
// package-manager extension (catalog:, patch:, portal:, workspace:, etc.)
|
|
67
|
+
// as unconfirmed rather than guessing that an update may replace it.
|
|
68
|
+
return specifier.length > 0 && !specifier.includes(':') && !/[\\/]/.test(specifier)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isNpmSpecifier(specifier: string, packageName: string): boolean {
|
|
72
|
+
if (!specifier.startsWith('npm:')) return isRegistrySpecifier(specifier)
|
|
73
|
+
const aliased = specifier.slice('npm:'.length)
|
|
74
|
+
if (aliased === packageName) return true
|
|
75
|
+
const prefix = `${packageName}@`
|
|
76
|
+
return aliased.startsWith(prefix) && isRegistrySpecifier(aliased.slice(prefix.length))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read only the profile manifest anchored by the current Cordis config tree.
|
|
81
|
+
* A malformed or unrelated tree never receives an update affordance.
|
|
82
|
+
*/
|
|
83
|
+
export function inspectProfileInstallation(
|
|
84
|
+
baseUrl: string | undefined,
|
|
85
|
+
packageName: string,
|
|
86
|
+
): ProfileInstallation {
|
|
87
|
+
const profileDir = profileDirectory(baseUrl)
|
|
88
|
+
if (profileDir === undefined) return { kind: 'unmanaged' }
|
|
89
|
+
let manifest: ProfileManifest
|
|
90
|
+
try {
|
|
91
|
+
manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ProfileManifest
|
|
92
|
+
} catch {
|
|
93
|
+
return { kind: 'unmanaged' }
|
|
94
|
+
}
|
|
95
|
+
const dependencies = record(manifest.dependencies)
|
|
96
|
+
const specifier = dependencies?.[packageName]
|
|
97
|
+
if (typeof specifier !== 'string' || !hasBundle(manifest, packageName)) {
|
|
98
|
+
return { kind: 'unmanaged' }
|
|
99
|
+
}
|
|
100
|
+
if (isLocalSpecifier(specifier)) return { kind: 'development' }
|
|
101
|
+
if (!isNpmSpecifier(specifier, packageName)) return { kind: 'unmanaged' }
|
|
102
|
+
return { kind: 'npm', profileDir, profileName: basename(profileDir) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Run the same fixed package update operation that a profile user would invoke. */
|
|
106
|
+
export function updateNpmProfilePackage(profileDir: string, packageName: string): Promise<void> {
|
|
107
|
+
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const child = spawn(command, ['update', packageName], {
|
|
110
|
+
cwd: profileDir,
|
|
111
|
+
stdio: 'ignore',
|
|
112
|
+
shell: false,
|
|
113
|
+
windowsHide: true,
|
|
114
|
+
})
|
|
115
|
+
child.once('error', reject)
|
|
116
|
+
child.once('exit', (code, signal) => {
|
|
117
|
+
if (code === 0) resolve()
|
|
118
|
+
else reject(new Error(`dsh-smooth-stream: pnpm update failed (${signal ?? String(code)})`))
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Shared wire vocabulary for the plugin-owned settings RPC channel. */
|
|
2
|
+
|
|
3
|
+
/** Dedicated, loopback-only RPC channel registered by the Host half. */
|
|
4
|
+
export const STREAM_SETTINGS_RPC_CHANNEL = '/smooth-stream'
|
|
5
|
+
|
|
6
|
+
/** Endpoints accepted by {@link STREAM_SETTINGS_RPC_CHANNEL}. */
|
|
7
|
+
export const STREAM_SETTINGS_RPC = {
|
|
8
|
+
read: 'settings.read',
|
|
9
|
+
write: 'settings.write',
|
|
10
|
+
upgrade: 'plugin.upgrade',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
/** How the active profile supplied this plugin. */
|
|
14
|
+
export type StreamInstallationKind = 'npm' | 'development' | 'unmanaged'
|
|
15
|
+
|
|
16
|
+
/** Redacted, plugin-owned configuration state returned to the browser. */
|
|
17
|
+
export interface StreamSettingsView {
|
|
18
|
+
/** Version of the package currently running in the Host process. */
|
|
19
|
+
version: string
|
|
20
|
+
/** Source class of this package in the active profile. */
|
|
21
|
+
installation: StreamInstallationKind
|
|
22
|
+
/** Whether the Host settings provider permits a write. */
|
|
23
|
+
writable: boolean
|
|
24
|
+
/** Current resolved preference. */
|
|
25
|
+
thinkAutoExpand: boolean
|
|
26
|
+
/** Whether a fixed npm update command is safe to offer. */
|
|
27
|
+
canUpgrade: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Successful package update acknowledgement; loading the new code needs a restart. */
|
|
31
|
+
export interface StreamUpgradeView {
|
|
32
|
+
restartRequired: true
|
|
33
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-owned settings for the smooth-stream plugin, exposed to the Host
|
|
3
|
+
* settings service and edited from the Web Settings "plugin configuration"
|
|
4
|
+
* page. This is the runtime-editable complement to {@link StreamConfig}: that
|
|
5
|
+
* contract is composed at load and bridged once through the boot global, while
|
|
6
|
+
* these preferences live in the durable user-settings document and take effect
|
|
7
|
+
* live.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Settings namespace registered by the Host and served through the plugin RPC. */
|
|
11
|
+
export const STREAM_SETTINGS_NS = 'smooth-stream'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Preferences a user may set. Deliberately separate from {@link StreamConfig}
|
|
15
|
+
* because the two change at different times: composition-time values go
|
|
16
|
+
* through the boot global, a live UI edit goes through the protected plugin RPC.
|
|
17
|
+
*/
|
|
18
|
+
export interface StreamSettings {
|
|
19
|
+
/**
|
|
20
|
+
* Whether a reasoning ("Think") block auto-expands while it is the
|
|
21
|
+
* streaming tail. Off keeps the block collapsed — the user can still open
|
|
22
|
+
* it by hand — and stops the running state from re-owning the disclosure.
|
|
23
|
+
*/
|
|
24
|
+
thinkAutoExpand: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Defaults shared by the Host schema and the client-side fallback. */
|
|
28
|
+
export const DEFAULT_STREAM_SETTINGS: StreamSettings = {
|
|
29
|
+
thinkAutoExpand: true,
|
|
30
|
+
}
|