dsh-manage-session 0.5.7 → 2.0.4

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 (67) hide show
  1. package/CHANGELOG.ja.md +33 -229
  2. package/CHANGELOG.ko.md +33 -229
  3. package/CHANGELOG.md +33 -363
  4. package/INSTALL.ja.md +164 -164
  5. package/INSTALL.ko.md +164 -164
  6. package/INSTALL.md +164 -164
  7. package/INSTALL.zh.md +164 -164
  8. package/LICENSE +21 -21
  9. package/README.en.md +41 -46
  10. package/README.ja.md +33 -57
  11. package/README.ko.md +32 -55
  12. package/README.md +64 -64
  13. package/cordis.patch.yml +19 -16
  14. package/dsh.plugin.json +4 -4
  15. package/lib/client.js +661 -3082
  16. package/lib/index.d.ts +79 -970
  17. package/lib/index.js +249 -2669
  18. package/locale/de.json +2 -2
  19. package/locale/en.json +2 -2
  20. package/locale/es.json +2 -2
  21. package/locale/fr.json +2 -2
  22. package/locale/it.json +2 -2
  23. package/locale/ja.json +2 -2
  24. package/locale/ko.json +2 -2
  25. package/locale/ru.json +2 -2
  26. package/locale/zh.json +2 -2
  27. package/package.json +15 -25
  28. package/screenshots.json +3 -3
  29. package/src/client/bridge-scope.ts +202 -213
  30. package/src/client/card.tsx +154 -154
  31. package/src/client/health-panel.tsx +49 -49
  32. package/src/client/host-api.ts +72 -6
  33. package/src/client/index.ts +594 -503
  34. package/src/client/locales.ts +893 -893
  35. package/src/client/manage-locales.ts +178 -0
  36. package/src/client/{search/manage.tsx → manage.tsx} +430 -404
  37. package/src/client/panel.tsx +131 -129
  38. package/src/client/session-menu-legacy.ts +2 -3
  39. package/src/client/session-menu.tsx +5 -5
  40. package/src/config.ts +43 -99
  41. package/src/host/health/attribution.ts +118 -118
  42. package/src/host/health/lossless.ts +128 -128
  43. package/src/host/health/repair.ts +2 -2
  44. package/src/host/health/source-kind.ts +350 -350
  45. package/src/host/history/archive-source.ts +250 -250
  46. package/src/host/history/archive.ts +249 -249
  47. package/src/host/history/favorites.ts +71 -71
  48. package/src/host/history/purge.ts +370 -370
  49. package/src/host/history/tombstones.ts +3 -3
  50. package/src/host/registry/archive-registry.ts +317 -317
  51. package/src/host/settings-bridge.ts +222 -264
  52. package/src/index.ts +66 -694
  53. package/src/client/search/card.tsx +0 -563
  54. package/src/client/search/host-api.ts +0 -213
  55. package/src/client/search/locales.ts +0 -861
  56. package/src/client/search/locate.ts +0 -330
  57. package/src/client/search/panel.tsx +0 -940
  58. package/src/client/search/platform.ts +0 -136
  59. package/src/host/index/archive-source.ts +0 -90
  60. package/src/host/index/drs-source.ts +0 -211
  61. package/src/host/index/engine.ts +0 -699
  62. package/src/host/index/extract.ts +0 -272
  63. package/src/host/index/peers.ts +0 -62
  64. package/src/host/index/rebuild.ts +0 -432
  65. package/src/host/index/schema.ts +0 -136
  66. package/src/host/index/snapshot.ts +0 -135
  67. package/src/host/index/sync.ts +0 -413
