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,312 @@
1
+ import { RailDispatch } from "./components/RailDispatch.js";
2
+ import { SettingsPage } from "./components/settings/SettingsPage.js";
3
+ import { StandaloneSettings } from "./components/settings/StandaloneSettings.js";
4
+ import { NavSettingsController } from "./settings.js";
5
+ import { en, zh } from "./locales.js";
6
+ import { goTo } from "./jump.js";
7
+ /** Locale namespace this plugin owns. */
8
+ const NS = 'mega-chat-nav';
9
+ /** Services required by this plugin. */
10
+ export const inject = ['slots', 'locale', 'sessions'];
11
+ function sessionsOf(ctx) {
12
+ return ctx.sessions;
13
+ }
14
+ /** 提问行 kind */
15
+ const QUESTION_KINDS = ['user', 'steering'];
16
+ /** 消息首文本块;缺省空串 */
17
+ function firstTextBlock(content) {
18
+ const block = content?.find((part) => typeof part?.text === 'string');
19
+ return block?.text ?? '';
20
+ }
21
+ /** 从节点窗口提取提问行(kind 过滤 + 首文本 + 按 anchorSeq 排序) */
22
+ function extractQuestionRows(nodes) {
23
+ return [...nodes]
24
+ .filter((node) => QUESTION_KINDS.includes(node.kind ?? ''))
25
+ .map((node) => {
26
+ const payload = (typeof node.data === 'object' && node.data !== null ? node.data : {});
27
+ return {
28
+ key: node.key,
29
+ anchorSeq: node.anchorSeq,
30
+ seq: payload.seq ?? -1,
31
+ time: payload.time ?? 0,
32
+ text: firstTextBlock(payload.content),
33
+ };
34
+ })
35
+ .sort((a, b) => a.anchorSeq - b.anchorSeq);
36
+ }
37
+ function claimApply() {
38
+ if (globalThis.__dshMegaChatNavApplied === true)
39
+ return false;
40
+ globalThis.__dshMegaChatNavApplied = true;
41
+ return true;
42
+ }
43
+ function releaseApply() {
44
+ globalThis.__dshMegaChatNavApplied = undefined;
45
+ }
46
+ /** Map the session snapshot to the jump-loop port surface. */
47
+ function jumpPortsFor(ctx, sessionId) {
48
+ const sessions = sessionsOf(ctx);
49
+ return {
50
+ snap: () => {
51
+ const binding = sessions.binding(sessionId);
52
+ const snap = binding?.session.getSnapshot();
53
+ if (snap === undefined)
54
+ return undefined;
55
+ return {
56
+ openState: snap.openState ?? '',
57
+ hasMore: snap.hasMore ?? false,
58
+ loadingOlder: snap.loadingOlder ?? false,
59
+ rows: (snap.chat?.nodes?.values() ?? []),
60
+ };
61
+ },
62
+ loadMore: async () => {
63
+ const binding = sessions.binding(sessionId);
64
+ if (binding === undefined)
65
+ throw new Error('session unavailable');
66
+ await binding.session.loadOlder();
67
+ },
68
+ active: () => document.querySelector('[data-chat-flow]') !== null,
69
+ locate: (key) => {
70
+ const rows = Array.from(document.querySelectorAll('[data-chat-anchor-key]'));
71
+ for (const row of rows) {
72
+ if (row.dataset.chatAnchorKey === key)
73
+ return row;
74
+ }
75
+ // 包含兜底(key 为完整锚点串,唯一命中)
76
+ for (const row of rows) {
77
+ const k = row.dataset.chatAnchorKey;
78
+ if (k !== undefined && k.includes(key))
79
+ return row;
80
+ }
81
+ // seq 兜底:取最后一个匹配(防误命中前一行)
82
+ if (key.startsWith('seq:')) {
83
+ const n = key.slice(4);
84
+ let found = null;
85
+ for (const row of rows) {
86
+ const k = row.dataset.chatAnchorKey;
87
+ if (k !== undefined && k.includes(n))
88
+ found = row;
89
+ }
90
+ if (found !== null)
91
+ return found;
92
+ }
93
+ return null;
94
+ },
95
+ reveal: (row, mode) => {
96
+ // 目标行顶部与视口顶留 12px 间距(不贴顶,视觉更舒适)
97
+ const scrollToRow = () => {
98
+ const port = document.querySelector('[data-conversation-scroll]');
99
+ if (port !== null) {
100
+ const rowTop = row.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop;
101
+ port.scrollTo({ top: rowTop - 12, behavior: mode });
102
+ }
103
+ else {
104
+ row.scrollIntoView({ behavior: mode, block: 'start' });
105
+ }
106
+ };
107
+ scrollToRow();
108
+ // 正文在 loadOlder prepend 落地后可能自动滚回底部(跟随滚动):
109
+ // 校验式重滚——仅当目标行明显偏离视口顶(被正文回滚/未到位)才重滚,
110
+ // 用户主动滚动阅读时不干预
111
+ const ensure = () => {
112
+ const port = document.querySelector('[data-conversation-scroll]');
113
+ if (port === null)
114
+ return;
115
+ const rowRect = row.getBoundingClientRect();
116
+ const portRect = port.getBoundingClientRect();
117
+ const offset = rowRect.top - portRect.top;
118
+ if (offset < -60 || offset > 120)
119
+ scrollToRow();
120
+ };
121
+ window.setTimeout(ensure, 300);
122
+ window.setTimeout(ensure, 800);
123
+ // 跳转确认高亮:监听目标气泡**进入视口(可见)**后才开始闪烁;
124
+ // 背景经 transition 平滑渐变(非突兀切换),主题色两轮缓慢变化
125
+ const bubble = row.querySelector('[class*="bubble"]')
126
+ ?? row.querySelector('[class*="messageBody"], [class*="bubbleBody"]');
127
+ if (bubble !== null) {
128
+ const original = bubble.style.background;
129
+ const themeBg = 'color-mix(in srgb, var(--dsw-alias-brand-primary) 38%, transparent)';
130
+ bubble.style.transition = 'background 350ms ease';
131
+ const paint = (on) => {
132
+ bubble.style.background = on ? themeBg : original;
133
+ };
134
+ // 轮询等待气泡可见(进入滚动容器视口);4s 兜底后也执行
135
+ const waitVisible = () => new Promise((resolve) => {
136
+ const port = document.querySelector('[data-conversation-scroll]');
137
+ let tries = 0;
138
+ const check = () => {
139
+ const r = bubble.getBoundingClientRect();
140
+ const portTop = port !== null ? port.getBoundingClientRect().top : 0;
141
+ const portBottom = port !== null ? port.getBoundingClientRect().bottom : window.innerHeight;
142
+ if ((r.bottom > portTop && r.top < portBottom) || tries > 40)
143
+ resolve();
144
+ else {
145
+ tries += 1;
146
+ window.setTimeout(check, 100);
147
+ }
148
+ };
149
+ check();
150
+ });
151
+ void waitVisible().then(() => {
152
+ // 两轮切换共约 1.5s:渐变到主题色 → 渐变回 → 再切 → 再回
153
+ paint(true);
154
+ window.setTimeout(() => paint(false), 400);
155
+ window.setTimeout(() => paint(true), 800);
156
+ window.setTimeout(() => paint(false), 1200);
157
+ window.setTimeout(() => { bubble.style.transition = ''; }, 1500);
158
+ });
159
+ }
160
+ },
161
+ scrollport: () => document.querySelector('[data-conversation-scroll]'),
162
+ now: () => Date.now(),
163
+ sleep: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)),
164
+ };
165
+ }
166
+ /**
167
+ * The session's `msgNavMessages` projection face (getSnapshot + subscribe).
168
+ * Undefined when the session is not bound or the host unit is not registered
169
+ * (e.g. a headless composition) — the strip then shows live-window dots only.
170
+ */
171
+ function navProjectionOf(ctx, sessionId) {
172
+ const face = sessionsOf(ctx).binding(sessionId)?.session.projections.faceOf('msgNavMessages');
173
+ if (face === undefined)
174
+ return undefined;
175
+ return {
176
+ getSnapshot: () => face.getSnapshot(),
177
+ subscribe: (listener) => face.subscribe(listener),
178
+ };
179
+ }
180
+ function createInject(ctx, settings) {
181
+ const sessions = sessionsOf(ctx);
182
+ return {
183
+ readQuestions: (sessionId) => {
184
+ const snap = sessions.binding(sessionId)?.session.getSnapshot();
185
+ if (snap === undefined || snap.chat?.nodes === undefined)
186
+ return [];
187
+ return extractQuestionRows(snap.chat.nodes.values());
188
+ },
189
+ subscribeList: (cb) => sessions.list.subscribe(cb),
190
+ subscribeContent: (sessionId, cb) => {
191
+ const binding = sessions.binding(sessionId);
192
+ if (binding === undefined)
193
+ return () => { };
194
+ return binding.session.subscribe(cb);
195
+ },
196
+ questionProjection: (sessionId) => navProjectionOf(ctx, sessionId),
197
+ jump: (sessionId, key, seq, currentSeq) => {
198
+ const ports = jumpPortsFor(ctx, sessionId);
199
+ ports.report = (code) => {
200
+ // Surface the failure through the component via a DOM event the
201
+ // strip listens for; simplest reliable cross-boundary channel here.
202
+ window.dispatchEvent(new CustomEvent('mega-chat-nav:jump-failed', { detail: code }));
203
+ };
204
+ ports.onLoading = (loading, pages) => {
205
+ window.dispatchEvent(new CustomEvent('mega-chat-nav:jump-loading', { detail: { loading, pages } }));
206
+ };
207
+ void goTo(ports, key, {}, settings.getSnapshot().scrollBehavior, seq, currentSeq);
208
+ },
209
+ style: () => settings.getSnapshot().style,
210
+ setStyle: (style) => settings.setStyle(style),
211
+ align: () => settings.getSnapshot().align,
212
+ subscribeSettings: (cb) => settings.subscribe(cb),
213
+ setAlign: (align) => settings.setAlign(align),
214
+ bandHeight: () => settings.getSnapshot().bandHeight,
215
+ setBandHeight: (band) => settings.setBandHeight(band),
216
+ scrollBehavior: () => settings.getSnapshot().scrollBehavior,
217
+ setScrollMode: (mode) => settings.setScrollMode(mode),
218
+ showCount: () => settings.getSnapshot().showCount,
219
+ setShowCount: (mode) => settings.setShowCount(mode),
220
+ showSearch: () => settings.getSnapshot().showSearch,
221
+ setShowSearch: (mode) => settings.setShowSearch(mode),
222
+ showQuickSettings: () => settings.getSnapshot().showQuickSettings,
223
+ setShowQuickSettings: (mode) => settings.setShowQuickSettings(mode),
224
+ showFavorites: () => settings.getSnapshot().showFavorites,
225
+ setShowFavorites: (mode) => settings.setShowFavorites(mode),
226
+ showPaging: () => settings.getSnapshot().showPaging,
227
+ setShowPaging: (mode) => settings.setShowPaging(mode),
228
+ searchScopes: () => settings.getSnapshot().searchScopes,
229
+ setSearchScopes: (scopes) => settings.setSearchScopes(scopes),
230
+ railOffset: () => settings.getSnapshot().railOffset,
231
+ setRailOffset: (offset) => settings.setRailOffset(offset),
232
+ cardCount: () => settings.getSnapshot().cardCount,
233
+ setCardCount: (count) => settings.setCardCount(count),
234
+ cardItems: () => settings.getSnapshot().cardItems,
235
+ setCardItems: (items) => settings.setCardItems(items),
236
+ markTone: () => settings.getSnapshot().markTone,
237
+ setMarkTone: (tone) => settings.setMarkTone(tone),
238
+ };
239
+ }
240
+ /**
241
+ * Register the mega-chat-nav surface.
242
+ * @param ctx - client root context.
243
+ */
244
+ export function apply(ctx) {
245
+ if (!claimApply())
246
+ return;
247
+ ctx.effect(() => releaseApply, 'mega-chat-nav: apply claim');
248
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'mega-chat-nav: dictionaries');
249
+ const t = ctx.locale.bind(NS);
250
+ const settings = new NavSettingsController();
251
+ const injected = createInject(ctx, settings);
252
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
253
+ name: 'shell.overlay',
254
+ id: 'mega-chat-nav',
255
+ order: 900,
256
+ locale: NS,
257
+ inject: () => ({ injected }),
258
+ }, RailDispatch));
259
+ // mega-settings 成员契约:元信息 seat + 页面 seat(宿主缺席 → 静默等待 → 自足)
260
+ ctx.slots.inject('mega.settings.member', () => ctx.slots.register({
261
+ name: 'mega.settings.member',
262
+ id: 'mega-chat-nav',
263
+ order: 10,
264
+ label: () => t('label'),
265
+ inject: () => ({ description: () => t('desc'), version: PKG_VERSION }),
266
+ }, ListRow));
267
+ ctx.slots.inject('mega.settings.member.page', () => ctx.slots.register({
268
+ name: 'mega.settings.member.page',
269
+ key: 'mega-chat-nav',
270
+ order: 10,
271
+ label: () => t('label'),
272
+ locale: NS,
273
+ inject: () => ({ injected }),
274
+ }, SettingsPage));
275
+ // 插件页配置入口:仅当未被 mega-settings 收纳时注册(收纳后注销,避免重复入口)。
276
+ // mega-settings 可能晚于本插件加载——注册后订阅 member 条目,宿主出现即注销。
277
+ {
278
+ let tabDispose = null;
279
+ const ensurePluginsTab = () => {
280
+ const hosted = (ctx.slots.entries?.('mega.settings.member')?.length ?? 0) > 0;
281
+ if (hosted) {
282
+ tabDispose?.();
283
+ tabDispose = null;
284
+ }
285
+ else if (tabDispose === null) {
286
+ tabDispose = ctx.slots.register({
287
+ name: 'settings.plugins.tab',
288
+ id: 'mega-chat-nav',
289
+ order: 100,
290
+ label: () => t('label'),
291
+ locale: NS,
292
+ inject: () => ({ injected, slots: ctx.slots }),
293
+ }, StandaloneSettings);
294
+ }
295
+ };
296
+ ensurePluginsTab();
297
+ const offMember = ctx.slots.subscribe?.('mega.settings.member', ensurePluginsTab);
298
+ ctx.effect(() => () => {
299
+ tabDispose?.();
300
+ tabDispose = null;
301
+ offMember?.();
302
+ }, 'mega-chat-nav: plugins tab ledger');
303
+ }
304
+ // 设置控制器挂载:settingsScope 服务就绪后绑定 scope(未就绪时配置读写走默认值降级)
305
+ ctx.inject(['settingsScope'], (scopeCtx) => {
306
+ settings.attach(scopeCtx.get('settingsScope'));
307
+ });
308
+ }
309
+ /** 成员列表行占位(元信息 seat 用;宿主自绘列表时不渲染) */
310
+ function ListRow() {
311
+ return null;
312
+ }
@@ -0,0 +1,255 @@
1
+ // 跳转编排(client 装配层自研):定位目标行——按方向分流(向下滚动 / 向上补历史),
2
+ // 等待渲染并滚动;失败回退。结果码与词条绑定(jump-failed 事件契约)。
3
+ //
4
+ // 关键语义:chat.nodes 只是「虚拟渲染窗口」(DOM 渲染行),目标行在已加载范围内
5
+ // 但未渲染时**不能**当作「需要加载更早历史」——必须按目标 seq 与渲染窗口的关系分流。
6
+ const BUDGET = {
7
+ totalTimeoutMs: 30_000,
8
+ maxPages: 300,
9
+ rowWaitMs: 8_000,
10
+ pollMs: 60,
11
+ };
12
+ /** 行是否为可渲染锚点行 */
13
+ function isVisibleRow(row) {
14
+ return row.visibility !== 'hidden';
15
+ }
16
+ /** 快照中是否已含目标行 */
17
+ function hasTarget(rows, key) {
18
+ for (const row of rows) {
19
+ if (isVisibleRow(row) && row.key === key)
20
+ return true;
21
+ }
22
+ return false;
23
+ }
24
+ /** 渲染窗口的锚点序号极值;空窗口返回 null */
25
+ function seqRange(rows) {
26
+ let min = null;
27
+ let max = null;
28
+ for (const row of rows) {
29
+ if (min === null || row.anchorSeq < min)
30
+ min = row.anchorSeq;
31
+ if (max === null || row.anchorSeq > max)
32
+ max = row.anchorSeq;
33
+ }
34
+ return min === null || max === null ? null : { min, max };
35
+ }
36
+ /** 当前窗口最小锚点序号;空窗口返回 null */
37
+ function smallestSeq(rows) {
38
+ const range = seqRange(rows);
39
+ return range === null ? null : range.min;
40
+ }
41
+ /** 回退目标:排除指定 key 的最近可渲染行(取最新锚点序号) */
42
+ function retreatTo(rows, excludeKey) {
43
+ let best = null;
44
+ for (const row of rows) {
45
+ if (row.visibility === 'hidden')
46
+ continue;
47
+ if (row.key === excludeKey)
48
+ continue;
49
+ if (best === null || row.anchorSeq > best.anchorSeq)
50
+ best = { key: row.key, anchorSeq: row.anchorSeq };
51
+ }
52
+ return best;
53
+ }
54
+ /** 等待某行渲染(预算内轮询);视图失活返回 null */
55
+ async function waitForRow(ports, rowKey, pollMs, rowWaitMs) {
56
+ for (let waited = 0; waited <= rowWaitMs; waited += pollMs) {
57
+ if (!ports.active())
58
+ return null;
59
+ const row = ports.locate(rowKey);
60
+ if (row !== null)
61
+ return row;
62
+ await ports.sleep(pollMs);
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * 滚动定位:按方向逐屏滚动(触发虚拟渲染)直至目标行出现或到达边界。
68
+ * 若快照渲染窗口已含目标但 DOM 尚未挂载(渲染延迟),放慢等待 DOM。
69
+ * 返回命中的行;未命中返回 null。
70
+ */
71
+ async function scrollLocate(ports, key, dir, maxSteps, stepMs) {
72
+ const port = ports.scrollport();
73
+ if (port === null)
74
+ return null;
75
+ for (let i = 0; i < maxSteps; i++) {
76
+ const row = ports.locate(key);
77
+ if (row !== null)
78
+ return row;
79
+ const before = port.scrollTop;
80
+ const step = Math.max(200, Math.round(port.clientHeight * 0.4));
81
+ port.scrollBy({ top: dir * step });
82
+ await ports.sleep(stepMs);
83
+ const floor = Math.max(0, port.scrollHeight - port.clientHeight);
84
+ if (Math.abs(port.scrollTop - before) < 2) {
85
+ // 到边界:反向回扫若干步(虚拟渲染滞后可能已滚过头)
86
+ if (dir === 1) {
87
+ for (let i = 0; i < 10; i++) {
88
+ const row = ports.locate(key);
89
+ if (row !== null)
90
+ return row;
91
+ port.scrollBy({ top: -Math.round(port.clientHeight * 0.4) });
92
+ await ports.sleep(stepMs);
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ // 滚动后:虚拟渲染窗口已覆盖目标(数据窗口含目标行)→ 放慢等待 DOM 挂载
98
+ const snap = ports.snap();
99
+ if (snap !== undefined && hasTarget(snap.rows, key)) {
100
+ const rendered = await waitForRow(ports, key, Math.max(100, stepMs), 2500);
101
+ if (rendered !== null)
102
+ return rendered;
103
+ return null;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * 跳转到 key 对应行:
110
+ * - 目标已在渲染窗口 → 等待 DOM 并滚动;
111
+ * - 目标在下方/窗口内(targetSeq ≥ 渲染窗口最小值)→ 向下滚动定位(无需加载);
112
+ * - 目标更早(targetSeq < 渲染窗口最小值 或未知)→ 预算内 loadOlder 补历史,每页后向上滚动定位;
113
+ * - 全部失败 → 回退最近可渲染行。
114
+ */
115
+ export async function goTo(ports, key, options = {}, mode = 'smooth', targetSeq, currentSeq) {
116
+ const budget = { ...BUDGET, ...options };
117
+ const fail = (code, fallback = false) => {
118
+ ports.report?.(code, fallback);
119
+ return fallback ? { ok: false, code, fallback: true } : { ok: false, code };
120
+ };
121
+ if (!ports.active())
122
+ return fail('VIEW_INACTIVE');
123
+ const deadline = ports.now() + budget.totalTimeoutMs;
124
+ let pages = 0;
125
+ let lastNotified = -1;
126
+ const notifyLoading = (loading, count) => {
127
+ const key2 = loading ? count : -1;
128
+ if (key2 === lastNotified)
129
+ return;
130
+ lastNotified = key2;
131
+ ports.onLoading?.(loading, count);
132
+ };
133
+ // 阶段一:确保目标进入渲染窗口
134
+ for (;;) {
135
+ const snap = ports.snap();
136
+ if (snap === undefined) {
137
+ notifyLoading(false, 0);
138
+ return fail('VIEW_INACTIVE');
139
+ }
140
+ if (hasTarget(snap.rows, key)) {
141
+ notifyLoading(false, 0);
142
+ break;
143
+ }
144
+ if (ports.now() > deadline) {
145
+ notifyLoading(false, 0);
146
+ return fail('TIMEOUT');
147
+ }
148
+ // 明确在下方(目标比当前阅读位置晚):向下滚动定位,绝不加载。
149
+ // 方向判定优先用当前阅读位置 seq(渲染窗口极值含虚拟缓冲,不可靠)
150
+ if (targetSeq !== undefined) {
151
+ const range = seqRange(snap.rows);
152
+ const below = currentSeq !== undefined
153
+ ? targetSeq > currentSeq
154
+ : range !== null && targetSeq > range.max;
155
+ if (below) {
156
+ const found = await scrollLocate(ports, key, 1, 500, budget.pollMs * 2);
157
+ if (found !== null) {
158
+ ports.reveal(found, mode);
159
+ return { ok: true };
160
+ }
161
+ notifyLoading(false, 0);
162
+ return fail('TARGET_HIDDEN');
163
+ }
164
+ }
165
+ // 其余情况:先向上滚动定位(目标可能已加载但位于虚拟缓冲/视口上方未渲染),
166
+ // 仅在目标距渲染窗口较近时尝试,避免无谓的大范围滚动
167
+ if (!snap.loadingOlder) {
168
+ const range = seqRange(snap.rows);
169
+ const closeEnough = targetSeq === undefined || range === null || targetSeq >= range.min - 40;
170
+ if (closeEnough) {
171
+ const up = await scrollLocate(ports, key, -1, 40, budget.pollMs * 2);
172
+ if (up !== null) {
173
+ notifyLoading(false, 0);
174
+ ports.reveal(up, mode);
175
+ return { ok: true };
176
+ }
177
+ }
178
+ }
179
+ // 更早方向:补历史
180
+ if (snap.openState !== 'open') {
181
+ if (snap.openState === 'error') {
182
+ notifyLoading(false, 0);
183
+ return fail('VIEW_INACTIVE');
184
+ }
185
+ await ports.sleep(budget.pollMs);
186
+ continue;
187
+ }
188
+ if (snap.hasMore !== true) {
189
+ notifyLoading(false, 0);
190
+ return fail('NOT_FOUND');
191
+ }
192
+ if (pages >= budget.maxPages) {
193
+ notifyLoading(false, 0);
194
+ return fail('TIMEOUT');
195
+ }
196
+ notifyLoading(true, pages + 1);
197
+ if (snap.loadingOlder) {
198
+ await ports.sleep(budget.pollMs);
199
+ continue;
200
+ }
201
+ const before = smallestSeq(snap.rows);
202
+ await ports.loadMore();
203
+ pages += 1;
204
+ notifyLoading(true, pages);
205
+ // loadOlder 的 Promise resolve 可能早于数据落地:轮询等待窗口最小 seq 真正前移
206
+ let after = before;
207
+ for (let waited = 0; waited <= 5000; waited += budget.pollMs) {
208
+ const s2 = ports.snap();
209
+ const cur = smallestSeq(s2 === undefined ? [] : s2.rows);
210
+ if (cur !== null && (before === null || cur < before)) {
211
+ after = cur;
212
+ break;
213
+ }
214
+ await ports.sleep(budget.pollMs);
215
+ }
216
+ if (after === null || (before !== null && after >= before)) {
217
+ // 加载无进展:最后向上滚动定位一次再判失败
218
+ const up = await scrollLocate(ports, key, -1, 40, budget.pollMs * 2);
219
+ if (up !== null) {
220
+ notifyLoading(false, 0);
221
+ ports.reveal(up, mode);
222
+ return { ok: true };
223
+ }
224
+ notifyLoading(false, 0);
225
+ return fail('NOT_FOUND');
226
+ }
227
+ // 循环继续:新加载的行在视口上方,顶部迭代会再向上滚动定位
228
+ }
229
+ // 阶段二:等待渲染并滚动;隐藏则回退
230
+ const row = await waitForRow(ports, key, budget.pollMs, budget.rowWaitMs);
231
+ if (row !== null) {
232
+ ports.reveal(row, mode);
233
+ return { ok: true };
234
+ }
235
+ // 目标已入渲染窗口但 DOM 未挂载(虚拟渲染延迟):双向滚动定位兜底
236
+ const snap0 = ports.snap();
237
+ if (snap0 !== undefined && hasTarget(snap0.rows, key)) {
238
+ const located = await scrollLocate(ports, key, -1, 60, budget.pollMs * 2)
239
+ ?? await scrollLocate(ports, key, 1, 60, budget.pollMs * 2);
240
+ if (located !== null) {
241
+ ports.reveal(located, mode);
242
+ return { ok: true };
243
+ }
244
+ }
245
+ const snap = ports.snap();
246
+ const fallback = retreatTo(snap === undefined ? [] : snap.rows, key);
247
+ if (fallback !== null) {
248
+ const fbRow = await waitForRow(ports, fallback.key, budget.pollMs, budget.rowWaitMs);
249
+ if (fbRow !== null) {
250
+ ports.reveal(fbRow, mode);
251
+ return fail('TARGET_HIDDEN', true);
252
+ }
253
+ }
254
+ return fail('TARGET_HIDDEN', false);
255
+ }