@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.
Files changed (103) hide show
  1. package/README.md +91 -27
  2. package/lib/client.js +1254 -598
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +573 -109
  5. package/lib/types/client/components/AsidePanel.d.ts +3 -19
  6. package/lib/types/client/components/Bubble.d.ts +6 -3
  7. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  8. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  9. package/lib/types/client/components/FailCard.d.ts +9 -0
  10. package/lib/types/client/components/Fold.d.ts +19 -0
  11. package/lib/types/client/components/HoverTip.d.ts +22 -0
  12. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  13. package/lib/types/client/components/MsgActions.d.ts +14 -0
  14. package/lib/types/client/components/NavPanel.d.ts +9 -11
  15. package/lib/types/client/components/RoleDrawer.d.ts +3 -2
  16. package/lib/types/client/components/ToolRow.d.ts +2 -2
  17. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  18. package/lib/types/client/hooks/useGroupChatState.d.ts +0 -16
  19. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  20. package/lib/types/client/lib/model.d.ts +1 -7
  21. package/lib/types/client/utils/utils.d.ts +4 -4
  22. package/lib/types/core/constraints.d.ts +61 -0
  23. package/lib/types/core/errors.d.ts +54 -0
  24. package/lib/types/core/json.d.ts +6 -0
  25. package/lib/types/core/types.d.ts +76 -60
  26. package/lib/types/host/api/actions.d.ts +1 -1
  27. package/lib/types/host/engine/conversation.d.ts +5 -3
  28. package/lib/types/host/engine/defaults.d.ts +13 -0
  29. package/lib/types/host/engine/fold.d.ts +17 -0
  30. package/lib/types/host/engine/index.d.ts +1 -0
  31. package/lib/types/host/engine/retitle.d.ts +5 -5
  32. package/lib/types/host/service.d.ts +1 -1
  33. package/lib/types/host/state.d.ts +2 -0
  34. package/lib/types/index.d.ts +2 -0
  35. package/lib/types/shared/file-mention-grammar.d.ts +3 -12
  36. package/package.json +1 -1
  37. package/src/client/GroupChatPanel.tsx +25 -59
  38. package/src/client/components/AsidePanel.tsx +50 -23
  39. package/src/client/components/Bubble.tsx +45 -18
  40. package/src/client/components/ChatPanel.tsx +17 -12
  41. package/src/client/components/Composer.tsx +26 -20
  42. package/src/client/components/ConstraintList.tsx +64 -0
  43. package/src/client/components/FailCard.tsx +49 -0
  44. package/src/client/components/Fold.tsx +72 -0
  45. package/src/client/components/HoverTip.tsx +134 -0
  46. package/src/client/components/MessageFlow.tsx +78 -54
  47. package/src/client/components/MsgActions.tsx +85 -0
  48. package/src/client/components/NavPanel.tsx +102 -79
  49. package/src/client/components/RoleDrawer.tsx +29 -7
  50. package/src/client/components/ThinkRow.tsx +7 -3
  51. package/src/client/components/ToolRow.tsx +9 -5
  52. package/src/client/hooks/useComposer.ts +54 -56
  53. package/src/client/hooks/useGroupChatState.ts +7 -17
  54. package/src/client/lib/composer-draft.ts +47 -0
  55. package/src/client/lib/model.ts +1 -14
  56. package/src/client/lib/styles.ts +73 -12
  57. package/src/client/react-dom-shim.d.ts +2 -0
  58. package/src/client/utils/utils.ts +6 -6
  59. package/src/core/constraints.ts +204 -0
  60. package/src/core/errors.ts +174 -0
  61. package/src/core/json.ts +18 -0
  62. package/src/core/types.ts +52 -19
  63. package/src/host/api/actions.ts +49 -14
  64. package/src/host/broadcast.ts +3 -3
  65. package/src/host/engine/conversation.ts +104 -40
  66. package/src/host/engine/defaults.ts +28 -0
  67. package/src/host/engine/fold.ts +99 -0
  68. package/src/host/engine/index.ts +1 -0
  69. package/src/host/engine/retitle.ts +24 -41
  70. package/src/host/materials/materials.ts +2 -0
  71. package/src/host/persistence/persistence.ts +18 -1
  72. package/src/host/service.ts +1 -1
  73. package/src/host/state.ts +5 -4
  74. package/src/host/tools/tools.ts +2 -2
  75. package/src/index.ts +2 -0
  76. package/src/shared/file-mention-grammar.test.ts +1 -38
  77. package/src/shared/file-mention-grammar.ts +7 -33
  78. package/lib/types/client/Bubble.d.ts +0 -10
  79. package/lib/types/client/Glyph.d.ts +0 -12
  80. package/lib/types/client/GroupChatPanel.old.d.ts +0 -10
  81. package/lib/types/client/GroupChatSettingsSection.d.ts +0 -15
  82. package/lib/types/client/PermissionSelect.d.ts +0 -13
  83. package/lib/types/client/RoleDrawer.d.ts +0 -17
  84. package/lib/types/client/ThinkRow.d.ts +0 -9
  85. package/lib/types/client/ToolRow.d.ts +0 -9
  86. package/lib/types/client/api.d.ts +0 -8
  87. package/lib/types/client/components.d.ts +0 -22
  88. package/lib/types/client/drawer.d.ts +0 -17
  89. package/lib/types/client/glyph.d.ts +0 -12
  90. package/lib/types/client/model.d.ts +0 -78
  91. package/lib/types/client/panel.d.ts +0 -10
  92. package/lib/types/client/settings.d.ts +0 -15
  93. package/lib/types/client/styles.d.ts +0 -10
  94. package/lib/types/client/ui.d.ts +0 -17
  95. package/lib/types/client/utils.d.ts +0 -22
  96. package/lib/types/host/actions.d.ts +0 -34
  97. package/lib/types/host/conversation.d.ts +0 -25
  98. package/lib/types/host/http.d.ts +0 -16
  99. package/lib/types/host/materials.d.ts +0 -32
  100. package/lib/types/host/persistence.d.ts +0 -31
  101. package/lib/types/host/routes.d.ts +0 -11
  102. package/lib/types/host/store.d.ts +0 -65
  103. 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
