@roaming-ai/dsh-group-chat 0.2.1 → 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.
Files changed (59) hide show
  1. package/README.md +3 -2
  2. package/lib/client.js +986 -298
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +524 -52
  5. package/lib/types/client/components/Bubble.d.ts +6 -3
  6. package/lib/types/client/components/ChatPanel.d.ts +1 -0
  7. package/lib/types/client/components/ConstraintList.d.ts +11 -0
  8. package/lib/types/client/components/FailCard.d.ts +9 -0
  9. package/lib/types/client/components/Fold.d.ts +19 -0
  10. package/lib/types/client/components/HoverTip.d.ts +22 -0
  11. package/lib/types/client/components/MessageFlow.d.ts +1 -0
  12. package/lib/types/client/components/MsgActions.d.ts +14 -0
  13. package/lib/types/client/hooks/useComposer.d.ts +6 -1
  14. package/lib/types/client/lib/composer-draft.d.ts +17 -0
  15. package/lib/types/core/constraints.d.ts +61 -0
  16. package/lib/types/core/errors.d.ts +54 -0
  17. package/lib/types/core/types.d.ts +22 -0
  18. package/lib/types/host/api/actions.d.ts +1 -1
  19. package/lib/types/host/engine/conversation.d.ts +5 -3
  20. package/lib/types/host/engine/fold.d.ts +17 -0
  21. package/lib/types/host/engine/index.d.ts +1 -0
  22. package/lib/types/host/engine/retitle.d.ts +5 -5
  23. package/lib/types/host/service.d.ts +1 -1
  24. package/lib/types/host/state.d.ts +2 -0
  25. package/lib/types/index.d.ts +2 -0
  26. package/package.json +1 -1
  27. package/src/client/GroupChatPanel.tsx +18 -3
  28. package/src/client/components/AsidePanel.tsx +10 -8
  29. package/src/client/components/Bubble.tsx +45 -18
  30. package/src/client/components/ChatPanel.tsx +17 -12
  31. package/src/client/components/Composer.tsx +26 -20
  32. package/src/client/components/ConstraintList.tsx +69 -0
  33. package/src/client/components/FailCard.tsx +49 -0
  34. package/src/client/components/Fold.tsx +72 -0
  35. package/src/client/components/HoverTip.tsx +134 -0
  36. package/src/client/components/MessageFlow.tsx +77 -54
  37. package/src/client/components/MsgActions.tsx +85 -0
  38. package/src/client/components/NavPanel.tsx +6 -1
  39. package/src/client/components/ThinkRow.tsx +7 -3
  40. package/src/client/components/ToolRow.tsx +7 -3
  41. package/src/client/hooks/useComposer.ts +40 -3
  42. package/src/client/hooks/useGroupChatState.ts +5 -4
  43. package/src/client/lib/composer-draft.ts +47 -0
  44. package/src/client/lib/styles.ts +74 -13
  45. package/src/client/react-dom-shim.d.ts +2 -0
  46. package/src/core/constraints.ts +210 -0
  47. package/src/core/errors.ts +184 -0
  48. package/src/core/json.ts +1 -0
  49. package/src/core/types.ts +28 -3
  50. package/src/host/api/actions.ts +35 -1
  51. package/src/host/broadcast.ts +3 -3
  52. package/src/host/engine/conversation.ts +103 -38
  53. package/src/host/engine/fold.ts +114 -0
  54. package/src/host/engine/index.ts +1 -0
  55. package/src/host/engine/retitle.ts +16 -11
  56. package/src/host/persistence/persistence.ts +18 -1
  57. package/src/host/service.ts +1 -1
  58. package/src/host/state.ts +5 -4
  59. package/src/index.ts +2 -0
