@roaming-ai/dsh-group-chat 0.2.2 → 0.3.1
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.md +91 -27
- package/lib/client.js +1254 -598
- package/lib/client.js.map +1 -1
- package/lib/index.js +573 -109
- package/lib/types/client/components/AsidePanel.d.ts +3 -19
- package/lib/types/client/components/Bubble.d.ts +6 -3
- package/lib/types/client/components/ChatPanel.d.ts +1 -0
- package/lib/types/client/components/ConstraintList.d.ts +11 -0
- package/lib/types/client/components/FailCard.d.ts +9 -0
- package/lib/types/client/components/Fold.d.ts +19 -0
- package/lib/types/client/components/HoverTip.d.ts +22 -0
- package/lib/types/client/components/MessageFlow.d.ts +1 -0
- package/lib/types/client/components/MsgActions.d.ts +14 -0
- package/lib/types/client/components/NavPanel.d.ts +9 -11
- package/lib/types/client/components/RoleDrawer.d.ts +3 -2
- package/lib/types/client/components/ToolRow.d.ts +2 -2
- package/lib/types/client/hooks/useComposer.d.ts +6 -1
- package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
- package/lib/types/client/lib/composer-draft.d.ts +17 -0
- package/lib/types/client/lib/model.d.ts +1 -7
- package/lib/types/client/utils/utils.d.ts +4 -4
- package/lib/types/core/constraints.d.ts +61 -0
- package/lib/types/core/errors.d.ts +54 -0
- package/lib/types/core/json.d.ts +6 -0
- package/lib/types/core/types.d.ts +76 -60
- package/lib/types/host/api/actions.d.ts +1 -1
- package/lib/types/host/engine/conversation.d.ts +5 -3
- package/lib/types/host/engine/defaults.d.ts +13 -0
- package/lib/types/host/engine/fold.d.ts +17 -0
- package/lib/types/host/engine/index.d.ts +1 -0
- package/lib/types/host/engine/retitle.d.ts +5 -5
- package/lib/types/host/service.d.ts +1 -1
- package/lib/types/host/state.d.ts +2 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/shared/file-mention-grammar.d.ts +3 -12
- package/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +25 -59
- package/src/client/components/AsidePanel.tsx +50 -23
- package/src/client/components/Bubble.tsx +45 -18
- package/src/client/components/ChatPanel.tsx +17 -12
- package/src/client/components/Composer.tsx +26 -20
- package/src/client/components/ConstraintList.tsx +64 -0
- package/src/client/components/FailCard.tsx +49 -0
- package/src/client/components/Fold.tsx +72 -0
- package/src/client/components/HoverTip.tsx +134 -0
- package/src/client/components/MessageFlow.tsx +78 -54
- package/src/client/components/MsgActions.tsx +85 -0
- package/src/client/components/NavPanel.tsx +102 -79
- package/src/client/components/RoleDrawer.tsx +29 -7
- package/src/client/components/ThinkRow.tsx +7 -3
- package/src/client/components/ToolRow.tsx +9 -5
- package/src/client/hooks/useComposer.ts +54 -56
- package/src/client/hooks/useGroupChatState.ts +7 -17
- package/src/client/lib/composer-draft.ts +47 -0
- package/src/client/lib/model.ts +1 -14
- package/src/client/lib/styles.ts +73 -12
- package/src/client/react-dom-shim.d.ts +2 -0
- package/src/client/utils/utils.ts +6 -6
- package/src/core/constraints.ts +204 -0
- package/src/core/errors.ts +174 -0
- package/src/core/json.ts +18 -0
- package/src/core/types.ts +52 -19
- package/src/host/api/actions.ts +49 -14
- package/src/host/broadcast.ts +3 -3
- package/src/host/engine/conversation.ts +104 -40
- package/src/host/engine/defaults.ts +28 -0
- package/src/host/engine/fold.ts +99 -0
- package/src/host/engine/index.ts +1 -0
- package/src/host/engine/retitle.ts +24 -41
- package/src/host/materials/materials.ts +2 -0
- package/src/host/persistence/persistence.ts +18 -1
- package/src/host/service.ts +1 -1
- package/src/host/state.ts +5 -4
- package/src/host/tools/tools.ts +2 -2
- package/src/index.ts +2 -0
- package/src/shared/file-mention-grammar.test.ts +1 -38
- package/src/shared/file-mention-grammar.ts +7 -33
- package/lib/types/client/Bubble.d.ts +0 -10
- package/lib/types/client/Glyph.d.ts +0 -12
- package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
- package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
- package/lib/types/client/PermissionSelect.d.ts +0 -13
- package/lib/types/client/RoleDrawer.d.ts +0 -17
- package/lib/types/client/ThinkRow.d.ts +0 -9
- package/lib/types/client/ToolRow.d.ts +0 -9
- package/lib/types/client/api.d.ts +0 -8
- package/lib/types/client/components.d.ts +0 -22
- package/lib/types/client/drawer.d.ts +0 -17
- package/lib/types/client/glyph.d.ts +0 -12
- package/lib/types/client/model.d.ts +0 -78
- package/lib/types/client/panel.d.ts +0 -10
- package/lib/types/client/settings.d.ts +0 -15
- package/lib/types/client/styles.d.ts +0 -10
- package/lib/types/client/ui.d.ts +0 -17
- package/lib/types/client/utils.d.ts +0 -22
- package/lib/types/host/actions.d.ts +0 -34
- package/lib/types/host/conversation.d.ts +0 -25
- package/lib/types/host/http.d.ts +0 -16
- package/lib/types/host/materials.d.ts +0 -32
- package/lib/types/host/persistence.d.ts +0 -31
- package/lib/types/host/routes.d.ts +0 -11
- package/lib/types/host/store.d.ts +0 -65
- package/lib/types/host/tools.d.ts +0 -28
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
import type { ReactNode, KeyboardEvent as ReactKeyboardEvent, ClipboardEvent as ReactClipboardEvent, DragEvent as ReactDragEvent } from 'react'
|
|
7
7
|
import { Icon, P } from '../lib/ui.ts'
|
|
8
8
|
import { PermissionSelect } from './PermissionSelect.tsx'
|
|
9
|
+
import { HoverTip } from './HoverTip.tsx'
|
|
9
10
|
import { escapeRegExp, type ClientSnapshot, type SnapshotRole } from '../lib/model.ts'
|
|
10
11
|
import type { AtToken } from '../../shared/file-mention-grammar.ts'
|
|
11
12
|
|
|
13
|
+
/** 轮数控件 hover:宿主 Tooltip 三行说明(pre-line)。 */
|
|
14
|
+
const ROUNDS_HINT = '一轮 = 参与角色各说一次。\n要他们自己互相反驳、你不插话时再加轮。\n要边看边插话,就留 1,再点发送。'
|
|
15
|
+
|
|
12
16
|
interface ComposerProps {
|
|
13
17
|
snap: ClientSnapshot
|
|
14
18
|
sess: ClientSnapshot['sessions'][number] | null
|
|
@@ -102,23 +106,25 @@ export function Composer(props: ComposerProps): ReactNode {
|
|
|
102
106
|
{err ? <div className="dsgc-err">{err}</div> : null}
|
|
103
107
|
<div className="dsgc-parts">
|
|
104
108
|
<span className="dsgc-partslabel">参与角色</span>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
109
|
+
<div className="dsgc-partlist">
|
|
110
|
+
{mentionedRoles.length
|
|
111
|
+
? <span className="dsgc-partslabel">已 @ {mentionedRoles.map((r) => r.name).join('、')}(本轮仅被点名成员发言)</span>
|
|
112
|
+
: enabledRoles.length
|
|
113
|
+
? enabledRoles.map((r) => (
|
|
114
|
+
<button
|
|
115
|
+
key={r.id}
|
|
116
|
+
type="button"
|
|
117
|
+
className={'dsgc-partchip' + (participants.includes(r.id) ? ' on' : '')}
|
|
118
|
+
onClick={() => togglePart(r.id)}
|
|
119
|
+
disabled={busyNow}
|
|
120
|
+
title={busyNow ? '对话进行中,暂停调整' : '点击切换本轮是否参与'}
|
|
121
|
+
>
|
|
122
|
+
<span className="dsgc-chipdot" style={{ background: r.color || '#888' }} />
|
|
123
|
+
{r.name}
|
|
124
|
+
</button>
|
|
125
|
+
))
|
|
126
|
+
: <span className="dsgc-hint">还没有启用的角色,请在右侧添加</span>}
|
|
127
|
+
</div>
|
|
122
128
|
</div>
|
|
123
129
|
<div className="dsgc-card">
|
|
124
130
|
<div className="dsgc-mentionwrap">
|
|
@@ -210,16 +216,16 @@ export function Composer(props: ComposerProps): ReactNode {
|
|
|
210
216
|
onSelect={(tier) => { void mutate({ op: 'setPermissionTier', groupId: group.id, tier }) }}
|
|
211
217
|
/>
|
|
212
218
|
<span style={{ flex: 1 }} />
|
|
213
|
-
<
|
|
219
|
+
<HoverTip label={ROUNDS_HINT} side="top" delayMs={500} maxWidth={280} className="dsgc-rounds">
|
|
214
220
|
<button type="button" className="dsgc-roundbtn" aria-label="减少轮数" disabled={rounds <= 1} onClick={() => { setRounds(Math.max(1, rounds - 1)) }}>
|
|
215
221
|
{Icon(P.IconChevronLeftOutline14, 12)}
|
|
216
222
|
</button>
|
|
217
|
-
<span className="dsgc-roundnum"
|
|
223
|
+
<span className="dsgc-roundnum">{rounds}</span>
|
|
218
224
|
<button type="button" className="dsgc-roundbtn" aria-label="增加轮数" disabled={rounds >= 10} onClick={() => { setRounds(Math.min(10, rounds + 1)) }}>
|
|
219
225
|
{Icon(P.IconChevronRightOutline14, 12)}
|
|
220
226
|
</button>
|
|
221
227
|
<span style={{ padding: '0 6px 0 2px' }}>轮</span>
|
|
222
|
-
</
|
|
228
|
+
</HoverTip>
|
|
223
229
|
{busyNow
|
|
224
230
|
? (
|
|
225
231
|
<P.Button variant="outline" className="dsgc-stopbtn" onClick={() => { void stopRun() }}>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话流折点处:只读结论备忘卡(超过 4 条默认露 3 条)。
|
|
3
|
+
* @module dsh-group-chat/client/components
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, type ReactNode } from 'react'
|
|
7
|
+
import type { SessionConstraint } from '../../core/types.ts'
|
|
8
|
+
import { KIND_LABEL } from '../../core/constraints.ts'
|
|
9
|
+
import { ClipWell, Fold } from './Fold.tsx'
|
|
10
|
+
|
|
11
|
+
interface ConstraintListProps {
|
|
12
|
+
items: SessionConstraint[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ConstraintRow(props: { item: SessionConstraint }): ReactNode {
|
|
16
|
+
const { item } = props
|
|
17
|
+
return (
|
|
18
|
+
<div className="dsgc-constraint">
|
|
19
|
+
<span className={'dsgc-ckind ' + item.kind}>{KIND_LABEL[item.kind]}</span>
|
|
20
|
+
<span className="dsgc-ctext">{item.text}</span>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ConstraintList(props: ConstraintListProps): ReactNode {
|
|
26
|
+
const { items } = props
|
|
27
|
+
const [open, setOpen] = useState(false)
|
|
28
|
+
if (!items.length) return null
|
|
29
|
+
const overflow = items.length > 4
|
|
30
|
+
const head = overflow ? items.slice(0, 3) : items
|
|
31
|
+
const extra = overflow ? items.slice(3) : []
|
|
32
|
+
const rest = items.length - 3
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<section className="dsgc-constraints" aria-labelledby="dsgc-constraints-title">
|
|
36
|
+
<div className="dsgc-chead">
|
|
37
|
+
<h2 id="dsgc-constraints-title" className="dsgc-ctitle">结论备忘</h2>
|
|
38
|
+
<p className="dsgc-cdesc">窗口外消息折成的已定 / 否决 / 未决,供后续角色接着用。</p>
|
|
39
|
+
</div>
|
|
40
|
+
<ClipWell maxHeight={160} className="dsgc-clist" watch={open}>
|
|
41
|
+
{head.map((c, i) => <ConstraintRow key={i} item={c} />)}
|
|
42
|
+
{overflow
|
|
43
|
+
? (
|
|
44
|
+
<Fold open={open}>
|
|
45
|
+
{extra.map((c, i) => <ConstraintRow key={i + 3} item={c} />)}
|
|
46
|
+
</Fold>
|
|
47
|
+
)
|
|
48
|
+
: null}
|
|
49
|
+
</ClipWell>
|
|
50
|
+
{overflow
|
|
51
|
+
? (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="dsgc-cmore"
|
|
55
|
+
onClick={() => { setOpen(!open) }}
|
|
56
|
+
aria-expanded={open}
|
|
57
|
+
>
|
|
58
|
+
{open ? '收起' : '还有 ' + rest + ' 条约束'}
|
|
59
|
+
</button>
|
|
60
|
+
)
|
|
61
|
+
: null}
|
|
62
|
+
</section>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 角色发言失败卡:人话标题 + 可展开原文。操作条由 Bubble 放在气泡外下方。
|
|
3
|
+
* @module dsh-group-chat/client/FailCard
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, type ReactNode } from 'react'
|
|
7
|
+
import { Icon, P } from '../lib/ui.ts'
|
|
8
|
+
import { classifySpeakFailure } from '../../core/errors.ts'
|
|
9
|
+
import { ClipWell, Fold } from './Fold.tsx'
|
|
10
|
+
|
|
11
|
+
export interface FailCardProps {
|
|
12
|
+
raw: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FailCard(props: FailCardProps): ReactNode {
|
|
16
|
+
const view = classifySpeakFailure(props.raw)
|
|
17
|
+
const [open, setOpen] = useState(false)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="dsgc-fail">
|
|
21
|
+
<div className="dsgc-failhead">
|
|
22
|
+
<span className="dsgc-failicon" aria-hidden="true">{Icon(P.IconWarningOutline16, 14)}</span>
|
|
23
|
+
<div className="dsgc-failcopy">
|
|
24
|
+
<div className="dsgc-failtitle">{view.title}</div>
|
|
25
|
+
{view.detail ? <div className="dsgc-faildetail">{view.detail}</div> : null}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
{view.raw
|
|
29
|
+
? (
|
|
30
|
+
<>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className={'dsgc-failmore' + (open ? ' open' : '')}
|
|
34
|
+
aria-expanded={open}
|
|
35
|
+
onClick={() => { setOpen((v) => !v) }}
|
|
36
|
+
>
|
|
37
|
+
{open ? '收起原始错误' : '查看原始错误'}
|
|
38
|
+
</button>
|
|
39
|
+
<Fold open={open}>
|
|
40
|
+
<ClipWell maxHeight={220} watch={open}>
|
|
41
|
+
<pre className="dsgc-failraw">{view.raw}</pre>
|
|
42
|
+
</ClipWell>
|
|
43
|
+
</Fold>
|
|
44
|
+
</>
|
|
45
|
+
)
|
|
46
|
+
: null}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话内折叠:高度 0fr→1fr + 溢出滚动遮罩。思考 / 工具 / 失败原文 / 结论备忘共用。
|
|
3
|
+
* @module dsh-group-chat/client/components
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
interface FoldProps {
|
|
9
|
+
open: boolean
|
|
10
|
+
children: ReactNode
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Fold(props: FoldProps): ReactNode {
|
|
15
|
+
const { open, children, className } = props
|
|
16
|
+
return (
|
|
17
|
+
<div className={'dsgc-fold' + (open ? ' open' : '') + (className ? ' ' + className : '')}>
|
|
18
|
+
<div className="dsgc-fold-inner">{children}</div>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ClipWellProps {
|
|
24
|
+
children: ReactNode
|
|
25
|
+
maxHeight: number
|
|
26
|
+
className?: string
|
|
27
|
+
watch?: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ClipWell(props: ClipWellProps): ReactNode {
|
|
31
|
+
const { children, maxHeight, className, watch } = props
|
|
32
|
+
const [clip, setClip] = useState({ up: false, down: false })
|
|
33
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
34
|
+
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
const el = ref.current
|
|
37
|
+
if (!el) return
|
|
38
|
+
const measure = (): void => {
|
|
39
|
+
if (el.clientHeight < 2) {
|
|
40
|
+
setClip((cur) => cur.up || cur.down ? { up: false, down: false } : cur)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const max = el.scrollHeight - el.clientHeight
|
|
44
|
+
const up = el.scrollTop > 1
|
|
45
|
+
const down = max > 1 && el.scrollTop < max - 1
|
|
46
|
+
setClip((cur) => cur.up === up && cur.down === down ? cur : { up, down })
|
|
47
|
+
}
|
|
48
|
+
measure()
|
|
49
|
+
const ro = new ResizeObserver(measure)
|
|
50
|
+
ro.observe(el)
|
|
51
|
+
for (const child of el.children) ro.observe(child)
|
|
52
|
+
el.addEventListener('scroll', measure, { passive: true })
|
|
53
|
+
el.addEventListener('transitionend', measure)
|
|
54
|
+
return () => {
|
|
55
|
+
ro.disconnect()
|
|
56
|
+
el.removeEventListener('scroll', measure)
|
|
57
|
+
el.removeEventListener('transitionend', measure)
|
|
58
|
+
}
|
|
59
|
+
}, [watch])
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={'dsgc-clip' + (clip.up ? ' can-up' : '') + (clip.down ? ' can-down' : '')}>
|
|
63
|
+
<div
|
|
64
|
+
className={'dsgc-clip-scroll' + (className ? ' ' + className : '')}
|
|
65
|
+
ref={ref}
|
|
66
|
+
style={{ maxHeight } as CSSProperties}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 对齐宿主 Tooltip 的 hover 气泡:portal 到 document.body,躲开
|
|
3
|
+
* `.dsgc-root` 的 container-type 把 position:fixed 按容器定位。
|
|
4
|
+
* @module dsh-group-chat/client/components
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
8
|
+
import { createPortal } from 'react-dom'
|
|
9
|
+
|
|
10
|
+
export type HoverTipSide = 'top' | 'right'
|
|
11
|
+
|
|
12
|
+
interface HoverTipProps {
|
|
13
|
+
label: string
|
|
14
|
+
side?: HoverTipSide
|
|
15
|
+
delayMs?: number
|
|
16
|
+
maxWidth?: number
|
|
17
|
+
className?: string
|
|
18
|
+
children: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AnchorBox {
|
|
22
|
+
x: number
|
|
23
|
+
top: number
|
|
24
|
+
bottom: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param props.label 气泡正文(pre-line)
|
|
29
|
+
* @param props.side 默认 right(对齐侧栏会话行);composer 轮数用 top
|
|
30
|
+
* @param props.delayMs hover 延迟,默认 500;键盘 focus 立即出
|
|
31
|
+
*/
|
|
32
|
+
export function HoverTip(props: HoverTipProps): ReactNode {
|
|
33
|
+
const { label, side = 'right', delayMs = 500, maxWidth, className, children } = props
|
|
34
|
+
const anchorRef = useRef<HTMLDivElement>(null)
|
|
35
|
+
const bubbleRef = useRef<HTMLSpanElement>(null)
|
|
36
|
+
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
37
|
+
const hover = useRef(false)
|
|
38
|
+
const [pos, setPos] = useState<AnchorBox | null>(null)
|
|
39
|
+
const [placement, setPlacement] = useState<'top' | 'bottom' | 'right'>(side)
|
|
40
|
+
|
|
41
|
+
const cancel = (): void => {
|
|
42
|
+
if (showTimer.current === null) return
|
|
43
|
+
clearTimeout(showTimer.current)
|
|
44
|
+
showTimer.current = null
|
|
45
|
+
}
|
|
46
|
+
const show = (): void => {
|
|
47
|
+
const el = anchorRef.current
|
|
48
|
+
if (!el || !label) return
|
|
49
|
+
const r = el.getBoundingClientRect()
|
|
50
|
+
setPlacement(side)
|
|
51
|
+
setPos({
|
|
52
|
+
x: side === 'right' ? r.right + 10 : r.left + r.width / 2,
|
|
53
|
+
top: r.top,
|
|
54
|
+
bottom: r.bottom,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
const hideIfIdle = (): void => {
|
|
58
|
+
if (!hover.current && !(anchorRef.current && anchorRef.current.contains(document.activeElement))) setPos(null)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
useEffect(() => cancel, [])
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
if (!pos) return
|
|
64
|
+
const fit = (): void => {
|
|
65
|
+
const el = bubbleRef.current
|
|
66
|
+
if (!el) return
|
|
67
|
+
el.style.left = pos.x + 'px'
|
|
68
|
+
const r = el.getBoundingClientRect()
|
|
69
|
+
let dx = 0
|
|
70
|
+
if (r.right > window.innerWidth - 12) dx = window.innerWidth - 12 - r.right
|
|
71
|
+
if (r.left + dx < 12) dx = 12 - r.left
|
|
72
|
+
el.style.left = pos.x + dx + 'px'
|
|
73
|
+
if (side === 'right') return
|
|
74
|
+
const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - 12
|
|
75
|
+
const fitsAbove = pos.top - 8 - r.height >= 12
|
|
76
|
+
if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
|
|
77
|
+
if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
|
|
78
|
+
}
|
|
79
|
+
fit()
|
|
80
|
+
window.addEventListener('resize', fit)
|
|
81
|
+
return () => { window.removeEventListener('resize', fit) }
|
|
82
|
+
}, [pos, placement, label, side])
|
|
83
|
+
|
|
84
|
+
const y = pos === null
|
|
85
|
+
? 0
|
|
86
|
+
: placement === 'right'
|
|
87
|
+
? pos.top + (pos.bottom - pos.top) / 2
|
|
88
|
+
: placement === 'top'
|
|
89
|
+
? pos.top - 8
|
|
90
|
+
: pos.bottom + 8
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<div
|
|
95
|
+
ref={anchorRef}
|
|
96
|
+
className={className}
|
|
97
|
+
onMouseEnter={() => {
|
|
98
|
+
hover.current = true
|
|
99
|
+
cancel()
|
|
100
|
+
if (delayMs <= 0) { show(); return }
|
|
101
|
+
showTimer.current = setTimeout(() => { showTimer.current = null; show() }, delayMs)
|
|
102
|
+
}}
|
|
103
|
+
onMouseLeave={() => {
|
|
104
|
+
hover.current = false
|
|
105
|
+
cancel()
|
|
106
|
+
hideIfIdle()
|
|
107
|
+
}}
|
|
108
|
+
onFocus={() => { cancel(); show() }}
|
|
109
|
+
onBlur={(e) => {
|
|
110
|
+
const next = e.relatedTarget as Node | null
|
|
111
|
+
if (next && anchorRef.current && anchorRef.current.contains(next)) return
|
|
112
|
+
cancel()
|
|
113
|
+
hideIfIdle()
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{children}
|
|
117
|
+
</div>
|
|
118
|
+
{pos
|
|
119
|
+
? createPortal(
|
|
120
|
+
<span
|
|
121
|
+
ref={bubbleRef}
|
|
122
|
+
className="dsgc-hovertip"
|
|
123
|
+
data-side={placement}
|
|
124
|
+
role="tooltip"
|
|
125
|
+
style={{ left: pos.x, top: y, ...(maxWidth ? { maxWidth } : {}) }}
|
|
126
|
+
>
|
|
127
|
+
{label}
|
|
128
|
+
</span>,
|
|
129
|
+
document.body,
|
|
130
|
+
)
|
|
131
|
+
: null}
|
|
132
|
+
</>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ReactNode, CSSProperties } from 'react'
|
|
7
|
+
import { WINDOW_SIZE } from '../../core/constraints.ts'
|
|
7
8
|
import { Icon, P } from '../lib/ui.ts'
|
|
8
9
|
import { Bubble } from './Bubble.tsx'
|
|
10
|
+
import { ConstraintList } from './ConstraintList.tsx'
|
|
9
11
|
import { ThinkRow } from './ThinkRow.tsx'
|
|
12
|
+
import { resolveFailedRole } from '../../core/errors.ts'
|
|
10
13
|
import { roleById, type ClientSnapshot } from '../lib/model.ts'
|
|
11
14
|
import { MD_LABELS } from '../lib/model.ts'
|
|
12
15
|
|
|
@@ -16,68 +19,56 @@ interface MessageFlowProps {
|
|
|
16
19
|
busyNow: boolean
|
|
17
20
|
msgById: Record<string, ClientSnapshot['messages'][number]>
|
|
18
21
|
action: (payload: Record<string, unknown>) => Promise<unknown>
|
|
22
|
+
onRetrySpeak: (messageId: string) => void
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export function MessageFlow(props: MessageFlowProps): ReactNode {
|
|
22
|
-
const { snap, sess, busyNow, msgById, action } = props
|
|
26
|
+
const { snap, sess, busyNow, msgById, action, onRetrySpeak } = props
|
|
23
27
|
|
|
24
28
|
const bubbles: ReactNode[] = []
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
className="dsgc-avatar"
|
|
45
|
-
style={{ border: '2px solid ' + (lr.color || '#888'), '--role-color': lr.color || '#888' } as CSSProperties}
|
|
46
|
-
>
|
|
47
|
-
{lr.name.slice(0, 1)}
|
|
29
|
+
const replaceId = busyNow ? snap.run.replaceMessageId : null
|
|
30
|
+
const replaceMsg = replaceId ? msgById[replaceId] : null
|
|
31
|
+
const liveRoleId = snap.run.currentRoleId || (replaceMsg && (replaceMsg.failedRoleId || replaceMsg.speaker)) || null
|
|
32
|
+
const lr = busyNow && liveRoleId ? roleById(snap, liveRoleId) : null
|
|
33
|
+
|
|
34
|
+
const live = lr
|
|
35
|
+
? (
|
|
36
|
+
<div key="__live" className="dsgc-msg live">
|
|
37
|
+
<div
|
|
38
|
+
className="dsgc-avatar"
|
|
39
|
+
style={{ border: '2px solid ' + (lr.color || '#888'), '--role-color': lr.color || '#888' } as CSSProperties}
|
|
40
|
+
>
|
|
41
|
+
{lr.name.slice(0, 1)}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="dsgc-msgbody">
|
|
44
|
+
<div className="dsgc-msghead">
|
|
45
|
+
<span className="dsgc-msgname">{lr.name}</span>
|
|
46
|
+
<span className="dsgc-msgmodel">{lr.provider} / {lr.model}</span>
|
|
47
|
+
<span className="dsgc-msgtime dsgc-typing">深度求索...</span>
|
|
48
48
|
</div>
|
|
49
|
-
<div className="dsgc-
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
? (
|
|
62
|
-
<div className="dsgc-pending">
|
|
63
|
-
{Icon(P.IconThinkOutline14, 14)}
|
|
64
|
-
<span>思考中</span>
|
|
65
|
-
<span className="dsgc-pendingdots" aria-hidden="true"><i /><i /><i /></span>
|
|
66
|
-
</div>
|
|
49
|
+
<div className="dsgc-msgtext live">
|
|
50
|
+
{snap.run.partialReasoning ? <ThinkRow text={snap.run.partialReasoning} running /> : null}
|
|
51
|
+
{snap.run.partial ? <P.MarkdownText text={snap.run.partial} streaming labels={MD_LABELS} /> : null}
|
|
52
|
+
{/* 首 delta 前的「思考中」占位(深度思考模型首字节可能等数秒到数十秒,
|
|
53
|
+
空白气泡会被感知为卡死);delta 到达后被真实思考行/正文自然替换 */}
|
|
54
|
+
{!snap.run.partial && !snap.run.partialReasoning
|
|
55
|
+
? (
|
|
56
|
+
<div className="dsgc-pending">
|
|
57
|
+
{Icon(P.IconThinkOutline14, 14)}
|
|
58
|
+
<span>思考中</span>
|
|
59
|
+
<span className="dsgc-pendingdots" aria-hidden="true"><i /><i /><i /></span>
|
|
60
|
+
</div>
|
|
67
61
|
)
|
|
68
|
-
|
|
69
|
-
</div>
|
|
62
|
+
: null}
|
|
70
63
|
</div>
|
|
71
|
-
</div
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
72
66
|
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// 命令确认卡片
|
|
67
|
+
: null
|
|
68
|
+
|
|
77
69
|
const pc = busyNow && snap.run.pendingConfirm && sess && snap.run.sessionId === sess.id ? snap.run.pendingConfirm : null
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
bubbles.push(
|
|
70
|
+
const confirm = pc
|
|
71
|
+
? (
|
|
81
72
|
<div key="__confirm" className="dsgc-confirm">
|
|
82
73
|
<div className="dsgc-confirmtitle">
|
|
83
74
|
{Icon(P.IconWarningOutline16, 14)}
|
|
@@ -89,9 +80,42 @@ export function MessageFlow(props: MessageFlowProps): ReactNode {
|
|
|
89
80
|
<P.Button variant="primary" size="sm" onClick={() => { void action({ kind: 'confirmCommand', toolCallId: pc.toolCallId, allow: true }) }}>允许</P.Button>
|
|
90
81
|
<P.Button variant="outline" size="sm" onClick={() => { void action({ kind: 'confirmCommand', toolCallId: pc.toolCallId, allow: false }) }}>拒绝</P.Button>
|
|
91
82
|
</div>
|
|
92
|
-
</div
|
|
93
|
-
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
: null
|
|
86
|
+
|
|
87
|
+
let livePlaced = false
|
|
88
|
+
|
|
89
|
+
if (sess) {
|
|
90
|
+
// 循环不变量:本群角色表只过滤一次(流式期间每帧执行的热路径)
|
|
91
|
+
const groupRoles = snap.roles.filter((r) => r.groupId === sess.groupId)
|
|
92
|
+
const ids = sess.messageIds
|
|
93
|
+
const memo = sess.constraints && sess.constraints.length
|
|
94
|
+
? <ConstraintList key={sess.id + '-constraints'} items={sess.constraints} />
|
|
95
|
+
: null
|
|
96
|
+
// 折点 = 最近 WINDOW_SIZE 条之前。消息都还在窗口内时卡放流顶。
|
|
97
|
+
const foldAt = memo && ids.length > WINDOW_SIZE ? ids.length - WINDOW_SIZE : 0
|
|
98
|
+
if (memo && foldAt === 0) bubbles.push(memo)
|
|
99
|
+
for (let i = 0; i < ids.length; i++) {
|
|
100
|
+
if (memo && foldAt > 0 && i === foldAt) bubbles.push(memo)
|
|
101
|
+
const m = msgById[ids[i]]
|
|
102
|
+
if (!m) continue
|
|
103
|
+
// 角色在父级解析:Bubble 按值 memo(不依赖 snap identity),流式帧
|
|
104
|
+
// 不再触发已完成消息的全量重渲(每帧仅 live 行与派生列表变化)
|
|
105
|
+
const role = resolveFailedRole(m, groupRoles) || (m.speaker !== 'user' && m.speaker !== 'system' ? roleById(snap, m.speaker) : null)
|
|
106
|
+
// 原地重试:该槽位换成 live(确认卡紧随其后),不要钉在列表末尾
|
|
107
|
+
if (replaceId && m.id === replaceId) {
|
|
108
|
+
if (live) bubbles.push(live)
|
|
109
|
+
if (confirm) bubbles.push(confirm)
|
|
110
|
+
livePlaced = true
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
bubbles.push(<Bubble key={m.id} m={m} role={role} busy={!!snap.run.running} onRetry={onRetrySpeak} />)
|
|
114
|
+
}
|
|
94
115
|
}
|
|
95
116
|
|
|
117
|
+
if (live && !livePlaced) bubbles.push(live)
|
|
118
|
+
if (confirm && !livePlaced) bubbles.push(confirm)
|
|
119
|
+
|
|
96
120
|
return <>{bubbles}</>
|
|
97
121
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息操作条:贴在气泡外下方。用户/角色消息悬停出「复制」;失败卡常驻「复制 / 重试」。
|
|
3
|
+
* @module dsh-group-chat/client/MsgActions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
7
|
+
import { Icon, P } from '../lib/ui.ts'
|
|
8
|
+
|
|
9
|
+
export interface MsgActionsProps {
|
|
10
|
+
copyText: string
|
|
11
|
+
onRetry?: () => void
|
|
12
|
+
retryDisabled?: boolean
|
|
13
|
+
retryTitle?: string
|
|
14
|
+
/** 失败卡:不依赖悬停,始终可见。 */
|
|
15
|
+
always?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function writeClipboard(text: string): Promise<boolean> {
|
|
19
|
+
try {
|
|
20
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
21
|
+
await navigator.clipboard.writeText(text)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
} catch { /* fall through */ }
|
|
25
|
+
try {
|
|
26
|
+
const el = document.createElement('textarea')
|
|
27
|
+
el.value = text
|
|
28
|
+
el.setAttribute('readonly', '')
|
|
29
|
+
el.style.position = 'fixed'
|
|
30
|
+
el.style.left = '-9999px'
|
|
31
|
+
document.body.appendChild(el)
|
|
32
|
+
el.select()
|
|
33
|
+
const ok = document.execCommand('copy')
|
|
34
|
+
document.body.removeChild(el)
|
|
35
|
+
return ok
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function MsgActions(props: MsgActionsProps): ReactNode {
|
|
42
|
+
const { copyText, onRetry, retryDisabled, retryTitle, always } = props
|
|
43
|
+
const [copied, setCopied] = useState(false)
|
|
44
|
+
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
45
|
+
useEffect(() => () => { if (copiedTimer.current) clearTimeout(copiedTimer.current) }, [])
|
|
46
|
+
|
|
47
|
+
const copy = async (): Promise<void> => {
|
|
48
|
+
if (!copyText) return
|
|
49
|
+
const ok = await writeClipboard(copyText)
|
|
50
|
+
if (!ok) return
|
|
51
|
+
setCopied(true)
|
|
52
|
+
if (copiedTimer.current) clearTimeout(copiedTimer.current)
|
|
53
|
+
copiedTimer.current = setTimeout(() => { setCopied(false) }, 1400)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={'dsgc-msgops' + (always ? ' always' : '')} role="group" aria-label="消息操作">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={'dsgc-msgop' + (copied ? ' done' : '')}
|
|
61
|
+
title={copied ? '已复制' : '复制'}
|
|
62
|
+
aria-label={copied ? '已复制' : '复制'}
|
|
63
|
+
onClick={(e) => { e.stopPropagation(); void copy() }}
|
|
64
|
+
>
|
|
65
|
+
{Icon(copied ? P.IconCheckOutline14 : P.IconCopyOutline16, 14)}
|
|
66
|
+
<span>{copied ? '已复制' : '复制'}</span>
|
|
67
|
+
</button>
|
|
68
|
+
{onRetry
|
|
69
|
+
? (
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
className="dsgc-msgop retry"
|
|
73
|
+
title={retryTitle || '重试'}
|
|
74
|
+
aria-label={retryTitle || '重试'}
|
|
75
|
+
disabled={retryDisabled}
|
|
76
|
+
onClick={(e) => { e.stopPropagation(); if (!retryDisabled) onRetry() }}
|
|
77
|
+
>
|
|
78
|
+
{Icon(P.IconRefreshOutline14, 14)}
|
|
79
|
+
<span>重试</span>
|
|
80
|
+
</button>
|
|
81
|
+
)
|
|
82
|
+
: null}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|