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,716 @@
1
+ // 简约风格:轨道逻辑层(数据装配 / 布局钉位 / 阅读间谍 / 条带翻页 / 卡片预览 / 失败提示)
2
+ // 全部为本风格自研实现:数据形态(RailMarker)、几何常量与算法均为插件自身设计
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { DEFAULT_ALIGN, DEFAULT_BAND, DEFAULT_CARD_COUNT, DEFAULT_CARD_ITEMS, DEFAULT_SCROLL, DEFAULT_SEARCH_SCOPES, DEFAULT_SHOW, DEFAULT_STYLE } from "../../settings.js";
5
+ /* ================= 数据装配 ================= */
6
+ /** 锚点 key:镜像聊天行锚点公式 '<kind长度>:<kind><id>',kind 段固定 'input-message'(DOM 契约) */
7
+ function anchorKey(id) {
8
+ return '13:input-message' + String(id);
9
+ }
10
+ /** 投影值 → 条目(结构守卫:缺 id/seq/turn 的脏数据丢弃) */
11
+ function projectedEntries(face) {
12
+ const value = face?.getSnapshot();
13
+ if (!Array.isArray(value))
14
+ return [];
15
+ return value.filter((item) => typeof item === 'object' && item !== null
16
+ && typeof item.id === 'string'
17
+ && typeof item.seq === 'number'
18
+ && typeof item.turn === 'number');
19
+ }
20
+ /** 结构相等:members 全等即内容一致(React bail out 用) */
21
+ function sameMarkers(a, b) {
22
+ if (a.length !== b.length)
23
+ return false;
24
+ for (let i = 0; i < a.length; i++) {
25
+ if (a[i].key !== b[i].key || a[i].members.length !== b[i].members.length)
26
+ return false;
27
+ for (let j = 0; j < a[i].members.length; j++) {
28
+ if (a[i].members[j] !== b[i].members[j])
29
+ return false;
30
+ }
31
+ }
32
+ return true;
33
+ }
34
+ /**
35
+ * 回合标记装配:投影条目(全量)+ 活窗口行(投影未收录才补)统一排序后按回合分组。
36
+ * 同回合连续条目合并为同一标记(锚点取首问)。
37
+ */
38
+ function buildMarkers(projected, live) {
39
+ const seen = new Set();
40
+ const merged = [];
41
+ for (const p of projected) {
42
+ const key = anchorKey(p.id);
43
+ if (seen.has(key))
44
+ continue;
45
+ seen.add(key);
46
+ merged.push({ turn: p.turn, seq: p.seq, key, time: p.time, text: p.text, metrics: p.metrics });
47
+ }
48
+ for (const l of live) {
49
+ if (seen.has(l.key))
50
+ continue;
51
+ seen.add(l.key);
52
+ merged.push({ turn: null, seq: l.anchorSeq, key: l.key, time: l.time, text: l.text });
53
+ }
54
+ merged.sort((a, b) => a.seq - b.seq);
55
+ const markers = [];
56
+ for (const m of merged) {
57
+ const tail = markers[markers.length - 1];
58
+ if (tail !== undefined && tail.turn !== null && tail.turn === m.turn) {
59
+ markers[markers.length - 1] = { ...tail, texts: [...tail.texts, m.text], members: [...tail.members, m.key] };
60
+ }
61
+ else {
62
+ markers.push({ turn: m.turn, key: m.key, seq: m.seq, time: m.time, texts: [m.text], members: [m.key], metrics: m.metrics });
63
+ }
64
+ }
65
+ return markers;
66
+ }
67
+ /** 圆点数据:会话可见性 + 标记装配(订阅集中管理,共用一条刷新管线) */
68
+ export function useMarkerData(injected, useSessions) {
69
+ const current = useSessions?.((s) => s.current);
70
+ const summary = useSessions?.((s) => (current === undefined ? undefined : s.byId?.[String(current)]));
71
+ // 仅对话视图显示([data-chat-flow] 只在对话消息流渲染;Trajectory 视图与会话未加载时不存在)
72
+ const [viewOk, setViewOk] = useState(() => document.querySelector('[data-chat-flow]') !== null);
73
+ useEffect(() => {
74
+ const check = () => setViewOk(document.querySelector('[data-chat-flow]') !== null);
75
+ check();
76
+ const observer = typeof MutationObserver === 'undefined' ? null : new MutationObserver(check);
77
+ observer?.observe(document.body, { childList: true, subtree: true });
78
+ return () => { observer?.disconnect(); };
79
+ }, []);
80
+ const visible = current !== undefined && summary !== undefined && summary.blank !== true && viewOk;
81
+ const [markers, setMarkers] = useState([]);
82
+ const cacheRef = useRef([]);
83
+ useEffect(() => {
84
+ if (!visible || current === undefined || injected === undefined) {
85
+ cacheRef.current = [];
86
+ setMarkers([]);
87
+ return;
88
+ }
89
+ const face = injected.questionProjection(current);
90
+ const refresh = () => {
91
+ const next = buildMarkers(projectedEntries(face), injected.readQuestions(current));
92
+ if (sameMarkers(next, cacheRef.current)) {
93
+ setMarkers(cacheRef.current);
94
+ return;
95
+ }
96
+ cacheRef.current = next;
97
+ setMarkers(next);
98
+ };
99
+ refresh();
100
+ const offProjection = face?.subscribe(refresh) ?? (() => { });
101
+ const offContent = injected.subscribeContent(current, refresh);
102
+ return () => {
103
+ offProjection();
104
+ offContent();
105
+ };
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [injected, current, visible]);
108
+ return { sessionId: current, visible, markers };
109
+ }
110
+ /* ================= 布局钉位 ================= */
111
+ /** 对话列根元素(桌面 rail 钉位基准——保持历史稳定实现) */
112
+ function conversationRoot() {
113
+ return document.querySelector('[data-slot="conversation"] > div[data-phase]');
114
+ }
115
+ /** 手机搜索按钮宿主:对话滚动容器 [data-conversation-scroll](sticky 粘附基准) */
116
+ function mobileSearchHost() {
117
+ return document.querySelector('[data-conversation-scroll]');
118
+ }
119
+ /** 期望几何:rail 钉到对话列(对齐侧决定 edge 归属);offset 为 rail 与对话列边缘的距离 */
120
+ function desiredGeometry(panel, align, offset) {
121
+ const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null;
122
+ const root = conversationRoot();
123
+ if (frame === null || root === null)
124
+ return null;
125
+ const fr = frame.getBoundingClientRect();
126
+ const rr = root.getBoundingClientRect();
127
+ if (rr.height <= 0 || rr.width <= 0)
128
+ return null;
129
+ // 取整到整数像素:getBoundingClientRect 的浮点噪声(如 3.05e-05px)
130
+ // 会让字符串比较永远 dirty,布局循环停不下来
131
+ const top = Math.round(rr.top - fr.top);
132
+ const height = Math.round(rr.height);
133
+ return align === 'right'
134
+ ? { top: top + 'px', height: height + 'px', edge: Math.round(fr.right - rr.right + offset) + 'px', side: 'right' }
135
+ : { top: top + 'px', height: height + 'px', edge: Math.round(rr.left - fr.left + offset) + 'px', side: 'left' };
136
+ }
137
+ /** 布局钉位:期望几何 → 比对 → 写入(rAF 循环,连续 3 帧稳定停车;resize/尺寸变化唤醒) */
138
+ export function usePinning(panelRef, visible, align, offset = 0, sessionId) {
139
+ useEffect(() => {
140
+ if (!visible)
141
+ return;
142
+ let raf = 0;
143
+ let idle = 0;
144
+ let alive = true;
145
+ let observer = null;
146
+ let domObserver = null;
147
+ // 三态:missing=目标(对话区)尚未出现(会话/工作区切换重建中——不停车,持续等);
148
+ // changed=刚写入(重置稳定计数);stable=目标在且无变化(计入稳定帧)
149
+ const paint = () => {
150
+ const panel = panelRef.current;
151
+ if (panel === null)
152
+ return 'missing';
153
+ const want = desiredGeometry(panel, align, offset);
154
+ if (want === null)
155
+ return 'missing';
156
+ const other = want.side === 'right' ? 'left' : 'right';
157
+ const dirty = panel.style.top !== want.top
158
+ || panel.style.height !== want.height
159
+ || panel.style[want.side] !== want.edge
160
+ || panel.style[other] !== '';
161
+ if (!dirty)
162
+ return 'stable';
163
+ panel.style.top = want.top;
164
+ panel.style.height = want.height;
165
+ panel.style[want.side] = want.edge;
166
+ panel.style[other] = '';
167
+ return 'changed';
168
+ };
169
+ const loop = () => {
170
+ if (!alive)
171
+ return;
172
+ raf = 0;
173
+ const result = paint();
174
+ if (result === 'changed')
175
+ idle = 0;
176
+ else if (result === 'stable')
177
+ idle += 1;
178
+ else
179
+ idle = 0; // missing:对话区重建中,持续校准不停车
180
+ if (idle < 3)
181
+ raf = requestAnimationFrame(loop);
182
+ };
183
+ const nudge = () => {
184
+ if (!alive)
185
+ return;
186
+ idle = 0;
187
+ if (raf === 0)
188
+ raf = requestAnimationFrame(loop);
189
+ };
190
+ observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(nudge);
191
+ if (observer !== null) {
192
+ const panel = panelRef.current;
193
+ const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null;
194
+ const root = conversationRoot();
195
+ if (frame !== null)
196
+ observer.observe(frame, { box: 'border-box' });
197
+ if (root !== null)
198
+ observer.observe(root, { box: 'border-box' });
199
+ }
200
+ // 对话区 DOM 重建(会话/工作区切换)会替换观察目标——ResizeObserver 感知不到,
201
+ // 用 MutationObserver 监听 body 子树变化唤醒校准(nudge 有 rAF 合并,开销可控)
202
+ if (typeof MutationObserver !== 'undefined') {
203
+ domObserver = new MutationObserver(nudge);
204
+ domObserver.observe(document.body, { childList: true, subtree: true });
205
+ }
206
+ nudge();
207
+ window.addEventListener('resize', nudge);
208
+ return () => {
209
+ alive = false;
210
+ if (raf !== 0)
211
+ cancelAnimationFrame(raf);
212
+ observer?.disconnect();
213
+ domObserver?.disconnect();
214
+ window.removeEventListener('resize', nudge);
215
+ };
216
+ }, [panelRef, visible, align, offset, sessionId]);
217
+ }
218
+ /** 采样已渲染行:为每个标记取组内首个已渲染成员 */
219
+ function probeRows(flow, portTop, markers) {
220
+ const rendered = new Map();
221
+ for (const row of flow.querySelectorAll('[data-chat-anchor-key]')) {
222
+ const key = row.dataset.chatAnchorKey;
223
+ if (key !== undefined && !rendered.has(key))
224
+ rendered.set(key, row);
225
+ }
226
+ const probes = [];
227
+ for (const marker of markers) {
228
+ for (const member of marker.members) {
229
+ const row = rendered.get(member);
230
+ if (row !== undefined) {
231
+ probes.push({ key: marker.key, top: row.getBoundingClientRect().top - portTop });
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ return probes;
237
+ }
238
+ /**
239
+ * 阅读位置判定:视口内(top >= 阅读线)的**第一条**标记 = 正在阅读的标记。
240
+ * 判据贴近「视口顶部正在看哪一条」——点击跳转后目标行贴顶(top≈0)即选中,
241
+ * 避免旧判据(线上方最后一行)因 scrollIntoView 的像素偏移误选中上一行。
242
+ * 全部行已滚过视口时取最后一行(最靠近视口底)。
243
+ */
244
+ function readingTurnAt(rows, line) {
245
+ if (rows.length === 0)
246
+ return null;
247
+ for (const row of rows) {
248
+ if (row.top >= line)
249
+ return row.key;
250
+ }
251
+ return rows[rows.length - 1].key;
252
+ }
253
+ /** 阅读间谍:滚动/尺寸变化按 rAF 采样行位置,标记当前所在回合。
254
+ * 返回 [currentKey, forceKey]——forceKey 用于点击跳转瞬间强制激活目标,
255
+ * 滚动稳定后由采样判据接管。 */
256
+ export function useReadingSpy(visible, markers) {
257
+ const [currentKey, setCurrentKey] = useState(null);
258
+ const forceKey = (key) => setCurrentKey(key);
259
+ useEffect(() => {
260
+ if (!visible || markers.length === 0) {
261
+ setCurrentKey(null);
262
+ return;
263
+ }
264
+ let raf = 0;
265
+ let disposed = false;
266
+ let observer = null;
267
+ let trackedFlow = null;
268
+ const sample = () => {
269
+ raf = 0;
270
+ const scrollport = document.querySelector('[data-conversation-scroll]');
271
+ const flow = document.querySelector('[data-chat-flow]');
272
+ if (scrollport === null || flow === null) {
273
+ setCurrentKey(null);
274
+ return;
275
+ }
276
+ if (observer !== null && trackedFlow !== flow) {
277
+ observer.disconnect();
278
+ observer.observe(flow);
279
+ trackedFlow = flow;
280
+ }
281
+ const portRect = scrollport.getBoundingClientRect();
282
+ if (portRect.height <= 0)
283
+ return;
284
+ const next = readingTurnAt(probeRows(flow, portRect.top, markers), 0);
285
+ setCurrentKey((prev) => (prev === next ? prev : next));
286
+ };
287
+ const schedule = () => {
288
+ if (!disposed && raf === 0)
289
+ raf = requestAnimationFrame(sample);
290
+ };
291
+ observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
292
+ sample();
293
+ document.addEventListener('scroll', schedule, true);
294
+ window.addEventListener('resize', schedule);
295
+ return () => {
296
+ disposed = true;
297
+ if (raf !== 0)
298
+ cancelAnimationFrame(raf);
299
+ observer?.disconnect();
300
+ document.removeEventListener('scroll', schedule, true);
301
+ window.removeEventListener('resize', schedule);
302
+ };
303
+ }, [visible, markers]);
304
+ return [currentKey, forceKey];
305
+ }
306
+ /* ================= 条带翻页 ================= */
307
+ /** 圆点几何(与样式一致) */
308
+ const DOT_DIAMETER = 8;
309
+ const DOT_SPACING = 6;
310
+ const ROWS_PER_PAGE = 5;
311
+ /** 一次翻页的滚动距离 */
312
+ function pageDistance() {
313
+ return ROWS_PER_PAGE * (DOT_DIAMETER + DOT_SPACING);
314
+ }
315
+ /** 滚动位置钳制到 [0, max] */
316
+ function clampScroll(pos, max) {
317
+ const ceiling = Math.max(0, max);
318
+ if (pos < 0)
319
+ return 0;
320
+ if (pos > ceiling)
321
+ return ceiling;
322
+ return pos;
323
+ }
324
+ /** 可见性快照:元素是否与条带相交 */
325
+ function bandVisibility(list, els) {
326
+ const band = list.getBoundingClientRect();
327
+ return els.map((el) => {
328
+ const r = el.getBoundingClientRect();
329
+ return r.bottom > band.top && r.top < band.bottom;
330
+ });
331
+ }
332
+ /** 入场批次:滚动后新进入条带的元素 key(dir=向下时逆序排列,配合错峰动画) */
333
+ function enteringBatch(list, els, before, dir) {
334
+ const band = list.getBoundingClientRect();
335
+ const fresh = [];
336
+ els.forEach((el, i) => {
337
+ if (before[i])
338
+ return;
339
+ const r = el.getBoundingClientRect();
340
+ const key = el.dataset.megaChatNavKey;
341
+ if (key !== undefined && r.bottom > band.top && r.top < band.bottom)
342
+ fresh.push(key);
343
+ });
344
+ if (dir === 1)
345
+ fresh.reverse();
346
+ const batch = new Map();
347
+ fresh.forEach((key, order) => batch.set(key, { dir, order }));
348
+ return batch;
349
+ }
350
+ /** 条带翻页:▲/▼ 步进(固定 5 行/页)+ 溢出检测 + 入场错峰动画 */
351
+ export function useBandPaging(listRef, markers) {
352
+ const [scrollable, setScrollable] = useState(false);
353
+ const [, bumpScroll] = useState(0); // 滚动位置变化仅作重渲染触发
354
+ const [revealed, setRevealed] = useState(null);
355
+ const revealTimer = useRef(null);
356
+ const readBand = () => {
357
+ const list = listRef.current;
358
+ if (list === null)
359
+ return { top: 0, max: 0 };
360
+ return { top: list.scrollTop, max: Math.max(0, list.scrollHeight - list.clientHeight) };
361
+ };
362
+ const syncScroll = () => {
363
+ bumpScroll((n) => n + 1);
364
+ };
365
+ const pageBy = (dir) => {
366
+ const list = listRef.current;
367
+ if (list === null)
368
+ return;
369
+ const { max } = readBand();
370
+ const target = clampScroll(list.scrollTop + dir * pageDistance(), max);
371
+ if (target === list.scrollTop)
372
+ return;
373
+ const els = Array.from(list.querySelectorAll('[data-mega-chat-nav-key]'));
374
+ const before = bandVisibility(list, els);
375
+ list.scrollTop = target;
376
+ syncScroll();
377
+ const batch = enteringBatch(list, els, before, dir);
378
+ if (batch.size === 0)
379
+ return;
380
+ setRevealed(batch);
381
+ if (revealTimer.current !== null)
382
+ window.clearTimeout(revealTimer.current);
383
+ revealTimer.current = window.setTimeout(() => setRevealed(null), 320 + batch.size * 35 + 120);
384
+ };
385
+ // 溢出检测:标记或布局变化时重测
386
+ useEffect(() => {
387
+ const list = listRef.current;
388
+ if (list === null)
389
+ return;
390
+ const check = () => {
391
+ setScrollable(list.scrollHeight > list.clientHeight + 1);
392
+ syncScroll();
393
+ };
394
+ check();
395
+ if (typeof ResizeObserver === 'undefined')
396
+ return;
397
+ const observer = new ResizeObserver(check);
398
+ observer.observe(list);
399
+ return () => observer.disconnect();
400
+ // eslint-disable-next-line react-hooks/exhaustive-deps
401
+ }, [markers]);
402
+ useEffect(() => () => {
403
+ if (revealTimer.current !== null)
404
+ window.clearTimeout(revealTimer.current);
405
+ // eslint-disable-next-line react-hooks/exhaustive-deps
406
+ }, []);
407
+ const { top, max } = readBand();
408
+ return {
409
+ scrollable,
410
+ revealed,
411
+ syncScroll,
412
+ pageBy,
413
+ canPageUp: scrollable && top > 0,
414
+ canPageDown: scrollable && top < max - 1, // 1px 容差:滚动到底不再显示 ▼
415
+ };
416
+ }
417
+ /* ================= 卡片预览(风格能力:hoverCards) ================= */
418
+ /** 卡片尺寸档位(档 0 选中卡最宽全文本 / 档 1 / 档 2 最窄) */
419
+ const CARD_SIZES = [
420
+ { widthPx: 380, fontSize: 13, maxLines: 6 },
421
+ { widthPx: 300, fontSize: 12.5, maxLines: 2 },
422
+ { widthPx: 240, fontSize: 12, maxLines: 1 },
423
+ ];
424
+ /** 距离 → 卡片呈现参数;聚焦窗口外返回 null */
425
+ export function cardMetrics(distance) {
426
+ const magnitude = distance < 0 ? -distance : distance;
427
+ if (magnitude > CARD_SIZES.length - 1)
428
+ return null;
429
+ return CARD_SIZES[magnitude] ?? CARD_SIZES[CARD_SIZES.length - 1];
430
+ }
431
+ /** 距离 → 聚焦档(窗口内 0..2,窗口外 null;圆点缩放层级用) */
432
+ export function markerTier(distance) {
433
+ const magnitude = distance < 0 ? -distance : distance;
434
+ return magnitude > CARD_SIZES.length - 1 ? null : magnitude;
435
+ }
436
+ /** 距离 → 放大倍数:仅选中点放大,邻居/窗口外均为 1 */
437
+ export function markerScale(distance) {
438
+ return distance === 0 ? 1.5 : 1;
439
+ }
440
+ /** 卡片窗口:选中标记 ± radius 内的成员 */
441
+ function cardSpan(markers, index, radius) {
442
+ const lo = Math.max(0, index - radius);
443
+ const hi = Math.min(markers.length - 1, index + radius);
444
+ const out = [];
445
+ for (let i = lo; i <= hi; i++) {
446
+ out.push({ dot: markers[i], distance: Math.abs(i - index) });
447
+ }
448
+ return out;
449
+ }
450
+ /**
451
+ * 卡片预览:悬停标记弹出级联卡(minimal 能力,enabled=false 时整组失效——
452
+ * 其他风格可无卡片)。
453
+ */
454
+ export function useCardPreview(markers, align, radius, enabled) {
455
+ const [focus, setFocus] = useState(null);
456
+ const clearTimer = useRef(null);
457
+ const cancelClear = () => {
458
+ if (clearTimer.current !== null) {
459
+ window.clearTimeout(clearTimer.current);
460
+ clearTimer.current = null;
461
+ }
462
+ };
463
+ const scheduleClear = () => {
464
+ cancelClear();
465
+ clearTimer.current = window.setTimeout(() => setFocus(null), 240);
466
+ };
467
+ const openCards = (marker, target) => {
468
+ if (!enabled)
469
+ return;
470
+ const index = markers.findIndex((m) => m.key === marker.key);
471
+ if (index < 0)
472
+ return;
473
+ cancelClear();
474
+ const r = target.getBoundingClientRect();
475
+ const horizontal = align === 'right'
476
+ ? { right: window.innerWidth - r.left + 10 }
477
+ : { left: r.right + 10 };
478
+ setFocus({ key: marker.key, items: cardSpan(markers, index, radius), centerY: r.top + r.height / 2, ...horizontal });
479
+ };
480
+ // 半径(卡片数)变化时重算已打开的预览窗口
481
+ useEffect(() => {
482
+ if (focus === null || !enabled)
483
+ return;
484
+ const index = markers.findIndex((m) => m.key === focus.key);
485
+ if (index < 0)
486
+ return;
487
+ setFocus((prev) => (prev !== null && prev.key === focus.key ? { ...prev, items: cardSpan(markers, index, radius) } : prev));
488
+ // eslint-disable-next-line react-hooks/exhaustive-deps
489
+ }, [radius, markers, enabled]);
490
+ useEffect(() => () => {
491
+ if (clearTimer.current !== null)
492
+ window.clearTimeout(clearTimer.current);
493
+ // eslint-disable-next-line react-hooks/exhaustive-deps
494
+ }, []);
495
+ return { focus, openCards, cancelClear, scheduleClear };
496
+ }
497
+ /* ================= 失败提示 ================= */
498
+ /** 跳转失败提示:监听 mega-chat-nav:jump-failed 事件,1.8s 后自动消退 */
499
+ export function useFailureNotice(t) {
500
+ const [notice, setNotice] = useState(null);
501
+ const timer = useRef(null);
502
+ useEffect(() => {
503
+ const onFail = (event) => {
504
+ const code = event.detail ?? 'TIMEOUT';
505
+ const known = ['jump.inactive', 'jump.hidden', 'jump.notfound', 'jump.timeout'];
506
+ setNotice(t(known.includes(code) ? code : 'jump.timeout'));
507
+ if (timer.current !== null)
508
+ window.clearTimeout(timer.current);
509
+ timer.current = window.setTimeout(() => setNotice(null), 1800);
510
+ };
511
+ window.addEventListener('mega-chat-nav:jump-failed', onFail);
512
+ return () => {
513
+ window.removeEventListener('mega-chat-nav:jump-failed', onFail);
514
+ if (timer.current !== null)
515
+ window.clearTimeout(timer.current);
516
+ };
517
+ }, [t]);
518
+ return notice;
519
+ }
520
+ /* ================= 加载较早记录提示 ================= */
521
+ /** 监听跳转编排的扩窗加载状态,显示「正在加载较早记录以定位…(第 N 页)」 */
522
+ export function useLoadingNotice(t) {
523
+ const [state, setState] = useState({ loading: false, pages: 0 });
524
+ const timer = useRef(null);
525
+ useEffect(() => {
526
+ const onLoading = (event) => {
527
+ const detail = event.detail;
528
+ const on = detail?.loading === true;
529
+ setState({ loading: on, pages: detail?.pages ?? 0 });
530
+ if (on) {
531
+ if (timer.current !== null)
532
+ window.clearTimeout(timer.current);
533
+ timer.current = window.setTimeout(() => setState({ loading: false, pages: 0 }), 10_000); // 兜底:最长 10s
534
+ }
535
+ };
536
+ window.addEventListener('mega-chat-nav:jump-loading', onLoading);
537
+ return () => {
538
+ window.removeEventListener('mega-chat-nav:jump-loading', onLoading);
539
+ if (timer.current !== null)
540
+ window.clearTimeout(timer.current);
541
+ };
542
+ }, []);
543
+ return state.loading ? t('jump.loading', { n: state.pages }) : null;
544
+ }
545
+ /* ================= 设置订阅 ================= */
546
+ /** 设置订阅:全部配置项跟随(align/bandHeight/cardCount/显示模式/动画方式) */
547
+ export function useNavSettings(injected) {
548
+ const [style, setStyle] = useState(() => injected?.style() ?? DEFAULT_STYLE);
549
+ const [align, setAlign] = useState(() => injected?.align() ?? DEFAULT_ALIGN);
550
+ const [bandHeight, setBandHeight] = useState(() => injected?.bandHeight() ?? DEFAULT_BAND);
551
+ const [cardCount, setCardCount] = useState(() => injected?.cardCount() ?? DEFAULT_CARD_COUNT);
552
+ const [cardItems, setCardItems] = useState(() => injected?.cardItems() ?? [...DEFAULT_CARD_ITEMS]);
553
+ const [scrollBehavior, setScrollBehavior] = useState(() => injected?.scrollBehavior() ?? DEFAULT_SCROLL);
554
+ const [showCount, setShowCount] = useState(() => injected?.showCount() ?? DEFAULT_SHOW);
555
+ const [showSearch, setShowSearch] = useState(() => injected?.showSearch() ?? DEFAULT_SHOW);
556
+ const [showQuickSettings, setShowQuickSettings] = useState(() => injected?.showQuickSettings() ?? DEFAULT_SHOW);
557
+ const [showFavorites, setShowFavorites] = useState(() => injected?.showFavorites() ?? DEFAULT_SHOW);
558
+ const [searchScopes, setSearchScopes] = useState(() => injected?.searchScopes() ?? [...DEFAULT_SEARCH_SCOPES]);
559
+ const [railOffset, setRailOffset] = useState(() => injected?.railOffset() ?? 8);
560
+ useEffect(() => {
561
+ if (injected === undefined)
562
+ return;
563
+ return injected.subscribeSettings(() => {
564
+ setStyle(injected.style());
565
+ setAlign(injected.align());
566
+ setBandHeight(injected.bandHeight());
567
+ setCardCount(injected.cardCount());
568
+ setCardItems(injected.cardItems());
569
+ setScrollBehavior(injected.scrollBehavior());
570
+ setShowCount(injected.showCount());
571
+ setShowSearch(injected.showSearch());
572
+ setShowQuickSettings(injected.showQuickSettings());
573
+ setShowFavorites(injected.showFavorites());
574
+ setSearchScopes(injected.searchScopes());
575
+ setRailOffset(injected.railOffset());
576
+ });
577
+ }, [injected]);
578
+ return { style, align, bandHeight, cardCount, cardItems, scrollBehavior, showCount, showSearch, showQuickSettings, showFavorites, searchScopes, railOffset };
579
+ }
580
+ /* ================= 时间工具(卡片/搜索结果相对时间) ================= */
581
+ /** 相对时间:同日 HH:MM / 同年 MM-DD HH:MM / 跨年 YYYY-MM-DD HH:MM;非法输入返回空串 */
582
+ export function clockText(time, now) {
583
+ if (!(time > 0) || !Number.isFinite(time))
584
+ return '';
585
+ const stamp = new Date(time);
586
+ const pad = (n) => String(n).padStart(2, '0');
587
+ const hm = pad(stamp.getHours()) + ':' + pad(stamp.getMinutes());
588
+ const ref = new Date(now);
589
+ const sameDay = stamp.getFullYear() === ref.getFullYear()
590
+ && stamp.getMonth() === ref.getMonth()
591
+ && stamp.getDate() === ref.getDate();
592
+ if (sameDay)
593
+ return hm;
594
+ const md = pad(stamp.getMonth() + 1) + '-' + pad(stamp.getDate());
595
+ if (stamp.getFullYear() === ref.getFullYear())
596
+ return md + ' ' + hm;
597
+ return stamp.getFullYear() + '-' + md + ' ' + hm;
598
+ }
599
+ /* ================= 收藏 ================= */
600
+ const FAV_STORAGE_PREFIX = 'mega-chat-nav:favorites:';
601
+ function readFavorites(sessionId) {
602
+ if (sessionId === undefined)
603
+ return new Set();
604
+ try {
605
+ const raw = window.localStorage.getItem(FAV_STORAGE_PREFIX + sessionId);
606
+ if (raw === null)
607
+ return new Set();
608
+ const parsed = JSON.parse(raw);
609
+ if (!Array.isArray(parsed))
610
+ return new Set();
611
+ return new Set(parsed.filter((k) => typeof k === 'string'));
612
+ }
613
+ catch {
614
+ return new Set();
615
+ }
616
+ }
617
+ function writeFavorites(sessionId, keys) {
618
+ try {
619
+ window.localStorage.setItem(FAV_STORAGE_PREFIX + sessionId, JSON.stringify([...keys]));
620
+ }
621
+ catch { /* 存储不可用时收藏仅内存态 */ }
622
+ }
623
+ /** 收藏状态:localStorage 持久化 + 切换动作(跨刷新保留) */
624
+ export function useFavorites(sessionId) {
625
+ const [favorites, setFavorites] = useState(() => readFavorites(sessionId));
626
+ useEffect(() => {
627
+ setFavorites(readFavorites(sessionId));
628
+ }, [sessionId]);
629
+ const toggle = (key) => {
630
+ setFavorites((prev) => {
631
+ const next = new Set(prev);
632
+ if (next.has(key))
633
+ next.delete(key);
634
+ else
635
+ next.add(key);
636
+ if (sessionId !== undefined)
637
+ writeFavorites(sessionId, next);
638
+ return next;
639
+ });
640
+ };
641
+ return { favorites, toggle };
642
+ }
643
+ /* ================= 性能指标格式化 ================= */
644
+ /** 毫秒 → 人类可读时长(1分21秒 / 10秒) */
645
+ export function durationText(ms) {
646
+ const total = Math.max(0, Math.round(ms / 1000));
647
+ if (total >= 60) {
648
+ const m = Math.floor(total / 60);
649
+ const s = total % 60;
650
+ return m + '分' + s + '秒';
651
+ }
652
+ return total + '秒';
653
+ }
654
+ /** 完整指标行:本轮用时 1分21秒 · 首 token 10秒 · 188 tok/s */
655
+ export function metricsLine(metrics) {
656
+ const parts = [];
657
+ parts.push('本轮用时 ' + durationText(metrics.durationMs));
658
+ if (metrics.firstTokenMs > 0)
659
+ parts.push('首 token ' + durationText(metrics.firstTokenMs));
660
+ if (metrics.tokensPerSec > 0)
661
+ parts.push(metrics.tokensPerSec + ' tok/s');
662
+ return parts.join(' · ');
663
+ }
664
+ /* ================= 手机模式 ================= */
665
+ /** 手机模式:页面宽度小于 breakpoint(默认 1024px),resize 实时跟随 */
666
+ export function useMobileMode(breakpoint = 1024) {
667
+ const [mobile, setMobile] = useState(() => window.innerWidth < breakpoint);
668
+ useEffect(() => {
669
+ const onResize = () => setMobile(window.innerWidth < breakpoint);
670
+ window.addEventListener('resize', onResize);
671
+ return () => window.removeEventListener('resize', onResize);
672
+ }, [breakpoint]);
673
+ return mobile;
674
+ }
675
+ /* ================= 手机模式搜索按钮(零高 sticky wrapper + absolute 按钮) ================= */
676
+ /**
677
+ * 手机模式:向对话滚动容器 [data-conversation-scroll] 头部插入零高 wrapper
678
+ * (width:100%; height:0; sticky top:0; overflow:visible),按钮 absolute 相对
679
+ * wrapper 定位——sticky 粘在滚动视口顶部,按钮不随内容滚走、不占布局空间。
680
+ */
681
+ export function useMobileSearchButton(onClick, enabled, align, label) {
682
+ useEffect(() => {
683
+ if (!enabled)
684
+ return;
685
+ const host = mobileSearchHost();
686
+ if (host === null)
687
+ return;
688
+ // 零高 wrapper:sticky 粘在滚动容器顶部、不占空间;按钮 absolute 相对 wrapper 定位
689
+ const wrap = document.createElement('div');
690
+ wrap.style.width = '100%';
691
+ wrap.style.height = '0';
692
+ wrap.style.margin = '0';
693
+ wrap.style.position = 'sticky';
694
+ wrap.style.top = '0';
695
+ wrap.style.overflow = 'visible';
696
+ wrap.style.zIndex = '60';
697
+ wrap.style.pointerEvents = 'none'; // wrapper 不拦截,按钮自身可点
698
+ const btn = document.createElement('button');
699
+ btn.type = 'button';
700
+ btn.className = 'mgs-config-gear mgs-mobileSearch';
701
+ btn.setAttribute('aria-label', label);
702
+ btn.style.position = 'absolute';
703
+ btn.style.top = '8px';
704
+ btn.style[align === 'right' ? 'right' : 'left'] = '8px';
705
+ btn.style.zIndex = '60';
706
+ btn.style.pointerEvents = 'auto';
707
+ btn.innerHTML = '<svg class="mgs-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>';
708
+ btn.addEventListener('click', onClick);
709
+ wrap.appendChild(btn);
710
+ // 保证 wrapper 是父级第一个元素(sticky 相对滚动容器视口顶部)
711
+ host.insertBefore(wrap, host.firstChild);
712
+ return () => {
713
+ wrap.remove();
714
+ };
715
+ }, [enabled, onClick, align, label]);
716
+ }