dsh-smooth-stream 0.1.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/LICENSE +21 -0
- package/README.md +88 -0
- package/README.zh.md +88 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1288 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +73 -0
- package/package.json +135 -0
- package/src/boot-config.ts +28 -0
- package/src/client/AnimatedDisclosure.tsx +85 -0
- package/src/client/FollowHost.tsx +21 -0
- package/src/client/TypewriterAssistantNodeView.module.css +240 -0
- package/src/client/TypewriterAssistantNodeView.tsx +319 -0
- package/src/client/TypewriterToolNodeView.tsx +54 -0
- package/src/client/index.ts +122 -0
- package/src/client/teleprompterGlide.ts +426 -0
- package/src/client/useFpsGuard.ts +84 -0
- package/src/client/useSmoothStreamContent.ts +468 -0
- package/src/config.ts +50 -0
- package/src/css-modules.d.ts +9 -0
- package/src/index.ts +2 -0
- package/src/plugin.ts +54 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
min-width: 0;
|
|
5
|
+
color: var(--dsw-alias-label-primary);
|
|
6
|
+
font-size: 16px;
|
|
7
|
+
line-height: 28px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.body {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
gap: 16px;
|
|
14
|
+
min-width: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.think {
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.thinkRow {
|
|
23
|
+
position: relative;
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.think[data-state='running'] .thinkRow::after {
|
|
28
|
+
content: '';
|
|
29
|
+
position: absolute;
|
|
30
|
+
inset-block: 0;
|
|
31
|
+
left: 0;
|
|
32
|
+
width: 300px;
|
|
33
|
+
background: linear-gradient(
|
|
34
|
+
90deg,
|
|
35
|
+
transparent 0%,
|
|
36
|
+
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
|
37
|
+
transparent 100%
|
|
38
|
+
);
|
|
39
|
+
animation: dsh-smooth-stream-think-sweep 2.6s ease-out infinite;
|
|
40
|
+
pointer-events: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@keyframes dsh-smooth-stream-think-sweep {
|
|
44
|
+
0% { left: -300px; }
|
|
45
|
+
90%, 100% { left: 100%; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.thinkLeading {
|
|
49
|
+
flex-shrink: 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.thinkChevron {
|
|
53
|
+
color: var(--dsw-alias-label-secondary);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.thinkTitle {
|
|
57
|
+
font-weight: 400;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.thinkSeparator {
|
|
61
|
+
flex: none;
|
|
62
|
+
width: 2px;
|
|
63
|
+
height: 2px;
|
|
64
|
+
margin: 0 8px;
|
|
65
|
+
border-radius: 1px;
|
|
66
|
+
background: var(--dsw-alias-label-caption);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.thinkSummary {
|
|
70
|
+
min-width: 0;
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
flex: 1 1 auto;
|
|
73
|
+
color: var(--dsw-alias-label-tertiary);
|
|
74
|
+
font-size: 14px;
|
|
75
|
+
line-height: 24px;
|
|
76
|
+
text-overflow: ellipsis;
|
|
77
|
+
white-space: nowrap;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.thinkSummary[data-follow-end] {
|
|
81
|
+
text-overflow: clip;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.thinkBody {
|
|
85
|
+
padding: 4px 0 4px 22px;
|
|
86
|
+
color: var(--dsw-alias-label-tertiary);
|
|
87
|
+
font-size: 14px;
|
|
88
|
+
line-height: 24px;
|
|
89
|
+
white-space: pre-wrap;
|
|
90
|
+
word-break: break-word;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* AnimatedDisclosure chrome: mirrors the harness DisclosureRow geometry
|
|
95
|
+
* ([16px leading] gap 6 [title 14/24]) plus the hover icon->chevron preview.
|
|
96
|
+
*/
|
|
97
|
+
.disclosureRoot {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
width: 100%;
|
|
101
|
+
min-width: 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.disclosureRow {
|
|
105
|
+
position: relative;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
height: 24px;
|
|
110
|
+
min-width: 0;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.disclosureLeading {
|
|
115
|
+
position: relative;
|
|
116
|
+
flex: none;
|
|
117
|
+
width: 16px;
|
|
118
|
+
height: 16px;
|
|
119
|
+
display: inline-flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
margin-right: 6px;
|
|
123
|
+
color: var(--dsw-alias-label-tertiary);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.disclosureIconIdle {
|
|
127
|
+
display: inline-flex;
|
|
128
|
+
opacity: 1;
|
|
129
|
+
transition: opacity 100ms ease;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.disclosureChevronHover {
|
|
133
|
+
position: absolute;
|
|
134
|
+
inset: 0;
|
|
135
|
+
margin: auto;
|
|
136
|
+
opacity: 0;
|
|
137
|
+
transition: opacity 100ms ease;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.disclosureRow:hover .disclosureIconIdle {
|
|
141
|
+
opacity: 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.disclosureRow:hover .disclosureChevronHover {
|
|
145
|
+
opacity: 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.disclosureTitle {
|
|
149
|
+
flex: none;
|
|
150
|
+
font-size: 14px;
|
|
151
|
+
line-height: 24px;
|
|
152
|
+
color: var(--dsw-alias-label-secondary);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/*
|
|
156
|
+
* The animated body: one grid track transitions 0fr <-> 1fr, so the height
|
|
157
|
+
* glides to the body's natural size with no measured pixel target. The direct
|
|
158
|
+
* child clips to the track; `visibility` (not display) keeps a collapsed body
|
|
159
|
+
* out of the accessibility tree and tab order while it still measures for the
|
|
160
|
+
* animation - it flips visible immediately on expand and waits for the height
|
|
161
|
+
* to collapse before hiding. Rides the deepsuite curve tokens with fallbacks
|
|
162
|
+
* for standalone composition.
|
|
163
|
+
*/
|
|
164
|
+
.disclosureContent {
|
|
165
|
+
display: grid;
|
|
166
|
+
grid-template-rows: 1fr;
|
|
167
|
+
visibility: visible;
|
|
168
|
+
transition:
|
|
169
|
+
grid-template-rows var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
170
|
+
visibility 0s;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.disclosureContent[data-collapsed] {
|
|
174
|
+
grid-template-rows: 0fr;
|
|
175
|
+
visibility: hidden;
|
|
176
|
+
transition:
|
|
177
|
+
grid-template-rows var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, cubic-bezier(0.4, 0, 0.2, 1)),
|
|
178
|
+
visibility 0s var(--ds-transition-duration, 0.2s);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.disclosureContent > * {
|
|
182
|
+
min-height: 0;
|
|
183
|
+
overflow: hidden;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@media (prefers-reduced-motion: reduce) {
|
|
187
|
+
.think[data-state='running'] .thinkRow::after {
|
|
188
|
+
animation: none;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.disclosureContent,
|
|
192
|
+
.disclosureContent[data-collapsed] {
|
|
193
|
+
transition: none;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.stopped {
|
|
198
|
+
align-self: flex-start;
|
|
199
|
+
padding: 0 6px;
|
|
200
|
+
border-radius: 6px;
|
|
201
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
202
|
+
color: var(--dsw-alias-label-tertiary);
|
|
203
|
+
font-size: 11px;
|
|
204
|
+
line-height: 18px;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.visuallyHidden {
|
|
208
|
+
position: absolute;
|
|
209
|
+
overflow: hidden;
|
|
210
|
+
width: 1px;
|
|
211
|
+
height: 1px;
|
|
212
|
+
padding: 0;
|
|
213
|
+
border: 0;
|
|
214
|
+
margin: -1px;
|
|
215
|
+
clip: rect(0 0 0 0);
|
|
216
|
+
clip-path: inset(50%);
|
|
217
|
+
white-space: nowrap;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/*
|
|
221
|
+
* Follow host: ordinary document flow. The last text block owns the
|
|
222
|
+
* conversation scrollport's bottom-follow while the reply reveals
|
|
223
|
+
* (see useConversationFollow).
|
|
224
|
+
*/
|
|
225
|
+
.follow {
|
|
226
|
+
min-width: 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/*
|
|
230
|
+
* Trim the half-leading above and below each text block so line boxes hug
|
|
231
|
+
* the font's ascent/descent edges. `text` edges are used instead of
|
|
232
|
+
* `cap alphabetic` because assistant text is CJK-heavy and cap-height
|
|
233
|
+
* trimming would clip ideographic glyphs.
|
|
234
|
+
*/
|
|
235
|
+
@supports (text-box-trim: trim-both) {
|
|
236
|
+
.root :is(p, h1, h2, h3, h4, h5, h6, li, blockquote) {
|
|
237
|
+
text-box-trim: trim-both;
|
|
238
|
+
text-box-edge: text;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { memo, useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
|
2
|
+
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
3
|
+
import { ImageGallery, type ImageLoader, type MessageImageLabels } from '@deepseek-ai/dsh-client-ui-attachment'
|
|
4
|
+
import type { ChatNodeViewProps, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
5
|
+
import { AnimatedDisclosure } from './AnimatedDisclosure.tsx'
|
|
6
|
+
import { useSmoothStreamContent, type StreamSmoothingPreset } from './useSmoothStreamContent.ts'
|
|
7
|
+
import { useFpsGuard } from './useFpsGuard.ts'
|
|
8
|
+
import { FollowHost } from './FollowHost.tsx'
|
|
9
|
+
import { DEFAULT_STREAM_CONFIG, type StreamMode } from '../config.ts'
|
|
10
|
+
import css from './TypewriterAssistantNodeView.module.css'
|
|
11
|
+
|
|
12
|
+
type AssistantProps = ChatNodeViewProps<'assistant-step'>
|
|
13
|
+
type MarkdownProps = Pick<ComponentProps<typeof MarkdownText>, 'codeLabels' | 'fileMentions' | 'text'>
|
|
14
|
+
|
|
15
|
+
function usePrefersReducedMotion(): boolean {
|
|
16
|
+
const [reduced, setReduced] = useState(
|
|
17
|
+
() => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true,
|
|
18
|
+
)
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (typeof window === 'undefined' || window.matchMedia === undefined) return
|
|
21
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
22
|
+
const onChange = () => setReduced(query.matches)
|
|
23
|
+
query.addEventListener('change', onChange)
|
|
24
|
+
return () => query.removeEventListener('change', onChange)
|
|
25
|
+
}, [])
|
|
26
|
+
return reduced
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface AnimatedMarkdownTextProps extends MarkdownProps {
|
|
30
|
+
streaming: boolean
|
|
31
|
+
announce: boolean
|
|
32
|
+
/** True on the last text block: that block owns conversation follow. */
|
|
33
|
+
ownFollow: boolean
|
|
34
|
+
preset: StreamSmoothingPreset
|
|
35
|
+
shouldHoldBack: () => boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Smooth streaming text arm. While the reply runs, the accumulated source is
|
|
40
|
+
* revealed through the smoother at a rate that tracks the model's arrival
|
|
41
|
+
* and rendered by the Harness `MarkdownText`
|
|
42
|
+
* streaming arm (incremental parse, frozen non-tail blocks), so there is no
|
|
43
|
+
* raw-text tail and no text-to-markdown swap: the tree stays markdown
|
|
44
|
+
* throughout. The last text block owns conversation-port follow so wraps
|
|
45
|
+
* glide instead of snapping. Once the stream closes and the reveal queue
|
|
46
|
+
* drains, the settled full parse (KaTeX math, fence highlighting, file
|
|
47
|
+
* mentions) swaps in exactly once.
|
|
48
|
+
*/
|
|
49
|
+
function AnimatedMarkdownText({
|
|
50
|
+
text,
|
|
51
|
+
codeLabels,
|
|
52
|
+
fileMentions,
|
|
53
|
+
streaming,
|
|
54
|
+
announce,
|
|
55
|
+
ownFollow,
|
|
56
|
+
preset,
|
|
57
|
+
shouldHoldBack,
|
|
58
|
+
}: AnimatedMarkdownTextProps) {
|
|
59
|
+
const reduced = usePrefersReducedMotion()
|
|
60
|
+
const [typing, setTyping] = useState(streaming)
|
|
61
|
+
const speedCpsRef = useRef(35)
|
|
62
|
+
const displayed = useSmoothStreamContent(text, {
|
|
63
|
+
enabled: typing && !reduced,
|
|
64
|
+
preset,
|
|
65
|
+
shouldHoldBack,
|
|
66
|
+
speedCpsRef,
|
|
67
|
+
})
|
|
68
|
+
const shown = reduced ? text : displayed
|
|
69
|
+
const live = typing && !reduced
|
|
70
|
+
|
|
71
|
+
// The stream closed: keep revealing the remaining queue, then swap to the
|
|
72
|
+
// settled parse exactly once. The markdown tree stays mounted until then.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (typing && !streaming && shown.length === text.length) setTyping(false)
|
|
75
|
+
}, [shown, streaming, text, typing])
|
|
76
|
+
|
|
77
|
+
if (live) {
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
{announce && <span className={css.visuallyHidden} aria-live="polite">{text}</span>}
|
|
81
|
+
<FollowHost active={ownFollow} speedCpsRef={speedCpsRef}>
|
|
82
|
+
<MarkdownText text={shown} streaming codeLabels={codeLabels} />
|
|
83
|
+
</FollowHost>
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
return <MarkdownText text={text} codeLabels={codeLabels} fileMentions={fileMentions} />
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function imageLabels(t: AssistantProps['t']): MessageImageLabels {
|
|
91
|
+
return {
|
|
92
|
+
image: t('image.label'),
|
|
93
|
+
open: t('image.openOriginal'),
|
|
94
|
+
openNamed: label => t('image.openOriginalLabel', { label }),
|
|
95
|
+
loading: t('image.loading'),
|
|
96
|
+
loadFailed: t('image.loadFailed'),
|
|
97
|
+
lightbox: {
|
|
98
|
+
dialog: t('image.preview'),
|
|
99
|
+
close: t('image.closePreview'),
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function firstLine(text: string): string {
|
|
105
|
+
const newline = text.indexOf('\n')
|
|
106
|
+
return newline === -1 ? text : text.slice(0, newline)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function latestLine(text: string): string {
|
|
110
|
+
const visible = text.trimEnd()
|
|
111
|
+
const newline = visible.lastIndexOf('\n')
|
|
112
|
+
return newline === -1 ? visible : visible.slice(newline + 1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Built-in Think disclosure with a smoothed `text` feed. Chevron and row
|
|
117
|
+
* click stay on the disclosure chrome, which the plugin's AnimatedDisclosure
|
|
118
|
+
* renders with a height-animated body (the harness primitive would mount and
|
|
119
|
+
* unmount it, which cannot glide). The row opens only while this block is
|
|
120
|
+
* the streaming tail and closes as soon as thinking ends — a later block,
|
|
121
|
+
* or the assistant node settling — not when the rest of the reply is
|
|
122
|
+
* still streaming.
|
|
123
|
+
*/
|
|
124
|
+
function AnimatedReasoning({
|
|
125
|
+
text,
|
|
126
|
+
running,
|
|
127
|
+
preset,
|
|
128
|
+
shouldHoldBack,
|
|
129
|
+
t,
|
|
130
|
+
}: {
|
|
131
|
+
text: string
|
|
132
|
+
running: boolean
|
|
133
|
+
preset: StreamSmoothingPreset
|
|
134
|
+
shouldHoldBack: () => boolean
|
|
135
|
+
t: AssistantProps['t']
|
|
136
|
+
}) {
|
|
137
|
+
const reduced = usePrefersReducedMotion()
|
|
138
|
+
const [expanded, setExpanded] = useState(running)
|
|
139
|
+
const summaryRef = useRef<HTMLSpanElement>(null)
|
|
140
|
+
const speedCpsRef = useRef(35)
|
|
141
|
+
const displayed = useSmoothStreamContent(text, {
|
|
142
|
+
enabled: running && !reduced,
|
|
143
|
+
preset,
|
|
144
|
+
shouldHoldBack,
|
|
145
|
+
speedCpsRef,
|
|
146
|
+
})
|
|
147
|
+
const shown = running && !reduced ? displayed : text
|
|
148
|
+
const summary = running ? latestLine(shown) : firstLine(text)
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
setExpanded(running)
|
|
152
|
+
}, [running])
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const element = summaryRef.current
|
|
156
|
+
if (element === null) return
|
|
157
|
+
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
|
|
158
|
+
}, [running, summary])
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<FollowHost active={running} speedCpsRef={speedCpsRef}>
|
|
162
|
+
<div className={css.think} data-variant="think" data-state={running ? 'running' : 'ok'}>
|
|
163
|
+
{running && <span className={css.visuallyHidden}>{t('row.running')}</span>}
|
|
164
|
+
<AnimatedDisclosure
|
|
165
|
+
rowClassName={css.thinkRow}
|
|
166
|
+
leadingClassName={css.thinkLeading}
|
|
167
|
+
titleClassName={css.thinkTitle}
|
|
168
|
+
chevronClassName={css.thinkChevron}
|
|
169
|
+
icon={<IconThinkOutline14 size={14} />}
|
|
170
|
+
title="Think"
|
|
171
|
+
open={expanded}
|
|
172
|
+
onToggle={() => { setExpanded(value => !value) }}
|
|
173
|
+
collapsedContent={(
|
|
174
|
+
<>
|
|
175
|
+
<span className={css.thinkSeparator} aria-hidden />
|
|
176
|
+
<span ref={summaryRef} className={css.thinkSummary} data-follow-end={running || undefined}>{summary}</span>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<div className={css.thinkBody}>{shown}</div>
|
|
181
|
+
</AnimatedDisclosure>
|
|
182
|
+
</div>
|
|
183
|
+
</FollowHost>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Assistant node renderer for the typewriter overlay. Text observed while
|
|
189
|
+
* streaming is revealed by the smoother through the Harness Markdown
|
|
190
|
+
* renderer at a rate that tracks arrival. Reasoning blocks keep the
|
|
191
|
+
* built-in Think disclosure and only receive a smoothed text feed; the
|
|
192
|
+
* last text or reasoning block owns conversation-port follow so wraps
|
|
193
|
+
* glide. The FPS guard holds offscreen
|
|
194
|
+
* reveals when the frame rate is degraded. Settled text renders with the
|
|
195
|
+
* full Markdown pipeline.
|
|
196
|
+
*/
|
|
197
|
+
export const TypewriterAssistantNodeView = memo(function TypewriterAssistantNodeView({
|
|
198
|
+
mode: _mode = DEFAULT_STREAM_CONFIG.mode,
|
|
199
|
+
preset = DEFAULT_STREAM_CONFIG.preset,
|
|
200
|
+
revealCharsPerSec: _revealCharsPerSec = DEFAULT_STREAM_CONFIG.revealCharsPerSec,
|
|
201
|
+
scrollSpeedPxPerSec: _scrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec,
|
|
202
|
+
maxScrollSpeedPxPerSec: _maxScrollSpeedPxPerSec = DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec,
|
|
203
|
+
node,
|
|
204
|
+
useTurnData,
|
|
205
|
+
openFile,
|
|
206
|
+
loadImage,
|
|
207
|
+
fileMentions,
|
|
208
|
+
t,
|
|
209
|
+
}: AssistantProps & {
|
|
210
|
+
mode?: StreamMode
|
|
211
|
+
preset?: StreamSmoothingPreset
|
|
212
|
+
revealCharsPerSec?: number
|
|
213
|
+
scrollSpeedPxPerSec?: number
|
|
214
|
+
maxScrollSpeedPxPerSec?: number
|
|
215
|
+
}) {
|
|
216
|
+
const data = node.data
|
|
217
|
+
const streaming = data.status === 'running'
|
|
218
|
+
const { ref: guardRef, shouldHoldBack } = useFpsGuard(streaming)
|
|
219
|
+
const rootSpeedRef = useRef(35)
|
|
220
|
+
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
|
221
|
+
? node.location.turn
|
|
222
|
+
: undefined
|
|
223
|
+
const tail = useTurnData('turn-tail')
|
|
224
|
+
const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
|
|
225
|
+
if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
|
|
226
|
+
if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
|
|
227
|
+
return { turn, seq: data.finalNode.seq, openFile }
|
|
228
|
+
}, [data.finalNode, openFile, tail, turn])
|
|
229
|
+
const mentions = useMemo(
|
|
230
|
+
() => owner === undefined ? undefined : fileMentions(owner),
|
|
231
|
+
[fileMentions, owner],
|
|
232
|
+
)
|
|
233
|
+
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
|
234
|
+
const imageLoader: ImageLoader = loadImage ?? (async () => {
|
|
235
|
+
throw new Error(t('image.serviceUnavailable'))
|
|
236
|
+
})
|
|
237
|
+
const hasVisible = streaming
|
|
238
|
+
|| data.status === 'interrupted'
|
|
239
|
+
|| data.blocks.some(block => block.kind !== 'tool-call')
|
|
240
|
+
if (!hasVisible) return null
|
|
241
|
+
|
|
242
|
+
const rendered: ReactNode[] = []
|
|
243
|
+
const last = data.blocks.length - 1
|
|
244
|
+
let lastFollow = -1
|
|
245
|
+
for (let index = 0; index < data.blocks.length; index += 1) {
|
|
246
|
+
const kind = data.blocks[index]?.kind
|
|
247
|
+
if (kind === 'text' || kind === 'reasoning') lastFollow = index
|
|
248
|
+
}
|
|
249
|
+
for (let index = 0; index < data.blocks.length; index += 1) {
|
|
250
|
+
const block = data.blocks[index]
|
|
251
|
+
if (block === undefined) continue
|
|
252
|
+
switch (block.kind) {
|
|
253
|
+
case 'text':
|
|
254
|
+
rendered.push(
|
|
255
|
+
<AnimatedMarkdownText
|
|
256
|
+
key={index}
|
|
257
|
+
text={block.text}
|
|
258
|
+
codeLabels={codeLabels}
|
|
259
|
+
fileMentions={mentions}
|
|
260
|
+
streaming={streaming}
|
|
261
|
+
announce={index === last}
|
|
262
|
+
ownFollow={!streaming && index === lastFollow}
|
|
263
|
+
preset={preset}
|
|
264
|
+
shouldHoldBack={shouldHoldBack}
|
|
265
|
+
/>,
|
|
266
|
+
)
|
|
267
|
+
break
|
|
268
|
+
case 'reasoning':
|
|
269
|
+
rendered.push(
|
|
270
|
+
<AnimatedReasoning
|
|
271
|
+
key={index}
|
|
272
|
+
text={block.text}
|
|
273
|
+
running={streaming && index === last}
|
|
274
|
+
preset={preset}
|
|
275
|
+
shouldHoldBack={shouldHoldBack}
|
|
276
|
+
t={t}
|
|
277
|
+
/>,
|
|
278
|
+
)
|
|
279
|
+
break
|
|
280
|
+
case 'image': {
|
|
281
|
+
const start = index
|
|
282
|
+
const group = [block]
|
|
283
|
+
while (index + 1 < data.blocks.length) {
|
|
284
|
+
const next = data.blocks[index + 1]
|
|
285
|
+
if (next === undefined || next.kind !== 'image') break
|
|
286
|
+
group.push(next)
|
|
287
|
+
index += 1
|
|
288
|
+
}
|
|
289
|
+
rendered.push(
|
|
290
|
+
<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={imageLabels(t)} />,
|
|
291
|
+
)
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
case 'tool-call':
|
|
295
|
+
break
|
|
296
|
+
case 'other':
|
|
297
|
+
rendered.push(
|
|
298
|
+
<JsonBlock
|
|
299
|
+
key={index}
|
|
300
|
+
label={t('message.unknownBlock')}
|
|
301
|
+
payload={block.block}
|
|
302
|
+
truncatedLabel={total => t('json.truncated', { total })}
|
|
303
|
+
/>,
|
|
304
|
+
)
|
|
305
|
+
break
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div ref={guardRef} className={css.root} data-streaming={streaming || undefined}>
|
|
311
|
+
<FollowHost active={streaming} speedCpsRef={rootSpeedRef}>
|
|
312
|
+
<div className={css.body}>
|
|
313
|
+
{rendered}
|
|
314
|
+
{data.status === 'interrupted' && <span className={css.stopped}>{t('message.stopped')}</span>}
|
|
315
|
+
</div>
|
|
316
|
+
</FollowHost>
|
|
317
|
+
</div>
|
|
318
|
+
)
|
|
319
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createElement, useRef, type ComponentType } from 'react'
|
|
2
|
+
import { FollowHost } from './FollowHost.tsx'
|
|
3
|
+
|
|
4
|
+
/** Props forwarded through a follow wrap; extra kit seats pass through. */
|
|
5
|
+
export type FollowWrapProps = {
|
|
6
|
+
node?: unknown
|
|
7
|
+
renderSlot?: unknown
|
|
8
|
+
} & Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* True while a Chat node is still growing: an assistant/workflow `status:
|
|
12
|
+
* 'running'` payload, a Tool root that has not settled (`kind` absent), or a
|
|
13
|
+
* model-retry whose current attempt is still `scheduled`.
|
|
14
|
+
* @param node - The Chat node's view `node` prop.
|
|
15
|
+
* @returns whether this row should own conversation follow.
|
|
16
|
+
*/
|
|
17
|
+
export function isGrowingChatNode(node: unknown): boolean {
|
|
18
|
+
if (node === null || typeof node !== 'object' || !('data' in node)) return false
|
|
19
|
+
const data = (node as { data: unknown }).data
|
|
20
|
+
if (data === null || typeof data !== 'object') return false
|
|
21
|
+
if ('status' in data && (data as { status: unknown }).status === 'running') return true
|
|
22
|
+
if ('root' in data) {
|
|
23
|
+
const root = (data as { root: unknown }).root
|
|
24
|
+
if (root !== null && typeof root === 'object' && !('kind' in root)) return true
|
|
25
|
+
}
|
|
26
|
+
if ('current' in data) {
|
|
27
|
+
const current = (data as { current: unknown }).current
|
|
28
|
+
if (
|
|
29
|
+
current !== null
|
|
30
|
+
&& typeof current === 'object'
|
|
31
|
+
&& 'retryState' in current
|
|
32
|
+
&& (current as { retryState: unknown }).retryState === 'scheduled'
|
|
33
|
+
) return true
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wrap a prior Chat node renderer so a growing row shares conversation
|
|
40
|
+
* follow. Presentation stays with the wrapped component; kit seats
|
|
41
|
+
* (`renderSlot`, locale, inject) pass through unchanged.
|
|
42
|
+
* @param Inner - The already-registered row component.
|
|
43
|
+
* @returns A follow-hosted row.
|
|
44
|
+
*/
|
|
45
|
+
export function wrapFollowNodeView(Inner: ComponentType<FollowWrapProps>) {
|
|
46
|
+
return function TypewriterFollowNodeView(props: FollowWrapProps) {
|
|
47
|
+
const speedCpsRef = useRef(35)
|
|
48
|
+
return (
|
|
49
|
+
<FollowHost active={isGrowingChatNode(props.node)} speedCpsRef={speedCpsRef}>
|
|
50
|
+
{createElement(Inner, props)}
|
|
51
|
+
</FollowHost>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|