dsh-smooth-stream 0.4.2 → 0.5.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 +98 -89
- package/README.md +95 -87
- package/lib/client.js +801 -1040
- package/lib/client.js.map +1 -1
- package/lib/index.js +8 -8
- package/package.json +11 -5
- package/src/client/AgentRowEntrance.module.css +43 -0
- package/src/client/FollowHost.tsx +18 -1
- package/src/client/SmoothStreamCard.tsx +10 -10
- package/src/client/TypewriterAssistantNodeView.tsx +15 -5
- package/src/client/TypewriterToolNodeView.tsx +9 -1
- package/src/client/clientStore.ts +71 -12
- package/src/client/index.ts +13 -29
- package/src/client/locales.ts +5 -5
- package/src/client/smooth-stream-card-controller.ts +6 -6
- package/src/client/smooth-stream-settings-api.ts +4 -4
- package/src/client/teleprompterGlide.ts +1021 -75
- package/src/plugin.ts +17 -8
- package/src/settings-api.ts +2 -2
- package/src/settings.ts +6 -8
- package/src/client/auto-collapse-controller.ts +0 -1072
package/lib/index.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
1
|
import Schema from "@deepseek-ai/schemastery";
|
|
3
2
|
import { readFileSync } from "node:fs";
|
|
4
3
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
@@ -164,8 +163,8 @@ const STREAM_SETTINGS_NS = "smooth-stream";
|
|
|
164
163
|
/** Defaults shared by the Host schema and the client-side fallback. */
|
|
165
164
|
const DEFAULT_STREAM_SETTINGS = {
|
|
166
165
|
enabled: true,
|
|
166
|
+
controlScroll: true,
|
|
167
167
|
thinkAutoExpand: true,
|
|
168
|
-
autoCollapse: true,
|
|
169
168
|
debugEnabled: false,
|
|
170
169
|
debugTuning: {
|
|
171
170
|
revealScale: 1,
|
|
@@ -200,8 +199,8 @@ const Config = Schema.object({
|
|
|
200
199
|
*/
|
|
201
200
|
const StreamSettingsSchema = Schema.object({
|
|
202
201
|
enabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.enabled),
|
|
202
|
+
controlScroll: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.controlScroll),
|
|
203
203
|
thinkAutoExpand: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.thinkAutoExpand),
|
|
204
|
-
autoCollapse: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.autoCollapse),
|
|
205
204
|
debugEnabled: Schema.boolean().default(DEFAULT_STREAM_SETTINGS.debugEnabled),
|
|
206
205
|
debugTuning: Schema.object({
|
|
207
206
|
revealScale: Schema.number().min(.25).max(2).default(DEFAULT_STREAM_SETTINGS.debugTuning.revealScale),
|
|
@@ -229,7 +228,8 @@ function apply(ctx, config) {
|
|
|
229
228
|
httpCtx.effect(() => httpCtx.webServer.tapIndex((html) => injectStreamConfig(html, config)), "dsh-smooth-stream: boot config bridge");
|
|
230
229
|
});
|
|
231
230
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
232
|
-
const
|
|
231
|
+
const settingsNamespace = STREAM_SETTINGS_NS;
|
|
232
|
+
const scope = settingsCtx.settings.register(settingsNamespace, StreamSettingsSchema, { applies: "live" });
|
|
233
233
|
settingsCtx.inject(["connection"], (connectionCtx) => {
|
|
234
234
|
let upgrade;
|
|
235
235
|
const view = () => {
|
|
@@ -240,8 +240,8 @@ function apply(ctx, config) {
|
|
|
240
240
|
installation: installation.kind,
|
|
241
241
|
writable: connectionCtx.settings.writable,
|
|
242
242
|
enabled: settings.enabled,
|
|
243
|
+
controlScroll: settings.controlScroll,
|
|
243
244
|
thinkAutoExpand: settings.thinkAutoExpand,
|
|
244
|
-
autoCollapse: settings.autoCollapse,
|
|
245
245
|
canUpgrade: installation.kind === "npm"
|
|
246
246
|
};
|
|
247
247
|
};
|
|
@@ -263,11 +263,11 @@ function apply(ctx, config) {
|
|
|
263
263
|
value: view()
|
|
264
264
|
};
|
|
265
265
|
if (endpoint === STREAM_SETTINGS_RPC.write) {
|
|
266
|
-
if (typeof payload !== "object" || payload === null || Array.isArray(payload) || typeof payload.enabled !== "boolean" || typeof payload.
|
|
266
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload) || typeof payload.enabled !== "boolean" || typeof payload.controlScroll !== "boolean" || typeof payload.thinkAutoExpand !== "boolean") return {
|
|
267
267
|
ok: false,
|
|
268
268
|
error: {
|
|
269
269
|
code: "settings-rejected",
|
|
270
|
-
message: "enabled,
|
|
270
|
+
message: "enabled, controlScroll and thinkAutoExpand must be booleans",
|
|
271
271
|
details: { ns: STREAM_SETTINGS_NS }
|
|
272
272
|
}
|
|
273
273
|
};
|
|
@@ -292,8 +292,8 @@ function apply(ctx, config) {
|
|
|
292
292
|
};
|
|
293
293
|
await scope.update({
|
|
294
294
|
enabled: next.enabled,
|
|
295
|
+
controlScroll: next.controlScroll,
|
|
295
296
|
thinkAutoExpand: next.thinkAutoExpand,
|
|
296
|
-
autoCollapse: next.autoCollapse,
|
|
297
297
|
...hasDebug ? {
|
|
298
298
|
debugEnabled: next.debugEnabled,
|
|
299
299
|
debugTuning: next.debugTuning
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-smooth-stream",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Fluid streaming rendering and silky scrolling for DeepSeek Harness replies, including Markdown, code blocks, tables, and tool results.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -81,12 +81,18 @@
|
|
|
81
81
|
"scripts": {
|
|
82
82
|
"benchmark": "node scripts/run-stream-benchmark.mjs",
|
|
83
83
|
"build": "tsdown",
|
|
84
|
+
"dev:watch": "tsdown --watch",
|
|
85
|
+
"host:sync": "pnpm build && node scripts/check-lib-fresh.mjs",
|
|
84
86
|
"build:repro": "esbuild repro/main.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --alias:@deepseek-ai/dsh-client-runtime=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-runtime/client=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-ui-primitives=./repro/shims/primitives.tsx --outfile=repro/bundle.js",
|
|
87
|
+
"build:stress": "esbuild repro/stress-120fps.tsx --bundle --format=esm --platform=browser --loader:.css=local-css --alias:@deepseek-ai/dsh-client-runtime=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-runtime/client=./repro/shims/client-runtime.ts --alias:@deepseek-ai/dsh-client-ui-primitives=./repro/shims/primitives.tsx --outfile=repro/stress-120fps.bundle.js",
|
|
88
|
+
"stress:120fps": "node scripts/stress-test-120fps.mjs",
|
|
89
|
+
"test:rebound": "node scripts/verify-y-rebound.mjs",
|
|
85
90
|
"prepare": "tsdown",
|
|
86
91
|
"test": "vitest run",
|
|
87
92
|
"typecheck": "tsc -p tsconfig.json --pretty false",
|
|
88
|
-
"test:smooth": "node scripts/probe-tailbob.mjs 1200 20 && node scripts/verify-overflow.mjs --runs 3",
|
|
89
|
-
"probe:overlap": "node scripts/probe-overlap.mjs"
|
|
93
|
+
"test:smooth": "node scripts/verify-y-rebound.mjs && node scripts/probe-tailbob.mjs 1200 20 && node scripts/verify-overflow.mjs --runs 3",
|
|
94
|
+
"probe:overlap": "node scripts/probe-overlap.mjs",
|
|
95
|
+
"probe:stability": "node scripts/verify-follow-stability.mjs"
|
|
90
96
|
},
|
|
91
97
|
"peerDependencies": {
|
|
92
98
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
@@ -139,12 +145,12 @@
|
|
|
139
145
|
"devDependencies": {
|
|
140
146
|
"@deepseek-ai/cordis": "4.0.1",
|
|
141
147
|
"@deepseek-ai/dsh-client-connection": "0.1.0-rc.6",
|
|
142
|
-
"@deepseek-ai/dsh-client-locale": "0.1.
|
|
148
|
+
"@deepseek-ai/dsh-client-locale": "0.1.2-alpha.3",
|
|
143
149
|
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
|
|
144
150
|
"@deepseek-ai/dsh-client-store": "0.1.2-alpha.3",
|
|
145
151
|
"@deepseek-ai/dsh-client-ui-attachment": "0.1.0-rc.6",
|
|
146
152
|
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.6",
|
|
147
|
-
"@deepseek-ai/dsh-client-ui-primitives": "0.1.
|
|
153
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.3",
|
|
148
154
|
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.6",
|
|
149
155
|
"@deepseek-ai/dsh-client-ui-settings-plugins": "0.1.0-rc.6",
|
|
150
156
|
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Opaque Agent rows (tools, retries, context, etc.) cannot reuse the
|
|
3
|
+
* assistant's character queue. This is a single compositor-friendly reveal:
|
|
4
|
+
* the row arrives with a short rise/fade and a left-to-right mask, echoing the
|
|
5
|
+
* assistant's reading cadence without re-typing status text or countdowns.
|
|
6
|
+
*/
|
|
7
|
+
.surface {
|
|
8
|
+
min-width: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.surface[data-entrance='active'] {
|
|
12
|
+
animation: dsh-smooth-stream-agent-row-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
13
|
+
will-change: opacity, transform;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@keyframes dsh-smooth-stream-agent-row-in {
|
|
17
|
+
0% {
|
|
18
|
+
opacity: 0;
|
|
19
|
+
transform: translate3d(0, 4px, 0);
|
|
20
|
+
clip-path: inset(0 100% 0 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
55% {
|
|
24
|
+
opacity: 1;
|
|
25
|
+
clip-path: inset(0 18% 0 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
100% {
|
|
29
|
+
opacity: 1;
|
|
30
|
+
transform: translate3d(0, 0, 0);
|
|
31
|
+
clip-path: inset(0 0 0 0);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@media (prefers-reduced-motion: reduce) {
|
|
36
|
+
.surface[data-entrance='active'] {
|
|
37
|
+
animation: none;
|
|
38
|
+
opacity: 1;
|
|
39
|
+
transform: none;
|
|
40
|
+
clip-path: none;
|
|
41
|
+
will-change: auto;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -19,7 +19,10 @@ export function FollowHost({
|
|
|
19
19
|
revealScaleRef,
|
|
20
20
|
predictive = true,
|
|
21
21
|
predictiveRef,
|
|
22
|
+
controlScroll = true,
|
|
22
23
|
hostRef,
|
|
24
|
+
className,
|
|
25
|
+
entranceActive,
|
|
23
26
|
children,
|
|
24
27
|
}: {
|
|
25
28
|
active: boolean
|
|
@@ -32,8 +35,13 @@ export function FollowHost({
|
|
|
32
35
|
revealScaleRef?: { current: number } | undefined
|
|
33
36
|
predictive?: boolean
|
|
34
37
|
predictiveRef?: { current: boolean } | undefined
|
|
38
|
+
/** False leaves conversation bottom-follow to the Host. */
|
|
39
|
+
controlScroll?: boolean
|
|
35
40
|
hostRef?: { current: HTMLDivElement | null } | undefined
|
|
36
41
|
children: ReactNode
|
|
42
|
+
className?: string | undefined
|
|
43
|
+
/** Optional marker for a generic row's one-shot entrance animation. */
|
|
44
|
+
entranceActive?: boolean | undefined
|
|
37
45
|
}) {
|
|
38
46
|
const localRootRef = useRef<HTMLDivElement>(null)
|
|
39
47
|
const rootRef = hostRef ?? localRootRef
|
|
@@ -48,6 +56,7 @@ export function FollowHost({
|
|
|
48
56
|
predictiveRef,
|
|
49
57
|
entranceExtentRef,
|
|
50
58
|
revealedCharsRef,
|
|
59
|
+
controlScroll,
|
|
51
60
|
)
|
|
52
61
|
useEffect(() => {
|
|
53
62
|
if (onGrowth === undefined || typeof ResizeObserver === 'undefined') return
|
|
@@ -79,5 +88,13 @@ export function FollowHost({
|
|
|
79
88
|
if (growthFrame !== null) cancelAnimationFrame(growthFrame)
|
|
80
89
|
}
|
|
81
90
|
}, [onGrowth])
|
|
82
|
-
return
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
ref={rootRef}
|
|
94
|
+
className={className === undefined ? css.follow : `${css.follow} ${className}`}
|
|
95
|
+
data-entrance={entranceActive === undefined ? undefined : entranceActive ? 'active' : 'idle'}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
83
100
|
}
|
|
@@ -73,29 +73,29 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
|
73
73
|
</label>
|
|
74
74
|
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
75
75
|
<span className={css.fieldHead}>
|
|
76
|
-
<span className={css.label}>{t('
|
|
76
|
+
<span className={css.label}>{t('controlScroll')}</span>
|
|
77
77
|
<input
|
|
78
78
|
type="checkbox"
|
|
79
79
|
className={css.toggle}
|
|
80
|
-
checked={state.
|
|
80
|
+
checked={state.controlScroll}
|
|
81
81
|
disabled={!state.writable || state.saving || !state.enabled}
|
|
82
|
-
onChange={(event) => { props.edit({
|
|
82
|
+
onChange={(event) => { props.edit({ controlScroll: event.target.checked }) }}
|
|
83
83
|
/>
|
|
84
84
|
</span>
|
|
85
|
-
<span className={css.hint}>{t('
|
|
85
|
+
<span className={css.hint}>{t('controlScrollHint')}</span>
|
|
86
86
|
</label>
|
|
87
|
-
<label className={css.field}>
|
|
87
|
+
<label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
88
88
|
<span className={css.fieldHead}>
|
|
89
|
-
<span className={css.label}>{t('
|
|
89
|
+
<span className={css.label}>{t('thinkAutoExpand')}</span>
|
|
90
90
|
<input
|
|
91
91
|
type="checkbox"
|
|
92
92
|
className={css.toggle}
|
|
93
|
-
checked={state.
|
|
94
|
-
disabled={!state.writable || state.saving}
|
|
95
|
-
onChange={(event) => { props.edit({
|
|
93
|
+
checked={state.thinkAutoExpand}
|
|
94
|
+
disabled={!state.writable || state.saving || !state.enabled}
|
|
95
|
+
onChange={(event) => { props.edit({ thinkAutoExpand: event.target.checked }) }}
|
|
96
96
|
/>
|
|
97
97
|
</span>
|
|
98
|
-
<span className={css.hint}>{t('
|
|
98
|
+
<span className={css.hint}>{t('thinkAutoExpandHint')}</span>
|
|
99
99
|
</label>
|
|
100
100
|
<label className={state.debugAvailable ? css.field : `${css.field} ${css.fieldDisabled}`}>
|
|
101
101
|
<span className={css.fieldHead}>
|
|
@@ -12,7 +12,7 @@ import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
|
|
|
12
12
|
import css from './TypewriterAssistantNodeView.module.css'
|
|
13
13
|
|
|
14
14
|
type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
15
|
-
type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, '
|
|
15
|
+
type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'labels' | 'fileMentions' | 'text'>
|
|
16
16
|
|
|
17
17
|
function usePrefersReducedMotion(): boolean {
|
|
18
18
|
const [reduced, setReduced] = useState(
|
|
@@ -38,6 +38,7 @@ interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
|
38
38
|
onPredictiveChange?: ((predictive: boolean) => void) | undefined
|
|
39
39
|
preset: StreamSmoothingPreset
|
|
40
40
|
shouldHoldBack: () => boolean
|
|
41
|
+
controlScroll?: boolean
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/** Conservative fallback before the streaming Markdown tail has geometry. */
|
|
@@ -286,7 +287,7 @@ const StreamAnnouncement = memo(function StreamAnnouncement({
|
|
|
286
287
|
*/
|
|
287
288
|
function AnimatedMarkdownText({
|
|
288
289
|
text,
|
|
289
|
-
|
|
290
|
+
labels,
|
|
290
291
|
fileMentions,
|
|
291
292
|
streaming,
|
|
292
293
|
ownFollow,
|
|
@@ -296,6 +297,7 @@ function AnimatedMarkdownText({
|
|
|
296
297
|
onPredictiveChange,
|
|
297
298
|
preset,
|
|
298
299
|
shouldHoldBack,
|
|
300
|
+
controlScroll = true,
|
|
299
301
|
}: AnimatedMarkdownTextProps) {
|
|
300
302
|
const reduced = usePrefersReducedMotion()
|
|
301
303
|
const [typing, setTyping] = useState(streaming)
|
|
@@ -355,6 +357,7 @@ function AnimatedMarkdownText({
|
|
|
355
357
|
revealedCharsRef={followRevealedCharsRef}
|
|
356
358
|
revealScaleRef={followRevealScaleRef}
|
|
357
359
|
predictive={streaming}
|
|
360
|
+
controlScroll={controlScroll}
|
|
358
361
|
hostRef={followRootRef}
|
|
359
362
|
>
|
|
360
363
|
<MarkdownText
|
|
@@ -363,7 +366,7 @@ function AnimatedMarkdownText({
|
|
|
363
366
|
// Rendering `text` early bypasses the drain and teleports the tail.
|
|
364
367
|
text={live ? shown : text}
|
|
365
368
|
streaming={live}
|
|
366
|
-
|
|
369
|
+
labels={labels}
|
|
367
370
|
fileMentions={live ? undefined : fileMentions}
|
|
368
371
|
/>
|
|
369
372
|
</FollowHost>
|
|
@@ -514,6 +517,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
514
517
|
scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
|
|
515
518
|
maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
|
|
516
519
|
thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
520
|
+
controlScroll = true,
|
|
517
521
|
node,
|
|
518
522
|
useTurnData,
|
|
519
523
|
openFile,
|
|
@@ -527,6 +531,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
527
531
|
scrollSpeedPxPerSec?: number
|
|
528
532
|
maxScrollSpeedPxPerSec?: number
|
|
529
533
|
thinkAutoExpand?: boolean
|
|
534
|
+
controlScroll?: boolean
|
|
530
535
|
}) {
|
|
531
536
|
const data = node.data
|
|
532
537
|
const streaming = data.status === 'running'
|
|
@@ -568,7 +573,10 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
568
573
|
() => owner === undefined ? undefined : fileMentions(owner),
|
|
569
574
|
[fileMentions, owner],
|
|
570
575
|
)
|
|
571
|
-
const
|
|
576
|
+
const markdownLabels = useMemo(() => ({
|
|
577
|
+
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
|
|
578
|
+
footnotes: t('markdown.footnotes'),
|
|
579
|
+
}), [t])
|
|
572
580
|
const imageLoader: ImageLoader = loadImage ?? (async () => {
|
|
573
581
|
throw new Error(t('image.serviceUnavailable'))
|
|
574
582
|
})
|
|
@@ -597,7 +605,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
597
605
|
<AnimatedMarkdownText
|
|
598
606
|
key={index}
|
|
599
607
|
text={block.text}
|
|
600
|
-
|
|
608
|
+
labels={markdownLabels}
|
|
601
609
|
fileMentions={mentions}
|
|
602
610
|
streaming={streaming}
|
|
603
611
|
ownFollow={!streaming && index === lastFollow}
|
|
@@ -607,6 +615,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
607
615
|
onPredictiveChange={index === lastFollow ? updateTextPrediction : undefined}
|
|
608
616
|
preset={preset}
|
|
609
617
|
shouldHoldBack={shouldHoldBack}
|
|
618
|
+
controlScroll={controlScroll}
|
|
610
619
|
/>,
|
|
611
620
|
)
|
|
612
621
|
break
|
|
@@ -663,6 +672,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
663
672
|
revealedCharsRef={rootRevealedCharsRef}
|
|
664
673
|
revealScaleRef={rootRevealScaleRef}
|
|
665
674
|
predictiveRef={rootPredictiveRef}
|
|
675
|
+
controlScroll={controlScroll}
|
|
666
676
|
>
|
|
667
677
|
<div className={css.body}>
|
|
668
678
|
{rendered}
|
|
@@ -2,6 +2,7 @@ import { createElement, useCallback, useEffect, useLayoutEffect, useRef, useStat
|
|
|
2
2
|
import { FollowHost } from './FollowHost.tsx'
|
|
3
3
|
import { useProgressiveDomText } from './useProgressiveDomText.ts'
|
|
4
4
|
import { hasRecentConversationFollow } from './teleprompterGlide.ts'
|
|
5
|
+
import entranceCss from './AgentRowEntrance.module.css'
|
|
5
6
|
|
|
6
7
|
/** Props forwarded through a follow wrap; extra kit seats pass through. */
|
|
7
8
|
export type FollowWrapProps = {
|
|
@@ -118,8 +119,12 @@ function liveAgentTailMode(root: HTMLElement): 'turn' | 'handoff' | null {
|
|
|
118
119
|
* @param Inner - The already-registered row component.
|
|
119
120
|
* @returns A follow-hosted row.
|
|
120
121
|
*/
|
|
121
|
-
export function wrapFollowNodeView(
|
|
122
|
+
export function wrapFollowNodeView(
|
|
123
|
+
Inner: ComponentType<FollowWrapProps>,
|
|
124
|
+
useControlScroll?: () => boolean,
|
|
125
|
+
) {
|
|
122
126
|
return function TypewriterFollowNodeView(props: FollowWrapProps) {
|
|
127
|
+
const controlScroll = useControlScroll?.() ?? true
|
|
123
128
|
const speedCpsRef = useRef(35)
|
|
124
129
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
125
130
|
const growing = isGrowingChatNode(props.node)
|
|
@@ -215,12 +220,15 @@ export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>) {
|
|
|
215
220
|
onGrowth={followable ? onGrowth : undefined}
|
|
216
221
|
entranceExtentRef={growthExtentRef}
|
|
217
222
|
speedCpsRef={speedCpsRef}
|
|
223
|
+
controlScroll={controlScroll}
|
|
218
224
|
// Generic Agent rows reveal and spring their measured growth, but do
|
|
219
225
|
// not continuously reserve space for a future Markdown line wrap.
|
|
220
226
|
// Keeping that assistant-only prediction here creates an idle gap
|
|
221
227
|
// above TurnStatus and a visible return when a short Tool row settles.
|
|
222
228
|
predictive={false}
|
|
223
229
|
hostRef={hostRef}
|
|
230
|
+
className={entranceCss.surface}
|
|
231
|
+
entranceActive={entering || growthPulse}
|
|
224
232
|
>
|
|
225
233
|
{createElement(Inner, props)}
|
|
226
234
|
</FollowHost>
|
|
@@ -13,29 +13,88 @@
|
|
|
13
13
|
|
|
14
14
|
type ClientRuntimeClient = typeof import('@deepseek-ai/dsh-client-runtime/client')
|
|
15
15
|
|
|
16
|
+
export interface SnapshotStoreLike<T> {
|
|
17
|
+
getSnapshot(): T
|
|
18
|
+
subscribe(listener: (state: T) => void): () => void
|
|
19
|
+
update(mutator: (draft: T) => void): void
|
|
20
|
+
set(next: T): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fallbackSnapshotStore<T extends object>(init: T): SnapshotStoreLike<T> {
|
|
24
|
+
let state = init
|
|
25
|
+
const listeners = new Set<(state: T) => void>()
|
|
26
|
+
return {
|
|
27
|
+
getSnapshot: () => state,
|
|
28
|
+
subscribe: listener => {
|
|
29
|
+
listeners.add(listener)
|
|
30
|
+
return () => { listeners.delete(listener) }
|
|
31
|
+
},
|
|
32
|
+
update: mutator => {
|
|
33
|
+
const draft = Object.create(
|
|
34
|
+
Object.getPrototypeOf(state),
|
|
35
|
+
Object.getOwnPropertyDescriptors(state),
|
|
36
|
+
) as T
|
|
37
|
+
mutator(draft)
|
|
38
|
+
state = draft
|
|
39
|
+
for (const listener of [...listeners]) listener(state)
|
|
40
|
+
},
|
|
41
|
+
set: next => {
|
|
42
|
+
state = next
|
|
43
|
+
for (const listener of [...listeners]) listener(state)
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
/**
|
|
17
49
|
* Runtime require: the module-loader factory scopes its own resolver over the
|
|
18
50
|
* booting kernel's table; under Node (vitest) there is no global require, so
|
|
19
51
|
* fall back to `createRequire`.
|
|
20
52
|
*/
|
|
21
|
-
function pickRequire(): NodeRequire {
|
|
22
|
-
if (typeof require === 'function')
|
|
53
|
+
function pickRequire(): NodeRequire | null {
|
|
54
|
+
if (typeof require === 'function') {
|
|
55
|
+
try {
|
|
56
|
+
return require
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
23
61
|
if (typeof process !== 'undefined' && typeof process.getBuiltinModule === 'function') {
|
|
24
|
-
|
|
25
|
-
|
|
62
|
+
try {
|
|
63
|
+
const { createRequire } = process.getBuiltinModule('node:module')
|
|
64
|
+
return createRequire(process.cwd() + '/package.json')
|
|
65
|
+
} catch {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
26
68
|
}
|
|
27
|
-
|
|
69
|
+
return null
|
|
28
70
|
}
|
|
29
71
|
|
|
30
|
-
let clientStore: ClientRuntimeClient
|
|
72
|
+
let clientStore: Partial<ClientRuntimeClient> | undefined
|
|
31
73
|
|
|
32
74
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
75
|
+
const req = pickRequire()
|
|
76
|
+
if (req !== null) {
|
|
77
|
+
try {
|
|
78
|
+
// 0.1.2+ seeds the split store package directly into the module table.
|
|
79
|
+
clientStore = req('@deepseek-ai/dsh-client-store') as ClientRuntimeClient
|
|
80
|
+
} catch {
|
|
81
|
+
try {
|
|
82
|
+
// ≤ 0.1.1 owns the store engine on the client-runtime client face.
|
|
83
|
+
clientStore = req('@deepseek-ai/dsh-client-runtime/client') as ClientRuntimeClient
|
|
84
|
+
} catch {
|
|
85
|
+
// Fallback below
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
35
89
|
} catch {
|
|
36
|
-
//
|
|
37
|
-
|
|
90
|
+
// Fallback below
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SnapshotStore<T> {
|
|
94
|
+
getSnapshot(): T
|
|
95
|
+
subscribe(listener: () => void): () => void
|
|
96
|
+
update(mutator: (draft: T) => void): void
|
|
97
|
+
set(next: T): void
|
|
38
98
|
}
|
|
39
99
|
|
|
40
|
-
export const createSnapshotStore = clientStore
|
|
41
|
-
export type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
100
|
+
export const createSnapshotStore = (clientStore?.createSnapshotStore ?? fallbackSnapshotStore) as <T extends object>(init: T) => SnapshotStore<T>
|
package/src/client/index.ts
CHANGED
|
@@ -7,7 +7,6 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client
|
|
|
7
7
|
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
8
8
|
import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
9
9
|
import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
|
|
10
|
-
import { AutoCollapseController } from './auto-collapse-controller.ts'
|
|
11
10
|
import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
|
|
12
11
|
import { SmoothStreamCard } from './SmoothStreamCard.tsx'
|
|
13
12
|
import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
|
|
@@ -88,7 +87,10 @@ function readBootConfig(): StreamConfig {
|
|
|
88
87
|
* @param ctx - Browser context carrying the slot registry.
|
|
89
88
|
* @returns Restorer that puts the original components back.
|
|
90
89
|
*/
|
|
91
|
-
function wrapAgentChatRows(
|
|
90
|
+
function wrapAgentChatRows(
|
|
91
|
+
ctx: ClientContext,
|
|
92
|
+
useControlScroll: () => boolean,
|
|
93
|
+
): () => void {
|
|
92
94
|
const restores: Array<() => void> = []
|
|
93
95
|
const wrapped = new WeakSet<object>()
|
|
94
96
|
|
|
@@ -99,7 +101,7 @@ function wrapAgentChatRows(ctx: ClientContext): () => void {
|
|
|
99
101
|
const current = entry.component
|
|
100
102
|
if (!isWrappableComponent(current) || wrapped.has(current)) continue
|
|
101
103
|
const inner = current as ComponentType<FollowWrapProps>
|
|
102
|
-
const next = wrapFollowNodeView(inner)
|
|
104
|
+
const next = wrapFollowNodeView(inner, useControlScroll)
|
|
103
105
|
wrapped.add(next)
|
|
104
106
|
entry.component = next
|
|
105
107
|
restores.push(() => {
|
|
@@ -154,8 +156,8 @@ class SettingsCell {
|
|
|
154
156
|
if (
|
|
155
157
|
pending === this.pending
|
|
156
158
|
&& next.enabled === this.value.enabled
|
|
159
|
+
&& next.controlScroll === this.value.controlScroll
|
|
157
160
|
&& next.thinkAutoExpand === this.value.thinkAutoExpand
|
|
158
|
-
&& next.autoCollapse === this.value.autoCollapse
|
|
159
161
|
&& next.debugEnabled === this.value.debugEnabled
|
|
160
162
|
&& next.debugTuning === this.value.debugTuning
|
|
161
163
|
) return
|
|
@@ -169,11 +171,6 @@ class SettingsCell {
|
|
|
169
171
|
return !this.pending && this.value.enabled
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
/** Whether finished turns should fold behind a summary row right now. */
|
|
173
|
-
autoCollapseActive(): boolean {
|
|
174
|
-
return !this.pending && this.value.autoCollapse
|
|
175
|
-
}
|
|
176
|
-
|
|
177
174
|
readonly getSnapshot = (): StreamSettings => this.value
|
|
178
175
|
|
|
179
176
|
readonly subscribe = (listener: () => void): () => void => {
|
|
@@ -196,25 +193,11 @@ class SettingsCell {
|
|
|
196
193
|
export function apply(ctx: ClientContext): void {
|
|
197
194
|
const config = readBootConfig()
|
|
198
195
|
const settings = new SettingsCell()
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// flipping the preference restores every folded row immediately.
|
|
205
|
-
ctx.inject(['slots'], () => {
|
|
206
|
-
const autoCollapse = new AutoCollapseController()
|
|
207
|
-
const syncAutoCollapse = (): void => {
|
|
208
|
-
if (settings.autoCollapseActive()) autoCollapse.start()
|
|
209
|
-
else autoCollapse.stop()
|
|
210
|
-
}
|
|
211
|
-
const unsubscribe = settings.subscribe(syncAutoCollapse)
|
|
212
|
-
syncAutoCollapse()
|
|
213
|
-
return () => {
|
|
214
|
-
unsubscribe()
|
|
215
|
-
autoCollapse.stop()
|
|
216
|
-
}
|
|
217
|
-
})
|
|
196
|
+
const useControlScroll = (): boolean => useSyncExternalStore(
|
|
197
|
+
settings.subscribe,
|
|
198
|
+
() => settings.getSnapshot().controlScroll,
|
|
199
|
+
() => settings.getSnapshot().controlScroll,
|
|
200
|
+
)
|
|
218
201
|
|
|
219
202
|
// The card talks to the plugin-owned loopback RPC, so the core settings
|
|
220
203
|
// namespace allowlist cannot make it disappear. The stream still applies
|
|
@@ -286,6 +269,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
286
269
|
scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
|
|
287
270
|
maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
|
|
288
271
|
thinkAutoExpand: preferences.thinkAutoExpand,
|
|
272
|
+
controlScroll: preferences.controlScroll,
|
|
289
273
|
})
|
|
290
274
|
}
|
|
291
275
|
ctx.slots.inject('conversation.chat.node', () => {
|
|
@@ -298,7 +282,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
298
282
|
return
|
|
299
283
|
}
|
|
300
284
|
if (releaseTakeover !== undefined) return
|
|
301
|
-
const unwrap = wrapAgentChatRows(ctx)
|
|
285
|
+
const unwrap = wrapAgentChatRows(ctx, useControlScroll)
|
|
302
286
|
const unshadow = ctx.slots.register({
|
|
303
287
|
name: 'conversation.chat.node',
|
|
304
288
|
key: 'assistant-step',
|
package/src/client/locales.ts
CHANGED
|
@@ -7,8 +7,8 @@ export const NS = 'settings.smoothStream'
|
|
|
7
7
|
export type SmoothStreamLocaleKey =
|
|
8
8
|
| 'title' | 'description'
|
|
9
9
|
| 'enabled' | 'enabledHint'
|
|
10
|
+
| 'controlScroll' | 'controlScrollHint'
|
|
10
11
|
| 'thinkAutoExpand' | 'thinkAutoExpandHint'
|
|
11
|
-
| 'autoCollapse' | 'autoCollapseHint'
|
|
12
12
|
| 'debugEnabled' | 'debugEnabledHint' | 'debugUnavailable'
|
|
13
13
|
| 'debugPanelTitle' | 'debugPanelToggle' | 'debugPanelClose' | 'debugGuide'
|
|
14
14
|
| 'debugLive' | 'debugIdle' | 'debugUnsaved'
|
|
@@ -35,10 +35,10 @@ export const en: Record<SmoothStreamLocaleKey, string> = {
|
|
|
35
35
|
description: 'How replies are revealed while they stream.',
|
|
36
36
|
enabled: 'Enable smooth streaming',
|
|
37
37
|
enabledHint: 'Let this plugin render and follow streaming replies. Turn off to use the built-in Harness renderer.',
|
|
38
|
+
controlScroll: 'Take over scrolling',
|
|
39
|
+
controlScrollHint: 'On by default; smooth-stream writes the conversation scroll position. Turn off to leave bottom-follow to Harness.',
|
|
38
40
|
thinkAutoExpand: 'Auto-expand thinking',
|
|
39
41
|
thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
|
|
40
|
-
autoCollapse: 'Collapse finished work',
|
|
41
|
-
autoCollapseHint: 'When a reply finishes, fold its thinking, tools, and intermediate output behind one “Processed” summary so only the final answer shows. Click the summary to expand again; works with streaming on or off.',
|
|
42
42
|
debugEnabled: 'Show render diagnostics',
|
|
43
43
|
debugEnabledHint: 'Show live streaming and scroll metrics on the right side of the chat. Tune values there, then save them here.',
|
|
44
44
|
debugUnavailable: 'Live diagnostics require a newer plugin Host.',
|
|
@@ -115,10 +115,10 @@ export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
|
115
115
|
description: '回复在流式输出时如何逐字展现。',
|
|
116
116
|
enabled: '启用丝滑流式渲染',
|
|
117
117
|
enabledHint: '由本插件渲染并跟随流式回复;关闭后使用 Harness 内置渲染。',
|
|
118
|
+
controlScroll: '接管滚动',
|
|
119
|
+
controlScrollHint: '默认开启,由丝滑流式写入会话滚动位置;关闭后贴底滚动交给 Harness。',
|
|
118
120
|
thinkAutoExpand: '自动展开思考',
|
|
119
121
|
thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
|
|
120
|
-
autoCollapse: '完成后自动折叠',
|
|
121
|
-
autoCollapseHint: '回复处理完成后,把思考、工具与过程输出折叠为一行“已处理”摘要,只展示最终回复;点击摘要可再次展开。开启或关闭流式渲染均可用,默认开启。',
|
|
122
122
|
debugEnabled: '显示渲染调试面板',
|
|
123
123
|
debugEnabledHint: '在聊天右侧显示流式渲染和滚动的实时参数,可在面板中调节并在这里保存。',
|
|
124
124
|
debugUnavailable: '当前 Host 版本不支持实时调试,请先更新插件 Host。',
|
|
@@ -22,8 +22,8 @@ export interface SmoothStreamCardState {
|
|
|
22
22
|
saving: boolean
|
|
23
23
|
failed: boolean
|
|
24
24
|
enabled: boolean
|
|
25
|
+
controlScroll: boolean
|
|
25
26
|
thinkAutoExpand: boolean
|
|
26
|
-
autoCollapse: boolean
|
|
27
27
|
debugEnabled: boolean
|
|
28
28
|
debugTuning: StreamDebugTuning
|
|
29
29
|
debugAvailable: boolean
|
|
@@ -52,7 +52,7 @@ export class SmoothStreamCardController {
|
|
|
52
52
|
private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
|
|
53
53
|
private loaded: StreamSettingsView | undefined
|
|
54
54
|
private loadedDebug: StreamDebugSettingsView | undefined
|
|
55
|
-
private stagedBase: Pick<StreamSettings, 'enabled' | '
|
|
55
|
+
private stagedBase: Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> | undefined
|
|
56
56
|
private stagedDebug: Pick<StreamSettings, 'debugEnabled' | 'debugTuning'> | undefined
|
|
57
57
|
private saving = false
|
|
58
58
|
private failed = false
|
|
@@ -90,12 +90,12 @@ export class SmoothStreamCardController {
|
|
|
90
90
|
hooks: { smoothStreamCard: this.store },
|
|
91
91
|
edit: (patch) => {
|
|
92
92
|
if (this.saving) return
|
|
93
|
-
if (patch.enabled !== undefined || patch.
|
|
93
|
+
if (patch.enabled !== undefined || patch.controlScroll !== undefined || patch.thinkAutoExpand !== undefined) {
|
|
94
94
|
this.stagedBase = {
|
|
95
95
|
...this.baseValues(),
|
|
96
96
|
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
97
|
+
...(patch.controlScroll === undefined ? {} : { controlScroll: patch.controlScroll }),
|
|
97
98
|
...(patch.thinkAutoExpand === undefined ? {} : { thinkAutoExpand: patch.thinkAutoExpand }),
|
|
98
|
-
...(patch.autoCollapse === undefined ? {} : { autoCollapse: patch.autoCollapse }),
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
if (this.loadedDebug !== undefined && (patch.debugEnabled !== undefined || patch.debugTuning !== undefined)) {
|
|
@@ -140,11 +140,11 @@ export class SmoothStreamCardController {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
private baseValues(): Pick<StreamSettings, 'enabled' | '
|
|
143
|
+
private baseValues(): Pick<StreamSettings, 'enabled' | 'controlScroll' | 'thinkAutoExpand'> {
|
|
144
144
|
return this.stagedBase ?? {
|
|
145
145
|
enabled: this.loaded?.enabled ?? DEFAULT_STREAM_SETTINGS.enabled,
|
|
146
|
+
controlScroll: this.loaded?.controlScroll ?? DEFAULT_STREAM_SETTINGS.controlScroll,
|
|
146
147
|
thinkAutoExpand: this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
147
|
-
autoCollapse: this.loaded?.autoCollapse ?? DEFAULT_STREAM_SETTINGS.autoCollapse,
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|