- {mentionedRoles.length
106
- ? <span className="dsgc-partslabel">已 @ {mentionedRoles.map((r) => r.name).join('、')}(本轮仅被点名成员发言)</span>
107
- : enabledRoles.length
108
- ? enabledRoles.map((r) => (
109
- <button
110
- key={r.id}
111
- type="button"
112
- className={'dsgc-partchip' + (participants.includes(r.id) ? ' on' : '')}
113
- onClick={() => togglePart(r.id)}
114
- disabled={busyNow}
115
- title={busyNow ? '对话进行中,暂停调整' : '点击切换本轮是否参与'}
116
- >
117
- <span className="dsgc-chipdot" style={{ background: r.color || '#888' }} />
118
- {r.name}
119
- </button>
120
- ))
121
- : <span className="dsgc-hint">还没有启用的角色,请在右侧添加</span>}
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
- <div className="dsgc-rounds" title="自由讨论的轮数(1–10):一轮 = 全体参与角色按顺序各发言一次">
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" title="轮数">{rounds}</span>
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
- </div>
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
- if (sess) {
27
- for (const mid of sess.messageIds) {
28
- const m = msgById[mid]
29
- if (!m) continue
30
- // 角色在父级解析:Bubble 按值 memo(不依赖 snap identity),流式帧
31
- // 不再触发已完成消息的全量重渲(每帧仅 live 行与派生列表变化)
32
- const role = m.speaker !== 'user' && m.speaker !== 'system' ? roleById(snap, m.speaker) : null
33
- bubbles.push(<Bubble key={m.id} m={m} role={role} />)
34
- }
35
- }
36
-
37
- // 流式尾巴
38
- if (busyNow && snap.run.currentRoleId) {
39
- const lr = roleById(snap, snap.run.currentRoleId)
40
- if (lr) {
41
- bubbles.push(
42
- <div key="__live" className="dsgc-msg live">
43
- <div
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-msgbody">
50
- <div className="dsgc-msghead">
51
- <span className="dsgc-msgname">{lr.name}</span>
52
- <span className="dsgc-msgmodel">{lr.provider} / {lr.model}</span>
53
- <span className="dsgc-msgtime dsgc-typing">深度求索...</span>
54
- </div>
55
- <div className="dsgc-msgtext live">
56
- {snap.run.partialReasoning ? <ThinkRow text={snap.run.partialReasoning} running /> : null}
57
- {snap.run.partial ? <P.MarkdownText text={snap.run.partial} streaming labels={MD_LABELS} /> : null}
58
- {/* 首 delta 前的「思考中」占位(深度思考模型首字节可能等数秒到数十秒,
59
- 空白气泡会被感知为卡死);delta 到达后被真实思考行/正文自然替换 */}
60
- {!snap.run.partial && !snap.run.partialReasoning
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
- : null}
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
- if (pc) {
79
- const lr = roleById(snap, snap.run.currentRoleId)
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
+ }