@jcy2387/dsh-conversation-ui 0.1.0-rc.12
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/LICENSE +21 -0
- package/README.en.md +123 -0
- package/README.md +123 -0
- package/conversation-ui-off.yml +3 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +3672 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +302 -0
- package/package.json +182 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +89 -0
- package/src/client/ConversationCard.module.css +253 -0
- package/src/client/ConversationCard.tsx +122 -0
- package/src/client/DeliverablesCard.module.css +187 -0
- package/src/client/DeliverablesCard.tsx +142 -0
- package/src/client/FollowHost.tsx +25 -0
- package/src/client/ToolActivityGroup.tsx +341 -0
- package/src/client/TurnPreludeUserNodeView.tsx +258 -0
- package/src/client/TurnProcessMember.tsx +92 -0
- package/src/client/TypewriterAssistantNodeView.module.css +1260 -0
- package/src/client/TypewriterAssistantNodeView.tsx +390 -0
- package/src/client/TypewriterToolNodeView.tsx +916 -0
- package/src/client/conversation-ui-card-controller.ts +162 -0
- package/src/client/conversation-ui-settings-api.ts +65 -0
- package/src/client/deliverables.ts +263 -0
- package/src/client/index.ts +242 -0
- package/src/client/locales.ts +74 -0
- package/src/client/teleprompterGlide.ts +590 -0
- package/src/client/turnElapsed.ts +27 -0
- package/src/client/turnProcessFold.ts +224 -0
- package/src/client/useConversationContent.ts +471 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/config.ts +44 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/package-meta.ts +39 -0
- package/src/plugin.ts +155 -0
- package/src/profile-installation.ts +121 -0
- package/src/settings-api.ts +33 -0
- package/src/settings.ts +30 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { memo, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
JsonBlock,
|
|
4
|
+
MarkdownText,
|
|
5
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
6
|
+
import type { ChatNodeViewProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
7
|
+
import { useConversationContent, type ConversationSmoothingPreset } from './useConversationContent.ts'
|
|
8
|
+
import { useFpsGuard } from './useFpsGuard.ts'
|
|
9
|
+
import { FollowHost } from './FollowHost.tsx'
|
|
10
|
+
import { DEFAULT_CONVERSATION_CONFIG, type ConversationMode } from '../config.ts'
|
|
11
|
+
import { turnProcessMemberId, useTurnProcessFold } from './turnProcessFold.ts'
|
|
12
|
+
import { TurnProcessMember } from './TurnProcessMember.tsx'
|
|
13
|
+
import { formatTurnElapsed, formatTurnProcessed } from './turnElapsed.ts'
|
|
14
|
+
import css from './TypewriterAssistantNodeView.module.css'
|
|
15
|
+
|
|
16
|
+
type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
17
|
+
type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'codeLabels' | 'fileMentions' | 'text'>
|
|
18
|
+
|
|
19
|
+
function usePrefersReducedMotion(): boolean {
|
|
20
|
+
const [reduced, setReduced] = useState(
|
|
21
|
+
() => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true,
|
|
22
|
+
)
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (typeof window === 'undefined' || window.matchMedia === undefined) return
|
|
25
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
26
|
+
const onChange = () => setReduced(query.matches)
|
|
27
|
+
query.addEventListener('change', onChange)
|
|
28
|
+
return () => query.removeEventListener('change', onChange)
|
|
29
|
+
}, [])
|
|
30
|
+
return reduced
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
34
|
+
streaming: boolean
|
|
35
|
+
announce: boolean
|
|
36
|
+
/** True on the last text block: that block owns conversation follow. */
|
|
37
|
+
ownFollow: boolean
|
|
38
|
+
mode: ConversationMode
|
|
39
|
+
preset: ConversationSmoothingPreset
|
|
40
|
+
revealCharsPerSec: number
|
|
41
|
+
scrollSpeedPxPerSec: number
|
|
42
|
+
maxScrollSpeedPxPerSec: number
|
|
43
|
+
shouldHoldBack: () => boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Smooth streaming text arm. While the reply runs, the accumulated source is
|
|
48
|
+
* revealed through the smoother at a rate that tracks the model's arrival
|
|
49
|
+
* and rendered by the Harness `MarkdownText`
|
|
50
|
+
* streaming arm (incremental parse, frozen non-tail blocks), so there is no
|
|
51
|
+
* raw-text tail and no text-to-markdown swap: the tree stays markdown
|
|
52
|
+
* throughout. The last text block owns conversation-port follow so wraps
|
|
53
|
+
* glide instead of snapping. Once the stream closes and the reveal queue
|
|
54
|
+
* drains, the settled full parse (KaTeX math, fence highlighting, file
|
|
55
|
+
* mentions) swaps in exactly once.
|
|
56
|
+
*/
|
|
57
|
+
function AnimatedMarkdownText({
|
|
58
|
+
text,
|
|
59
|
+
codeLabels,
|
|
60
|
+
fileMentions,
|
|
61
|
+
streaming,
|
|
62
|
+
announce,
|
|
63
|
+
ownFollow,
|
|
64
|
+
mode,
|
|
65
|
+
preset,
|
|
66
|
+
revealCharsPerSec,
|
|
67
|
+
scrollSpeedPxPerSec,
|
|
68
|
+
maxScrollSpeedPxPerSec,
|
|
69
|
+
shouldHoldBack,
|
|
70
|
+
}: AnimatedMarkdownTextProps) {
|
|
71
|
+
const reduced = usePrefersReducedMotion()
|
|
72
|
+
const [typing, setTyping] = useState(streaming)
|
|
73
|
+
const speedCpsRef = useRef(35)
|
|
74
|
+
const displayed = useConversationContent(text, {
|
|
75
|
+
enabled: mode === 'typewriter' && typing && !reduced,
|
|
76
|
+
preset,
|
|
77
|
+
shouldHoldBack,
|
|
78
|
+
steadyCps: mode === 'typewriter' ? revealCharsPerSec : undefined,
|
|
79
|
+
speedCpsRef,
|
|
80
|
+
})
|
|
81
|
+
const shown = reduced || mode === 'teleprompter' ? text : displayed
|
|
82
|
+
const live = typing && !reduced
|
|
83
|
+
|
|
84
|
+
// The stream closed: keep revealing the remaining queue, then swap to the
|
|
85
|
+
// settled parse exactly once. The markdown tree stays mounted until then.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (typing && !streaming && shown.length === text.length) setTyping(false)
|
|
88
|
+
}, [shown, streaming, text, typing])
|
|
89
|
+
|
|
90
|
+
if (live) {
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
{announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
|
|
94
|
+
<FollowHost
|
|
95
|
+
active={ownFollow}
|
|
96
|
+
speedCpsRef={speedCpsRef}
|
|
97
|
+
minSpeedPxPerSec={scrollSpeedPxPerSec}
|
|
98
|
+
maxSpeedPxPerSec={maxScrollSpeedPxPerSec}
|
|
99
|
+
>
|
|
100
|
+
<div className={css.markdownFlow}>
|
|
101
|
+
<MarkdownText text={shown} streaming codeLabels={codeLabels} />
|
|
102
|
+
</div>
|
|
103
|
+
</FollowHost>
|
|
104
|
+
</>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return (
|
|
108
|
+
<div className={css.markdownFlow}>
|
|
109
|
+
<MarkdownText text={text} codeLabels={codeLabels} fileMentions={fileMentions} />
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Think is part of the Turn document flow rather than a second disclosure
|
|
116
|
+
* card. It uses the same streaming smoother as ordinary assistant prose, so
|
|
117
|
+
* reasoning reads as a continuous body while the surrounding Turn still owns
|
|
118
|
+
* process folding and viewport follow.
|
|
119
|
+
*/
|
|
120
|
+
function AnimatedReasoning({
|
|
121
|
+
text,
|
|
122
|
+
running,
|
|
123
|
+
mode,
|
|
124
|
+
preset,
|
|
125
|
+
revealCharsPerSec,
|
|
126
|
+
shouldHoldBack,
|
|
127
|
+
}: {
|
|
128
|
+
text: string
|
|
129
|
+
running: boolean
|
|
130
|
+
mode: ConversationMode
|
|
131
|
+
preset: ConversationSmoothingPreset
|
|
132
|
+
revealCharsPerSec: number
|
|
133
|
+
shouldHoldBack: () => boolean
|
|
134
|
+
}) {
|
|
135
|
+
const reduced = usePrefersReducedMotion()
|
|
136
|
+
const [typing, setTyping] = useState(running)
|
|
137
|
+
const speedCpsRef = useRef(35)
|
|
138
|
+
const displayed = useConversationContent(text, {
|
|
139
|
+
enabled: mode === 'typewriter' && typing && !reduced,
|
|
140
|
+
preset,
|
|
141
|
+
shouldHoldBack,
|
|
142
|
+
steadyCps: mode === 'typewriter' ? revealCharsPerSec : undefined,
|
|
143
|
+
speedCpsRef,
|
|
144
|
+
})
|
|
145
|
+
const shown = reduced || mode === 'teleprompter' ? text : displayed
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (typing && !running && shown.length === text.length) setTyping(false)
|
|
148
|
+
}, [shown, running, text, typing])
|
|
149
|
+
const live = typing && !reduced
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
className={css.reasoningText}
|
|
153
|
+
data-stream-node="reasoning"
|
|
154
|
+
data-stream-state={running ? 'running' : 'settled'}
|
|
155
|
+
data-stream-reasoning=""
|
|
156
|
+
>
|
|
157
|
+
{live ? <MarkdownText text={shown} streaming /> : <MarkdownText text={text} />}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Assistant renderer for the Codex-style event stream. Teleprompter mode
|
|
164
|
+
* publishes each latest model snapshot immediately; typewriter mode reveals
|
|
165
|
+
* complete grapheme clusters at the configured rate. Reasoning stays in the
|
|
166
|
+
* same document flow as ordinary prose, and the final Assistant message is marked separately
|
|
167
|
+
* from intermediate process updates. The FPS guard holds offscreen typewriter
|
|
168
|
+
* commits when the frame rate is degraded. Settled text uses the full Markdown
|
|
169
|
+
* pipeline.
|
|
170
|
+
*/
|
|
171
|
+
export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
|
|
172
|
+
mode = DEFAULT_CONVERSATION_CONFIG.mode,
|
|
173
|
+
preset = DEFAULT_CONVERSATION_CONFIG.preset,
|
|
174
|
+
revealCharsPerSec = DEFAULT_CONVERSATION_CONFIG.revealCharsPerSec,
|
|
175
|
+
scrollSpeedPxPerSec = DEFAULT_CONVERSATION_CONFIG.scrollSpeedPxPerSec,
|
|
176
|
+
maxScrollSpeedPxPerSec = DEFAULT_CONVERSATION_CONFIG.maxScrollSpeedPxPerSec,
|
|
177
|
+
node,
|
|
178
|
+
sessionId,
|
|
179
|
+
useTurnData,
|
|
180
|
+
openFile,
|
|
181
|
+
renderMessageImages,
|
|
182
|
+
fileMentions,
|
|
183
|
+
t,
|
|
184
|
+
}: AssistantProps & {
|
|
185
|
+
mode?: ConversationMode
|
|
186
|
+
preset?: ConversationSmoothingPreset
|
|
187
|
+
revealCharsPerSec?: number
|
|
188
|
+
scrollSpeedPxPerSec?: number
|
|
189
|
+
maxScrollSpeedPxPerSec?: number
|
|
190
|
+
thinkAutoExpand?: boolean
|
|
191
|
+
}) {
|
|
192
|
+
const data = node.data
|
|
193
|
+
const streaming = data.status === 'running'
|
|
194
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
195
|
+
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
196
|
+
const rootSpeedRef = useRef(35)
|
|
197
|
+
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
|
198
|
+
? node.location.turn
|
|
199
|
+
: undefined
|
|
200
|
+
const tail = useTurnData('turn-tail')
|
|
201
|
+
const finalAnswer = data.status === 'settled'
|
|
202
|
+
&& data.finalNode !== undefined
|
|
203
|
+
&& tail?.closing?.finalNode.seq === data.finalNode.seq
|
|
204
|
+
const phase = streaming
|
|
205
|
+
? 'streaming'
|
|
206
|
+
: data.status === 'interrupted' ? 'interrupted' : finalAnswer ? 'final' : 'process'
|
|
207
|
+
const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
|
|
208
|
+
if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
|
|
209
|
+
if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
|
|
210
|
+
return { turn, seq: data.finalNode.seq, openFile }
|
|
211
|
+
}, [data.finalNode, openFile, tail, turn])
|
|
212
|
+
const mentions = useMemo(
|
|
213
|
+
() => owner === undefined ? undefined : fileMentions(owner),
|
|
214
|
+
[fileMentions, owner],
|
|
215
|
+
)
|
|
216
|
+
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
|
217
|
+
const hasVisible = streaming
|
|
218
|
+
|| data.status === 'interrupted'
|
|
219
|
+
|| data.blocks.some(block => block.kind !== 'tool-call')
|
|
220
|
+
const address = turn === undefined ? undefined : { sessionId, turn: turn.turn }
|
|
221
|
+
const processMemberId = (phase === 'streaming' || phase === 'process') && address !== undefined && hasVisible
|
|
222
|
+
? turnProcessMemberId(address, node.key)
|
|
223
|
+
: undefined
|
|
224
|
+
const successfulFinal = finalAnswer
|
|
225
|
+
&& turn?.status === 'closed'
|
|
226
|
+
&& turn.start !== undefined
|
|
227
|
+
&& turn.end !== undefined
|
|
228
|
+
&& turn.end.data.reason.kind === 'completed'
|
|
229
|
+
const completedLabel = successfulFinal && turn?.start !== undefined && turn.end !== undefined
|
|
230
|
+
? formatTurnElapsed(turn.end.time - turn.start.time, t)
|
|
231
|
+
: undefined
|
|
232
|
+
const fold = useTurnProcessFold(address, {
|
|
233
|
+
completedLabel,
|
|
234
|
+
})
|
|
235
|
+
const memberOrder = typeof node.anchorSeq === 'number'
|
|
236
|
+
? node.anchorSeq
|
|
237
|
+
: data.finalNode?.seq ?? 0
|
|
238
|
+
const formatRunningElapsed = useMemo(
|
|
239
|
+
() => (ms: number) => formatTurnProcessed(ms, t),
|
|
240
|
+
[t],
|
|
241
|
+
)
|
|
242
|
+
if (!hasVisible) return null
|
|
243
|
+
|
|
244
|
+
const rendered: ReactNode[] = []
|
|
245
|
+
const last = data.blocks.length - 1
|
|
246
|
+
let lastFollow = -1
|
|
247
|
+
for (let index = 0; index < data.blocks.length; index += 1) {
|
|
248
|
+
const kind = data.blocks[index]?.kind
|
|
249
|
+
if (kind === 'text' || kind === 'reasoning') lastFollow = index
|
|
250
|
+
}
|
|
251
|
+
for (let index = 0; index < data.blocks.length; index += 1) {
|
|
252
|
+
const block = data.blocks[index]
|
|
253
|
+
if (block === undefined) continue
|
|
254
|
+
switch (block.kind) {
|
|
255
|
+
case 'text':
|
|
256
|
+
rendered.push(
|
|
257
|
+
<AnimatedMarkdownText
|
|
258
|
+
key={index}
|
|
259
|
+
text={block.text}
|
|
260
|
+
codeLabels={codeLabels}
|
|
261
|
+
fileMentions={mentions}
|
|
262
|
+
streaming={streaming}
|
|
263
|
+
announce={index === last}
|
|
264
|
+
ownFollow={!streaming && index === lastFollow}
|
|
265
|
+
mode={mode}
|
|
266
|
+
preset={preset}
|
|
267
|
+
revealCharsPerSec={revealCharsPerSec}
|
|
268
|
+
scrollSpeedPxPerSec={scrollSpeedPxPerSec}
|
|
269
|
+
maxScrollSpeedPxPerSec={maxScrollSpeedPxPerSec}
|
|
270
|
+
shouldHoldBack={shouldHoldBack}
|
|
271
|
+
/>,
|
|
272
|
+
)
|
|
273
|
+
break
|
|
274
|
+
case 'reasoning':
|
|
275
|
+
{
|
|
276
|
+
const reasoning = (
|
|
277
|
+
<AnimatedReasoning
|
|
278
|
+
key={index}
|
|
279
|
+
text={block.text}
|
|
280
|
+
running={streaming && index === last}
|
|
281
|
+
mode={mode}
|
|
282
|
+
preset={preset}
|
|
283
|
+
revealCharsPerSec={revealCharsPerSec}
|
|
284
|
+
shouldHoldBack={shouldHoldBack}
|
|
285
|
+
/>
|
|
286
|
+
)
|
|
287
|
+
// A closing assistant node is intentionally kept visible so its
|
|
288
|
+
// final answer cannot disappear with the completed Turn fold. Its
|
|
289
|
+
// private reasoning is still process content, however, and must be
|
|
290
|
+
// registered as a fold member rather than left in the final prose.
|
|
291
|
+
if (finalAnswer && address !== undefined) {
|
|
292
|
+
rendered.push(
|
|
293
|
+
<TurnProcessMember
|
|
294
|
+
key={index}
|
|
295
|
+
address={address}
|
|
296
|
+
memberId={turnProcessMemberId(address, `${node.key}:reasoning:${index}`)}
|
|
297
|
+
// Keep final-node reasoning after any earlier process member;
|
|
298
|
+
// otherwise equal synthetic seqs could move the disclosure
|
|
299
|
+
// button into the final-answer row.
|
|
300
|
+
memberOrder={memberOrder + 0.5 + index}
|
|
301
|
+
>
|
|
302
|
+
{reasoning}
|
|
303
|
+
</TurnProcessMember>,
|
|
304
|
+
)
|
|
305
|
+
} else {
|
|
306
|
+
rendered.push(reasoning)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
break
|
|
310
|
+
case 'image': {
|
|
311
|
+
const start = index
|
|
312
|
+
const group = [block]
|
|
313
|
+
while (index + 1 < data.blocks.length) {
|
|
314
|
+
const next = data.blocks[index + 1]
|
|
315
|
+
if (next === undefined || next.kind !== 'image') break
|
|
316
|
+
group.push(next)
|
|
317
|
+
index += 1
|
|
318
|
+
}
|
|
319
|
+
rendered.push(
|
|
320
|
+
<span key={start}>{renderMessageImages({ images: group, align: 'start' })}</span>,
|
|
321
|
+
)
|
|
322
|
+
break
|
|
323
|
+
}
|
|
324
|
+
case 'tool-call':
|
|
325
|
+
break
|
|
326
|
+
case 'other':
|
|
327
|
+
rendered.push(
|
|
328
|
+
<JsonBlock
|
|
329
|
+
key={index}
|
|
330
|
+
label={t('message.unknownBlock')}
|
|
331
|
+
payload={block.block}
|
|
332
|
+
truncatedLabel={total => t('json.truncated', { total })}
|
|
333
|
+
/>,
|
|
334
|
+
)
|
|
335
|
+
break
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const content = (
|
|
340
|
+
<>
|
|
341
|
+
{successfulFinal && fold.controls === '' && completedLabel !== undefined && (
|
|
342
|
+
<div className={css.turnFoldRow} data-turn-fold-row="">
|
|
343
|
+
<span className={css.turnFoldLabel}>{completedLabel}</span>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
<FollowHost
|
|
347
|
+
active={streaming && !reducedMotion}
|
|
348
|
+
speedCpsRef={rootSpeedRef}
|
|
349
|
+
minSpeedPxPerSec={scrollSpeedPxPerSec}
|
|
350
|
+
maxSpeedPxPerSec={maxScrollSpeedPxPerSec}
|
|
351
|
+
>
|
|
352
|
+
<div className={css.body}>
|
|
353
|
+
{rendered}
|
|
354
|
+
{data.status === 'interrupted' && <span className={css.stopped}>{t('message.stopped')}</span>}
|
|
355
|
+
</div>
|
|
356
|
+
</FollowHost>
|
|
357
|
+
</>
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if (processMemberId !== undefined && address !== undefined) {
|
|
361
|
+
return (
|
|
362
|
+
<TurnProcessMember
|
|
363
|
+
address={address}
|
|
364
|
+
memberId={processMemberId}
|
|
365
|
+
memberOrder={memberOrder}
|
|
366
|
+
turnStartTime={turn?.start?.time}
|
|
367
|
+
formatRunningElapsed={formatRunningElapsed}
|
|
368
|
+
memberRef={guardRef}
|
|
369
|
+
className={css.root}
|
|
370
|
+
data-stream-node="assistant-step"
|
|
371
|
+
data-stream-phase={phase}
|
|
372
|
+
data-streaming={streaming || undefined}
|
|
373
|
+
>
|
|
374
|
+
{content}
|
|
375
|
+
</TurnProcessMember>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<div
|
|
381
|
+
ref={guardRef}
|
|
382
|
+
className={css.root}
|
|
383
|
+
data-stream-node="assistant-step"
|
|
384
|
+
data-stream-phase={phase}
|
|
385
|
+
data-streaming={streaming || undefined}
|
|
386
|
+
>
|
|
387
|
+
{content}
|
|
388
|
+
</div>
|
|
389
|
+
)
|
|
390
|
+
})
|