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
|
@@ -30,12 +30,46 @@ export interface DebugMetrics {
|
|
|
30
30
|
followRevealScale: number
|
|
31
31
|
followFollowing: boolean
|
|
32
32
|
followConstrained: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Terminal-follow phase. `terminal-drain` (producer stopped, reveal queue
|
|
35
|
+
* still owes text) and `host-cascade` (host swapping status / collapsing
|
|
36
|
+
* Think / mounting the tail) need OPPOSITE corrections, so they must be
|
|
37
|
+
* distinguishable in the panel rather than inferred from `followActive`.
|
|
38
|
+
*/
|
|
39
|
+
followTerminalPhase: FollowTerminalPhase
|
|
40
|
+
/** Physical owned bottom margin currently written into the DOM. */
|
|
41
|
+
followRunwayPx: number
|
|
42
|
+
/** Budget the terminal phase may spend, capped by the runway. */
|
|
43
|
+
followTerminalBudgetPx: number
|
|
44
|
+
/** `runwayPx - visibleReserve`: the term that turns into visible motion. */
|
|
45
|
+
followBaselineShiftPx: number
|
|
46
|
+
/** Screen-space reading-anchor delta this frame (null when unmeasurable). */
|
|
47
|
+
followAnchorDeltaPx: number | null
|
|
48
|
+
/** Reveal characters the smoother still owes after the producer stopped. */
|
|
49
|
+
followRemainingRevealChars: number
|
|
33
50
|
scrollTop: number | null
|
|
34
51
|
scrollHeight: number | null
|
|
35
52
|
clientHeight: number | null
|
|
36
53
|
lastUpdatedMs: number | null
|
|
37
54
|
}
|
|
38
55
|
|
|
56
|
+
export type FollowTerminalPhase = 'live' | 'terminal-drain' | 'host-cascade' | 'natural'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Allocation-free read of the newest stream metric. The follower polls this
|
|
60
|
+
* every frame to learn whether the producer has stopped while reveal work
|
|
61
|
+
* remains, and `getSnapshot()` would allocate a fresh state object per frame
|
|
62
|
+
* just to answer one boolean.
|
|
63
|
+
*/
|
|
64
|
+
export function readNewestStreamMetric(): { producerComplete: boolean; backlog: number } | null {
|
|
65
|
+
let newest: StreamMetric | undefined
|
|
66
|
+
for (const candidate of streamMetrics.values()) {
|
|
67
|
+
if (newest === undefined || candidate.updatedAt > newest.updatedAt) newest = candidate
|
|
68
|
+
}
|
|
69
|
+
if (newest === undefined) return null
|
|
70
|
+
return { producerComplete: newest.producerComplete, backlog: newest.backlog }
|
|
71
|
+
}
|
|
72
|
+
|
|
39
73
|
export interface DebugRuntimeState {
|
|
40
74
|
available: boolean
|
|
41
75
|
enabled: boolean
|
|
@@ -70,6 +104,12 @@ interface StreamMetric {
|
|
|
70
104
|
targetChars: number
|
|
71
105
|
displayedChars: number
|
|
72
106
|
active: boolean
|
|
107
|
+
/**
|
|
108
|
+
* The producer (model stream) has stopped even if the reveal queue still owes
|
|
109
|
+
* characters. The follower needs exactly this bit to tell a `terminal-drain`
|
|
110
|
+
* from live streaming, and it cannot be derived from `backlog > 0` alone.
|
|
111
|
+
*/
|
|
112
|
+
producerComplete: boolean
|
|
73
113
|
updatedAt: number
|
|
74
114
|
}
|
|
75
115
|
|
|
@@ -85,6 +125,12 @@ interface FollowMetric {
|
|
|
85
125
|
scrollHeight: number
|
|
86
126
|
clientHeight: number
|
|
87
127
|
active: boolean
|
|
128
|
+
terminalPhase: FollowTerminalPhase
|
|
129
|
+
runwayPx: number
|
|
130
|
+
terminalBudgetPx: number
|
|
131
|
+
baselineShiftPx: number
|
|
132
|
+
readingAnchorDeltaPx: number | null
|
|
133
|
+
remainingRevealChars: number
|
|
88
134
|
updatedAt: number
|
|
89
135
|
}
|
|
90
136
|
|
|
@@ -105,6 +151,12 @@ const EMPTY_METRICS: DebugMetrics = {
|
|
|
105
151
|
followRevealScale: 1,
|
|
106
152
|
followFollowing: false,
|
|
107
153
|
followConstrained: false,
|
|
154
|
+
followTerminalPhase: 'live',
|
|
155
|
+
followRunwayPx: 0,
|
|
156
|
+
followTerminalBudgetPx: 0,
|
|
157
|
+
followBaselineShiftPx: 0,
|
|
158
|
+
followAnchorDeltaPx: null,
|
|
159
|
+
followRemainingRevealChars: 0,
|
|
108
160
|
scrollTop: null,
|
|
109
161
|
scrollHeight: null,
|
|
110
162
|
clientHeight: null,
|
|
@@ -181,6 +233,12 @@ function currentMetrics(timestamp: number): DebugMetrics {
|
|
|
181
233
|
followRevealScale: follow?.revealScale ?? 1,
|
|
182
234
|
followFollowing: follow?.following ?? false,
|
|
183
235
|
followConstrained: follow?.constrained ?? false,
|
|
236
|
+
followTerminalPhase: follow?.terminalPhase ?? 'live',
|
|
237
|
+
followRunwayPx: follow?.runwayPx ?? 0,
|
|
238
|
+
followTerminalBudgetPx: follow?.terminalBudgetPx ?? 0,
|
|
239
|
+
followBaselineShiftPx: follow?.baselineShiftPx ?? 0,
|
|
240
|
+
followAnchorDeltaPx: follow?.readingAnchorDeltaPx ?? null,
|
|
241
|
+
followRemainingRevealChars: follow?.remainingRevealChars ?? 0,
|
|
184
242
|
scrollTop: follow?.scrollTop ?? null,
|
|
185
243
|
scrollHeight: follow?.scrollHeight ?? null,
|
|
186
244
|
clientHeight: follow?.clientHeight ?? null,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
3
|
+
|
|
4
|
+
type IconProps = { className?: string | undefined; size?: number | undefined }
|
|
5
|
+
type IconExports = Record<string, ComponentType<IconProps> | undefined>
|
|
6
|
+
|
|
7
|
+
// Harness renamed size-suffixed icon exports to weight-suffixed names. Resolve
|
|
8
|
+
// at runtime so this plugin bundle works with both the older published
|
|
9
|
+
// primitive package used for development and current Harness module tables.
|
|
10
|
+
const icons = primitives as unknown as IconExports
|
|
11
|
+
|
|
12
|
+
function resolveIcon(currentName: string, legacyName: string): ComponentType<IconProps> {
|
|
13
|
+
const icon = icons[currentName] ?? icons[legacyName]
|
|
14
|
+
if (icon === undefined) {
|
|
15
|
+
throw new Error(`dsh-smooth-stream: Harness icon export is missing (${currentName}, ${legacyName})`)
|
|
16
|
+
}
|
|
17
|
+
return icon
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const IconChevronDown = resolveIcon('IconChevronDownOutlineRegular', 'IconChevronDownOutline14')
|
|
21
|
+
export const IconClose = resolveIcon('IconCloseOutlineRegular', 'IconCloseOutline16')
|
|
22
|
+
export const IconCode = resolveIcon('IconCodeOutlineRegular', 'IconCodeOutline16')
|
|
23
|
+
export const IconCopy = resolveIcon('IconCopyOutlineRegular', 'IconCopyOutline16')
|
|
24
|
+
export const IconQuestion = resolveIcon('IconQuestionOutlineRegular', 'IconQuestionOutline14')
|
|
25
|
+
export const IconRefresh = resolveIcon('IconRefreshOutlineRegular', 'IconRefreshOutline14')
|
|
26
|
+
export const IconRefreshSmall = resolveIcon('IconRefreshOutlineRegular', 'IconRefreshOutline16')
|
|
27
|
+
export const IconThink = resolveIcon('IconThinkOutlineRegular', 'IconThinkOutline14')
|
package/src/client/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
|
|
|
13
13
|
import { createSmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
|
|
14
14
|
import { DebugPanel } from './DebugPanel.tsx'
|
|
15
15
|
import { debugRuntime } from './debugRuntime.ts'
|
|
16
|
-
import { NS as SETTINGS_NS, en, zh } from './locales.ts'
|
|
16
|
+
import { NS as SETTINGS_NS, CHAT_NS, en, zh, chatEn, chatZh } from './locales.ts'
|
|
17
17
|
import { DEFAULT_STREAM_CONFIG, STREAM_BOOT_GLOBAL, type StreamConfig } from '../config.ts'
|
|
18
18
|
import { DEFAULT_STREAM_SETTINGS, STREAM_SETTINGS_NS, type StreamSettings } from '../settings.ts'
|
|
19
19
|
|
|
@@ -37,8 +37,34 @@ const STREAM_PRESETS: readonly string[] = ['realtime', 'balanced', 'silky']
|
|
|
37
37
|
* generic follow boundary. This is deliberately keyed by the owner that
|
|
38
38
|
* provides the renderer, not by individual tool names, so new Context,
|
|
39
39
|
* Command, and Tool rows are covered automatically.
|
|
40
|
+
*
|
|
41
|
+
* Host chrome and controllers are excluded on a different rule: `turn-process`
|
|
42
|
+
* (the completed-turn process summary control) and `turn-tail` (the turn usage
|
|
43
|
+
* footer) are not Agent output. The Host renders them as bare controls and
|
|
44
|
+
* flips their visibility itself, so wrapping them has three measured costs and
|
|
45
|
+
* no benefit:
|
|
46
|
+
*
|
|
47
|
+
* 1. The wrapper element defeats ChatView's `.flowItem:empty { display: none }`
|
|
48
|
+
* rule. A `turn-process` renderer returns null whenever the Host's fold is
|
|
49
|
+
* unavailable (e.g. `processWindowReady === false`), and that null render is
|
|
50
|
+
* exactly what the `:empty` rule exists to erase; with a wrapper the row
|
|
51
|
+
* stays in flow and still consumes the 16px column gap, painting an empty
|
|
52
|
+
* band where the fold control should be.
|
|
53
|
+
* 2. The summary label (`N tool calls · M messages`) is not streamed model
|
|
54
|
+
* text, so pushing it through the character-reveal engine is wrong by
|
|
55
|
+
* construction.
|
|
56
|
+
* 3. The row would inherit the generic entrance animation (opacity + clip-path)
|
|
57
|
+
* on a row the Host may mount and hide inside a single commit.
|
|
40
58
|
*/
|
|
41
|
-
const SKIP_WRAP = new Set([
|
|
59
|
+
const SKIP_WRAP = new Set([
|
|
60
|
+
'assistant-step',
|
|
61
|
+
'user',
|
|
62
|
+
'steering',
|
|
63
|
+
'command-input',
|
|
64
|
+
'turn-process',
|
|
65
|
+
'turn-tail',
|
|
66
|
+
'tool-call',
|
|
67
|
+
])
|
|
42
68
|
|
|
43
69
|
/** React function/class or an exotic component such as memo/forwardRef/lazy. */
|
|
44
70
|
function isWrappableComponent(value: unknown): value is ComponentType<FollowWrapProps> {
|
|
@@ -157,8 +183,10 @@ class SettingsCell {
|
|
|
157
183
|
pending === this.pending
|
|
158
184
|
&& next.enabled === this.value.enabled
|
|
159
185
|
&& next.controlScroll === this.value.controlScroll
|
|
186
|
+
&& next.preset === this.value.preset
|
|
160
187
|
&& next.motionPreference === this.value.motionPreference
|
|
161
188
|
&& next.thinkAutoExpand === this.value.thinkAutoExpand
|
|
189
|
+
&& next.logarithmicFade === this.value.logarithmicFade
|
|
162
190
|
&& next.debugEnabled === this.value.debugEnabled
|
|
163
191
|
&& next.debugTuning === this.value.debugTuning
|
|
164
192
|
) return
|
|
@@ -172,6 +200,16 @@ class SettingsCell {
|
|
|
172
200
|
return !this.pending && this.value.enabled
|
|
173
201
|
}
|
|
174
202
|
|
|
203
|
+
/**
|
|
204
|
+
* True only once an attached settings service has produced its loaded values.
|
|
205
|
+
* Until then — and when no settings service is composed at all — `value` is
|
|
206
|
+
* still the shared client default, so callers that must not let that default
|
|
207
|
+
* shadow the install-time config have to ask before trusting it.
|
|
208
|
+
*/
|
|
209
|
+
ready(): boolean {
|
|
210
|
+
return this.card?.getSnapshot().status === 'ready'
|
|
211
|
+
}
|
|
212
|
+
|
|
175
213
|
readonly getSnapshot = (): StreamSettings => this.value
|
|
176
214
|
|
|
177
215
|
readonly subscribe = (listener: () => void): () => void => {
|
|
@@ -200,6 +238,55 @@ export function apply(ctx: ClientContext): void {
|
|
|
200
238
|
() => settings.getSnapshot().controlScroll,
|
|
201
239
|
)
|
|
202
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Layered `t` for the assistant renderer.
|
|
243
|
+
*
|
|
244
|
+
* The renderer's keys have no single owner: `conversation` owns the
|
|
245
|
+
* `image.*` family in every Harness version, `chat` (0.1.5+) owns
|
|
246
|
+
* `message.think`, and three `message.*` keys MOVED from `conversation` to
|
|
247
|
+
* `chat` between the version this package pins and the current one. Binding
|
|
248
|
+
* the slot to either namespace alone degrades a real slice of the UI to raw
|
|
249
|
+
* keys, which is the defect this replaces.
|
|
250
|
+
*
|
|
251
|
+
* The order is deliberate: `conversation` first, so the pinned Harness keeps
|
|
252
|
+
* resolving the keys it still owns there; then `chat` for what moved or is
|
|
253
|
+
* new; then this plugin's own namespace for keys no Harness version
|
|
254
|
+
* provides. A namespace that is not registered returns its key unchanged
|
|
255
|
+
* (`LocaleRuntime.bind` does not throw), so an older Harness without `chat`
|
|
256
|
+
* falls straight through.
|
|
257
|
+
*
|
|
258
|
+
* The reference is built once and held stable: the seat feeds a memoized
|
|
259
|
+
* renderer, and a fresh identity per render would defeat that memoization.
|
|
260
|
+
* Until the locale service arrives the seat's own binding stays in use.
|
|
261
|
+
*/
|
|
262
|
+
let assistantT: AssistantProps['t'] | undefined
|
|
263
|
+
|
|
264
|
+
// Locale routing belongs to the renderer lifecycle, not to the optional
|
|
265
|
+
// Connection-backed settings card. Keeping this injection independent means
|
|
266
|
+
// a deployment can stream localized replies without Connection, and a
|
|
267
|
+
// transient disconnect cannot dispose the renderer's fallback dictionary.
|
|
268
|
+
ctx.inject(['locale'], (localeCtx) => {
|
|
269
|
+
localeCtx.effect(
|
|
270
|
+
() => localeCtx.locale.register(CHAT_NS, { zh: chatZh, en: chatEn }),
|
|
271
|
+
'dsh-smooth-stream: conversation fallback dictionary',
|
|
272
|
+
)
|
|
273
|
+
const conversationT = localeCtx.locale.bind('conversation')
|
|
274
|
+
const chatT = localeCtx.locale.bind('chat') as (key: string, params?: Record<string, unknown>) => string
|
|
275
|
+
const fallbackT = localeCtx.locale.bind(CHAT_NS) as (key: string, params?: Record<string, unknown>) => string
|
|
276
|
+
const merged = (key: string, params?: Record<string, unknown>): string => {
|
|
277
|
+
const primary = (conversationT as (k: string, p?: unknown) => string)(key, params)
|
|
278
|
+
if (primary !== key) return primary
|
|
279
|
+
const secondary = chatT(key, params)
|
|
280
|
+
if (secondary !== key) return secondary
|
|
281
|
+
return fallbackT(key, params)
|
|
282
|
+
}
|
|
283
|
+
const bound = merged as unknown as AssistantProps['t']
|
|
284
|
+
assistantT = bound
|
|
285
|
+
return () => {
|
|
286
|
+
if (assistantT === bound) assistantT = undefined
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
203
290
|
// The card talks to the plugin-owned loopback RPC, so the core settings
|
|
204
291
|
// namespace allowlist cannot make it disappear. The stream still applies
|
|
205
292
|
// with defaults when the optional Settings UI or Connection is absent.
|
|
@@ -264,18 +351,30 @@ export function apply(ctx: ClientContext): void {
|
|
|
264
351
|
)
|
|
265
352
|
return createElement(TypewriterAssistantNodeView, {
|
|
266
353
|
...props,
|
|
354
|
+
// Override the seat's single-namespace binding with the layered lookup
|
|
355
|
+
// described above. Falls back to the seat's own binding only if the
|
|
356
|
+
// locale service never arrived (renderer then receives the prop it
|
|
357
|
+
// already had).
|
|
358
|
+
...(assistantT === undefined ? {} : { t: assistantT }),
|
|
267
359
|
mode: config.mode,
|
|
268
|
-
preset
|
|
360
|
+
// Loaded settings hold the resolved preset (user layer over the install
|
|
361
|
+
// config), so they own the prop. Without them — no settings service, a
|
|
362
|
+
// failed load, or the frames before the first read lands — the shared
|
|
363
|
+
// client default is NOT an authority on the installed preset; the boot
|
|
364
|
+
// config is.
|
|
365
|
+
preset: settings.ready() ? preferences.preset ?? config.preset : config.preset,
|
|
269
366
|
revealCharsPerSec: config.revealCharsPerSec,
|
|
270
367
|
scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
|
|
271
368
|
maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
|
|
272
369
|
thinkAutoExpand: preferences.thinkAutoExpand,
|
|
370
|
+
logarithmicFade: preferences.logarithmicFade,
|
|
273
371
|
controlScroll: preferences.controlScroll,
|
|
274
372
|
motionPreference: preferences.motionPreference,
|
|
275
373
|
})
|
|
276
374
|
}
|
|
277
375
|
ctx.slots.inject('conversation.chat.node', () => {
|
|
278
376
|
let releaseTakeover: (() => void) | undefined
|
|
377
|
+
let usedSwap = false
|
|
279
378
|
|
|
280
379
|
const syncTakeover = (): void => {
|
|
281
380
|
if (!settings.takeoverEnabled()) {
|
|
@@ -285,24 +384,67 @@ export function apply(ctx: ClientContext): void {
|
|
|
285
384
|
}
|
|
286
385
|
if (releaseTakeover !== undefined) return
|
|
287
386
|
const unwrap = wrapAgentChatRows(ctx, useControlScroll)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
387
|
+
// dsh 0.2.x renders every keyed entry of `conversation.chat.node` as
|
|
388
|
+
// its own seat, so a lower-priority registration no longer shadows the
|
|
389
|
+
// built-in assistant row — the same assistant step then renders twice
|
|
390
|
+
// (built-in + takeover), duplicating every reasoning block in the
|
|
391
|
+
// transcript. Swap the built-in entry's component in place instead,
|
|
392
|
+
// exactly like the other keyed rows below; this keeps the original
|
|
393
|
+
// children, locale, and inject seats on every kernel generation. The
|
|
394
|
+
// shadow registration survives only as a fallback for compositions
|
|
395
|
+
// that declare no built-in assistant row to swap.
|
|
396
|
+
const builtin = ctx.slots.entries('conversation.chat.node').find(
|
|
397
|
+
entry => entry.options.key === 'assistant-step'
|
|
398
|
+
// The pinned SlotOptions type predates `registrant`; every current
|
|
399
|
+
// runtime carries it (see the register spread in the slots package).
|
|
400
|
+
&& (entry.options as { registrant?: string }).registrant !== 'dsh-smooth-stream'
|
|
401
|
+
&& entry.component !== configured,
|
|
402
|
+
)
|
|
403
|
+
let unshadow: () => void
|
|
404
|
+
if (builtin !== undefined && isWrappableComponent(builtin.component)) {
|
|
405
|
+
usedSwap = true
|
|
406
|
+
const inner = builtin.component as ComponentType<FollowWrapProps>
|
|
407
|
+
builtin.component = configured
|
|
408
|
+
unshadow = () => {
|
|
409
|
+
if (builtin.component === configured) builtin.component = inner
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
unshadow = ctx.slots.register({
|
|
413
|
+
name: 'conversation.chat.node',
|
|
414
|
+
key: 'assistant-step',
|
|
415
|
+
priority: -100,
|
|
416
|
+
// `conversation` (not `chat`): it is the namespace the pinned Harness
|
|
417
|
+
// actually registers, so it is the one whose keys must keep resolving.
|
|
418
|
+
// The layered `t` handed to the renderer covers what this namespace
|
|
419
|
+
// does not own. A namespace the composition does not declare would
|
|
420
|
+
// degrade every `t()` call to its raw key.
|
|
421
|
+
locale: 'conversation',
|
|
422
|
+
registrant: 'dsh-smooth-stream',
|
|
423
|
+
}, configured)
|
|
424
|
+
}
|
|
295
425
|
releaseTakeover = () => {
|
|
296
|
-
// Stop observing slot changes before the
|
|
426
|
+
// Stop observing slot changes before the takeover is removed.
|
|
297
427
|
unwrap()
|
|
298
428
|
unshadow()
|
|
299
429
|
}
|
|
300
430
|
}
|
|
301
431
|
|
|
432
|
+
// If the shadow fallback was taken because the built-in assistant row
|
|
433
|
+
// had not registered yet, re-take through the in-place swap as soon as
|
|
434
|
+
// it appears, so 0.2.x never renders the row twice.
|
|
435
|
+
const offSlotChange = ctx.on('slots/changed', (key: string) => {
|
|
436
|
+
if (key !== 'conversation.chat.node' || usedSwap || releaseTakeover === undefined) return
|
|
437
|
+
if (!settings.takeoverEnabled()) return
|
|
438
|
+
releaseTakeover()
|
|
439
|
+
releaseTakeover = undefined
|
|
440
|
+
syncTakeover()
|
|
441
|
+
})
|
|
442
|
+
|
|
302
443
|
const unsubscribe = settings.subscribe(syncTakeover)
|
|
303
444
|
syncTakeover()
|
|
304
445
|
return () => {
|
|
305
446
|
unsubscribe()
|
|
447
|
+
offSlotChange()
|
|
306
448
|
releaseTakeover?.()
|
|
307
449
|
}
|
|
308
450
|
})
|
package/src/client/locales.ts
CHANGED
|
@@ -3,16 +3,65 @@
|
|
|
3
3
|
/** Dictionary namespace owned by this plugin's settings card. */
|
|
4
4
|
export const NS = 'settings.smoothStream'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Namespace owning the conversation keys THIS PLUGIN must be able to render
|
|
8
|
+
* but the Harness does not always provide.
|
|
9
|
+
*
|
|
10
|
+
* The plugin consumes keys from two Harness namespaces, and which one owns a
|
|
11
|
+
* given key is not stable across Harness versions:
|
|
12
|
+
*
|
|
13
|
+
* - `conversation` (`dsh-client-ui-conversation`) owns the `image.*` family in
|
|
14
|
+
* every version, and also owned `message.stopped`, `message.unknownBlock`
|
|
15
|
+
* and `json.truncated` before those moved to `chat`.
|
|
16
|
+
* - `chat` (`dsh-client-ui-chat`, present from 0.1.5 on) owns `message.think`
|
|
17
|
+
* and now owns the three keys above. It does not exist at all in rc.6, the
|
|
18
|
+
* version this package still pins in devDependencies.
|
|
19
|
+
*
|
|
20
|
+
* Neither namespace alone can serve every key this plugin renders, so
|
|
21
|
+
* `index.ts` layers them. This plugin-owned namespace is the final layer; it
|
|
22
|
+
* exists because a plugin may NOT register into `chat` or `conversation` —
|
|
23
|
+
* `LocaleRuntime.register` throws when the (namespace, locale) pair already
|
|
24
|
+
* has an owner, and the Harness owns both.
|
|
25
|
+
*/
|
|
26
|
+
export const CHAT_NS = 'smoothStream.chat'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Keys the plugin resolves through {@link CHAT_NS}. Only keys with no
|
|
30
|
+
* dependable Harness owner belong here; anything the Harness already provides
|
|
31
|
+
* must keep coming from the Harness so a language pack can override it.
|
|
32
|
+
*/
|
|
33
|
+
export type SmoothStreamChatKey = 'message.think' | 'image.serviceUnavailable'
|
|
34
|
+
|
|
35
|
+
/** English fallback for {@link SmoothStreamChatKey}. */
|
|
36
|
+
export const chatEn: Record<SmoothStreamChatKey, string> = {
|
|
37
|
+
'message.think': 'Think',
|
|
38
|
+
// Removed from the Harness dictionaries in 0.1.5 (rc.6 had it under
|
|
39
|
+
// `conversation`), and the plugin interpolates it into a thrown Error, so
|
|
40
|
+
// the raw key must never reach a reader.
|
|
41
|
+
'image.serviceUnavailable': 'Image service unavailable',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Simplified Chinese fallback for {@link SmoothStreamChatKey}. */
|
|
45
|
+
export const chatZh: Record<SmoothStreamChatKey, string> = {
|
|
46
|
+
'message.think': '思考',
|
|
47
|
+
'image.serviceUnavailable': '图片服务不可用',
|
|
48
|
+
}
|
|
49
|
+
|
|
6
50
|
/** Locale keys the card renders. */
|
|
7
51
|
export type SmoothStreamLocaleKey =
|
|
8
52
|
| 'title' | 'description'
|
|
9
53
|
| 'enabled' | 'enabledHint'
|
|
10
54
|
| 'controlScroll' | 'controlScrollHint'
|
|
55
|
+
| 'preset' | 'presetHint'
|
|
56
|
+
| 'presetRealtime' | 'presetRealtimeHint'
|
|
57
|
+
| 'presetBalanced' | 'presetBalancedHint'
|
|
58
|
+
| 'presetSilky' | 'presetSilkyHint'
|
|
11
59
|
| 'motionPreference' | 'motionPreferenceHint'
|
|
12
60
|
| 'motionAuto' | 'motionAutoHint'
|
|
13
61
|
| 'motionForceSmooth' | 'motionForceSmoothHint'
|
|
14
62
|
| 'motionForceReduced' | 'motionForceReducedHint'
|
|
15
63
|
| 'thinkAutoExpand' | 'thinkAutoExpandHint'
|
|
64
|
+
| 'logarithmicFade' | 'logarithmicFadeHint'
|
|
16
65
|
| 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
|
|
17
66
|
| 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
|
|
18
67
|
| 'debugLive' | 'debugIdle' | 'debugUnsaved'
|
|
@@ -21,6 +70,8 @@ export type SmoothStreamLocaleKey =
|
|
|
21
70
|
| 'debugFps' | 'debugFrameTime' | 'debugBacklog' | 'debugRevealSpeed' | 'debugProgress'
|
|
22
71
|
| 'debugFollowState' | 'debugFollowing' | 'debugReleased'
|
|
23
72
|
| 'debugLag' | 'debugVelocity' | 'debugReserve' | 'debugCapacity' | 'debugAppliedScale'
|
|
73
|
+
| 'debugTerminalPhase' | 'debugOwnedRunway' | 'debugTerminalBudget' | 'debugBaselineShift'
|
|
74
|
+
| 'debugAnchorDelta' | 'debugPhaseLive' | 'debugPhaseDrain' | 'debugPhaseCascade' | 'debugPhaseNatural'
|
|
24
75
|
| 'debugRevealMultiplier' | 'debugQueuePressure' | 'debugMaxReveal'
|
|
25
76
|
| 'debugSpringStiffness' | 'debugSpringDamping' | 'debugSpringMass'
|
|
26
77
|
| 'debugRunway' | 'debugReserveResponse' | 'debugBackpressureMin'
|
|
@@ -41,6 +92,14 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
|
|
|
41
92
|
enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
|
|
42
93
|
controlScroll: 'Take over scrolling',
|
|
43
94
|
controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
|
|
95
|
+
preset: 'Pacing preset',
|
|
96
|
+
presetHint: 'Smoothing cadence profile for text reveal and glide.',
|
|
97
|
+
presetRealtime: 'Realtime',
|
|
98
|
+
presetRealtimeHint: 'Aggressive cadence, low latency, tracks output closely.',
|
|
99
|
+
presetBalanced: 'Balanced',
|
|
100
|
+
presetBalancedHint: 'Default balance between pace and visual rhythm.',
|
|
101
|
+
presetSilky: 'Silky',
|
|
102
|
+
presetSilkyHint: 'Higher backlog buffer, gentle EMA damping, maximum fluidity.',
|
|
44
103
|
motionPreference: 'Motion',
|
|
45
104
|
motionPreferenceHint: 'How streaming responds to the system reduce-motion setting.',
|
|
46
105
|
motionAuto: 'Follow system',
|
|
@@ -50,6 +109,8 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
|
|
|
50
109
|
motionForceReduced: 'Always raw',
|
|
51
110
|
motionForceReducedHint: 'Render raw text even when the system allows motion.',
|
|
52
111
|
thinkAutoExpand: 'Auto-expand thinking',
|
|
112
|
+
logarithmicFade: 'Logarithmic fade',
|
|
113
|
+
logarithmicFadeHint: 'Fade new answer and expanded thinking text into view. Follows the motion preference.',
|
|
53
114
|
thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
|
|
54
115
|
debugEnabled: 'Show render diagnostics',
|
|
55
116
|
debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
|
|
@@ -82,6 +143,15 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
|
|
|
82
143
|
debugReserve: 'Reserve',
|
|
83
144
|
debugCapacity: 'Capacity',
|
|
84
145
|
debugAppliedScale: 'Applied scale',
|
|
146
|
+
debugTerminalPhase: 'Terminal phase',
|
|
147
|
+
debugOwnedRunway: 'Owned runway',
|
|
148
|
+
debugTerminalBudget: 'Terminal budget',
|
|
149
|
+
debugBaselineShift: 'Baseline shift',
|
|
150
|
+
debugAnchorDelta: 'Anchor Δ',
|
|
151
|
+
debugPhaseLive: 'live',
|
|
152
|
+
debugPhaseDrain: 'terminal-drain',
|
|
153
|
+
debugPhaseCascade: 'host-cascade',
|
|
154
|
+
debugPhaseNatural: 'natural',
|
|
85
155
|
debugRevealMultiplier: 'Reveal multiplier',
|
|
86
156
|
debugQueuePressure: 'Queue pressure',
|
|
87
157
|
debugMaxReveal: 'Maximum reveal',
|
|
@@ -129,6 +199,14 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
|
129
199
|
enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
|
|
130
200
|
controlScroll: '接管滚动',
|
|
131
201
|
controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
|
|
202
|
+
preset: '手感预设',
|
|
203
|
+
presetHint: '文字吐字节奏与视口跟随阻尼风格。',
|
|
204
|
+
presetRealtime: '激进快速 (realtime)',
|
|
205
|
+
presetRealtimeHint: '缓冲低、流速快,紧跟模型生成。',
|
|
206
|
+
presetBalanced: '均衡自然 (balanced)',
|
|
207
|
+
presetBalancedHint: '官方默认,平衡流速与节奏感。',
|
|
208
|
+
presetSilky: '极致丝滑 (silky)',
|
|
209
|
+
presetSilkyHint: '字符积压缓冲加大、阻尼更高、EMA 平滑度更好,视觉呼吸感最强。',
|
|
132
210
|
motionPreference: '动效偏好',
|
|
133
211
|
motionPreferenceHint: '流式渲染如何响应系统的「减少动态效果」设置。',
|
|
134
212
|
motionAuto: '跟随系统',
|
|
@@ -138,6 +216,8 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
|
138
216
|
motionForceReduced: '始终原始',
|
|
139
217
|
motionForceReducedHint: '即使系统允许动效,也直接呈现原始文本。',
|
|
140
218
|
thinkAutoExpand: '自动展开思考',
|
|
219
|
+
logarithmicFade: '对数淡入',
|
|
220
|
+
logarithmicFadeHint: '让回答正文和展开的思考文字由淡变实,遵循动效偏好。',
|
|
141
221
|
thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
|
|
142
222
|
debugEnabled: '显示渲染调试面板',
|
|
143
223
|
debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
|
|
@@ -170,6 +250,15 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
|
170
250
|
debugReserve: '预留空间',
|
|
171
251
|
debugCapacity: '安全容量',
|
|
172
252
|
debugAppliedScale: '实际倍率',
|
|
253
|
+
debugTerminalPhase: '收尾阶段',
|
|
254
|
+
debugOwnedRunway: '实体留白',
|
|
255
|
+
debugTerminalBudget: '收尾预算',
|
|
256
|
+
debugBaselineShift: '基线位移',
|
|
257
|
+
debugAnchorDelta: '锚点位移',
|
|
258
|
+
debugPhaseLive: '流式中',
|
|
259
|
+
debugPhaseDrain: '收尾排空',
|
|
260
|
+
debugPhaseCascade: '宿主交接',
|
|
261
|
+
debugPhaseNatural: '自然态',
|
|
173
262
|
debugRevealMultiplier: '揭示倍率',
|
|
174
263
|
debugQueuePressure: '队列压力',
|
|
175
264
|
debugMaxReveal: '最大揭示速度',
|
|
@@ -212,5 +301,6 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
|
212
301
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
213
302
|
interface LocaleNamespaceMap {
|
|
214
303
|
'settings.smoothStream': SmoothStreamLocaleKey
|
|
304
|
+
'smoothStream.chat': SmoothStreamChatKey
|
|
215
305
|
}
|
|
216
306
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type StreamDebugTuning,
|
|
8
8
|
type StreamMotionPreference,
|
|
9
9
|
type StreamSettings,
|
|
10
|
+
type StreamSmoothingPreset,
|
|
10
11
|
} from '../settings.ts'
|
|
11
12
|
import type {
|
|
12
13
|
StreamDebugSettingsView,
|
|
@@ -24,8 +25,10 @@ export interface SmoothStreamCardState {
|
|
|
24
25
|
failed: boolean
|
|
25
26
|
enabled: boolean
|
|
26
27
|
controlScroll: boolean
|
|
28
|
+
preset: StreamSmoothingPreset
|
|
27
29
|
motionPreference: StreamMotionPreference
|
|
28
30
|
thinkAutoExpand: boolean
|
|
31
|
+
logarithmicFade: boolean
|
|
29
32
|
debugEnabled: boolean
|
|
30
33
|
debugTuning: StreamDebugTuning
|
|
31
34
|
debugAvailable: boolean
|
|
@@ -54,7 +57,7 @@ export class SmoothStreamCardController {
|
|
|
54
57
|
private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
|
|
55
58
|
private loaded: StreamSettingsView | undefined
|
|
56
59
|
private loadedDebug: StreamDebugSettingsView | undefined
|
|
57
|
-
private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> | undefined
|
|
60
|
+
private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> | undefined
|
|
58
61
|
private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
|
|
59
62
|
private saving = false
|
|
60
63
|
private failed = false
|
|
@@ -92,13 +95,15 @@ export class SmoothStreamCardController {
|
|
|
92
95
|
hooks: { smoothStreamCard: this.store },
|
|
93
96
|
edit: (patch) => {
|
|
94
97
|
if (this.saving) return
|
|
95
|
-
if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined) {
|
|
98
|
+
if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.preset !== undefined || patch.motionPreference !== undefined || patch.thinkAutoExpand !== undefined || patch.logarithmicFade !== undefined) {
|
|
96
99
|
this.stagedBase = {
|
|
97
100
|
...this.baseValues(),
|
|
98
101
|
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
99
102
|
...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
|
|
103
|
+
...(patch.preset === undefined ? {} : { preset: patch.preset }),
|
|
100
104
|
...(patch.motionPreference === undefined ? {} : { motionPreference: patch.motionPreference }),
|
|
101
105
|
...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
|
|
106
|
+
...(patch.logarithmicFade === undefined ? {} : { logarithmicFade: patch.logarithmicFade }),
|
|
102
107
|
}
|
|
103
108
|
}
|
|
104
109
|
if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
|
|
@@ -143,12 +148,14 @@ export class SmoothStreamCardController {
|
|
|
143
148
|
}
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> {
|
|
151
|
+
private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> {
|
|
147
152
|
return this.stagedBase ?? {
|
|
148
153
|
enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
|
|
149
154
|
controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
|
|
155
|
+
preset: this.loaded?.preset ?? DEFAULT_STREAM_SETTINGS.preset,
|
|
150
156
|
motionPreference: this.loaded?.motionPreference ?? DEFAULT_STREAM_SETTINGS.motionPreference,
|
|
151
157
|
thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
158
|
+
logarithmicFade: this.loaded?.logarithmicFade ?? DEFAULT_STREAM_SETTINGS.logarithmicFade,
|
|
152
159
|
}
|
|
153
160
|
}
|
|
154
161
|
|
|
@@ -24,12 +24,14 @@ function settingsView(value: unknown): StreamSettingsView {
|
|
|
24
24
|
|| typeof data.writable !== 'boolean'
|
|
25
25
|
|| typeof data.enabled !== 'boolean'
|
|
26
26
|
|| typeof data.controlScroll !== 'boolean'
|
|
27
|
+
|| (data.preset !== undefined && !['realtime', 'balanced', 'silky'].includes(data.preset as string))
|
|
27
28
|
|| !['auto', 'force-smooth', 'force-reduced'].includes(data.motionPreference as string)
|
|
28
29
|
|| typeof data.thinkAutoExpand !== 'boolean'
|
|
30
|
+
|| (data.logarithmicFade !== undefined && typeof data.logarithmicFade !== 'boolean')
|
|
29
31
|
|| typeof data.canUpgrade !== 'boolean') {
|
|
30
32
|
throw new Error('dsh-smooth-stream: malformed settings response')
|
|
31
33
|
}
|
|
32
|
-
return data as unknown as StreamSettingsView
|
|
34
|
+
return { ...data, preset: (data.preset ?? 'balanced') as StreamSettingsView['preset'], logarithmicFade: data.logarithmicFade ?? true } as unknown as StreamSettingsView
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
function upgradeView(value: unknown): StreamUpgradeView {
|
|
@@ -69,7 +71,7 @@ function accepted(result: Awaited<ReturnType<ConnectionHandle['rpc']['call']>>):
|
|
|
69
71
|
/** Narrow client contract consumed by the staged settings-card controller. */
|
|
70
72
|
export interface SmoothStreamSettingsApi {
|
|
71
73
|
read(): Promise<StreamSettingsView>
|
|
72
|
-
write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
|
|
74
|
+
write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView>
|
|
73
75
|
readDebug(): Promise<StreamDebugSettingsView>
|
|
74
76
|
writeDebug(settings: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>): Promise<StreamDebugSettingsView>
|
|
75
77
|
upgrade(): Promise<StreamUpgradeView>
|
|
@@ -81,15 +83,17 @@ export function createSmoothStreamSettingsApi(connection: ConnectionHandle): Smo
|
|
|
81
83
|
async read(): Promise<StreamSettingsView> {
|
|
82
84
|
return settingsView(accepted(await connection.rpc.call(STREAM_SETTINGS_RPC_CHANNEL, STREAM_SETTINGS_RPC.read, {})))
|
|
83
85
|
},
|
|
84
|
-
async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'motionPreference' | 'thinkAutoExpand'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
|
|
86
|
+
async write(settings: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'preset' | 'motionPreference' | 'thinkAutoExpand' | 'logarithmicFade'> & Partial<Pick<StreamSettings, 'debugEnabled' | 'debugTuning'>>): Promise<StreamSettingsView> {
|
|
85
87
|
return settingsView(accepted(await connection.rpc.call(
|
|
86
88
|
STREAM_SETTINGS_RPC_CHANNEL,
|
|
87
89
|
STREAM_SETTINGS_RPC.write,
|
|
88
90
|
{
|
|
89
91
|
enabled: settings.enabled,
|
|
90
92
|
controlScroll: settings.controlScroll,
|
|
93
|
+
preset: settings.preset,
|
|
91
94
|
motionPreference: settings.motionPreference,
|
|
92
95
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
96
|
+
logarithmicFade: settings.logarithmicFade,
|
|
93
97
|
...(settings.debugEnabled === undefined || settings.debugTuning === undefined
|
|
94
98
|
? {}
|
|
95
99
|
: { debugEnabled: settings.debugEnabled, debugTuning: settings.debugTuning }),
|