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