@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,25 @@
|
|
|
1
|
+
import { useRef, type ReactNode } from 'react'
|
|
2
|
+
import { useConversationFollow } from './teleprompterGlide.ts'
|
|
3
|
+
import css from './TypewriterAssistantNodeView.module.css'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Document-flow host that owns conversation-port follow while `active`.
|
|
7
|
+
* Shared by assistant blocks and every other growing Chat row.
|
|
8
|
+
*/
|
|
9
|
+
export function FollowHost({
|
|
10
|
+
active,
|
|
11
|
+
speedCpsRef,
|
|
12
|
+
minSpeedPxPerSec,
|
|
13
|
+
maxSpeedPxPerSec,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
active: boolean
|
|
17
|
+
speedCpsRef: { current: number }
|
|
18
|
+
minSpeedPxPerSec?: number | undefined
|
|
19
|
+
maxSpeedPxPerSec?: number | undefined
|
|
20
|
+
children: ReactNode
|
|
21
|
+
}) {
|
|
22
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
23
|
+
useConversationFollow(rootRef, active, speedCpsRef, minSpeedPxPerSec, maxSpeedPxPerSec)
|
|
24
|
+
return <div ref={rootRef} className={css.follow}>{children}</div>
|
|
25
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { createPortal } from 'react-dom'
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type Ref,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import {
|
|
12
|
+
IconChevronDownOutline14,
|
|
13
|
+
IconChevronRightOutline14,
|
|
14
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
15
|
+
import {
|
|
16
|
+
useTurnProcessFold,
|
|
17
|
+
type TurnFoldAddress,
|
|
18
|
+
} from './turnProcessFold.ts'
|
|
19
|
+
import { TurnProcessMember } from './TurnProcessMember.tsx'
|
|
20
|
+
import css from './TypewriterAssistantNodeView.module.css'
|
|
21
|
+
|
|
22
|
+
export type ToolActivityGroupPosition = 'start' | 'middle' | 'end'
|
|
23
|
+
|
|
24
|
+
export interface ToolActivityGroupInfo {
|
|
25
|
+
readonly id: string
|
|
26
|
+
readonly domId: string
|
|
27
|
+
readonly semantic: string
|
|
28
|
+
readonly label: string
|
|
29
|
+
readonly count: number
|
|
30
|
+
readonly position: ToolActivityGroupPosition
|
|
31
|
+
readonly hasGrowing: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ToolActivityGroupState {
|
|
35
|
+
collapsed: boolean
|
|
36
|
+
readonly listeners: Set<() => void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ToolActivityMount {
|
|
40
|
+
target: HTMLElement | null
|
|
41
|
+
readonly listeners: Set<() => void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const groupStates = new Map<string, ToolActivityGroupState>()
|
|
45
|
+
const groupMounts = new Map<string, ToolActivityMount>()
|
|
46
|
+
|
|
47
|
+
function groupState(id: string): ToolActivityGroupState {
|
|
48
|
+
const current = groupStates.get(id)
|
|
49
|
+
if (current !== undefined) return current
|
|
50
|
+
const created: ToolActivityGroupState = { collapsed: false, listeners: new Set() }
|
|
51
|
+
groupStates.set(id, created)
|
|
52
|
+
return created
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function groupMount(id: string): ToolActivityMount {
|
|
56
|
+
const current = groupMounts.get(id)
|
|
57
|
+
if (current !== undefined) return current
|
|
58
|
+
const created: ToolActivityMount = { target: null, listeners: new Set() }
|
|
59
|
+
groupMounts.set(id, created)
|
|
60
|
+
return created
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function notify(listeners: Set<() => void>): void {
|
|
64
|
+
for (const listener of listeners) listener()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function subscribeGroup(id: string, listener: () => void): () => void {
|
|
68
|
+
const state = groupState(id)
|
|
69
|
+
state.listeners.add(listener)
|
|
70
|
+
return () => {
|
|
71
|
+
state.listeners.delete(listener)
|
|
72
|
+
if (state.listeners.size === 0) groupStates.delete(id)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function subscribeMount(id: string, listener: () => void): () => void {
|
|
77
|
+
const mount = groupMount(id)
|
|
78
|
+
mount.listeners.add(listener)
|
|
79
|
+
return () => {
|
|
80
|
+
mount.listeners.delete(listener)
|
|
81
|
+
if (mount.listeners.size === 0 && mount.target === null) groupMounts.delete(id)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setMount(id: string, target: HTMLElement | null): void {
|
|
86
|
+
const mount = groupMount(id)
|
|
87
|
+
if (mount.target === target) return
|
|
88
|
+
mount.target = target
|
|
89
|
+
notify(mount.listeners)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hash(value: string): string {
|
|
93
|
+
let result = 2_166_136_261
|
|
94
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
95
|
+
result ^= value.charCodeAt(index)
|
|
96
|
+
result = Math.imul(result, 16_777_619)
|
|
97
|
+
}
|
|
98
|
+
return (result >>> 0).toString(36)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Stable identity for one contiguous semantic tool group in a Turn. */
|
|
102
|
+
export function toolActivityGroupId(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
turn: number,
|
|
105
|
+
semantic: string,
|
|
106
|
+
firstNodeKey: string,
|
|
107
|
+
): string {
|
|
108
|
+
return `dsh-tool-group-${hash(`${sessionId}\u0000${turn}\u0000${semantic}\u0000${firstNodeKey}`)}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function useToolActivityFold(id: string): { collapsed: boolean; toggle: () => void; collapse: () => void } {
|
|
112
|
+
const subscribe = useCallback((listener: () => void) => subscribeGroup(id, listener), [id])
|
|
113
|
+
const collapsed = useSyncExternalStore(
|
|
114
|
+
subscribe,
|
|
115
|
+
() => groupState(id).collapsed,
|
|
116
|
+
() => false,
|
|
117
|
+
)
|
|
118
|
+
const toggle = useCallback(() => {
|
|
119
|
+
const state = groupState(id)
|
|
120
|
+
state.collapsed = !state.collapsed
|
|
121
|
+
notify(state.listeners)
|
|
122
|
+
}, [id])
|
|
123
|
+
const collapse = useCallback(() => {
|
|
124
|
+
const state = groupState(id)
|
|
125
|
+
if (state.collapsed) return
|
|
126
|
+
state.collapsed = true
|
|
127
|
+
notify(state.listeners)
|
|
128
|
+
}, [id])
|
|
129
|
+
return { collapsed, toggle, collapse }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function useToolActivityMount(id: string): HTMLElement | null {
|
|
133
|
+
const subscribe = useCallback((listener: () => void) => subscribeMount(id, listener), [id])
|
|
134
|
+
return useSyncExternalStore(
|
|
135
|
+
subscribe,
|
|
136
|
+
() => groupMount(id).target,
|
|
137
|
+
() => null,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface ScrollPositionSnapshot {
|
|
142
|
+
readonly port: HTMLElement
|
|
143
|
+
readonly top: number
|
|
144
|
+
readonly bottomGap: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function restoreScrollPosition(snapshot: ScrollPositionSnapshot): void {
|
|
148
|
+
const floor = Math.max(0, snapshot.port.scrollHeight - snapshot.port.clientHeight)
|
|
149
|
+
const target = snapshot.bottomGap <= 25
|
|
150
|
+
? floor
|
|
151
|
+
: Math.min(snapshot.top, floor)
|
|
152
|
+
snapshot.port.scrollTop = target
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function ToolActivityGroupFrame({
|
|
156
|
+
group,
|
|
157
|
+
collapsed,
|
|
158
|
+
toggle,
|
|
159
|
+
header,
|
|
160
|
+
children,
|
|
161
|
+
}: {
|
|
162
|
+
readonly group: ToolActivityGroupInfo
|
|
163
|
+
readonly collapsed: boolean
|
|
164
|
+
readonly toggle: () => void
|
|
165
|
+
readonly header: ReactNode
|
|
166
|
+
readonly children: ReactNode
|
|
167
|
+
}) {
|
|
168
|
+
const frameRef = useRef<HTMLDivElement | null>(null)
|
|
169
|
+
const pendingScroll = useRef<ScrollPositionSnapshot | null>(null)
|
|
170
|
+
const register = useCallback((element: HTMLDivElement | null) => {
|
|
171
|
+
frameRef.current = element
|
|
172
|
+
setMount(group.id, element)
|
|
173
|
+
}, [group.id])
|
|
174
|
+
const toggleWithScroll = useCallback(() => {
|
|
175
|
+
const port = frameRef.current?.closest<HTMLElement>('[data-conversation-scroll]')
|
|
176
|
+
if (port !== null && port !== undefined) {
|
|
177
|
+
const floor = Math.max(0, port.scrollHeight - port.clientHeight)
|
|
178
|
+
pendingScroll.current = {
|
|
179
|
+
port,
|
|
180
|
+
top: port.scrollTop,
|
|
181
|
+
bottomGap: floor - port.scrollTop,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
toggle()
|
|
185
|
+
}, [toggle])
|
|
186
|
+
useLayoutEffect(() => {
|
|
187
|
+
const snapshot = pendingScroll.current
|
|
188
|
+
if (snapshot === null) return
|
|
189
|
+
pendingScroll.current = null
|
|
190
|
+
restoreScrollPosition(snapshot)
|
|
191
|
+
if (typeof window === 'undefined') return
|
|
192
|
+
const frame = window.requestAnimationFrame(() => restoreScrollPosition(snapshot))
|
|
193
|
+
return () => window.cancelAnimationFrame(frame)
|
|
194
|
+
}, [collapsed])
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
ref={register}
|
|
198
|
+
id={group.domId}
|
|
199
|
+
className={css.toolGroupFrame}
|
|
200
|
+
data-stream-tool-group={group.semantic}
|
|
201
|
+
data-stream-tool-group-frame=""
|
|
202
|
+
data-stream-tool-group-position={group.position}
|
|
203
|
+
data-stream-tool-group-count={group.count}
|
|
204
|
+
data-stream-tool-group-collapsed={collapsed || undefined}
|
|
205
|
+
>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
className={css.toolGroupHeader}
|
|
209
|
+
aria-expanded={!collapsed}
|
|
210
|
+
aria-controls={group.domId}
|
|
211
|
+
data-stream-tool-group-toggle=""
|
|
212
|
+
onClick={toggleWithScroll}
|
|
213
|
+
>
|
|
214
|
+
{header}
|
|
215
|
+
<span className={css.toolGroupHeaderChevron} aria-hidden>
|
|
216
|
+
{collapsed ? <IconChevronRightOutline14 size={14} /> : <IconChevronDownOutline14 size={14} />}
|
|
217
|
+
</span>
|
|
218
|
+
</button>
|
|
219
|
+
{children}
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface ToolActivityGroupMemberProps {
|
|
225
|
+
readonly group: ToolActivityGroupInfo
|
|
226
|
+
readonly address: TurnFoldAddress
|
|
227
|
+
readonly memberId: string
|
|
228
|
+
readonly memberOrder: number
|
|
229
|
+
readonly turnStartTime?: number | undefined
|
|
230
|
+
readonly formatRunningElapsed?: ((ms: number) => string) | undefined
|
|
231
|
+
readonly memberRef?: Ref<HTMLDivElement> | undefined
|
|
232
|
+
readonly eventAttributes?: Readonly<Record<string, string | undefined>>
|
|
233
|
+
readonly header: ReactNode
|
|
234
|
+
readonly children: ReactNode
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Mount the first member as a group host and portal subsequent tool members
|
|
239
|
+
* into the same host. The Turn fold remains shared with ordinary process rows,
|
|
240
|
+
* while a contiguous run of commands gets one Codex-style disclosure block.
|
|
241
|
+
*/
|
|
242
|
+
export function ToolActivityGroupMember({
|
|
243
|
+
group,
|
|
244
|
+
address,
|
|
245
|
+
memberId,
|
|
246
|
+
memberOrder,
|
|
247
|
+
turnStartTime,
|
|
248
|
+
formatRunningElapsed,
|
|
249
|
+
memberRef,
|
|
250
|
+
eventAttributes,
|
|
251
|
+
header,
|
|
252
|
+
children,
|
|
253
|
+
}: ToolActivityGroupMemberProps) {
|
|
254
|
+
const fold = useTurnProcessFold(address, { memberId, memberOrder })
|
|
255
|
+
const activity = useToolActivityFold(group.id)
|
|
256
|
+
const mount = useToolActivityMount(group.id)
|
|
257
|
+
|
|
258
|
+
// Codex leaves a running tool group open while work is arriving, then folds
|
|
259
|
+
// the completed batch automatically. A manual toggle after completion still
|
|
260
|
+
// wins because this effect only reacts to the growing -> settled transition.
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (group.position === 'start' && !group.hasGrowing) activity.collapse()
|
|
263
|
+
}, [activity.collapse, group.hasGrowing, group.position])
|
|
264
|
+
|
|
265
|
+
if (group.position === 'start') {
|
|
266
|
+
return (
|
|
267
|
+
<TurnProcessMember
|
|
268
|
+
address={address}
|
|
269
|
+
memberId={memberId}
|
|
270
|
+
memberOrder={memberOrder}
|
|
271
|
+
turnStartTime={turnStartTime}
|
|
272
|
+
formatRunningElapsed={formatRunningElapsed}
|
|
273
|
+
className={css.toolGroupAnchor}
|
|
274
|
+
data-stream-tool-group-anchor=""
|
|
275
|
+
>
|
|
276
|
+
<ToolActivityGroupFrame
|
|
277
|
+
group={group}
|
|
278
|
+
collapsed={activity.collapsed}
|
|
279
|
+
toggle={activity.toggle}
|
|
280
|
+
header={header}
|
|
281
|
+
>
|
|
282
|
+
<div
|
|
283
|
+
{...eventAttributes}
|
|
284
|
+
ref={memberRef}
|
|
285
|
+
className={css.eventRow}
|
|
286
|
+
data-stream-tool-group-member=""
|
|
287
|
+
data-stream-tool-group-position="start"
|
|
288
|
+
data-stream-tool-group-collapsed={activity.collapsed || undefined}
|
|
289
|
+
>
|
|
290
|
+
{children}
|
|
291
|
+
</div>
|
|
292
|
+
</ToolActivityGroupFrame>
|
|
293
|
+
</TurnProcessMember>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const hidden = fold.collapsed || activity.collapsed
|
|
298
|
+
const member = (
|
|
299
|
+
<div
|
|
300
|
+
id={memberId}
|
|
301
|
+
data-turn-process=""
|
|
302
|
+
data-tool-group-member=""
|
|
303
|
+
data-tool-group-hidden={hidden || undefined}
|
|
304
|
+
hidden={hidden}
|
|
305
|
+
>
|
|
306
|
+
<div
|
|
307
|
+
{...eventAttributes}
|
|
308
|
+
ref={memberRef}
|
|
309
|
+
className={css.eventRow}
|
|
310
|
+
data-stream-tool-group-member=""
|
|
311
|
+
data-stream-tool-group-position={group.position}
|
|
312
|
+
data-stream-tool-group-collapsed={activity.collapsed || undefined}
|
|
313
|
+
>
|
|
314
|
+
{children}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if (mount === null) return member
|
|
320
|
+
return (
|
|
321
|
+
<span data-tool-group-proxy="" aria-hidden>
|
|
322
|
+
{createPortal(member, mount)}
|
|
323
|
+
</span>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Render the group heading with the semantic icon supplied by the tool view. */
|
|
328
|
+
export function ToolActivityGroupHeader({
|
|
329
|
+
label,
|
|
330
|
+
icon,
|
|
331
|
+
}: {
|
|
332
|
+
readonly label: string
|
|
333
|
+
readonly icon: ReactNode
|
|
334
|
+
}) {
|
|
335
|
+
return (
|
|
336
|
+
<>
|
|
337
|
+
<span className={css.toolGroupHeaderIcon} aria-hidden>{icon}</span>
|
|
338
|
+
<span className={css.toolGroupHeaderLabel}>{label}</span>
|
|
339
|
+
</>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createElement, useEffect, useState, type ComponentType } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
IconChevronDownOutline14,
|
|
4
|
+
IconChevronRightOutline14,
|
|
5
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
6
|
+
import type { FollowWrapProps } from './TypewriterToolNodeView.tsx'
|
|
7
|
+
import {
|
|
8
|
+
turnProcessBoundaryId,
|
|
9
|
+
useTurnProcessFold,
|
|
10
|
+
type TurnFoldAddress,
|
|
11
|
+
} from './turnProcessFold.ts'
|
|
12
|
+
import { formatTurnProcessed, type TurnElapsedTranslator } from './turnElapsed.ts'
|
|
13
|
+
import css from './TypewriterAssistantNodeView.module.css'
|
|
14
|
+
|
|
15
|
+
interface TimelineTurn {
|
|
16
|
+
readonly turn: number
|
|
17
|
+
readonly status: 'open' | 'closed' | 'unknown'
|
|
18
|
+
readonly start?: { readonly seq?: number; readonly time?: number } | undefined
|
|
19
|
+
readonly end?: { readonly seq?: number } | undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nodeKey(node: unknown): string | undefined {
|
|
23
|
+
if (node === null || typeof node !== 'object' || !('key' in node)) return undefined
|
|
24
|
+
return typeof (node as { key: unknown }).key === 'string' ? (node as { key: string }).key : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function userSeq(node: unknown): number | undefined {
|
|
28
|
+
if (node === null || typeof node !== 'object' || !('data' in node)) return undefined
|
|
29
|
+
const data = (node as { data: unknown }).data
|
|
30
|
+
if (data === null || typeof data !== 'object' || !('seq' in data)) return undefined
|
|
31
|
+
return typeof (data as { seq: unknown }).seq === 'number' ? (data as { seq: number }).seq : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function userTime(node: unknown): number | undefined {
|
|
35
|
+
if (node === null || typeof node !== 'object' || !('data' in node)) return undefined
|
|
36
|
+
const data = (node as { data: unknown }).data
|
|
37
|
+
if (data === null || typeof data !== 'object' || !('time' in data)) return undefined
|
|
38
|
+
return typeof (data as { time: unknown }).time === 'number' ? (data as { time: number }).time : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function chatParts(snapshot: unknown): {
|
|
42
|
+
readonly order: readonly unknown[]
|
|
43
|
+
readonly get: (key: string) => unknown
|
|
44
|
+
readonly turns: ReadonlyMap<number, TimelineTurn>
|
|
45
|
+
readonly turnOrder: readonly number[]
|
|
46
|
+
} | undefined {
|
|
47
|
+
if (snapshot === null || typeof snapshot !== 'object' || !('chat' in snapshot)) return undefined
|
|
48
|
+
const chat = (snapshot as { chat: unknown }).chat
|
|
49
|
+
if (chat === null || typeof chat !== 'object') return undefined
|
|
50
|
+
if (!('order' in chat) || !Array.isArray((chat as { order: unknown }).order)) return undefined
|
|
51
|
+
if (!('nodes' in chat) || !('timeline' in chat)) return undefined
|
|
52
|
+
const nodes = (chat as { nodes: unknown }).nodes
|
|
53
|
+
const timeline = (chat as { timeline: unknown }).timeline
|
|
54
|
+
if (nodes === null || typeof nodes !== 'object' || !('get' in nodes)) return undefined
|
|
55
|
+
if (timeline === null || typeof timeline !== 'object' || !('turns' in timeline) || !('turnOrder' in timeline)) return undefined
|
|
56
|
+
const get = (nodes as { get: unknown }).get
|
|
57
|
+
const turns = (timeline as { turns: unknown }).turns
|
|
58
|
+
const turnOrder = (timeline as { turnOrder: unknown }).turnOrder
|
|
59
|
+
if (typeof get !== 'function' || !(turns instanceof Map) || !Array.isArray(turnOrder)) return undefined
|
|
60
|
+
return {
|
|
61
|
+
order: (chat as { order: readonly unknown[] }).order,
|
|
62
|
+
get: (key: string) => get.call(nodes, key),
|
|
63
|
+
turns: turns as ReadonlyMap<number, TimelineTurn>,
|
|
64
|
+
turnOrder: turnOrder as readonly number[],
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function associatedTurn(snapshot: unknown, currentKey: string, currentSeq: number): TimelineTurn | undefined {
|
|
69
|
+
const chat = chatParts(snapshot)
|
|
70
|
+
if (chat === undefined) return undefined
|
|
71
|
+
const currentIndex = chat.order.indexOf(currentKey)
|
|
72
|
+
if (currentIndex === -1) return undefined
|
|
73
|
+
const current = chat.get(currentKey)
|
|
74
|
+
if (current !== null && typeof current === 'object' && 'location' in current) {
|
|
75
|
+
const location = (current as { location: unknown }).location
|
|
76
|
+
if (
|
|
77
|
+
location !== null
|
|
78
|
+
&& typeof location === 'object'
|
|
79
|
+
&& 'kind' in location
|
|
80
|
+
&& ((location as { kind: unknown }).kind === 'turn' || (location as { kind: unknown }).kind === 'step')
|
|
81
|
+
&& 'turn' in location
|
|
82
|
+
) {
|
|
83
|
+
const turn = (location as { turn: unknown }).turn
|
|
84
|
+
if (turn !== null && typeof turn === 'object' && 'turn' in turn) return turn as TimelineTurn
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let closest: TimelineTurn | undefined
|
|
88
|
+
let closestStartSeq = Number.NEGATIVE_INFINITY
|
|
89
|
+
for (const turnNumber of chat.turnOrder) {
|
|
90
|
+
const turn = chat.turns.get(turnNumber)
|
|
91
|
+
const startSeq = turn?.start?.seq
|
|
92
|
+
const endSeq = turn?.end?.seq
|
|
93
|
+
if (
|
|
94
|
+
turn !== undefined
|
|
95
|
+
&& typeof startSeq === 'number'
|
|
96
|
+
&& startSeq < currentSeq
|
|
97
|
+
&& (typeof endSeq !== 'number' || currentSeq < endSeq)
|
|
98
|
+
&& startSeq > closestStartSeq
|
|
99
|
+
) {
|
|
100
|
+
closest = turn
|
|
101
|
+
closestStartSeq = startSeq
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return closest
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isLatestUser(snapshot: unknown, currentKey: string): boolean {
|
|
108
|
+
const chat = chatParts(snapshot)
|
|
109
|
+
if (chat === undefined) return false
|
|
110
|
+
for (let index = chat.order.length - 1; index >= 0; index -= 1) {
|
|
111
|
+
const key = chat.order[index]
|
|
112
|
+
if (typeof key !== 'string') continue
|
|
113
|
+
const candidate = chat.get(key)
|
|
114
|
+
if (candidate === null || typeof candidate !== 'object' || !('kind' in candidate)) continue
|
|
115
|
+
if ((candidate as { kind: unknown }).kind === 'user') return key === currentKey
|
|
116
|
+
}
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isRunning(snapshot: unknown): boolean {
|
|
121
|
+
return snapshot !== null
|
|
122
|
+
&& typeof snapshot === 'object'
|
|
123
|
+
&& 'running' in snapshot
|
|
124
|
+
&& (snapshot as { running: unknown }).running === true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function useElapsedNow(active: boolean): number {
|
|
128
|
+
const [now, setNow] = useState(() => Date.now())
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!active) return
|
|
131
|
+
setNow(Date.now())
|
|
132
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1_000)
|
|
133
|
+
return () => window.clearInterval(timer)
|
|
134
|
+
}, [active])
|
|
135
|
+
return now
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function TurnPrelude({
|
|
139
|
+
active,
|
|
140
|
+
address,
|
|
141
|
+
boundaryId,
|
|
142
|
+
boundaryOrder,
|
|
143
|
+
startTime,
|
|
144
|
+
t,
|
|
145
|
+
}: {
|
|
146
|
+
readonly active: boolean
|
|
147
|
+
readonly address: TurnFoldAddress | undefined
|
|
148
|
+
readonly boundaryId: string
|
|
149
|
+
readonly boundaryOrder: number
|
|
150
|
+
readonly startTime: number
|
|
151
|
+
readonly t: TurnElapsedTranslator
|
|
152
|
+
}) {
|
|
153
|
+
const fold = useTurnProcessFold(address, { boundaryId, boundaryOrder })
|
|
154
|
+
const running = active && !fold.completed
|
|
155
|
+
const now = useElapsedNow(running)
|
|
156
|
+
if (!running && !fold.completed) return null
|
|
157
|
+
const label = fold.completed ? fold.label : formatTurnProcessed(now - startTime, t)
|
|
158
|
+
if (label === undefined) return null
|
|
159
|
+
return (
|
|
160
|
+
<div className={css.turnPrelude} data-turn-prelude="">
|
|
161
|
+
<div
|
|
162
|
+
className={css.turnFoldRow}
|
|
163
|
+
data-turn-fold-row=""
|
|
164
|
+
data-turn-fold-state={fold.completed ? 'completed' : 'running'}
|
|
165
|
+
>
|
|
166
|
+
{fold.completed ? (
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
className={css.turnFoldButton}
|
|
170
|
+
aria-expanded={!fold.collapsed}
|
|
171
|
+
aria-controls={fold.controls}
|
|
172
|
+
onClick={fold.toggle}
|
|
173
|
+
>
|
|
174
|
+
<span>{label}</span>
|
|
175
|
+
{fold.collapsed ? <IconChevronRightOutline14 /> : <IconChevronDownOutline14 />}
|
|
176
|
+
</button>
|
|
177
|
+
) : (
|
|
178
|
+
<span className={css.turnFoldLabel} aria-live="off">{label}</span>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
{running && !fold.hasMembers && (
|
|
182
|
+
<div className={css.turnWaiting} data-turn-waiting="" role="status" aria-live="polite">
|
|
183
|
+
思考中
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Add an immediate Turn status boundary directly after an ordinary user row. */
|
|
191
|
+
export function wrapTurnPreludeNodeView(Inner: ComponentType<FollowWrapProps>) {
|
|
192
|
+
return function TurnPreludeUserNodeView(props: FollowWrapProps) {
|
|
193
|
+
const key = nodeKey(props.node)
|
|
194
|
+
const seq = userSeq(props.node)
|
|
195
|
+
const time = userTime(props.node)
|
|
196
|
+
const useSession = props.useSession
|
|
197
|
+
if (
|
|
198
|
+
key === undefined
|
|
199
|
+
|| seq === undefined
|
|
200
|
+
|| time === undefined
|
|
201
|
+
|| typeof props.sessionId !== 'string'
|
|
202
|
+
|| typeof useSession !== 'function'
|
|
203
|
+
|| typeof props.t !== 'function'
|
|
204
|
+
) return createElement(Inner, props)
|
|
205
|
+
return (
|
|
206
|
+
<TurnPreludeUserWithSession
|
|
207
|
+
Inner={Inner}
|
|
208
|
+
props={props}
|
|
209
|
+
nodeKey={key}
|
|
210
|
+
nodeSeq={seq}
|
|
211
|
+
nodeTime={time}
|
|
212
|
+
sessionId={props.sessionId}
|
|
213
|
+
useSession={useSession as (selector: (snapshot: unknown) => unknown) => unknown}
|
|
214
|
+
t={props.t as TurnElapsedTranslator}
|
|
215
|
+
/>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function TurnPreludeUserWithSession({
|
|
221
|
+
Inner,
|
|
222
|
+
props,
|
|
223
|
+
nodeKey: currentKey,
|
|
224
|
+
nodeSeq,
|
|
225
|
+
nodeTime,
|
|
226
|
+
sessionId,
|
|
227
|
+
useSession,
|
|
228
|
+
t,
|
|
229
|
+
}: {
|
|
230
|
+
readonly Inner: ComponentType<FollowWrapProps>
|
|
231
|
+
readonly props: FollowWrapProps
|
|
232
|
+
readonly nodeKey: string
|
|
233
|
+
readonly nodeSeq: number
|
|
234
|
+
readonly nodeTime: number
|
|
235
|
+
readonly sessionId: string
|
|
236
|
+
readonly useSession: (selector: (snapshot: unknown) => unknown) => unknown
|
|
237
|
+
readonly t: TurnElapsedTranslator
|
|
238
|
+
}) {
|
|
239
|
+
const turn = useSession(snapshot => associatedTurn(snapshot, currentKey, nodeSeq)) as TimelineTurn | undefined
|
|
240
|
+
const running = useSession(isRunning) as boolean
|
|
241
|
+
const latestUser = useSession(snapshot => isLatestUser(snapshot, currentKey)) as boolean
|
|
242
|
+
const address = turn === undefined ? undefined : { sessionId, turn: turn.turn }
|
|
243
|
+
const active = turn?.status === 'open' || (turn === undefined && running && latestUser)
|
|
244
|
+
const startTime = typeof turn?.start?.time === 'number' ? turn.start.time : nodeTime
|
|
245
|
+
return (
|
|
246
|
+
<>
|
|
247
|
+
{createElement(Inner, props)}
|
|
248
|
+
<TurnPrelude
|
|
249
|
+
active={active}
|
|
250
|
+
address={address}
|
|
251
|
+
boundaryId={turnProcessBoundaryId(sessionId, currentKey)}
|
|
252
|
+
boundaryOrder={nodeSeq}
|
|
253
|
+
startTime={startTime}
|
|
254
|
+
t={t}
|
|
255
|
+
/>
|
|
256
|
+
</>
|
|
257
|
+
)
|
|
258
|
+
}
|