@@ -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,41 @@ 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
+ const ids = sess.messageIds
91
+ const memo = sess.constraints && sess.constraints.length
92
+ ? <ConstraintList key={sess.id + '-constraints'} items={sess.constraints} />
93
+ : null
94
+ // 折点 = 最近 WINDOW_SIZE 条之前。消息都还在窗口内时卡放流顶。
95
+ const foldAt = memo && ids.length > WINDOW_SIZE ? ids.length - WINDOW_SIZE : 0
96
+ if (memo && foldAt === 0) bubbles.push(memo)
97
+ for (let i = 0; i < ids.length; i++) {
98
+ if (memo && foldAt > 0 && i === foldAt) bubbles.push(memo)
99
+ const m = msgById[ids[i]]
100
+ if (!m) continue
101
+ // 角色在父级解析:Bubble 按值 memo(不依赖 snap identity),流式帧
102
+ // 不再触发已完成消息的全量重渲(每帧仅 live 行与派生列表变化)
103
+ const groupRoles = snap.roles.filter((r) => r.groupId === sess.groupId)
104
+ const role = resolveFailedRole(m, groupRoles) || (m.speaker !== 'user' && m.speaker !== 'system' ? roleById(snap, m.speaker) : null)
105
+ // 原地重试:该槽位换成 live(确认卡紧随其后),不要钉在列表末尾
106
+ if (replaceId && m.id === replaceId) {
107
+ if (live) bubbles.push(live)
108
+ if (confirm) bubbles.push(confirm)
109
+ livePlaced = true
110
+ continue
111
+ }
112
+ bubbles.push(<Bubble key={m.id} m={m} role={role} busy={!!snap.run.running} onRetry={onRetrySpeak} />)
113
+ }
94
114
  }
95
115
 
116
+ if (live && !livePlaced) bubbles.push(live)
117
+ if (confirm && !livePlaced) bubbles.push(confirm)
118
+
96
119
  return <>{bubbles}</>
97
120
  }
@@ -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
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react'
7
7
  import { Icon, P } from '../lib/ui.ts'
8
+ import { HoverTip } from './HoverTip.tsx'
8
9
  import { groupById, sessById, sessStatus, SESS_STATUS_LABEL, type ClientSnapshot } from '../lib/model.ts'
9
10
 
10
11
  interface NavPanelProps {
@@ -124,7 +125,11 @@ export function NavPanel(props: NavPanelProps): ReactNode {
124
125
  onClick={(e) => { e.stopPropagation() }}
125
126
  />
126
127
  )
127
- : <span className="dsgc-sess-name">{s.name}</span>}
128
+ : (
129
+ <HoverTip label={s.name} side="right" delayMs={500} className="dsgc-sess-name">
130
+ {s.name}
131
+ </HoverTip>
132
+ )}
128
133
  <span className="dsgc-nodeops">
129
134
  <button
130
135
  className="dsgc-opbtn"
@@ -6,6 +6,7 @@
6
6
  import { useState, type ReactNode } from 'react'
7
7
  import { P } from '../lib/ui.ts'
8
8
  import { firstLine, latestLine } from '../lib/model.ts'
9
+ import { ClipWell, Fold } from './Fold.tsx'
9
10
 
10
11
  export function ThinkRow(props: { text: string, running?: boolean }): ReactNode {
11
12
  const text = props.text || ''
@@ -24,9 +25,12 @@ export function ThinkRow(props: { text: string, running?: boolean }): ReactNode
24
25
  expandOnRowClick
25
26
  onToggle={() => { setExpanded((v) => !v) }}
26
27
  collapsedContent={text ? <span className="dsgc-thinksummary">{summary}</span> : null}
27
- >
28
- <div className="dsgc-thinkbody">{text}</div>
29
- </P.DisclosureRow>
28
+ />
29
+ <Fold open={expanded}>
30
+ <ClipWell maxHeight={320} watch={text}>
31
+ <div className="dsgc-thinkbody">{text}</div>
32
+ </ClipWell>
33
+ </Fold>
30
34
  </div>
31
35
  )
32
36
  }
@@ -6,6 +6,7 @@
6
6
  import { useState, type ReactNode } from 'react'
7
7
  import { Icon, P, pickPrimitive } from '../lib/ui.ts'
8
8
  import type { ToolCallView } from '../lib/model.ts'
9
+ import { ClipWell, Fold } from './Fold.tsx'
9
10
 
