@roaming-ai/dsh-group-chat 0.2.2 → 0.3.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/README.md +3 -2
- package/lib/client.js +984 -296
- package/lib/client.js.map +1 -1
- package/lib/index.js +524 -52
- 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/hooks/useComposer.d.ts +6 -1
- package/lib/types/client/lib/composer-draft.d.ts +17 -0
- package/lib/types/core/constraints.d.ts +61 -0
- package/lib/types/core/errors.d.ts +54 -0
- package/lib/types/core/types.d.ts +22 -0
- 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/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/package.json +1 -1
- package/src/client/GroupChatPanel.tsx +18 -3
- package/src/client/components/AsidePanel.tsx +10 -8
- 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 +69 -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 +77 -54
- package/src/client/components/MsgActions.tsx +85 -0
- package/src/client/components/NavPanel.tsx +6 -1
- package/src/client/components/ThinkRow.tsx +7 -3
- package/src/client/components/ToolRow.tsx +7 -3
- package/src/client/hooks/useComposer.ts +40 -3
- package/src/client/hooks/useGroupChatState.ts +5 -4
- package/src/client/lib/composer-draft.ts +47 -0
- package/src/client/lib/styles.ts +73 -12
- package/src/client/react-dom-shim.d.ts +2 -0
- package/src/core/constraints.ts +210 -0
- package/src/core/errors.ts +184 -0
- package/src/core/json.ts +1 -0
- package/src/core/types.ts +28 -3
- package/src/host/api/actions.ts +35 -1
- package/src/host/broadcast.ts +3 -3
- package/src/host/engine/conversation.ts +103 -38
- package/src/host/engine/fold.ts +114 -0
- package/src/host/engine/index.ts +1 -0
- package/src/host/engine/retitle.ts +16 -11
- package/src/host/persistence/persistence.ts +18 -1
- package/src/host/service.ts +1 -1
- package/src/host/state.ts +5 -4
- package/src/index.ts +2 -0
|
@@ -10,23 +10,40 @@
|
|
|
10
10
|
|
|
11
11
|
import { memo, type ReactNode } from 'react'
|
|
12
12
|
import { P } from '../lib/ui.ts'
|
|
13
|
-
import {
|
|
13
|
+
import { classifySpeakFailure, formatSpeakFailureCopy, isSpeakFailure } from '../../core/errors.ts'
|
|
14
|
+
import { fmtTime, MD_LABELS, type SnapshotRole } from '../lib/model.ts'
|
|
15
|
+
import type { SnapshotMessage } from '../lib/model.ts'
|
|
14
16
|
import { ThinkRow } from './ThinkRow.tsx'
|
|
15
17
|
import { ToolRow } from './ToolRow.tsx'
|
|
18
|
+
import { FailCard } from './FailCard.tsx'
|
|
19
|
+
import { MsgActions } from './MsgActions.tsx'
|
|
16
20
|
|
|
17
21
|
export interface BubbleProps {
|
|
18
|
-
m:
|
|
22
|
+
m: SnapshotMessage
|
|
19
23
|
/** 父级解析好的发言角色(user/system 消息为 null)——避免 Bubble 依赖 snap identity。 */
|
|
20
24
|
role: SnapshotRole | null
|
|
25
|
+
busy?: boolean
|
|
26
|
+
onRetry?: (messageId: string) => void
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
function BubbleInner({ m, role }: BubbleProps): ReactNode {
|
|
29
|
+
function BubbleInner({ m, role, busy, onRetry }: BubbleProps): ReactNode {
|
|
24
30
|
const isUser = m.speaker === 'user'
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
31
|
+
const isFail = isSpeakFailure(m)
|
|
32
|
+
const isSys = m.speaker === 'system' && !isFail && !role
|
|
33
|
+
const name = isUser ? '我' : role ? role.name : isSys ? '系统' : '成员'
|
|
34
|
+
if (isSys) return <div className="dsgc-sysmsg">{m.text}</div>
|
|
35
|
+
|
|
36
|
+
const retryTitle = !role
|
|
37
|
+
? '失败角色已不存在,无法重试'
|
|
38
|
+
: !role.enabled
|
|
39
|
+
? '该角色已停用,无法重试'
|
|
40
|
+
: busy
|
|
41
|
+
? '已有对话进行中,请先停止'
|
|
42
|
+
: '重试该角色发言'
|
|
43
|
+
const copyText = isFail ? formatSpeakFailureCopy(classifySpeakFailure(m.text)) : (m.text || '')
|
|
44
|
+
|
|
28
45
|
return (
|
|
29
|
-
<div className={'dsgc-msg' + (isUser ? ' mine' : '')}>
|
|
46
|
+
<div className={'dsgc-msg' + (isUser ? ' mine' : '') + (isFail ? ' fail' : '')}>
|
|
30
47
|
<div
|
|
31
48
|
className={'dsgc-avatar' + (isUser ? ' mine' : '')}
|
|
32
49
|
style={isUser || !role ? undefined : { border: '2px solid ' + (role.color || '#888') }}
|
|
@@ -39,15 +56,24 @@ function BubbleInner({ m, role }: BubbleProps): ReactNode {
|
|
|
39
56
|
{m.model ? <span className="dsgc-msgmodel" title={m.model}>{m.model}</span> : null}
|
|
40
57
|
{m.ts ? <span className="dsgc-msgtime">{fmtTime(m.ts)}</span> : null}
|
|
41
58
|
</div>
|
|
42
|
-
{
|
|
43
|
-
? <
|
|
44
|
-
:
|
|
45
|
-
<div className="dsgc-msgtext">
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
{isFail
|
|
60
|
+
? <FailCard raw={m.text} />
|
|
61
|
+
: isUser
|
|
62
|
+
? <div className="dsgc-msgtext">{m.text}</div>
|
|
63
|
+
: (
|
|
64
|
+
<div className="dsgc-msgtext">
|
|
65
|
+
{m.reasoning ? <ThinkRow text={m.reasoning} /> : null}
|
|
66
|
+
{(Array.isArray(m.toolCalls) ? m.toolCalls : []).map((c, i) => <ToolRow key={'tc' + i} c={c} />)}
|
|
67
|
+
<P.MarkdownText text={m.text || '(无内容)'} labels={MD_LABELS} />
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
<MsgActions
|
|
71
|
+
copyText={copyText}
|
|
72
|
+
always={isFail}
|
|
73
|
+
onRetry={isFail && onRetry ? () => { onRetry(m.id) } : undefined}
|
|
74
|
+
retryDisabled={busy || !role || !role.enabled}
|
|
75
|
+
retryTitle={retryTitle}
|
|
76
|
+
/>
|
|
51
77
|
</div>
|
|
52
78
|
</div>
|
|
53
79
|
)
|
|
@@ -55,10 +81,11 @@ function BubbleInner({ m, role }: BubbleProps): ReactNode {
|
|
|
55
81
|
|
|
56
82
|
/** 渲染相关字段的值比较(消息不可变;角色仅名/色参与渲染)。 */
|
|
57
83
|
function bubblePropsEqual(a: BubbleProps, b: BubbleProps): boolean {
|
|
84
|
+
if (a.busy !== b.busy || (a.onRetry == null) !== (b.onRetry == null)) return false
|
|
58
85
|
const x = a.m
|
|
59
86
|
const y = b.m
|
|
60
87
|
if (x !== y) {
|
|
61
|
-
if (x.id !== y.id || x.speaker !== y.speaker || x.text !== y.text || x.reasoning !== y.reasoning || x.model !== y.model || x.error !== y.error || x.ts !== y.ts) return false
|
|
88
|
+
if (x.id !== y.id || x.speaker !== y.speaker || x.text !== y.text || x.reasoning !== y.reasoning || x.model !== y.model || x.error !== y.error || x.failedRoleId !== y.failedRoleId || x.ts !== y.ts) return false
|
|
62
89
|
const ta = Array.isArray(x.toolCalls) ? x.toolCalls : []
|
|
63
90
|
const tb = Array.isArray(y.toolCalls) ? y.toolCalls : []
|
|
64
91
|
if (ta.length !== tb.length) return false
|
|
@@ -70,7 +97,7 @@ function bubblePropsEqual(a: BubbleProps, b: BubbleProps): boolean {
|
|
|
70
97
|
}
|
|
71
98
|
const ra = a.role
|
|
72
99
|
const rb = b.role
|
|
73
|
-
return (ra ? ra.name + '\u0000' + (ra.color || '') : '') === (rb ? rb.name + '\u0000' + (rb.color || '') : '')
|
|
100
|
+
return (ra ? ra.id + '\u0000' + ra.name + '\u0000' + (ra.color || '') + '\u0000' + (ra.enabled ? '1' : '0') : '') === (rb ? rb.id + '\u0000' + rb.name + '\u0000' + (rb.color || '') + '\u0000' + (rb.enabled ? '1' : '0') : '')
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
export const Bubble = memo(BubbleInner, bubblePropsEqual)
|
|
@@ -54,6 +54,7 @@ interface ChatPanelProps {
|
|
|
54
54
|
action: (payload: Record<string, unknown>) => Promise<unknown>
|
|
55
55
|
mutate: (args: Record<string, unknown>) => Promise<unknown>
|
|
56
56
|
setMention: (val: AtToken | null) => void
|
|
57
|
+
onRetrySpeak: (messageId: string) => void
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export function ChatPanel(props: ChatPanelProps): ReactNode {
|
|
@@ -101,6 +102,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
|
|
|
101
102
|
action,
|
|
102
103
|
mutate,
|
|
103
104
|
setMention,
|
|
105
|
+
onRetrySpeak,
|
|
104
106
|
} = props
|
|
105
107
|
|
|
106
108
|
const commitTopic = (): void => {
|
|
@@ -115,6 +117,7 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
|
|
|
115
117
|
busyNow={busyNow}
|
|
116
118
|
msgById={msgById}
|
|
117
119
|
action={action}
|
|
120
|
+
onRetrySpeak={onRetrySpeak}
|
|
118
121
|
/>
|
|
119
122
|
)
|
|
120
123
|
|
|
@@ -143,18 +146,20 @@ export function ChatPanel(props: ChatPanelProps): ReactNode {
|
|
|
143
146
|
</button>
|
|
144
147
|
|
|
145
148
|
<div className="dsgc-chathead">
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
149
|
+
<div className="dsgc-chathead-row">
|
|
150
|
+
{sess ? <span className="dsgc-sess-title" title={'当前会话:' + sess.name}>{sess.name}</span> : null}
|
|
151
|
+
<input
|
|
152
|
+
className="dsgc-topic"
|
|
153
|
+
value={topicDraft === null ? (sess ? sess.topic : '') : topicDraft}
|
|
154
|
+
placeholder="设置本会话主题(可选)…"
|
|
155
|
+
onChange={(e) => { setTopicDraft(e.target.value) }}
|
|
156
|
+
onBlur={commitTopic}
|
|
157
|
+
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
|
158
|
+
/>
|
|
159
|
+
<P.Button variant="ghost" size="sm" className="dsgc-clearbtn" title="清空当前会话的消息记录" onClick={() => { if (sess) setConfirmClear(true) }}>
|
|
160
|
+
清空
|
|
161
|
+
</P.Button>
|
|
162
|
+
</div>
|
|
158
163
|
</div>
|
|
159
164
|
|
|
160
165
|
<div className="dsgc-msgs" ref={scrollRef} onScroll={onMsgsScroll}>
|
|
@@ -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,69 @@
|
|
|
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 { ClipWell, Fold } from './Fold.tsx'
|
|
9
|
+
|
|
10
|
+
const KIND_LABEL: Record<SessionConstraint['kind'], string> = {
|
|
11
|
+
decided: '已定',
|
|
12
|
+
rejected: '否决',
|
|
13
|
+
open: '未决',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ConstraintListProps {
|
|
17
|
+
items: SessionConstraint[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ConstraintRow(props: { item: SessionConstraint }): ReactNode {
|
|
21
|
+
const { item } = props
|
|
22
|
+
return (
|
|
23
|
+
<div className="dsgc-constraint">
|
|
24
|
+
<span className={'dsgc-ckind ' + item.kind}>{KIND_LABEL[item.kind]}</span>
|
|
25
|
+
<span className="dsgc-ctext">{item.text}</span>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ConstraintList(props: ConstraintListProps): ReactNode {
|
|
31
|
+
const { items } = props
|
|
32
|
+
const [open, setOpen] = useState(false)
|
|
33
|
+
if (!items.length) return null
|
|
34
|
+
const overflow = items.length > 4
|
|
35
|
+
const head = overflow ? items.slice(0, 3) : items
|
|
36
|
+
const extra = overflow ? items.slice(3) : []
|
|
37
|
+
const rest = items.length - 3
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<section className="dsgc-constraints" aria-labelledby="dsgc-constraints-title">
|
|
41
|
+
<div className="dsgc-chead">
|
|
42
|
+
<h2 id="dsgc-constraints-title" className="dsgc-ctitle">结论备忘</h2>
|
|
43
|
+
<p className="dsgc-cdesc">窗口外消息折成的已定 / 否决 / 未决,供后续角色接着用。</p>
|
|
44
|
+
</div>
|
|
45
|
+
<ClipWell maxHeight={160} className="dsgc-clist" watch={open}>
|
|
46
|
+
{head.map((c, i) => <ConstraintRow key={i} item={c} />)}
|
|
47
|
+
{overflow
|
|
48
|
+
? (
|
|
49
|
+
<Fold open={open}>
|
|
50
|
+
{extra.map((c, i) => <ConstraintRow key={i + 3} item={c} />)}
|
|
51
|
+
</Fold>
|
|
52
|
+
)
|
|
53
|
+
: null}
|
|
54
|
+
</ClipWell>
|
|
55
|
+
{overflow
|
|
56
|
+
? (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className="dsgc-cmore"
|
|
60
|
+
onClick={() => { setOpen(!open) }}
|
|
61
|
+
aria-expanded={open}
|
|
62
|
+
>
|
|
63
|
+
{open ? '收起' : '还有 ' + rest + ' 条约束'}
|
|
64
|
+
</button>
|
|
65
|
+
)
|
|
66
|
+
: null}
|
|
67
|
+
</section>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -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
|
+
}
|