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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smooth-stream plugin configuration card, rendered inside the Web
|
|
3
|
+
* Settings "plugin configuration" page. One toggle, staged until the user
|
|
4
|
+
* saves — the same shape as the Host-shipped cards, hand-drawn because the
|
|
5
|
+
* Host cards' chrome is not exported for reuse.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
10
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
11
|
+
import { IconChevronDownOutline14, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
12
|
+
import type { SmoothStreamCardFace } from './smooth-stream-card-controller.ts'
|
|
13
|
+
import css from './SmoothStreamCard.module.css'
|
|
14
|
+
|
|
15
|
+
/** Props the renderer binds for the smooth-stream card. */
|
|
16
|
+
export type SmoothStreamCardProps =
|
|
17
|
+
PropsRuntime<'settings.plugin.item'>
|
|
18
|
+
& PropsLocale<'settings.smoothStream'>
|
|
19
|
+
& InjectFace<SmoothStreamCardFace>
|
|
20
|
+
|
|
21
|
+
/** Render the smooth-stream card independently of the core settings namespace allowlist. */
|
|
22
|
+
export function SmoothStreamCard(props: SmoothStreamCardProps) {
|
|
23
|
+
const { t } = props
|
|
24
|
+
const [open, setOpen] = useState(false)
|
|
25
|
+
const state = props.useSmoothStreamCard(snapshot => snapshot)
|
|
26
|
+
const blocked = !state.dirty || state.saving || state.status !== 'ready'
|
|
27
|
+
const versionLabel = state.version === undefined
|
|
28
|
+
? null
|
|
29
|
+
: t(state.installation === 'development' ? 'developmentVersion' : 'version')
|
|
30
|
+
.replace('{version}', state.version)
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<li className={open ? `${css.card} ${css.cardOpen}` : css.card}>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
className={css.header}
|
|
37
|
+
aria-expanded={open}
|
|
38
|
+
onClick={() => { setOpen(!open) }}
|
|
39
|
+
>
|
|
40
|
+
<span className={css.headText}>
|
|
41
|
+
<span className={css.name}>{t('title')}</span>
|
|
42
|
+
<span className={css.description}>{t('description')}</span>
|
|
43
|
+
</span>
|
|
44
|
+
{versionLabel === null ? null : <span className={css.version}>{versionLabel}</span>}
|
|
45
|
+
{state.dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
|
|
46
|
+
<IconChevronDownOutline14 className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
|
|
47
|
+
</button>
|
|
48
|
+
{open
|
|
49
|
+
? (
|
|
50
|
+
<div className={css.body}>
|
|
51
|
+
{state.status === 'loading' ? <p className={css.readOnly} role="status">{t('loading')}</p> : null}
|
|
52
|
+
{state.status === 'unavailable' ? (
|
|
53
|
+
<div className={css.failure}>
|
|
54
|
+
<p className={css.readOnly} role="status">{t('unavailable')}</p>
|
|
55
|
+
<button type="button" className={css.discard} onClick={props.reload}>{t('retry')}</button>
|
|
56
|
+
</div>
|
|
57
|
+
) : null}
|
|
58
|
+
{state.status === 'ready' ? (
|
|
59
|
+
<>
|
|
60
|
+
{!state.writable ? <p className={css.readOnly} role="status">{t('readOnly')}</p> : null}
|
|
61
|
+
<label className={css.field}>
|
|
62
|
+
<span className={css.fieldHead}>
|
|
63
|
+
<span className={css.label}>{t('thinkAutoExpand')}</span>
|
|
64
|
+
<input
|
|
65
|
+
type="checkbox"
|
|
66
|
+
className={css.toggle}
|
|
67
|
+
checked={state.thinkAutoExpand}
|
|
68
|
+
disabled={!state.writable}
|
|
69
|
+
onChange={(event) => { props.edit(event.target.checked) }}
|
|
70
|
+
/>
|
|
71
|
+
</span>
|
|
72
|
+
<span className={css.hint}>{t('thinkAutoExpandHint')}</span>
|
|
73
|
+
</label>
|
|
74
|
+
<div className={css.updateRow}>
|
|
75
|
+
<span className={css.updateCopy}>
|
|
76
|
+
<span className={css.label}>{t('updates')}</span>
|
|
77
|
+
<span className={css.hint}>
|
|
78
|
+
{state.restartRequired
|
|
79
|
+
? t('restartRequired')
|
|
80
|
+
: state.installation === 'npm' ? t('updateHint')
|
|
81
|
+
: state.installation === 'development' ? t('developmentBuild') : t('updateUnavailable')}
|
|
82
|
+
</span>
|
|
83
|
+
</span>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
className={css.update}
|
|
87
|
+
disabled={!state.canUpgrade || state.upgrading || state.restartRequired}
|
|
88
|
+
title={state.canUpgrade ? undefined : t('updateUnavailable')}
|
|
89
|
+
onClick={props.upgrade}
|
|
90
|
+
>
|
|
91
|
+
<span aria-hidden="true"><IconRefreshOutline14 /></span>
|
|
92
|
+
{t(state.upgrading ? 'updating' : 'update')}
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
{state.upgradeFailed ? <p className={css.failed} role="status">{t('updateFailed')}</p> : null}
|
|
96
|
+
<div className={css.footer}>
|
|
97
|
+
{state.failed ? <p className={css.failed} role="status">{t('saveFailed')}</p> : null}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className={css.discard}
|
|
101
|
+
disabled={!state.dirty || state.saving}
|
|
102
|
+
onClick={props.discard}
|
|
103
|
+
>
|
|
104
|
+
{t('discard')}
|
|
105
|
+
</button>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
className={css.save}
|
|
109
|
+
disabled={blocked}
|
|
110
|
+
onClick={props.save}
|
|
111
|
+
>
|
|
112
|
+
{t(state.saving ? 'saving' : 'save')}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</>
|
|
116
|
+
) : null}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
: null}
|
|
120
|
+
</li>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -218,8 +218,8 @@
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/*
|
|
221
|
-
* Follow host: ordinary document flow. The
|
|
222
|
-
*
|
|
221
|
+
* Follow host: ordinary document flow. The assistant node owns follow during
|
|
222
|
+
* streaming; the last text block takes over while its settled queue drains
|
|
223
223
|
* (see useConversationFollow).
|
|
224
224
|
*/
|
|
225
225
|
.follow {
|
|
@@ -7,6 +7,7 @@ import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothS
|
|
|
7
7
|
import { useFpsGuard } from './useFpsGuard.ts'
|
|
8
8
|
import { FollowHost } from './FollowHost.tsx'
|
|
9
9
|
import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
|
|
10
|
+
import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
|
|
10
11
|
import css from './TypewriterAssistantNodeView.module.css'
|
|
11
12
|
|
|
12
13
|
type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
@@ -125,31 +126,36 @@ function AnimatedReasoning({
|
|
|
125
126
|
text,
|
|
126
127
|
running,
|
|
127
128
|
preset,
|
|
129
|
+
thinkAutoExpand,
|
|
128
130
|
shouldHoldBack,
|
|
131
|
+
followSpeedCpsRef,
|
|
129
132
|
t,
|
|
130
133
|
}: {
|
|
131
134
|
text: string
|
|
132
135
|
running: boolean
|
|
133
136
|
preset: StreamSmoothingPreset
|
|
137
|
+
thinkAutoExpand: boolean
|
|
134
138
|
shouldHoldBack: () => boolean
|
|
139
|
+
followSpeedCpsRef?: { current: number } | undefined
|
|
135
140
|
t: AssistantProps['t']
|
|
136
141
|
}) {
|
|
137
142
|
const reduced = usePrefersReducedMotion()
|
|
138
|
-
const [expanded, setExpanded] = useState(running)
|
|
143
|
+
const [expanded, setExpanded] = useState(running && thinkAutoExpand)
|
|
139
144
|
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
140
|
-
const speedCpsRef = useRef(35)
|
|
141
145
|
const displayed = useSmoothStreamContent(text, {
|
|
142
146
|
enabled: running && !reduced,
|
|
143
147
|
preset,
|
|
144
148
|
shouldHoldBack,
|
|
145
|
-
speedCpsRef,
|
|
149
|
+
speedCpsRef: followSpeedCpsRef,
|
|
146
150
|
})
|
|
147
151
|
const shown = running && !reduced ? displayed : text
|
|
148
152
|
const summary = running ? latestLine(shown) : firstLine(text)
|
|
149
153
|
|
|
150
154
|
useEffect(() => {
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
// Only the running state owns disclosure while auto-expand is on; with it
|
|
156
|
+
// off, a manual toggle is never wrestled back by the stream.
|
|
157
|
+
if (thinkAutoExpand) setExpanded(running)
|
|
158
|
+
}, [running, thinkAutoExpand])
|
|
153
159
|
|
|
154
160
|
useEffect(() => {
|
|
155
161
|
const element = summaryRef.current
|
|
@@ -157,8 +163,9 @@ function AnimatedReasoning({
|
|
|
157
163
|
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
|
|
158
164
|
}, [running, summary])
|
|
159
165
|
|
|
166
|
+
// Preserve FollowHost's layout wrapper without mounting a second scroll owner.
|
|
160
167
|
return (
|
|
161
|
-
<
|
|
168
|
+
<div className={css.follow}>
|
|
162
169
|
<div className={css.think} data-variant="think" data-state={running ? 'running' : 'ok'}>
|
|
163
170
|
{running && <span className={css.visuallyHidden}>{t('row.running')}</span>}
|
|
164
171
|
<AnimatedDisclosure
|
|
@@ -180,7 +187,7 @@ function AnimatedReasoning({
|
|
|
180
187
|
<div className={css.thinkBody}>{shown}</div>
|
|
181
188
|
</AnimatedDisclosure>
|
|
182
189
|
</div>
|
|
183
|
-
</
|
|
190
|
+
</div>
|
|
184
191
|
)
|
|
185
192
|
}
|
|
186
193
|
|
|
@@ -189,10 +196,10 @@ function AnimatedReasoning({
|
|
|
189
196
|
* streaming is revealed by the smoother through the Harness Markdown
|
|
190
197
|
* renderer at a rate that tracks arrival. Reasoning blocks keep the
|
|
191
198
|
* built-in Think disclosure and only receive a smoothed text feed; the
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* reveals when the frame rate is degraded. Settled text
|
|
195
|
-
* full Markdown pipeline.
|
|
199
|
+
* outer node owns conversation-port follow while streaming; the final text
|
|
200
|
+
* block keeps ownership while its settled reveal queue drains. The FPS guard
|
|
201
|
+
* holds offscreen reveals when the frame rate is degraded. Settled text
|
|
202
|
+
* renders with the full Markdown pipeline.
|
|
196
203
|
*/
|
|
197
204
|
export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
|
|
198
205
|
mode: _mode = DEFAULT_STREAM_CONFIG.mode,
|
|
@@ -200,6 +207,7 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
200
207
|
revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec,
|
|
201
208
|
scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
|
|
202
209
|
maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
|
|
210
|
+
thinkAutoExpand = DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
203
211
|
node,
|
|
204
212
|
useTurnData,
|
|
205
213
|
openFile,
|
|
@@ -212,11 +220,16 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
212
220
|
revealCharsPerSec?: number
|
|
213
221
|
scrollSpeedPxPerSec?: number
|
|
214
222
|
maxScrollSpeedPxPerSec?: number
|
|
223
|
+
thinkAutoExpand?: boolean
|
|
215
224
|
}) {
|
|
216
225
|
const data = node.data
|
|
217
226
|
const streaming = data.status === 'running'
|
|
218
227
|
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
219
228
|
const rootSpeedRef = useRef(35)
|
|
229
|
+
const reasoningOwnsSpeed = streaming && data.blocks[data.blocks.length - 1]?.kind === 'reasoning'
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (!reasoningOwnsSpeed) rootSpeedRef.current = 35
|
|
232
|
+
}, [reasoningOwnsSpeed])
|
|
220
233
|
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
|
221
234
|
? node.location.turn
|
|
222
235
|
: undefined
|
|
@@ -272,7 +285,9 @@ export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNode
|
|
|
272
285
|
text={block.text}
|
|
273
286
|
running={streaming && index === last}
|
|
274
287
|
preset={preset}
|
|
288
|
+
thinkAutoExpand={thinkAutoExpand}
|
|
275
289
|
shouldHoldBack={shouldHoldBack}
|
|
290
|
+
followSpeedCpsRef={reasoningOwnsSpeed && index === last ? rootSpeedRef : undefined}
|
|
276
291
|
t={t}
|
|
277
292
|
/>,
|
|
278
293
|
)
|
package/src/client/index.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
|
-
import { createElement, type ComponentType } from 'react'
|
|
1
|
+
import { createElement, useSyncExternalStore, type ComponentType } from 'react'
|
|
2
2
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
3
3
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
4
|
+
// Type-only: the connection Context merge and the plugins section's SlotMap
|
|
5
|
+
// entry ('settings.plugin.item').
|
|
6
|
+
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
4
8
|
import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
5
9
|
import { TypewriterAssistantNodeView } from './TypewriterAssistantNodeView.tsx'
|
|
6
10
|
import { wrapFollowNodeView, type FollowWrapProps } from './TypewriterToolNodeView.tsx'
|
|
11
|
+
import { SmoothStreamCard } from './SmoothStreamCard.tsx'
|
|
12
|
+
import { SmoothStreamCardController } from './smooth-stream-card-controller.ts'
|
|
13
|
+
import { createSmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
|
|
14
|
+
import { NS as SETTINGS_NS, en, zh } from './locales.ts'
|
|
7
15
|
import { DEFAULT_STREAM_CONFIG, STREAM_BOOT_GLOBAL, type StreamConfig } from '../config.ts'
|
|
16
|
+
import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
|
|
8
17
|
|
|
9
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Cordis services required by the browser half. Only `slots` is load-bearing
|
|
20
|
+
* for the stream itself; locale and Connection power the configuration card
|
|
21
|
+
* and are wired through `ctx.inject` below so a deployment without them still
|
|
22
|
+
* streams with defaults.
|
|
23
|
+
*/
|
|
10
24
|
export const inject = ['slots']
|
|
11
25
|
|
|
12
26
|
type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
@@ -85,17 +99,95 @@ function wrapGrowingChatRows(ctx: ClientContext): () => void {
|
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
101
|
|
|
102
|
+
/**
|
|
103
|
+
* A live preference cell read by `useSyncExternalStore`. It starts on the
|
|
104
|
+
* shared default and, once the plugin-owned settings controller is attached,
|
|
105
|
+
* tracks the resolved `thinkAutoExpand` value. A composition (or test) that
|
|
106
|
+
* ships no settings surface keeps the cell on the default.
|
|
107
|
+
*/
|
|
108
|
+
class PreferenceCell {
|
|
109
|
+
private readonly listeners = new Set<() => void>()
|
|
110
|
+
private card: SmoothStreamCardController | undefined
|
|
111
|
+
private value = DEFAULT_STREAM_SETTINGS.thinkAutoExpand
|
|
112
|
+
|
|
113
|
+
/** Re-point the cell at the plugin-owned settings controller. */
|
|
114
|
+
attach(card: SmoothStreamCardController): () => void {
|
|
115
|
+
this.card = card
|
|
116
|
+
this.refresh()
|
|
117
|
+
const unsubscribe = card.subscribe(() => { this.refresh() })
|
|
118
|
+
return () => {
|
|
119
|
+
unsubscribe()
|
|
120
|
+
if (this.card !== card) return
|
|
121
|
+
this.card = undefined
|
|
122
|
+
this.refresh()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private read(): boolean {
|
|
127
|
+
return this.card?.getSnapshot().thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private refresh(): void {
|
|
131
|
+
const next = this.read()
|
|
132
|
+
if (next === this.value) return
|
|
133
|
+
this.value = next
|
|
134
|
+
for (const listener of this.listeners) listener()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
readonly getSnapshot = (): boolean => this.value
|
|
138
|
+
|
|
139
|
+
readonly subscribe = (listener: () => void): () => void => {
|
|
140
|
+
this.listeners.add(listener)
|
|
141
|
+
return () => { this.listeners.delete(listener) }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
88
145
|
/**
|
|
89
146
|
* Register the typewriter renderer after the conversation package declares the
|
|
90
147
|
* keyed Chat node seat. A lower priority shadows the built-in assistant row;
|
|
91
148
|
* every other growing row is wrapped in place so Tool cards, retries, and
|
|
92
149
|
* workflow runs share conversation follow. The Host-bridged configuration
|
|
93
|
-
* selects the render direction, smoothing preset, and glide speed
|
|
150
|
+
* selects the render direction, smoothing preset, and glide speed; the
|
|
151
|
+
* plugin-owned settings RPC supplies the live auto-expand preference when the
|
|
152
|
+
* settings surface is composed.
|
|
94
153
|
* @param ctx - Browser context carrying the shared slot registry.
|
|
95
154
|
*/
|
|
96
155
|
export function apply(ctx: ClientContext): void {
|
|
97
156
|
const config = readBootConfig()
|
|
157
|
+
const preference = new PreferenceCell()
|
|
158
|
+
|
|
159
|
+
// The card talks to the plugin-owned loopback RPC, so the core settings
|
|
160
|
+
// namespace allowlist cannot make it disappear. The stream still applies
|
|
161
|
+
// with defaults when the optional Settings UI or Connection is absent.
|
|
162
|
+
ctx.inject(['slots', 'locale', 'connection'], (settingsCtx) => {
|
|
163
|
+
const card = new SmoothStreamCardController(
|
|
164
|
+
// The shared Context augmentation also carries the Host-side Connection
|
|
165
|
+
// shape. This browser entry runs after the client provider installs its
|
|
166
|
+
// handle, so narrow through unknown to its client contract here.
|
|
167
|
+
createSmoothStreamSettingsApi(settingsCtx.get('connection') as unknown as ConnectionHandle),
|
|
168
|
+
)
|
|
169
|
+
const detachPreference = preference.attach(card)
|
|
170
|
+
card.start()
|
|
171
|
+
settingsCtx.effect(() => settingsCtx.locale.register(SETTINGS_NS, { zh, en }), 'dsh-smooth-stream: settings dictionaries')
|
|
172
|
+
settingsCtx.slots.inject('settings.plugin.item', () => settingsCtx.slots.register({
|
|
173
|
+
name: 'settings.plugin.item',
|
|
174
|
+
id: 'smooth-stream',
|
|
175
|
+
order: 30,
|
|
176
|
+
locale: SETTINGS_NS,
|
|
177
|
+
inject: () => card.inject(),
|
|
178
|
+
}, SmoothStreamCard))
|
|
179
|
+
return () => {
|
|
180
|
+
card.stop()
|
|
181
|
+
detachPreference()
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
98
185
|
const configured = function StreamConfiguredView(props: AssistantProps) {
|
|
186
|
+
const thinkAutoExpand = useSyncExternalStore(
|
|
187
|
+
preference.subscribe,
|
|
188
|
+
preference.getSnapshot,
|
|
189
|
+
preference.getSnapshot,
|
|
190
|
+
)
|
|
99
191
|
return createElement(TypewriterAssistantNodeView, {
|
|
100
192
|
...props,
|
|
101
193
|
mode: config.mode,
|
|
@@ -103,6 +195,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
103
195
|
revealCharsPerSec: config.revealCharsPerSec,
|
|
104
196
|
scrollSpeedPxPerSec: config.scrollSpeedPxPerSec,
|
|
105
197
|
maxScrollSpeedPxPerSec: config.maxScrollSpeedPxPerSec,
|
|
198
|
+
thinkAutoExpand,
|
|
106
199
|
})
|
|
107
200
|
}
|
|
108
201
|
ctx.slots.inject('conversation.chat.node', () => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Locale bundles for the smooth-stream plugin configuration card. */
|
|
2
|
+
|
|
3
|
+
/** Dictionary namespace owned by this plugin's settings card. */
|
|
4
|
+
export const NS = 'settings.smoothStream'
|
|
5
|
+
|
|
6
|
+
/** Locale keys the card renders. */
|
|
7
|
+
export type SmoothStreamLocaleKey =
|
|
8
|
+
| 'title' | 'description'
|
|
9
|
+
| 'thinkAutoExpand' | 'thinkAutoExpandHint'
|
|
10
|
+
| 'readOnly' | 'loading' | 'unavailable' | 'retry'
|
|
11
|
+
| 'version' | 'developmentVersion'
|
|
12
|
+
| 'updates' | 'updateHint' | 'developmentBuild' | 'updateUnavailable'
|
|
13
|
+
| 'update' | 'updating' | 'restartRequired' | 'updateFailed'
|
|
14
|
+
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed'
|
|
15
|
+
|
|
16
|
+
/** English copy. */
|
|
17
|
+
export const en: Record<SmoothStreamLocaleKey, string> = {
|
|
18
|
+
title: 'Smooth stream',
|
|
19
|
+
description: 'How replies are revealed while they stream.',
|
|
20
|
+
thinkAutoExpand: 'Auto-expand thinking',
|
|
21
|
+
thinkAutoExpandHint: 'Open the thinking block while it streams. Turn off to keep it collapsed.',
|
|
22
|
+
readOnly: 'This deployment stores settings read-only.',
|
|
23
|
+
loading: 'Loading plugin settings…',
|
|
24
|
+
unavailable: 'Plugin settings are unavailable in this connection.',
|
|
25
|
+
retry: 'Retry',
|
|
26
|
+
version: 'Version {version}',
|
|
27
|
+
developmentVersion: 'Development version {version}',
|
|
28
|
+
updates: 'Updates',
|
|
29
|
+
updateHint: 'Install the newest npm version, then restart Harness.',
|
|
30
|
+
developmentBuild: 'Linked source; updates are managed in the checkout.',
|
|
31
|
+
updateUnavailable: 'Updates are available only for an npm profile installation.',
|
|
32
|
+
update: 'Update',
|
|
33
|
+
updating: 'Updating…',
|
|
34
|
+
restartRequired: 'Updated. Restart Harness to load the new version.',
|
|
35
|
+
updateFailed: 'The package update failed; your current version is unchanged.',
|
|
36
|
+
save: 'Save',
|
|
37
|
+
saving: 'Saving…',
|
|
38
|
+
discard: 'Discard',
|
|
39
|
+
unsaved: 'Unsaved',
|
|
40
|
+
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Simplified Chinese copy. */
|
|
44
|
+
export const zh: Record<SmoothStreamLocaleKey, string> = {
|
|
45
|
+
title: '丝滑流式',
|
|
46
|
+
description: '回复在流式输出时如何逐字展现。',
|
|
47
|
+
thinkAutoExpand: '自动展开思考',
|
|
48
|
+
thinkAutoExpandHint: '思考块在流式时自动展开;关闭后保持折叠,可手动展开。',
|
|
49
|
+
readOnly: '本部署的设置为只读。',
|
|
50
|
+
loading: '正在加载插件设置…',
|
|
51
|
+
unavailable: '当前连接无法访问插件设置。',
|
|
52
|
+
retry: '重试',
|
|
53
|
+
version: '版本 {version}',
|
|
54
|
+
developmentVersion: '开发版本 {version}',
|
|
55
|
+
updates: '更新',
|
|
56
|
+
updateHint: '安装最新 npm 版本后重启 Harness。',
|
|
57
|
+
developmentBuild: '当前为本地链接版本,请在源码目录管理更新。',
|
|
58
|
+
updateUnavailable: '只有 profile 使用 npm 包时才能更新。',
|
|
59
|
+
update: '更新',
|
|
60
|
+
updating: '更新中…',
|
|
61
|
+
restartRequired: '已更新;重启 Harness 后加载新版本。',
|
|
62
|
+
updateFailed: '包更新失败,当前版本未改变。',
|
|
63
|
+
save: '保存',
|
|
64
|
+
saving: '保存中…',
|
|
65
|
+
discard: '放弃修改',
|
|
66
|
+
unsaved: '未保存',
|
|
67
|
+
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
71
|
+
interface LocaleNamespaceMap {
|
|
72
|
+
'settings.smoothStream': SmoothStreamLocaleKey
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Staged form state for the plugin-owned smooth-stream settings RPC. */
|
|
2
|
+
|
|
3
|
+
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
4
|
+
import { DEFAULT_STREAM_SETTINGS } from '../settings.ts'
|
|
5
|
+
import type { StreamInstallationKind, StreamSettingsView } from '../settings-api.ts'
|
|
6
|
+
import type { SmoothStreamSettingsApi } from './smooth-stream-settings-api.ts'
|
|
7
|
+
|
|
8
|
+
/** What the smooth-stream card renders. It remains visible while its Host RPC loads. */
|
|
9
|
+
export interface SmoothStreamCardState {
|
|
10
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
11
|
+
writable: boolean
|
|
12
|
+
dirty: boolean
|
|
13
|
+
saving: boolean
|
|
14
|
+
failed: boolean
|
|
15
|
+
thinkAutoExpand: boolean
|
|
16
|
+
version: string | undefined
|
|
17
|
+
installation: StreamInstallationKind
|
|
18
|
+
canUpgrade: boolean
|
|
19
|
+
upgrading: boolean
|
|
20
|
+
upgradeFailed: boolean
|
|
21
|
+
restartRequired: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The registration-side face injected into the settings slot renderer. */
|
|
25
|
+
export interface SmoothStreamCardFace {
|
|
26
|
+
hooks: {
|
|
27
|
+
smoothStreamCard: SnapshotStore<SmoothStreamCardState>
|
|
28
|
+
}
|
|
29
|
+
edit: (value: boolean) => void
|
|
30
|
+
save: () => void
|
|
31
|
+
discard: () => void
|
|
32
|
+
reload: () => void
|
|
33
|
+
upgrade: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Bridges the plugin's protected Host interface onto a staged settings form. */
|
|
37
|
+
export class SmoothStreamCardController {
|
|
38
|
+
private readonly store = createSnapshotStore<SmoothStreamCardState>(this.projection())
|
|
39
|
+
private loaded: StreamSettingsView | undefined
|
|
40
|
+
private staged: boolean | undefined
|
|
41
|
+
private saving = false
|
|
42
|
+
private failed = false
|
|
43
|
+
private upgrading = false
|
|
44
|
+
private upgradeFailed = false
|
|
45
|
+
private restartRequired = false
|
|
46
|
+
private loadGeneration = 0
|
|
47
|
+
private loadStatus: 'loading' | 'ready' | 'unavailable' = 'loading'
|
|
48
|
+
|
|
49
|
+
constructor(private readonly api: SmoothStreamSettingsApi) {}
|
|
50
|
+
|
|
51
|
+
/** Begin the background read after the card has been registered. */
|
|
52
|
+
start(): void {
|
|
53
|
+
void this.load()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Ignore a late response after the surrounding optional services unload. */
|
|
57
|
+
stop(): void {
|
|
58
|
+
this.loadGeneration += 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Current card snapshot, also consumed by the streaming preference cell. */
|
|
62
|
+
getSnapshot(): SmoothStreamCardState {
|
|
63
|
+
return this.store.getSnapshot()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Subscribe to state changes. */
|
|
67
|
+
subscribe(listener: () => void): () => void {
|
|
68
|
+
return this.store.subscribe(listener)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build the face consumed by the settings slot renderer. */
|
|
72
|
+
inject(): SmoothStreamCardFace {
|
|
73
|
+
return {
|
|
74
|
+
hooks: { smoothStreamCard: this.store },
|
|
75
|
+
edit: (value) => {
|
|
76
|
+
this.staged = value
|
|
77
|
+
this.failed = false
|
|
78
|
+
this.publish()
|
|
79
|
+
},
|
|
80
|
+
save: () => { void this.save() },
|
|
81
|
+
discard: () => {
|
|
82
|
+
if (this.staged === undefined && !this.failed) return
|
|
83
|
+
this.staged = undefined
|
|
84
|
+
this.failed = false
|
|
85
|
+
this.publish()
|
|
86
|
+
},
|
|
87
|
+
reload: () => { void this.load() },
|
|
88
|
+
upgrade: () => { void this.upgrade() },
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private projection(): SmoothStreamCardState {
|
|
93
|
+
return {
|
|
94
|
+
status: this.loadStatus,
|
|
95
|
+
writable: this.loaded?.writable ?? false,
|
|
96
|
+
dirty: this.staged !== undefined,
|
|
97
|
+
saving: this.saving,
|
|
98
|
+
failed: this.failed,
|
|
99
|
+
thinkAutoExpand: this.staged ?? this.loaded?.thinkAutoExpand ?? DEFAULT_STREAM_SETTINGS.thinkAutoExpand,
|
|
100
|
+
version: this.loaded?.version,
|
|
101
|
+
installation: this.loaded?.installation ?? 'unmanaged',
|
|
102
|
+
canUpgrade: this.loaded?.canUpgrade ?? false,
|
|
103
|
+
upgrading: this.upgrading,
|
|
104
|
+
upgradeFailed: this.upgradeFailed,
|
|
105
|
+
restartRequired: this.restartRequired,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async load(): Promise<void> {
|
|
110
|
+
const generation = ++this.loadGeneration
|
|
111
|
+
this.loadStatus = 'loading'
|
|
112
|
+
this.loaded = undefined
|
|
113
|
+
this.publish()
|
|
114
|
+
try {
|
|
115
|
+
const view = await this.api.read()
|
|
116
|
+
if (generation !== this.loadGeneration) return
|
|
117
|
+
this.loaded = view
|
|
118
|
+
this.loadStatus = 'ready'
|
|
119
|
+
} catch {
|
|
120
|
+
if (generation !== this.loadGeneration) return
|
|
121
|
+
this.loaded = undefined
|
|
122
|
+
this.loadStatus = 'unavailable'
|
|
123
|
+
}
|
|
124
|
+
this.publish()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async save(): Promise<void> {
|
|
128
|
+
if (this.staged === undefined || this.saving || this.loaded?.writable !== true) return
|
|
129
|
+
const value = this.staged
|
|
130
|
+
this.saving = true
|
|
131
|
+
this.failed = false
|
|
132
|
+
this.publish()
|
|
133
|
+
try {
|
|
134
|
+
this.loaded = await this.api.write(value)
|
|
135
|
+
this.staged = undefined
|
|
136
|
+
} catch {
|
|
137
|
+
this.failed = true
|
|
138
|
+
}
|
|
139
|
+
this.saving = false
|
|
140
|
+
this.publish()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async upgrade(): Promise<void> {
|
|
144
|
+
if (this.loaded?.canUpgrade !== true || this.upgrading) return
|
|
145
|
+
this.upgrading = true
|
|
146
|
+
this.upgradeFailed = false
|
|
147
|
+
this.restartRequired = false
|
|
148
|
+
this.publish()
|
|
149
|
+
try {
|
|
150
|
+
const result = await this.api.upgrade()
|
|
151
|
+
this.restartRequired = result.restartRequired
|
|
152
|
+
} catch {
|
|
153
|
+
this.upgradeFailed = true
|
|
154
|
+
}
|
|
155
|
+
this.upgrading = false
|
|
156
|
+
this.publish()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private publish(): void {
|
|
160
|
+
this.store.set(this.projection())
|
|
161
|
+
}
|
|
162
|
+
}
|