10
11
  const TOOL_ICONS: Record<string, string> = { read_file: 'IconBrowseOutline16', list_dir: 'IconFolderOpenOutline16', run_command: 'IconCodeOutline16' }
11
12
 
@@ -43,9 +44,12 @@ export function ToolRow(props: { c: ToolCallView }): ReactNode {
43
44
  {statusText + (dur ? ' · ' + dur : '')}
44
45
  </span>
45
46
  }
46
- >
47
- <div className="dsgc-toolbody">{c.output || '(无输出)'}</div>
48
- </P.DisclosureRow>
47
+ />
48
+ <Fold open={expanded}>
49
+ <ClipWell maxHeight={260} watch={c.output}>
50
+ <div className="dsgc-toolbody">{c.output || '(无输出)'}</div>
51
+ </ClipWell>
52
+ </Fold>
49
53
  </div>
50
54
  )
51
55
  }
@@ -3,8 +3,9 @@
3
3
  * @module dsh-group-chat/client/hooks
4
4
  */
5
5
 
6
- import { useCallback, useEffect, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
6
+ import { useCallback, useEffect, useLayoutEffect, useRef, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
7
7
  import { execCommand, queryAtCaret, serializeInput, chipHtml, fileChipHtml } from '../utils/utils.ts'
8
+ import { readComposerDraft, writeComposerDraft } from '../lib/composer-draft.ts'
8
9
  import type { AtToken } from '../../shared/file-mention-grammar.ts'
9
10
  import type { SnapshotRole } from '../lib/model.ts'
10
11
 
@@ -44,12 +45,18 @@ export function useComposerInput(
44
45
  setInput: (val: string) => void,
45
46
  setMention: (val: AtToken | null) => void,
46
47
  setMentionIdx: (val: number) => void,
48
+ sessionId?: string | null,
47
49
  ) {
48
- /** DOM 同步 input 状态(序列化) */
50
+ const sessionIdRef = useRef(sessionId)
51
+ sessionIdRef.current = sessionId
52
+
53
+ /** 从 DOM 同步 input 状态(序列化),并写入当前会话草稿槽。 */
49
54
  const syncFromDOM = useCallback((): void => {
50
55
  const el = inputRef.current
51
56
  if (!el) return
52
- setInput(serializeInput(el))
57
+ const text = serializeInput(el)
58
+ setInput(text)
59
+ writeComposerDraft(sessionIdRef.current, el.innerHTML, text)
53
60
  }, [inputRef, setInput])
54
61
 
55
62
  const onInputCE = useCallback((): void => {
@@ -84,6 +91,36 @@ export function useComposerInput(
84
91
  }
85
92
  }
86
93
 
94
+ /**
95
+ * 按会话恢复草稿:切会话时先把当前 HTML 写入旧槽,再灌入新槽;
96
+ * 面板重挂载(主会话⇄群聊)时 editor 是新节点,从模块缓存灌回。
97
+ */
98
+ export function useComposerDraft(
99
+ sessionId: string | null | undefined,
100
+ inputRef: React.RefObject<HTMLDivElement>,
101
+ setInput: (val: string) => void,
102
+ setMention: (val: AtToken | null) => void,
103
+ ): void {
104
+ const prevIdRef = useRef<string | null>(null)
105
+ useLayoutEffect(() => {
106
+ const el = inputRef.current
107
+ const prev = prevIdRef.current
108
+ const next = sessionId || null
109
+ if (el && prev && prev !== next) writeComposerDraft(prev, el.innerHTML, serializeInput(el))
110
+ prevIdRef.current = next
111
+ if (!next) return
112
+ const draft = readComposerDraft(next)
113
+ if (el) el.innerHTML = draft.html
114
+ setInput(draft.text)
115
+ setMention(null)
116
+ return () => {
117
+ const node = inputRef.current
118
+ const id = prevIdRef.current
119
+ if (node && id) writeComposerDraft(id, node.innerHTML, serializeInput(node))
120
+ }
121
+ }, [sessionId, inputRef, setInput, setMention])
122
+ }
123
+
87
124
  export function useMentionChip(
88
125
  inputRef: React.RefObject<HTMLDivElement>,
89
126
  setMention: (val: AtToken | null) => void,
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { useCallback, useEffect, useRef, useState } from 'react'
7
7
  import { api } from '../lib/api.ts'
8
+ import { readComposerDraft } from '../lib/composer-draft.ts'
8
9
  import type { ClientSnapshot, ModelsResponse, RoleDraft } from '../lib/model.ts'
9
10
  import type { AtToken } from '../../shared/file-mention-grammar.ts'
10
11
 
@@ -22,8 +23,8 @@ export interface ActionOk {
22
23
 
23
24
  /**
24
25
  * 面板切换缓存(模块级,跨挂载存活):main 为 keyed 槽,主会话⇄群聊切换会
25
- * 整体卸载重挂面板组件;缓存最近快照与选中项让重挂载瞬时恢复上次视图,
26
- * 挂载后的 state 拉取 / SSE 首帧再行校正(本机回路往返仅数毫秒)。
26
+ * 整体卸载重挂面板组件;缓存最近快照、选中项与按会话分槽的未发送草稿,
27
+ * 让重挂载瞬时恢复上次视图。挂载后的 state 拉取 / SSE 首帧再行校正。
27
28
  */
28
29
  let cachedSnap: ClientSnapshot | null = null
29
30
  let cachedGid: string | null = null
@@ -84,8 +85,8 @@ export function useGroupChatState() {
84
85
  const [partsSel, setPartsSel] = useState<string[] | null>(null)
85
86
  const [rounds, setRounds] = useState(1)
86
87
 
87
- // 输入与 @提及
88
- const [input, setInput] = useState('')
88
+ // 输入与 @提及(文本初值取当前选中会话草稿,HTML 由 useComposerDraft 灌回)
89
+ const [input, setInput] = useState(() => readComposerDraft(cachedSid).text)
89
90
  const [mention, setMention] = useState<AtToken | null>(null)
90
91
  const [mentionIdx, setMentionIdx] = useState(0)
91
92
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 未发送草稿:按会话 id 分槽,模块级跨挂载存活。
3
+ * 主会话⇄群聊会整页卸载 composer,contentEditable 的 HTML 必须自行记住。
4
+ * @module dsh-group-chat/client/composer-draft
5
+ */
6
+
7
+ export interface ComposerDraft {
8
+ html: string
9
+ text: string
10
+ }
11
+
12
+ const LIMIT = 32
13
+ const drafts = new Map<string, ComposerDraft>()
14
+
15
+ const empty = (): ComposerDraft => ({ html: '', text: '' })
16
+
17
+ /** 读指定会话草稿;命中则提到 LRU 最近端。 */
18
+ export function readComposerDraft(sessionId: string | null | undefined): ComposerDraft {
19
+ if (!sessionId) return empty()
20
+ const hit = drafts.get(sessionId)
21
+ if (!hit) return empty()
22
+ drafts.delete(sessionId)
23
+ drafts.set(sessionId, hit)
24
+ return hit
25
+ }
26
+
27
+ /** 写入;空草稿删槽。超出上限淘汰最旧槽。 */
28
+ export function writeComposerDraft(sessionId: string | null | undefined, html: string, text: string): void {
29
+ if (!sessionId) return
30
+ drafts.delete(sessionId)
31
+ if (!html && !text) return
32
+ drafts.set(sessionId, { html, text })
33
+ if (drafts.size > LIMIT) {
34
+ const oldest = drafts.keys().next().value
35
+ if (oldest !== undefined) drafts.delete(oldest)
36
+ }
37
+ }
38
+
39
+ /** 发送成功或明确丢弃时摘槽。 */
40
+ export function clearComposerDraft(sessionId: string | null | undefined): void {
41
+ if (sessionId) drafts.delete(sessionId)
42
+ }
43
+
44
+ /** 测试用:清空全部槽位。 */
45
+ export function resetComposerDrafts(): void {
46
+ drafts.clear()
47
+ }