@@ -1,330 +0,0 @@
1
- /**
2
- * 命中定位臂(B 路线 v3,T7.4):内容搜索点击打开会话后,把宿主会话视图 DOM
3
- * 里对应的那一行滚进视口并瞬时高亮。
4
- *
5
- * 前提实证(0.2.1 宿主前端包扫描):会话列表**无虚拟化**(无 virtuoso/
6
- * react-window 指纹,IntersectionObserver 仅做 sticky 标题与入视口激活),
7
- * 打开会话后历史行全量在 DOM。宿主导航接口面无消息锚点
8
- * (uiWorkspace.openSession(SessionTarget) 全签名),DOM 文本匹配是唯一通路;
9
- * 一切失败静默退化为「仅打开」(绝不抛、绝不遮主流程)。
10
- *
11
- * v2 教训(真机「还停在底部」):一次性 scrollIntoView 会被宿主会话打开后
12
- * 的贴底/滚动恢复覆盖——v3 改「巡逻-驻留」模型:每 TICK 重定位+补滚,
13
- * 目标行稳定驻留视口 STICK_MS 后才打高亮;用户手动滚动立即让位。
14
- *
15
- * v4 根因修正(宿主接口面实锤):dsh-client-ui-conversation 的 IConversation
16
- * 暴露 loadOlder()("Pull one older history page")——历史是**分页**的,
17
- * 首轮视口外找不到的真实原因是目标行根本没挂载。巡逻循环每 N tick 调
18
- * 一次 loadOlder 往回翻页,直到目标 turn 的行进入 DOM 再驻留跳转;
19
- * 翻页能力经 configureLocator 由 client apply 注入(本模块不持 ctx)。
20
- *
21
- * 数据面:同源 GET /api/dsh-reference-session-collector?op=session&session=<id>
22
- * ——行含 seq/text/search_text/surface_text/on_surface/compressed:
23
- * - on_surface=0(被替换原文行):GUI 不渲染该文本 → 放弃定位(不是失败);
24
- * - compressed=1:GUI 渲染的是链解析后的 surface_text(替身文本);
25
- * - 其余行优先 search_text(已剥宿主注入样板段=用户可见文本),退化 text。
26
- */
27
- import type { HostContentHit } from './host-api.ts'
28
-
29
- /** DRS fenced 路由(同源相对路径,与 dsh-reference-session-collector DRS_API_PATH 字面一致)。 */
30
- const DRS_ROUTE = '/api/dsh-reference-session-collector'
31
-
32
- /** 定位总超时(打开会话后 DOM 渲染窗口)。 */
33
- const LOCATE_TIMEOUT_MS = 8_000
34
-
35
- /** 高亮驻留时长后自动摘除。 */
36
- const FLASH_MS = 6_000
37
-
38
- /** needle 下限——短于此值的行("好的")歧义面太大,不值得定位。 */
39
- const MIN_NEEDLE_CHARS = 12
40
-
41
- /** needle 上限——取前缀足够唯一,过长整串匹配反而更易被渲染差异打断。 */
42
- const MAX_NEEDLE_CHARS = 80
43
-
44
- /** 驻留确认窗口:目标行在视口内稳定停留这么久才算跳转成功。 */
45
- const STICK_MS = 900
46
-
47
- /** 翻页上限:24 页 × 宿主页宽足够任何真机会话;防无限拉页。 */
48
- const MAX_OLDER_PAGES = 24
49
-
50
- /** 硬封顶:带翻页的定位窗口上限。 */
51
- const HARD_DEADLINE_MS = 30_000
52
-
53
- /** 巡逻间隔:会话重渲染会换 DOM 节点,每轮重新定位。 */
54
- const TICK_MS = 350
55
-
56
- /** 定位行在视口内的判定余量(顶部允许略出屏,代码块开头行高度不定)。 */
57
- const VISIBILITY_SLACK = 40
58
-
59
- /** 归一化/取 needle 用的一个 DRS 段行(wire 蛇形,只声明消费字段)。 */
60
- export interface LocateRowFace {
61
- seq: number
62
- /** 行归属 turn(阅读模式 [data-reader-turn] 直接寻址键)。 */
63
- turn?: number
64
- text?: string
65
- search_text?: string
66
- on_surface?: number
67
- compressed?: number
68
- surface_text?: string | null
69
- }
70
-
71
- /** markdown 结构符与空白——DOM 渲染后不含这些,两侧同抹才能对上。 */
72
- const MATCH_NOISE_RE = /[\s`*_~#>|[\]()!,。:;、"'\\{}-]+/gu
73
-
74
- /** 归一化比对文本:去空白与 markdown 噪声字符。 */
75
- export function normalizeForMatch(text: string): string {
76
- return text.replace(MATCH_NOISE_RE, '')
77
- }
78
-
79
- /**
80
- * 从段行选一条可定位 needle(按纯函数导出,供单测)。
81
- * @returns 归一化 needle;null=该行不可定位(被替换原文/无有效文本/过短歧义)。
82
- */
83
- export function resolveNeedle(row: LocateRowFace | undefined): string | null {
84
- if (row === undefined) return null
85
- if (Number(row.on_surface ?? 1) === 0) return null
86
- const sources: (string | null | undefined)[] = Number(row.compressed ?? 0) === 1
87
- ? [row.surface_text, row.search_text, row.text]
88
- : [row.search_text, row.text]
89
- for (const source of sources) {
90
- if (typeof source !== 'string' || source === '') continue
91
- // 行首段优先:渲染可能截断长行/拆段,首行完整命中概率最高。
92
- const firstLine = source.split('\n').find(line => line.trim() !== '') ?? source
93
- const needle = normalizeForMatch(firstLine).slice(0, MAX_NEEDLE_CHARS)
94
- if (needle.length >= MIN_NEEDLE_CHARS) return needle
95
- // 首行太短("好的")时退整段拼接再试一次,仍短则换下一来源。
96
- const whole = normalizeForMatch(source).slice(0, MAX_NEEDLE_CHARS)
97
- if (whole.length >= MIN_NEEDLE_CHARS) return whole
98
- }
99
- return null
100
- }
101
-
102
- /** 正文候选标签:只取消息渲染会用的块级/内联代码标签——不含 div/span,
103
- * 否则搜索面板自己的结果行(dsws_snippet 的 span,文本就含 needle 前缀)会抢先命中。 */
104
- const MESSAGE_TAGS = 'p, li, pre, blockquote, h1, h2, h3, h4, td, code'
105
-
106
- /** 阅读线偏移(dsh-tidy-display conversation-scroll.ts READING_LINE_OFFSET_PX 同值)。 */
107
- const READING_LINE_OFFSET_PX = 24
108
-
109
- /** 有布局才叫可见:未挂载页签(display:none)的 rect 全 0,旧视口判定会误判驻留。 */
110
- function hasLayout(el: Element): boolean {
111
- return (el as HTMLElement).offsetHeight > 0 || (el as HTMLElement).offsetWidth > 0
112
- }
113
-
114
- /** 祖先滚动盒(tidy-display 官方选择子);缺席时退化窗口滚动。 */
115
- function scrollerOf(el: Element): HTMLElement | null {
116
- return el.closest('[data-conversation-scroll]')
117
- }
118
-
119
- /** 在滚动窗口坐标系里把行着陆到顶部偏读线位——**绝不 scrollIntoView**
120
- * (dsh-tidy-display 实锤:它会爬祖先 scroller 把 sticky composer 抬离底部,
121
- * 随后宿主回贴 = 「还停在底部」);与阅读模式 landTurn 同款 scrollTop 数学。 */
122
- function landRow(el: Element, offsetPx = READING_LINE_OFFSET_PX): void {
123
- const html = el as HTMLElement
124
- const port = scrollerOf(html) ?? (el.ownerDocument.scrollingElement as HTMLElement | null) ?? html
125
- if (port === html) {
126
- html.scrollIntoView({ block: 'start', behavior: 'auto' })
127
- return
128
- }
129
- port.scrollTop += html.getBoundingClientRect().top - port.getBoundingClientRect().top - offsetPx
130
- }
131
-
132
- /** 行在**其滚动窗口**内是否已就位(驻留判定也用窗口系,不用视口系)。 */
133
- function landedInPort(el: Element): boolean {
134
- const html = el as HTMLElement
135
- if (!hasLayout(html)) return false
136
- const port = scrollerOf(html)
137
- if (port === null) {
138
- const rect = html.getBoundingClientRect()
139
- const vh = html.ownerDocument.defaultView?.innerHeight ?? 0
140
- return rect.top >= -VISIBILITY_SLACK && rect.bottom <= vh + VISIBILITY_SLACK
141
- }
142
- const flow = html.getBoundingClientRect().top - port.getBoundingClientRect().top
143
- return flow >= -VISIBILITY_SLACK && flow <= port.clientHeight + VISIBILITY_SLACK
144
- }
145
-
146
- /** 阅读模式优先:命中行若落在 [data-reader-turn] 段内,直接返该段(turn 级
147
- * 锚点由插件自标,比文本匹配稳);env.turn 给定时还能直接按 turn 号选段。 */
148
- function findReaderRow(root: ParentNode, needle: string, turn: number | undefined): Element | null {
149
- if (turn !== undefined && turn > 0) {
150
- const direct = root.querySelector(`[data-reader-turn="${String(turn)}"]`)
151
- if (direct !== null && hasLayout(direct)) return direct
152
- }
153
- for (const el of Array.from(root.querySelectorAll(MESSAGE_TAGS))) {
154
- if (!hasLayout(el)) continue
155
- if (el.closest('[class*="dsws_"], [role="dialog"]') !== null) continue
156
- const text = el.textContent
157
- if (text === null || !normalizeForMatch(text).includes(needle)) continue
158
- const section = el.closest('[data-reader-turn]')
159
- if (section !== null && hasLayout(section)) return section
160
- return el
161
- }
162
- return null
163
- }
164
-
165
- /**
166
- * 找正文行:多义时取 DOM 最深者(段落本体而非包裹层);**排除插件自家
167
- * 子树**(dsws_ 前缀类与任何 role=dialog 弹层)——面板行/管理台行都是
168
- * 目标文本的镜像,绝不允许当作跳转目的地。
169
- */
170
- function findMessageElement(root: ParentNode, needle: string, turn?: number): Element | null {
171
- // 阅读模式(dsh-tidy-display)在场时优先进其 turn 锚点体系;缺席自动走官方会话视图。
172
- const reader = findReaderRow(root, needle, turn)
173
- if (reader !== null) return reader
174
- let best: Element | null = null
175
- let bestDepth = -1
176
- for (const el of Array.from(root.querySelectorAll(MESSAGE_TAGS))) {
177
- if (!hasLayout(el)) continue // 未激活页签里的隐藏行不参与匹配(防驻留误判/跳错页签)
178
- const text = el.textContent
179
- if (text === null) continue
180
- if (el.closest('[class*="dsws_"], [role="dialog"]') !== null) continue
181
- if (!normalizeForMatch(text).includes(needle)) continue
182
- const section = el.closest('[data-reader-turn]')
183
- if (section !== null) continue // 段级已由 findReaderRow 处理,这里只收散行
184
- let depth = 0
185
- for (let n: Node | null = el.parentElement; n !== null; n = n.parentElement) depth += 1
186
- if (depth > bestDepth) {
187
- best = el
188
- bestDepth = depth
189
- }
190
- }
191
- return best
192
- }
193
-
194
- /** 瞬时高亮:类切换(不写 inline 样式——真机教训:prepend 历史后 React 复用
195
- * 节点,定时器迟到把旧样式写在不相干元素上)。首次使用时惰性注入样式表。 */
196
- const FLASH_CLASS = 'dss-located-flash'
197
- let flashCssReady = false
198
-
199
- function ensureFlashCss(doc: Document): void {
200
- if (flashCssReady) return
201
- flashCssReady = true
202
- const style = doc.createElement('style')
203
- style.textContent = `.${FLASH_CLASS}{outline:2px solid var(--dsw-alias-accent,#4a9eff);outline-offset:2px;transition:outline 120ms ease}`
204
- doc.head.appendChild(style)
205
- }
206
-
207
- function flashElement(el: Element): void {
208
- if (el.ownerDocument !== null) ensureFlashCss(el.ownerDocument)
209
- el.setAttribute('data-dss-located', '1')
210
- el.classList.add(FLASH_CLASS)
211
- setTimeout(() => {
212
- el.classList.remove(FLASH_CLASS)
213
- el.removeAttribute('data-dss-located')
214
- }, FLASH_MS)
215
- }
216
-
217
- /** 定位执行面(测试可注入假环境;缺省走真浏览器)。 */
218
- export interface LocateEnv {
219
- fetchImpl?: typeof fetch
220
- doc?: Document
221
- win?: Window
222
- timeoutMs?: number
223
- log?: (message: string) => void
224
- /** 覆盖注入的翻页供给(测试用);缺省走 configureLocator 的全局注入。 */
225
- loadOlder?: () => Promise<boolean>
226
- }
227
-
228
- /** 外部能力注入面:client apply 期接线宿主会话服务,模块自身零 ctx 依赖。 */
229
- export interface LocatorDeps {
230
- /** 拉一页更早历史;true=已发起(行数可能增),false=服务缺席/报错。 */
231
- loadOlder?: () => Promise<boolean>
232
- }
233
-
234
- let injectedDeps: LocatorDeps = {}
235
-
236
- /** 接线点:client apply 调一次;未接线时定位退化为 v3 纯 DOM 巡逻。 */
237
- export function configureLocator(deps: LocatorDeps): void {
238
- injectedDeps = deps
239
- }
240
-
241
- /** 目标行是否基本完整落在**其滚动窗口**内由 landedInPort 判定;旧视口函数已废弃。 */
242
-
243
- /**
244
- * 主管线:拉 DRS 段行 → 选 needle → 巡逻宿主 DOM 补滚 → 驻留后高亮。
245
- * fire-and-forget 语义:任何一步不满足都 resolve(false),从不抛。
246
- */
247
- export async function locateSearchHit(hit: HostContentHit, env: LocateEnv = {}): Promise<boolean> {
248
- const fetchImpl = env.fetchImpl ?? globalThis.fetch
249
- const doc = env.doc ?? (typeof document === 'undefined' ? undefined : document)
250
- if (doc === undefined) return false
251
- const win = env.win ?? doc.defaultView ?? (typeof window === 'undefined' ? undefined : window)
252
- if (win === undefined) return false
253
- const log = env.log ?? ((message: string): void => { console.debug(`[dsh-manage-session] ${message}`) })
254
- try {
255
- const response = await fetchImpl(`${DRS_ROUTE}?op=session&session=${encodeURIComponent(hit.sessionId)}`)
256
- if (!response.ok) { log(`locate: drs http ${String(response.status)}`); return false }
257
- const body = await response.json() as { ok?: boolean; rows?: LocateRowFace[] }
258
- if (body.ok !== true || !Array.isArray(body.rows)) { log('locate: drs envelope shape'); return false }
259
- const row = body.rows.find(r => Number(r.seq) === Number(hit.seq))
260
- const needle = resolveNeedle(row)
261
- if (needle === null) { log(`locate: skip row seq=${String(hit.seq)} (shadowed/短文本/无行)`); return false }
262
- const targetTurn = typeof row?.turn === 'number' ? row.turn : undefined
263
-
264
- const startedAt = Date.now()
265
- let deadline = startedAt + (env.timeoutMs ?? LOCATE_TIMEOUT_MS)
266
- const hardDeadline = startedAt + HARD_DEADLINE_MS
267
- let lastScrollAt = 0
268
- let userTookOver = false
269
- // 翻页供给:client apply 注入(IConversation.loadOlder 包装),env 可覆盖供测试。
270
- const pullOlder = env.loadOlder ?? injectedDeps.loadOlder
271
- let pagesPulled = 0
272
- let pullInFlight = false
273
- let lastPullAt = 0
274
- // pointerdown 必收:真机教训——巡逻窗口内用户点「加载更早记录」等页内按钮,
275
- // 不收手则我们的补滚与宿主 prepend 后的滚动锚定对打,画面错乱。
276
- const USER_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown'] as const
277
- const onUser = (): void => { userTookOver = true }
278
-
279
- return await new Promise<boolean>((resolve) => {
280
- const finish = (ok: boolean, why: string): void => {
281
- clearInterval(ticker)
282
- for (const ev of USER_EVENTS) win.removeEventListener(ev, onUser)
283
- log(`${why}: seq=${String(hit.seq)} ${hit.sessionId.slice(0, 12)} +${String(Date.now() - startedAt)}ms`)
284
- resolve(ok)
285
- }
286
- for (const ev of USER_EVENTS) win.addEventListener(ev, onUser, { passive: true, once: true })
287
- const ticker = setInterval(() => {
288
- if (userTookOver) { finish(false, 'yield user'); return }
289
- if (Date.now() >= deadline) { finish(false, `locate timeout (pages=${String(pagesPulled)})`); return }
290
- const el = findMessageElement(doc.body ?? doc, needle, targetTurn)
291
- if (el === null) {
292
- // 目标行不在 DOM:历史分页面(IConversation.loadOlder)——没找到就
293
- // 往回拉页,每拉一页给 2s 渲染窗口;拉满/拉尽则维持原超时。
294
- if (pullOlder !== undefined && !pullInFlight && pagesPulled < MAX_OLDER_PAGES
295
- && deadline < hardDeadline && Date.now() - lastPullAt >= TICK_MS * 2) {
296
- pullInFlight = true
297
- lastPullAt = Date.now()
298
- void Promise.resolve().then(() => pullOlder()).then((pulled) => {
299
- if (pulled) {
300
- pagesPulled += 1
301
- deadline = Math.min(hardDeadline, Date.now() + 2_000 + (env.timeoutMs ?? LOCATE_TIMEOUT_MS) / 2)
302
- } else {
303
- deadline = Math.min(deadline, Date.now()) // 服务缺席:不再等待
304
- }
305
- }).catch(() => {}).finally(() => { pullInFlight = false })
306
- }
307
- return // DOM 未就绪/会话还在切换:下一轮再找
308
- }
309
- if (landedInPort(el)) {
310
- // 从未滚过且本来就可见(同会话当前视口)=直达;否则须驻留过 STICK 窗。
311
- if (lastScrollAt === 0 || Date.now() - lastScrollAt >= STICK_MS) {
312
- flashElement(el)
313
- finish(true, 'located')
314
- }
315
- return
316
- }
317
- // 目标不在滚动窗口内:若距上次补滚超一个 tick,重新着陆(landRow 只写
318
- // scroller.scrollTop,绝不 scrollIntoView——后者会抬走 sticky composer
319
- // 并触发宿主回贴底部,tidy-display 实锤)。会话重渲染换节点也走这条。
320
- if (Date.now() - lastScrollAt >= TICK_MS) {
321
- landRow(el)
322
- lastScrollAt = Date.now()
323
- }
324
- }, TICK_MS)
325
- })
326
- } catch (err) {
327
- log(`locate error(silent): ${String(err instanceof Error ? err.message : err)}`)
328
- return false
329
- }
330
- }