dsh-mega-chat-nav 0.1.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 (192) hide show
  1. package/LICENSE +201 -0
  2. package/README.en.md +149 -0
  3. package/README.md +149 -0
  4. package/cordis.patch.yml +4 -0
  5. package/lib/client/NavOverlay.js +35 -0
  6. package/lib/client/NavRail.js +12 -0
  7. package/lib/client/QuestionNavSettingsTab.js +26 -0
  8. package/lib/client/QuestionNavStrip.js +555 -0
  9. package/lib/client/components/ConfigPanel.js +14 -0
  10. package/lib/client/components/DotRail.js +62 -0
  11. package/lib/client/components/NavOverlay.js +121 -0
  12. package/lib/client/components/NavRail.js +97 -0
  13. package/lib/client/components/RailDispatch.js +22 -0
  14. package/lib/client/components/RailHost.js +79 -0
  15. package/lib/client/components/SearchBox.js +66 -0
  16. package/lib/client/components/SettingsPage.js +46 -0
  17. package/lib/client/components/StandaloneSettings.js +16 -0
  18. package/lib/client/components/codex/CodexRail.js +139 -0
  19. package/lib/client/components/deepseek/DeepseekRail.js +264 -0
  20. package/lib/client/components/minimal/MinimalRail.js +135 -0
  21. package/lib/client/components/minimal/cards.js +32 -0
  22. package/lib/client/components/minimal/dot.js +11 -0
  23. package/lib/client/components/minimal/hint.js +30 -0
  24. package/lib/client/components/minimal/paging.js +5 -0
  25. package/lib/client/components/minimal/quick-settings.js +28 -0
  26. package/lib/client/components/minimal/search.js +67 -0
  27. package/lib/client/components/minimal/useRail.js +716 -0
  28. package/lib/client/components/rail/cards.js +32 -0
  29. package/lib/client/components/rail/hint.js +30 -0
  30. package/lib/client/components/rail/paging.js +5 -0
  31. package/lib/client/components/rail/quick-settings.js +28 -0
  32. package/lib/client/components/rail/search.js +76 -0
  33. package/lib/client/components/rail/useRail.js +845 -0
  34. package/lib/client/components/settings/SettingsPage.js +46 -0
  35. package/lib/client/components/settings/SettingsTab.js +17 -0
  36. package/lib/client/components/settings/StandaloneSettings.js +16 -0
  37. package/lib/client/components/shared/Cascade.js +26 -0
  38. package/lib/client/components/shared/ConfigPanel.js +27 -0
  39. package/lib/client/components/shared/Dot.js +8 -0
  40. package/lib/client/components/shared/Hint.js +5 -0
  41. package/lib/client/components/shared/PagingButton.js +5 -0
  42. package/lib/client/components/shared/RailShell.js +78 -0
  43. package/lib/client/components/shared/SearchBox.js +63 -0
  44. package/lib/client/components/shared/hooks.js +427 -0
  45. package/lib/client/components/shared/types.js +1 -0
  46. package/lib/client/config.js +1 -0
  47. package/lib/client/engines/jump.js +34 -0
  48. package/lib/client/engines/useAtBottom.js +25 -0
  49. package/lib/client/engines/useJump.js +40 -0
  50. package/lib/client/engines/useScrollSpy.js +88 -0
  51. package/lib/client/hooks.js +11 -0
  52. package/lib/client/index.js +312 -0
  53. package/lib/client/jump.js +255 -0
  54. package/lib/client/locales.js +142 -0
  55. package/lib/client/pure.js +61 -0
  56. package/lib/client/settings.js +369 -0
  57. package/lib/client/virtual/VirtualList.js +21 -0
  58. package/lib/client.js +3213 -0
  59. package/lib/compat.js +145 -0
  60. package/lib/config.js +37 -0
  61. package/lib/core/active-dot.js +17 -0
  62. package/lib/core/align.js +9 -0
  63. package/lib/core/config.js +37 -0
  64. package/lib/core/focus.js +38 -0
  65. package/lib/core/jump.js +98 -0
  66. package/lib/core/logic/active-dot.js +16 -0
  67. package/lib/core/logic/focus.js +34 -0
  68. package/lib/core/logic/jump.js +188 -0
  69. package/lib/core/logic/nodes.js +59 -0
  70. package/lib/core/model/nav-item.js +1 -0
  71. package/lib/core/model/turns.js +73 -0
  72. package/lib/core/nav-item.js +1 -0
  73. package/lib/core/nodes.js +57 -0
  74. package/lib/core/page-size.js +7 -0
  75. package/lib/core/question-entry.js +1 -0
  76. package/lib/core/time.js +23 -0
  77. package/lib/core/turn-dots.js +85 -0
  78. package/lib/core/utils/measure.js +24 -0
  79. package/lib/core/utils/time.js +26 -0
  80. package/lib/index.js +64 -0
  81. package/lib/nav-item.js +1 -0
  82. package/lib/nodes.js +59 -0
  83. package/lib/projection.js +126 -0
  84. package/lib/schema.js +26 -0
  85. package/lib/search.js +285 -0
  86. package/lib/settings.js +89 -0
  87. package/lib/types/client/NavOverlay.d.ts +18 -0
  88. package/lib/types/client/NavRail.d.ts +7 -0
  89. package/lib/types/client/QuestionNavSettingsTab.d.ts +16 -0
  90. package/lib/types/client/QuestionNavStrip.d.ts +48 -0
  91. package/lib/types/client/components/ConfigPanel.d.ts +14 -0
  92. package/lib/types/client/components/DotRail.d.ts +15 -0
  93. package/lib/types/client/components/NavOverlay.d.ts +30 -0
  94. package/lib/types/client/components/NavRail.d.ts +16 -0
  95. package/lib/types/client/components/RailDispatch.d.ts +8 -0
  96. package/lib/types/client/components/RailHost.d.ts +27 -0
  97. package/lib/types/client/components/SearchBox.d.ts +9 -0
  98. package/lib/types/client/components/SettingsPage.d.ts +9 -0
  99. package/lib/types/client/components/StandaloneSettings.d.ts +13 -0
  100. package/lib/types/client/components/codex/CodexRail.d.ts +3 -0
  101. package/lib/types/client/components/deepseek/DeepseekRail.d.ts +3 -0
  102. package/lib/types/client/components/minimal/MinimalRail.d.ts +8 -0
  103. package/lib/types/client/components/minimal/cards.d.ts +19 -0
  104. package/lib/types/client/components/minimal/dot.d.ts +14 -0
  105. package/lib/types/client/components/minimal/hint.d.ts +11 -0
  106. package/lib/types/client/components/minimal/paging.d.ts +10 -0
  107. package/lib/types/client/components/minimal/quick-settings.d.ts +8 -0
  108. package/lib/types/client/components/minimal/search.d.ts +27 -0
  109. package/lib/types/client/components/minimal/useRail.d.ts +90 -0
  110. package/lib/types/client/components/rail/cards.d.ts +19 -0
  111. package/lib/types/client/components/rail/hint.d.ts +11 -0
  112. package/lib/types/client/components/rail/paging.d.ts +10 -0
  113. package/lib/types/client/components/rail/quick-settings.d.ts +8 -0
  114. package/lib/types/client/components/rail/search.d.ts +34 -0
  115. package/lib/types/client/components/rail/useRail.d.ts +132 -0
  116. package/lib/types/client/components/settings/SettingsPage.d.ts +9 -0
  117. package/lib/types/client/components/settings/SettingsTab.d.ts +6 -0
  118. package/lib/types/client/components/settings/StandaloneSettings.d.ts +13 -0
  119. package/lib/types/client/components/shared/Cascade.d.ts +14 -0
  120. package/lib/types/client/components/shared/ConfigPanel.d.ts +8 -0
  121. package/lib/types/client/components/shared/Dot.d.ts +17 -0
  122. package/lib/types/client/components/shared/Hint.d.ts +6 -0
  123. package/lib/types/client/components/shared/PagingButton.d.ts +10 -0
  124. package/lib/types/client/components/shared/RailShell.d.ts +43 -0
  125. package/lib/types/client/components/shared/SearchBox.d.ts +18 -0
  126. package/lib/types/client/components/shared/hooks.d.ts +64 -0
  127. package/lib/types/client/components/shared/types.d.ts +101 -0
  128. package/lib/types/client/config.d.ts +9 -0
  129. package/lib/types/client/engines/jump.d.ts +17 -0
  130. package/lib/types/client/engines/useAtBottom.d.ts +1 -0
  131. package/lib/types/client/engines/useJump.d.ts +16 -0
  132. package/lib/types/client/engines/useScrollSpy.d.ts +12 -0
  133. package/lib/types/client/hooks.d.ts +7 -0
  134. package/lib/types/client/index.d.ts +31 -0
  135. package/lib/types/client/jump.d.ts +47 -0
  136. package/lib/types/client/locales.d.ts +142 -0
  137. package/lib/types/client/pure.d.ts +21 -0
  138. package/lib/types/client/settings.d.ts +173 -0
  139. package/lib/types/client/virtual/VirtualList.d.ts +15 -0
  140. package/lib/types/compat.d.ts +44 -0
  141. package/lib/types/config.d.ts +44 -0
  142. package/lib/types/core/active-dot.d.ts +15 -0
  143. package/lib/types/core/align.d.ts +10 -0
  144. package/lib/types/core/config.d.ts +44 -0
  145. package/lib/types/core/focus.d.ts +24 -0
  146. package/lib/types/core/jump.d.ts +39 -0
  147. package/lib/types/core/logic/active-dot.d.ts +15 -0
  148. package/lib/types/core/logic/focus.d.ts +22 -0
  149. package/lib/types/core/logic/jump.d.ts +39 -0
  150. package/lib/types/core/logic/nodes.d.ts +48 -0
  151. package/lib/types/core/model/nav-item.d.ts +12 -0
  152. package/lib/types/core/model/turns.d.ts +30 -0
  153. package/lib/types/core/nav-item.d.ts +12 -0
  154. package/lib/types/core/nodes.d.ts +43 -0
  155. package/lib/types/core/page-size.d.ts +8 -0
  156. package/lib/types/core/question-entry.d.ts +12 -0
  157. package/lib/types/core/time.d.ts +4 -0
  158. package/lib/types/core/turn-dots.d.ts +29 -0
  159. package/lib/types/core/utils/measure.d.ts +12 -0
  160. package/lib/types/core/utils/time.d.ts +4 -0
  161. package/lib/types/index.d.ts +4 -0
  162. package/lib/types/nav-item.d.ts +12 -0
  163. package/lib/types/nodes.d.ts +48 -0
  164. package/lib/types/projection.d.ts +71 -0
  165. package/lib/types/schema.d.ts +37 -0
  166. package/lib/types/search.d.ts +42 -0
  167. package/lib/types/settings.d.ts +99 -0
  168. package/package.json +71 -0
  169. package/src/client/components/RailDispatch.tsx +27 -0
  170. package/src/client/components/codex/CodexRail.tsx +348 -0
  171. package/src/client/components/deepseek/DeepseekRail.tsx +522 -0
  172. package/src/client/components/minimal/MinimalRail.tsx +324 -0
  173. package/src/client/components/minimal/dot.tsx +32 -0
  174. package/src/client/components/rail/cards.tsx +102 -0
  175. package/src/client/components/rail/hint.tsx +50 -0
  176. package/src/client/components/rail/paging.tsx +26 -0
  177. package/src/client/components/rail/quick-settings.tsx +130 -0
  178. package/src/client/components/rail/search.tsx +147 -0
  179. package/src/client/components/rail/useRail.ts +906 -0
  180. package/src/client/components/settings/SettingsPage.tsx +198 -0
  181. package/src/client/components/settings/StandaloneSettings.tsx +28 -0
  182. package/src/client/components/shared/types.ts +99 -0
  183. package/src/client/index.ts +388 -0
  184. package/src/client/jump.ts +293 -0
  185. package/src/client/locales.ts +143 -0
  186. package/src/client/settings.ts +482 -0
  187. package/src/client/styles.css +1580 -0
  188. package/src/compat.ts +161 -0
  189. package/src/index.ts +66 -0
  190. package/src/projection.ts +174 -0
  191. package/src/search.ts +291 -0
  192. package/src/settings.ts +169 -0
@@ -0,0 +1,906 @@
1
+ // 跨风格轨道逻辑层(minimal 圆点轨 / codex 刻度轨共用):
2
+ // 数据装配 / 布局钉位 / 阅读间谍 / 条带翻页 / 卡片预览 / 失败提示 / 收藏 / 手机模式。
3
+ // 节点数据形态(RailMarker)与几何常量(行距 14px)为插件自身设计——两风格行距一致,
4
+ // 翻页步距与跟读滚动对任意风格成立(风格差异只在节点视觉与 hover 呈现)。
5
+ import { useEffect, useRef, useState, type RefObject } from 'react'
6
+ import type { FocusState, NavInjected, ObservableFace, RailMarker, SessionListLike, Translate } from '../shared/types.ts'
7
+ import { DEFAULT_ALIGN, DEFAULT_BAND, DEFAULT_CARD_COUNT, DEFAULT_CARD_ITEMS, DEFAULT_MARK_TONE, DEFAULT_PAGING, DEFAULT_SCROLL, DEFAULT_SEARCH_SCOPES, DEFAULT_SHOW, DEFAULT_STYLE, type BandHeight, type CardCount, type CardItem, type MarkTone, type NavStyle, type RailAlign, type ScrollMode, type SearchScope, type ShowMode } from '../../settings.ts'
8
+
9
+ /* ================= 数据装配 ================= */
10
+
11
+ /** 锚点 key:镜像聊天行锚点公式 '<kind长度>:<kind><id>',kind 段固定 'input-message'(DOM 契约) */
12
+ function anchorKey(id: unknown): string {
13
+ return '13:input-message' + String(id)
14
+ }
15
+
16
+ /** 投影条目(含可选性能指标) */
17
+ interface ProjectedEntry {
18
+ turn: number
19
+ id: string
20
+ seq: number
21
+ time: number
22
+ text: string
23
+ metrics?: { durationMs: number; firstTokenMs: number; tokensPerSec: number }
24
+ }
25
+
26
+ /** 投影值 → 条目(结构守卫:缺 id/seq/turn 的脏数据丢弃) */
27
+ function projectedEntries(face: ObservableFace | undefined): ProjectedEntry[] {
28
+ const value = face?.getSnapshot()
29
+ if (!Array.isArray(value)) return []
30
+ return value.filter((item): item is ProjectedEntry =>
31
+ typeof item === 'object' && item !== null
32
+ && typeof (item as { id?: unknown }).id === 'string'
33
+ && typeof (item as { seq?: unknown }).seq === 'number'
34
+ && typeof (item as { turn?: unknown }).turn === 'number')
35
+ }
36
+
37
+ /** 结构相等:members 全等即内容一致(React bail out 用) */
38
+ function sameMarkers(a: readonly RailMarker[], b: readonly RailMarker[]): boolean {
39
+ if (a.length !== b.length) return false
40
+ for (let i = 0; i < a.length; i++) {
41
+ if (a[i].key !== b[i].key || a[i].members.length !== b[i].members.length) return false
42
+ for (let j = 0; j < a[i].members.length; j++) {
43
+ if (a[i].members[j] !== b[i].members[j]) return false
44
+ }
45
+ }
46
+ return true
47
+ }
48
+
49
+ /** 活窗口行最小面 */
50
+ interface LiveRow {
51
+ key: string
52
+ anchorSeq: number
53
+ time: number
54
+ text: string
55
+ }
56
+
57
+ /**
58
+ * 回合标记装配:投影条目(全量)+ 活窗口行(投影未收录才补)统一排序后按回合分组。
59
+ * 同回合连续条目合并为同一标记(锚点取首问)。
60
+ */
61
+ function buildMarkers(projected: readonly ProjectedEntry[], live: readonly LiveRow[]): RailMarker[] {
62
+ const seen = new Set<string>()
63
+ const merged: { turn: number | null; seq: number; key: string; time: number; text: string; metrics?: { durationMs: number; firstTokenMs: number; tokensPerSec: number } }[] = []
64
+ for (const p of projected) {
65
+ const key = anchorKey(p.id)
66
+ if (seen.has(key)) continue
67
+ seen.add(key)
68
+ merged.push({ turn: p.turn, seq: p.seq, key, time: p.time, text: p.text, metrics: p.metrics })
69
+ }
70
+ for (const l of live) {
71
+ if (seen.has(l.key)) continue
72
+ seen.add(l.key)
73
+ merged.push({ turn: null, seq: l.anchorSeq, key: l.key, time: l.time, text: l.text })
74
+ }
75
+ merged.sort((a, b) => a.seq - b.seq)
76
+ const markers: RailMarker[] = []
77
+ for (const m of merged) {
78
+ const tail = markers[markers.length - 1]
79
+ if (tail !== undefined && tail.turn !== null && tail.turn === m.turn) {
80
+ markers[markers.length - 1] = { ...tail, texts: [...tail.texts, m.text], members: [...tail.members, m.key] }
81
+ } else {
82
+ markers.push({ turn: m.turn, key: m.key, seq: m.seq, time: m.time, texts: [m.text], members: [m.key], metrics: m.metrics })
83
+ }
84
+ }
85
+ return markers
86
+ }
87
+
88
+ /** 圆点数据:会话可见性 + 标记装配(订阅集中管理,共用一条刷新管线) */
89
+ export function useMarkerData(
90
+ injected: NavInjected | undefined,
91
+ useSessions?: <S>(selector: (s: SessionListLike) => S) => S,
92
+ ): { sessionId: string | undefined; visible: boolean; markers: RailMarker[] } {
93
+ const current = useSessions?.((s) => s.current) as string | undefined
94
+ const summary = useSessions?.((s) => (current === undefined ? undefined : s.byId?.[String(current)]))
95
+ // 仅对话视图显示([data-chat-flow] 只在对话消息流渲染;Trajectory 视图与会话未加载时不存在)
96
+ const [viewOk, setViewOk] = useState<boolean>(() => document.querySelector('[data-chat-flow]') !== null)
97
+ useEffect(() => {
98
+ const check = (): void => setViewOk(document.querySelector('[data-chat-flow]') !== null)
99
+ check()
100
+ const observer = typeof MutationObserver === 'undefined' ? null : new MutationObserver(check)
101
+ observer?.observe(document.body, { childList: true, subtree: true })
102
+ return () => { observer?.disconnect() }
103
+ }, [])
104
+ const visible = current !== undefined && summary !== undefined && summary.blank !== true && viewOk
105
+ const [markers, setMarkers] = useState<RailMarker[]>([])
106
+ const cacheRef = useRef<RailMarker[]>([])
107
+
108
+ useEffect(() => {
109
+ if (!visible || current === undefined || injected === undefined) {
110
+ cacheRef.current = []
111
+ setMarkers([])
112
+ return
113
+ }
114
+ const face = injected.questionProjection(current)
115
+ // 内容刷新经 rAF 合并:流式输出/历史装载时一帧内可能多次变更事件,
116
+ // 只做一次装配(buildMarkers 遍历全量投影 + 活窗行,频繁执行会拖慢长会话)
117
+ let raf = 0
118
+ const refresh = (): void => {
119
+ if (raf !== 0) return
120
+ raf = requestAnimationFrame(() => {
121
+ raf = 0
122
+ const next = buildMarkers(projectedEntries(face), injected.readQuestions(current) as unknown as LiveRow[])
123
+ if (sameMarkers(next, cacheRef.current)) {
124
+ setMarkers(cacheRef.current)
125
+ return
126
+ }
127
+ cacheRef.current = next
128
+ setMarkers(next)
129
+ })
130
+ }
131
+ refresh()
132
+ const offProjection = face?.subscribe(refresh) ?? (() => {})
133
+ const offContent = injected.subscribeContent(current, refresh)
134
+ return () => {
135
+ if (raf !== 0) cancelAnimationFrame(raf)
136
+ offProjection()
137
+ offContent()
138
+ }
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, [injected, current, visible])
141
+
142
+ return { sessionId: current, visible, markers }
143
+ }
144
+
145
+ /* ================= 布局钉位 ================= */
146
+
147
+ /** 对话列根元素(桌面 rail 钉位基准——保持历史稳定实现) */
148
+ function conversationRoot(): HTMLElement | null {
149
+ return document.querySelector<HTMLElement>('[data-slot="conversation"] > div[data-phase]')
150
+ }
151
+
152
+ /** 手机搜索按钮宿主:对话滚动容器 [data-conversation-scroll](sticky 粘附基准) */
153
+ function mobileSearchHost(): HTMLElement | null {
154
+ return document.querySelector<HTMLElement>('[data-conversation-scroll]')
155
+ }
156
+
157
+ /** 期望几何:rail 钉到对话列(对齐侧决定 edge 归属);offset 为 rail 与对话列边缘的距离 */
158
+ function desiredGeometry(panel: HTMLElement, align: RailAlign, offset: number): { top: string; height: string; edge: string; side: 'left' | 'right' } | null {
159
+ const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null
160
+ const root = conversationRoot()
161
+ if (frame === null || root === null) return null
162
+ const fr = frame.getBoundingClientRect()
163
+ const rr = root.getBoundingClientRect()
164
+ if (rr.height <= 0 || rr.width <= 0) return null
165
+ // 取整到整数像素:getBoundingClientRect 的浮点噪声(如 3.05e-05px)
166
+ // 会让字符串比较永远 dirty,布局循环停不下来
167
+ const top = Math.round(rr.top - fr.top)
168
+ const height = Math.round(rr.height)
169
+ return align === 'right'
170
+ ? { top: top + 'px', height: height + 'px', edge: Math.round(fr.right - rr.right + offset) + 'px', side: 'right' }
171
+ : { top: top + 'px', height: height + 'px', edge: Math.round(rr.left - fr.left + offset) + 'px', side: 'left' }
172
+ }
173
+
174
+ /** 布局钉位:期望几何 → 比对 → 写入(rAF 循环,连续 3 帧稳定停车;resize/尺寸变化唤醒) */
175
+ /**
176
+ * 布局钉位:校准状态机(鲁棒 + 长会话低成本)。
177
+ *
178
+ * 会话/工作区切换时对话区可能整体重建、或复用同一容器异步填充——校准必须
179
+ * 覆盖这些路径,同时**行级消息增删绝不唤醒校准**(那会让长会话的 rAF 校准
180
+ * 循环停不下来,每帧 reflow 拖慢一切交互——与对话几何无关)。
181
+ *
182
+ * 状态机:
183
+ * - running:切换/尺寸事件后启动,每帧 calibrate 一次;
184
+ * changed → 重置稳定帧;stable → 连续 3 帧停车(parked);
185
+ * missing(对话区未出现/高度为 0)→ 不停车,继续等待重建完成。
186
+ * - parked:静默。唤醒条件:
187
+ * 1) ResizeObserver(frame / 对话区根元素尺寸变化)
188
+ * 2) window resize
189
+ * 3) MutationObserver 发现对话区根元素被移除或替换(会话/工作区切换)
190
+ * 4) effect 依赖变化重启(sessionId / visible / align / offset)
191
+ *
192
+ * 行级变化(追加消息、装载历史、其他插件改 body 子树)只做一次 O(1) 的
193
+ * isConnected 检查,不唤醒校准。
194
+ */
195
+ export function usePinning(panelRef: RefObject<HTMLDivElement>, visible: boolean, align: RailAlign, offset = 0, sessionId?: string): void {
196
+ useEffect(() => {
197
+ let alive = true
198
+ let observer: ResizeObserver | null = null
199
+ let domObserver: MutationObserver | null = null
200
+ let raf = 0
201
+ let parked = true
202
+ let stableFrames = 0
203
+ let retry: ReturnType<typeof setTimeout> | null = null
204
+ let watchedRoot: Element | null = null
205
+
206
+ // 目标缺失(对话区重建中/尚未挂载)时持续等待:missing 永不 parking,
207
+ // 但用 60ms 定时退避重试(不空转 rAF;重建完成后自动就位)
208
+ const calibrate = (): 'missing' | 'changed' | 'stable' => {
209
+ const panel = panelRef.current
210
+ if (panel === null) return 'missing'
211
+ const want = desiredGeometry(panel, align, offset)
212
+ if (want === null) return 'missing'
213
+ const other = want.side === 'right' ? 'left' : 'right'
214
+ const dirty = panel.style.top !== want.top
215
+ || panel.style.height !== want.height
216
+ || panel.style[want.side] !== want.edge
217
+ || panel.style[other] !== ''
218
+ if (!dirty) return 'stable'
219
+ panel.style.top = want.top
220
+ panel.style.height = want.height
221
+ panel.style[want.side] = want.edge
222
+ panel.style[other] = ''
223
+ return 'changed'
224
+ }
225
+ // 校准成功期间保持 watcher 指向当前 root(首次出现/被替换后重绑)——
226
+ // 否则 root 断开检测失去目标,会话切换不再唤醒
227
+ const ensureWatched = (): void => {
228
+ const root = conversationRoot()
229
+ if (root !== watchedRoot) bindResize()
230
+ }
231
+ const loop = (): void => {
232
+ if (!alive) return
233
+ raf = 0
234
+ const result = calibrate()
235
+ if (result === 'missing') {
236
+ // 目标未出现:定时退避重试(每 60ms),成功后转 rAF 稳定收敛
237
+ if (retry === null) retry = setTimeout(() => { retry = null; loop() }, 60)
238
+ return
239
+ }
240
+ ensureWatched()
241
+ if (result === 'changed') stableFrames = 0
242
+ else if (result === 'stable') stableFrames += 1
243
+ if (stableFrames >= 3) {
244
+ parked = true
245
+ return
246
+ }
247
+ raf = requestAnimationFrame(loop)
248
+ }
249
+ const run = (): void => {
250
+ if (!alive || !parked) return
251
+ parked = false
252
+ stableFrames = 0
253
+ if (retry !== null) { clearTimeout(retry); retry = null }
254
+ if (raf === 0) raf = requestAnimationFrame(loop)
255
+ }
256
+
257
+ // ResizeObserver:只观察 frame 与对话区根(尺寸变化才回调)
258
+ const bindResize = (): void => {
259
+ observer?.disconnect()
260
+ if (typeof ResizeObserver === 'undefined') return
261
+ observer = new ResizeObserver(run)
262
+ const frame = panelRef.current?.closest('[data-shell-overlay]')?.parentElement ?? null
263
+ if (frame !== null) observer.observe(frame, { box: 'border-box' })
264
+ const root = conversationRoot()
265
+ if (root !== null) {
266
+ observer.observe(root, { box: 'border-box' })
267
+ watchedRoot = root
268
+ }
269
+ }
270
+
271
+ // MutationObserver:仅在对话区根元素被移除/替换时唤醒。
272
+ // 行级变化(消息追加/装载/任何子树内部增删)只做 O(1) isConnected
273
+ // 检查后忽略——对话区复用同一容器异步填充的场景由 missing 循环兜底
274
+ // (root 短暂不可用或高度为 0 时校准停不下来,重建完成后自动就位)。
275
+ if (typeof MutationObserver !== 'undefined') {
276
+ domObserver = new MutationObserver(() => {
277
+ if (watchedRoot !== null && !watchedRoot.isConnected) {
278
+ // 根元素已被整体移除(会话/工作区切换):重新绑定并启动校准
279
+ bindResize()
280
+ run()
281
+ }
282
+ // watchedRoot 为空(初始尚未找到对话区)或仍在文档中:不唤醒。
283
+ // 首次出现由下方 run() 与 missing 循环处理。
284
+ })
285
+ domObserver.observe(document.body, { childList: true, subtree: true })
286
+ }
287
+ bindResize()
288
+ // 初始(含会话切换 effect 重启):无论 visible 与否先尝试校准——
289
+ // 对话区缺失时 loop 持续等待;找不到 panel(未挂载)时也在 missing
290
+ // 分支不断重试,挂载后自动就位。
291
+ run()
292
+ window.addEventListener('resize', run)
293
+ return () => {
294
+ alive = false
295
+ if (raf !== 0) cancelAnimationFrame(raf)
296
+ if (retry !== null) clearTimeout(retry)
297
+ observer?.disconnect()
298
+ domObserver?.disconnect()
299
+ window.removeEventListener('resize', run)
300
+ }
301
+ }, [panelRef, visible, align, offset, sessionId])
302
+ }
303
+
304
+ /* ================= 阅读间谍 ================= */
305
+
306
+ /**
307
+ * 阅读位置判定:按 DOM 顺序(= 时间顺序)扫描已渲染行,命中第一条
308
+ * top >= 阅读线的标记即返回——消息行再多也只读到阅读线附近即停,
309
+ * 不会为全部轮次做几何采样。
310
+ * 全部行仍在线下方时取最后一条(最接近视口底)。
311
+ */
312
+ function readingRowKey(flow: HTMLElement, portTop: number, line: number, markerSet: ReadonlySet<string>): string | null {
313
+ let lastKey: string | null = null
314
+ for (const row of flow.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
315
+ const key = row.dataset.chatAnchorKey
316
+ if (key === undefined) continue
317
+ if (markerSet.has(key)) {
318
+ const top = row.getBoundingClientRect().top - portTop
319
+ if (top >= line) return key
320
+ lastKey = key
321
+ }
322
+ }
323
+ return lastKey
324
+ }
325
+
326
+ /** 阅读间谍:滚动/尺寸变化按 rAF 采样行位置,标记当前所在回合。
327
+ * 返回 [currentKey, forceKey]——forceKey 用于点击跳转瞬间强制激活目标,
328
+ * 滚动稳定后由采样判据接管。 */
329
+ export function useReadingSpy(visible: boolean, markers: readonly RailMarker[]): [string | null, (key: string) => void] {
330
+ const [currentKey, setCurrentKey] = useState<string | null>(null)
331
+ const currentKeyRef = useRef<string | null>(null)
332
+ const forceKey = (key: string): void => { currentKeyRef.current = key; setCurrentKey(key) }
333
+ // 标记集合(members 展开一次;长会话避免每帧重复构造)
334
+ const markerSetRef = useRef<Set<string>>(new Set())
335
+ markerSetRef.current = new Set(markers.flatMap((m) => m.members))
336
+
337
+ useEffect(() => {
338
+ if (!visible || markers.length === 0) { setCurrentKey(null); return }
339
+ let raf = 0
340
+ let disposed = false
341
+ let observer: ResizeObserver | null = null
342
+ let trackedFlow: Element | null = null
343
+ let lastSample = 0
344
+
345
+ const sample = (): void => {
346
+ raf = 0
347
+ // 滚动节流:100ms 内不重复全量扫描(长会话行数多,逐帧扫会占满主线程)
348
+ const now = performance.now()
349
+ if (now - lastSample < 100 && currentKeyRef.current !== null) {
350
+ schedule()
351
+ return
352
+ }
353
+ lastSample = now
354
+ const scrollport = document.querySelector<HTMLElement>('[data-conversation-scroll]')
355
+ const flow = document.querySelector<HTMLElement>('[data-chat-flow]')
356
+ if (scrollport === null || flow === null) { setCurrentKey(null); return }
357
+ if (observer !== null && trackedFlow !== flow) {
358
+ observer.disconnect()
359
+ observer.observe(flow)
360
+ trackedFlow = flow
361
+ }
362
+ const portRect = scrollport.getBoundingClientRect()
363
+ if (portRect.height <= 0) return
364
+ const next = readingRowKey(flow, portRect.top, 0, markerSetRef.current)
365
+ currentKeyRef.current = next
366
+ setCurrentKey((prev) => (prev === next ? prev : next))
367
+ }
368
+ const schedule = (): void => {
369
+ if (!disposed && raf === 0) raf = requestAnimationFrame(sample)
370
+ }
371
+
372
+ observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule)
373
+ sample()
374
+ document.addEventListener('scroll', schedule, true)
375
+ window.addEventListener('resize', schedule)
376
+ return () => {
377
+ disposed = true
378
+ if (raf !== 0) cancelAnimationFrame(raf)
379
+ observer?.disconnect()
380
+ document.removeEventListener('scroll', schedule, true)
381
+ window.removeEventListener('resize', schedule)
382
+ }
383
+ }, [visible, markers])
384
+
385
+ return [currentKey, forceKey]
386
+ }
387
+
388
+ /* ================= 条带翻页 ================= */
389
+
390
+ /** 行内节点几何:节点 8px + 行距 6px = 行高 14px(minimal 圆点与 codex 刻度槽一致;
391
+ * 与样式 .mgcn-dots gap 6px / 节点 8px、codex 槽高 14px 对齐) */
392
+ export const NODE_SIZE = 8
393
+ export const ROW_GAP = 6
394
+ export const ROW_HEIGHT = NODE_SIZE + ROW_GAP // 14px:一次翻页的固定步距基准
395
+ export const ROWS_PER_PAGE = 5
396
+
397
+ /** 一次翻页的滚动距离 */
398
+ function pageDistance(): number {
399
+ return ROWS_PER_PAGE * ROW_HEIGHT
400
+ }
401
+
402
+ /** 滚动位置钳制到 [0, max] */
403
+ function clampScroll(pos: number, max: number): number {
404
+ const ceiling = Math.max(0, max)
405
+ if (pos < 0) return 0
406
+ if (pos > ceiling) return ceiling
407
+ return pos
408
+ }
409
+
410
+ /** 可见性快照:元素是否与条带相交 */
411
+ function bandVisibility(list: HTMLElement, els: readonly HTMLElement[]): boolean[] {
412
+ const band = list.getBoundingClientRect()
413
+ return els.map((el) => {
414
+ const r = el.getBoundingClientRect()
415
+ return r.bottom > band.top && r.top < band.bottom
416
+ })
417
+ }
418
+
419
+ /** 入场批次:滚动后新进入条带的元素 key(dir=向下时逆序排列,配合错峰动画) */
420
+ function enteringBatch(list: HTMLElement, els: readonly HTMLElement[], before: readonly boolean[], dir: 1 | -1): Map<string, { dir: 1 | -1; order: number }> {
421
+ const band = list.getBoundingClientRect()
422
+ const fresh: string[] = []
423
+ els.forEach((el, i) => {
424
+ if (before[i]) return
425
+ const r = el.getBoundingClientRect()
426
+ const key = el.dataset.megaChatNavKey
427
+ if (key !== undefined && r.bottom > band.top && r.top < band.bottom) fresh.push(key)
428
+ })
429
+ if (dir === 1) fresh.reverse()
430
+ const batch = new Map<string, { dir: 1 | -1; order: number }>()
431
+ fresh.forEach((key, order) => batch.set(key, { dir, order }))
432
+ return batch
433
+ }
434
+
435
+ /** 条带翻页:▲/▼ 步进(固定 5 行/页)+ 溢出检测 + 入场错峰动画 */
436
+ export function useBandPaging(
437
+ listRef: RefObject<HTMLDivElement>,
438
+ markers: readonly RailMarker[],
439
+ ): {
440
+ scrollable: boolean
441
+ revealed: ReadonlyMap<string, { dir: 1 | -1; order: number }> | null
442
+ syncScroll: () => void
443
+ pageBy: (dir: 1 | -1) => void
444
+ canPageUp: boolean
445
+ canPageDown: boolean
446
+ } {
447
+ const [scrollable, setScrollable] = useState(false)
448
+ const [, bumpScroll] = useState(0) // 滚动位置变化仅作重渲染触发
449
+ const [revealed, setRevealed] = useState<ReadonlyMap<string, { dir: 1 | -1; order: number }> | null>(null)
450
+ const revealTimer = useRef<number | null>(null)
451
+
452
+ const readBand = (): { top: number; max: number } => {
453
+ const list = listRef.current
454
+ if (list === null) return { top: 0, max: 0 }
455
+ return { top: list.scrollTop, max: Math.max(0, list.scrollHeight - list.clientHeight) }
456
+ }
457
+ const syncScroll = (): void => {
458
+ bumpScroll((n) => n + 1)
459
+ }
460
+ const pageBy = (dir: 1 | -1): void => {
461
+ const list = listRef.current
462
+ if (list === null) return
463
+ const { max } = readBand()
464
+ const target = clampScroll(list.scrollTop + dir * pageDistance(), max)
465
+ if (target === list.scrollTop) return
466
+ const els = Array.from(list.querySelectorAll<HTMLElement>('[data-mega-chat-nav-key]'))
467
+ const before = bandVisibility(list, els)
468
+ list.scrollTop = target
469
+ syncScroll()
470
+ const batch = enteringBatch(list, els, before, dir)
471
+ if (batch.size === 0) return
472
+ setRevealed(batch)
473
+ if (revealTimer.current !== null) window.clearTimeout(revealTimer.current)
474
+ revealTimer.current = window.setTimeout(() => setRevealed(null), 320 + batch.size * 35 + 120)
475
+ }
476
+
477
+ // 溢出检测:标记或布局变化时重测
478
+ useEffect(() => {
479
+ const list = listRef.current
480
+ if (list === null) return
481
+ const check = (): void => {
482
+ setScrollable(list.scrollHeight > list.clientHeight + 1)
483
+ syncScroll()
484
+ }
485
+ check()
486
+ if (typeof ResizeObserver === 'undefined') return
487
+ const observer = new ResizeObserver(check)
488
+ observer.observe(list)
489
+ return () => observer.disconnect()
490
+ // eslint-disable-next-line react-hooks/exhaustive-deps
491
+ }, [markers])
492
+
493
+ useEffect(() => () => {
494
+ if (revealTimer.current !== null) window.clearTimeout(revealTimer.current)
495
+ // eslint-disable-next-line react-hooks/exhaustive-deps
496
+ }, [])
497
+
498
+ const { top, max } = readBand()
499
+ return {
500
+ scrollable,
501
+ revealed,
502
+ syncScroll,
503
+ pageBy,
504
+ canPageUp: scrollable && top > 0,
505
+ canPageDown: scrollable && top < max - 1, // 1px 容差:滚动到底不再显示 ▼
506
+ }
507
+ }
508
+ /**
509
+ * 浮现态(minimal/codex 共用):进入 rail 立即显示;移出后至少停留 1s 再隐藏。
510
+ * peekCls 供 show=peek 模式使用(open 态不隐藏)。
511
+ */
512
+ export function usePeekState(): {
513
+ peekVisible: boolean
514
+ showPeek: () => void
515
+ hidePeek: () => void
516
+ peekCls: (mode: string, open: boolean) => string
517
+ } {
518
+ const [peekVisible, setPeekVisible] = useState(false)
519
+ const peekTimer = useRef<number | null>(null)
520
+ const showPeek = (): void => {
521
+ if (peekTimer.current !== null) {
522
+ window.clearTimeout(peekTimer.current)
523
+ peekTimer.current = null
524
+ }
525
+ setPeekVisible(true)
526
+ }
527
+ const hidePeek = (): void => {
528
+ if (peekTimer.current === null) {
529
+ peekTimer.current = window.setTimeout(() => {
530
+ peekTimer.current = null
531
+ setPeekVisible(false)
532
+ }, 1000)
533
+ }
534
+ }
535
+ useEffect(() => () => {
536
+ if (peekTimer.current !== null) window.clearTimeout(peekTimer.current)
537
+ }, [])
538
+ const peekCls = (mode: string, open: boolean): string => {
539
+ if (mode !== 'peek' || open) return ''
540
+ return peekVisible ? '' : ' mgcn-peek'
541
+ }
542
+ return { peekVisible, showPeek, hidePeek, peekCls }
543
+ }
544
+
545
+ /**
546
+ * 点击浮层之外(非 .mgcn-gear-wrap)→ 关闭 config/search 浮层(minimal/codex 共用)。
547
+ */
548
+ export function useClosePopoversOnOutside(
549
+ configOpen: boolean,
550
+ searchOpen: boolean,
551
+ close: () => void,
552
+ ): void {
553
+ useEffect(() => {
554
+ if (!configOpen && !searchOpen) return
555
+ const onDown = (e: PointerEvent): void => {
556
+ const target = e.target as HTMLElement | null
557
+ if (target === null || !target.closest('.mgcn-gear-wrap')) close()
558
+ }
559
+ document.addEventListener('pointerdown', onDown, true)
560
+ return () => document.removeEventListener('pointerdown', onDown, true)
561
+ }, [configOpen, searchOpen, close])
562
+ }
563
+
564
+ /* ================= 卡片预览(风格能力:hoverCards) ================= */
565
+
566
+ /** 卡片尺寸档位(档 0 选中卡最宽全文本 / 档 1 / 档 2 最窄) */
567
+ const CARD_SIZES = [
568
+ { widthPx: 380, fontSize: 13, maxLines: 6 },
569
+ { widthPx: 300, fontSize: 12.5, maxLines: 2 },
570
+ { widthPx: 240, fontSize: 12, maxLines: 1 },
571
+ ] as const
572
+
573
+ export interface CardMetrics {
574
+ widthPx: number
575
+ fontSize: number
576
+ maxLines: number
577
+ }
578
+
579
+ /** 距离 → 卡片呈现参数;聚焦窗口外返回 null */
580
+ export function cardMetrics(distance: number): CardMetrics | null {
581
+ const magnitude = distance < 0 ? -distance : distance
582
+ if (magnitude > CARD_SIZES.length - 1) return null
583
+ return CARD_SIZES[magnitude] ?? CARD_SIZES[CARD_SIZES.length - 1]
584
+ }
585
+
586
+ /** 距离 → 聚焦档(窗口内 0..2,窗口外 null;圆点缩放层级用) */
587
+ export function markerTier(distance: number): number | null {
588
+ const magnitude = distance < 0 ? -distance : distance
589
+ return magnitude > CARD_SIZES.length - 1 ? null : magnitude
590
+ }
591
+
592
+ /** 距离 → 放大倍数:仅选中点放大,邻居/窗口外均为 1 */
593
+ export function markerScale(distance: number): number {
594
+ return distance === 0 ? 1.5 : 1
595
+ }
596
+
597
+ /** 卡片窗口:选中标记 ± radius 内的成员 */
598
+ function cardSpan(markers: readonly RailMarker[], index: number, radius: number): { dot: RailMarker; distance: number }[] {
599
+ const lo = Math.max(0, index - radius)
600
+ const hi = Math.min(markers.length - 1, index + radius)
601
+ const out: { dot: RailMarker; distance: number }[] = []
602
+ for (let i = lo; i <= hi; i++) {
603
+ out.push({ dot: markers[i], distance: Math.abs(i - index) })
604
+ }
605
+ return out
606
+ }
607
+
608
+ /**
609
+ * 卡片预览:悬停标记弹出级联卡(minimal 能力,enabled=false 时整组失效——
610
+ * 其他风格可无卡片)。
611
+ */
612
+ export function useCardPreview(
613
+ markers: readonly RailMarker[],
614
+ align: RailAlign,
615
+ radius: number,
616
+ enabled: boolean,
617
+ ): {
618
+ focus: FocusState | null
619
+ openCards: (marker: RailMarker, target: HTMLElement) => void
620
+ cancelClear: () => void
621
+ scheduleClear: () => void
622
+ } {
623
+ const [focus, setFocus] = useState<FocusState | null>(null)
624
+ const clearTimer = useRef<number | null>(null)
625
+
626
+ const cancelClear = (): void => {
627
+ if (clearTimer.current !== null) {
628
+ window.clearTimeout(clearTimer.current)
629
+ clearTimer.current = null
630
+ }
631
+ }
632
+ const scheduleClear = (): void => {
633
+ cancelClear()
634
+ clearTimer.current = window.setTimeout(() => setFocus(null), 240)
635
+ }
636
+ const openCards = (marker: RailMarker, target: HTMLElement): void => {
637
+ if (!enabled) return
638
+ const index = markers.findIndex((m) => m.key === marker.key)
639
+ if (index < 0) return
640
+ cancelClear()
641
+ const r = target.getBoundingClientRect()
642
+ const horizontal = align === 'right'
643
+ ? { right: window.innerWidth - r.left + 10 }
644
+ : { left: r.right + 10 }
645
+ setFocus({ key: marker.key, items: cardSpan(markers, index, radius), centerY: r.top + r.height / 2, ...horizontal })
646
+ }
647
+
648
+ // 半径(卡片数)变化时重算已打开的预览窗口
649
+ useEffect(() => {
650
+ if (focus === null || !enabled) return
651
+ const index = markers.findIndex((m) => m.key === focus.key)
652
+ if (index < 0) return
653
+ setFocus((prev) => (prev !== null && prev.key === focus.key ? { ...prev, items: cardSpan(markers, index, radius) } : prev))
654
+ // eslint-disable-next-line react-hooks/exhaustive-deps
655
+ }, [radius, markers, enabled])
656
+
657
+ useEffect(() => () => {
658
+ if (clearTimer.current !== null) window.clearTimeout(clearTimer.current)
659
+ // eslint-disable-next-line react-hooks/exhaustive-deps
660
+ }, [])
661
+
662
+ return { focus, openCards, cancelClear, scheduleClear }
663
+ }
664
+
665
+ /* ================= 失败提示 ================= */
666
+
667
+ /** 跳转失败提示:监听 mega-chat-nav:jump-failed 事件,1.8s 后自动消退 */
668
+ export function useFailureNotice(t: Translate): string | null {
669
+ const [notice, setNotice] = useState<string | null>(null)
670
+ const timer = useRef<number | null>(null)
671
+
672
+ useEffect(() => {
673
+ const onFail = (event: Event): void => {
674
+ const code = (event as CustomEvent<string>).detail ?? 'TIMEOUT'
675
+ const known = ['jump.inactive', 'jump.hidden', 'jump.notfound', 'jump.timeout']
676
+ setNotice(t(known.includes(code) ? code : 'jump.timeout'))
677
+ if (timer.current !== null) window.clearTimeout(timer.current)
678
+ timer.current = window.setTimeout(() => setNotice(null), 1800)
679
+ }
680
+ window.addEventListener('mega-chat-nav:jump-failed', onFail)
681
+ return () => {
682
+ window.removeEventListener('mega-chat-nav:jump-failed', onFail)
683
+ if (timer.current !== null) window.clearTimeout(timer.current)
684
+ }
685
+ }, [t])
686
+
687
+ return notice
688
+ }
689
+
690
+ /* ================= 加载较早记录提示 ================= */
691
+
692
+ /** 监听跳转编排的扩窗加载状态,显示「正在加载较早记录以定位…(第 N 页)」 */
693
+ export function useLoadingNotice(t: Translate): string | null {
694
+ const [state, setState] = useState<{ loading: boolean; pages: number }>({ loading: false, pages: 0 })
695
+ const timer = useRef<number | null>(null)
696
+
697
+ useEffect(() => {
698
+ const onLoading = (event: Event): void => {
699
+ const detail = (event as CustomEvent<{ loading: boolean; pages: number }>).detail
700
+ const on = detail?.loading === true
701
+ setState({ loading: on, pages: detail?.pages ?? 0 })
702
+ if (on) {
703
+ if (timer.current !== null) window.clearTimeout(timer.current)
704
+ timer.current = window.setTimeout(() => setState({ loading: false, pages: 0 }), 10_000) // 兜底:最长 10s
705
+ }
706
+ }
707
+ window.addEventListener('mega-chat-nav:jump-loading', onLoading)
708
+ return () => {
709
+ window.removeEventListener('mega-chat-nav:jump-loading', onLoading)
710
+ if (timer.current !== null) window.clearTimeout(timer.current)
711
+ }
712
+ }, [])
713
+
714
+ return state.loading ? t('jump.loading', { n: state.pages }) : null
715
+ }
716
+
717
+ /* ================= 设置订阅 ================= */
718
+
719
+ /** 设置订阅:全部配置项跟随(align/bandHeight/cardCount/markTone/显示模式/动画方式) */
720
+ export function useNavSettings(injected: NavInjected | undefined): {
721
+ style: NavStyle
722
+ align: RailAlign
723
+ bandHeight: BandHeight
724
+ cardCount: CardCount
725
+ cardItems: CardItem[]
726
+ markTone: MarkTone
727
+ scrollBehavior: ScrollMode
728
+ showCount: ShowMode
729
+ showSearch: ShowMode
730
+ showQuickSettings: ShowMode
731
+ showFavorites: ShowMode
732
+ showPaging: ShowMode
733
+ searchScopes: SearchScope[]
734
+ railOffset: number
735
+ } {
736
+ const [style, setStyle] = useState<NavStyle>(() => injected?.style() ?? DEFAULT_STYLE)
737
+ const [align, setAlign] = useState<RailAlign>(() => injected?.align() ?? DEFAULT_ALIGN)
738
+ const [bandHeight, setBandHeight] = useState<BandHeight>(() => injected?.bandHeight() ?? DEFAULT_BAND)
739
+ const [cardCount, setCardCount] = useState<CardCount>(() => injected?.cardCount() ?? DEFAULT_CARD_COUNT[DEFAULT_STYLE])
740
+ const [cardItems, setCardItems] = useState<CardItem[]>(() => injected?.cardItems() ?? [...DEFAULT_CARD_ITEMS])
741
+ const [markTone, setMarkTone] = useState<MarkTone>(() => injected?.markTone() ?? DEFAULT_MARK_TONE)
742
+ const [scrollBehavior, setScrollBehavior] = useState<ScrollMode>(() => injected?.scrollBehavior() ?? DEFAULT_SCROLL)
743
+ const [showCount, setShowCount] = useState<ShowMode>(() => injected?.showCount() ?? DEFAULT_SHOW)
744
+ const [showSearch, setShowSearch] = useState<ShowMode>(() => injected?.showSearch() ?? DEFAULT_SHOW)
745
+ const [showQuickSettings, setShowQuickSettings] = useState<ShowMode>(() => injected?.showQuickSettings() ?? DEFAULT_SHOW)
746
+ const [showFavorites, setShowFavorites] = useState<ShowMode>(() => injected?.showFavorites() ?? DEFAULT_SHOW)
747
+ const [showPaging, setShowPaging] = useState<ShowMode>(() => injected?.showPaging() ?? DEFAULT_PAGING[DEFAULT_STYLE])
748
+ const [searchScopes, setSearchScopes] = useState<SearchScope[]>(() => injected?.searchScopes() ?? [...DEFAULT_SEARCH_SCOPES])
749
+ const [railOffset, setRailOffset] = useState<number>(() => injected?.railOffset() ?? 8)
750
+ useEffect(() => {
751
+ if (injected === undefined) return
752
+ return injected.subscribeSettings(() => {
753
+ setStyle(injected.style())
754
+ setAlign(injected.align())
755
+ setBandHeight(injected.bandHeight())
756
+ setCardCount(injected.cardCount())
757
+ setCardItems(injected.cardItems())
758
+ setMarkTone(injected.markTone())
759
+ setScrollBehavior(injected.scrollBehavior())
760
+ setShowCount(injected.showCount())
761
+ setShowSearch(injected.showSearch())
762
+ setShowQuickSettings(injected.showQuickSettings())
763
+ setShowFavorites(injected.showFavorites())
764
+ setShowPaging(injected.showPaging())
765
+ setSearchScopes(injected.searchScopes())
766
+ setRailOffset(injected.railOffset())
767
+ })
768
+ }, [injected])
769
+ return { style, align, bandHeight, cardCount, cardItems, markTone, scrollBehavior, showCount, showSearch, showQuickSettings, showFavorites, showPaging, searchScopes, railOffset }
770
+ }
771
+
772
+ /* ================= 时间工具(卡片/搜索结果相对时间) ================= */
773
+
774
+ /** 相对时间:同日 HH:MM / 同年 MM-DD HH:MM / 跨年 YYYY-MM-DD HH:MM;非法输入返回空串 */
775
+ export function clockText(time: number, now: number): string {
776
+ if (!(time > 0) || !Number.isFinite(time)) return ''
777
+ const stamp = new Date(time)
778
+ const pad = (n: number): string => String(n).padStart(2, '0')
779
+ const hm = pad(stamp.getHours()) + ':' + pad(stamp.getMinutes())
780
+ const ref = new Date(now)
781
+ const sameDay = stamp.getFullYear() === ref.getFullYear()
782
+ && stamp.getMonth() === ref.getMonth()
783
+ && stamp.getDate() === ref.getDate()
784
+ if (sameDay) return hm
785
+ const md = pad(stamp.getMonth() + 1) + '-' + pad(stamp.getDate())
786
+ if (stamp.getFullYear() === ref.getFullYear()) return md + ' ' + hm
787
+ return stamp.getFullYear() + '-' + md + ' ' + hm
788
+ }
789
+ /* ================= 收藏 ================= */
790
+
791
+ const FAV_STORAGE_PREFIX = 'mega-chat-nav:favorites:'
792
+
793
+ function readFavorites(sessionId: string | undefined): Set<string> {
794
+ if (sessionId === undefined) return new Set()
795
+ try {
796
+ const raw = window.localStorage.getItem(FAV_STORAGE_PREFIX + sessionId)
797
+ if (raw === null) return new Set()
798
+ const parsed = JSON.parse(raw) as unknown
799
+ if (!Array.isArray(parsed)) return new Set()
800
+ return new Set(parsed.filter((k): k is string => typeof k === 'string'))
801
+ } catch {
802
+ return new Set()
803
+ }
804
+ }
805
+
806
+ function writeFavorites(sessionId: string, keys: Set<string>): void {
807
+ try {
808
+ window.localStorage.setItem(FAV_STORAGE_PREFIX + sessionId, JSON.stringify([...keys]))
809
+ } catch { /* 存储不可用时收藏仅内存态 */ }
810
+ }
811
+
812
+ /** 收藏状态:localStorage 持久化 + 切换动作(跨刷新保留) */
813
+ export function useFavorites(sessionId: string | undefined): { favorites: Set<string>; toggle: (key: string) => void } {
814
+ const [favorites, setFavorites] = useState<Set<string>>(() => readFavorites(sessionId))
815
+
816
+ useEffect(() => {
817
+ setFavorites(readFavorites(sessionId))
818
+ }, [sessionId])
819
+
820
+ const toggle = (key: string): void => {
821
+ setFavorites((prev) => {
822
+ const next = new Set(prev)
823
+ if (next.has(key)) next.delete(key)
824
+ else next.add(key)
825
+ if (sessionId !== undefined) writeFavorites(sessionId, next)
826
+ return next
827
+ })
828
+ }
829
+
830
+ return { favorites, toggle }
831
+ }
832
+
833
+ /* ================= 性能指标格式化 ================= */
834
+
835
+ /** 毫秒 → 人类可读时长(1分21秒 / 10秒) */
836
+ export function durationText(ms: number): string {
837
+ const total = Math.max(0, Math.round(ms / 1000))
838
+ if (total >= 60) {
839
+ const m = Math.floor(total / 60)
840
+ const s = total % 60
841
+ return m + '分' + s + '秒'
842
+ }
843
+ return total + '秒'
844
+ }
845
+
846
+ /** 完整指标行:本轮用时 1分21秒 · 首 token 10秒 · 188 tok/s */
847
+ export function metricsLine(metrics: { durationMs: number; firstTokenMs: number; tokensPerSec: number }): string {
848
+ const parts: string[] = []
849
+ parts.push('本轮用时 ' + durationText(metrics.durationMs))
850
+ if (metrics.firstTokenMs > 0) parts.push('首 token ' + durationText(metrics.firstTokenMs))
851
+ if (metrics.tokensPerSec > 0) parts.push(metrics.tokensPerSec + ' tok/s')
852
+ return parts.join(' · ')
853
+ }
854
+ /* ================= 手机模式 ================= */
855
+
856
+ /** 手机模式:页面宽度小于 breakpoint(默认 1024px),resize 实时跟随 */
857
+ export function useMobileMode(breakpoint = 1024): boolean {
858
+ const [mobile, setMobile] = useState<boolean>(() => window.innerWidth < breakpoint)
859
+ useEffect(() => {
860
+ const onResize = (): void => setMobile(window.innerWidth < breakpoint)
861
+ window.addEventListener('resize', onResize)
862
+ return () => window.removeEventListener('resize', onResize)
863
+ }, [breakpoint])
864
+ return mobile
865
+ }
866
+ /* ================= 手机模式搜索按钮(零高 sticky wrapper + absolute 按钮) ================= */
867
+
868
+ /**
869
+ * 手机模式:向对话滚动容器 [data-conversation-scroll] 头部插入零高 wrapper
870
+ * (width:100%; height:0; sticky top:0; overflow:visible),按钮 absolute 相对
871
+ * wrapper 定位——sticky 粘在滚动视口顶部,按钮不随内容滚走、不占布局空间。
872
+ */
873
+ export function useMobileSearchButton(onClick: () => void, enabled: boolean, align: RailAlign, label: string): void {
874
+ useEffect(() => {
875
+ if (!enabled) return
876
+ const host = mobileSearchHost()
877
+ if (host === null) return
878
+ // 零高 wrapper:sticky 粘在滚动容器顶部、不占空间;按钮 absolute 相对 wrapper 定位
879
+ const wrap = document.createElement('div')
880
+ wrap.style.width = '100%'
881
+ wrap.style.height = '0'
882
+ wrap.style.margin = '0'
883
+ wrap.style.position = 'sticky'
884
+ wrap.style.top = '0'
885
+ wrap.style.overflow = 'visible'
886
+ wrap.style.zIndex = '60'
887
+ wrap.style.pointerEvents = 'none' // wrapper 不拦截,按钮自身可点
888
+ const btn = document.createElement('button')
889
+ btn.type = 'button'
890
+ btn.className = 'mgcn-config-gear mgcn-mobileSearch'
891
+ btn.setAttribute('aria-label', label)
892
+ btn.style.position = 'absolute'
893
+ btn.style.top = '8px'
894
+ btn.style[align === 'right' ? 'right' : 'left'] = '8px'
895
+ btn.style.zIndex = '60'
896
+ btn.style.pointerEvents = 'auto'
897
+ btn.innerHTML = '<svg class="mgcn-btn-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.6"/><line x1="10.4" y1="10.4" x2="14" y2="14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'
898
+ btn.addEventListener('click', onClick)
899
+ wrap.appendChild(btn)
900
+ // 保证 wrapper 是父级第一个元素(sticky 相对滚动容器视口顶部)
901
+ host.insertBefore(wrap, host.firstChild)
902
+ return () => {
903
+ wrap.remove()
904
+ }
905
+ }, [enabled, onClick, align, label])
906
+ }