dsh-manage-session 0.5.4 → 0.5.7
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.
- package/CHANGELOG.ja.md +22 -0
- package/CHANGELOG.ko.md +22 -0
- package/CHANGELOG.md +22 -0
- package/README.en.md +13 -11
- package/README.md +11 -10
- package/dsh.plugin.json +2 -2
- package/lib/client.js +900 -386
- package/lib/index.d.ts +241 -16
- package/lib/index.js +526 -71
- package/package.json +15 -9
- package/src/client/card.tsx +14 -10
- package/src/client/index.ts +53 -15
- package/src/client/locales.ts +36 -0
- package/src/client/panel.tsx +26 -36
- package/src/client/search/host-api.ts +5 -0
- package/src/client/search/locales.ts +81 -0
- package/src/client/search/locate.ts +330 -0
- package/src/client/search/manage.tsx +6 -2
- package/src/client/search/panel.tsx +154 -29
- package/src/client/session-menu-legacy.ts +249 -0
- package/src/client/session-menu.tsx +96 -0
- package/src/config.ts +17 -0
- package/src/host/history/tombstones.ts +100 -0
- package/src/host/index/drs-source.ts +211 -0
- package/src/host/index/engine.ts +699 -571
- package/src/host/index/rebuild.ts +432 -368
- package/src/host/index/sync.ts +413 -286
- package/src/host/settings-bridge.ts +27 -0
- package/src/index.ts +1499 -1421
- package/src/client/history-panel.tsx +0 -319
|
@@ -64,6 +64,11 @@ export const zh = {
|
|
|
64
64
|
'panel.noContent': '没有匹配的内容。',
|
|
65
65
|
'panel.contentHint': '输入内容关键词开始搜索。',
|
|
66
66
|
'panel.noText': '(无文本)',
|
|
67
|
+
'panel.delete': '删除',
|
|
68
|
+
'panel.delete.confirm': '确认删除',
|
|
69
|
+
'panel.delete.hint': '删除该会话(不可逆,与管理端同款链路)',
|
|
70
|
+
'panel.delete.done': '已删除会话 {n} 个',
|
|
71
|
+
'panel.delete.failed': '删除失败:{error}',
|
|
67
72
|
'panel.untitled': '(未命名)',
|
|
68
73
|
'panel.openSession': '打开会话',
|
|
69
74
|
'panel.footer.invoke': '唤出',
|
|
@@ -77,6 +82,10 @@ export const zh = {
|
|
|
77
82
|
'sort.time': '时间',
|
|
78
83
|
'sort.relevance.hint': '按匹配强度排序(默认)。',
|
|
79
84
|
'sort.time.hint': '按会话最后活动时间倒序,更新的排前面;同一时间再按匹配强度。',
|
|
85
|
+
'group.merge': '合并',
|
|
86
|
+
'group.expand': '分开',
|
|
87
|
+
'group.merge.hint': '每个会话只显示最强的一条命中(默认)。',
|
|
88
|
+
'group.expand.hint': '同一会话的多条命中分行显示(每行仍是可跳转的独立命中)。',
|
|
80
89
|
'type.user/message': '用户',
|
|
81
90
|
'type.assistant/message': '回复',
|
|
82
91
|
'type.tool/call': '工具调用',
|
|
@@ -180,6 +189,11 @@ export const en: Dict = {
|
|
|
180
189
|
'panel.noContent': 'No matching content.',
|
|
181
190
|
'panel.contentHint': 'Type keywords to search content.',
|
|
182
191
|
'panel.noText': '(no text)',
|
|
192
|
+
'panel.delete': 'Delete',
|
|
193
|
+
'panel.delete.confirm': 'Confirm delete',
|
|
194
|
+
'panel.delete.hint': 'Delete this session (irreversible, same pipeline as the manage view)',
|
|
195
|
+
'panel.delete.done': 'Deleted {n} session(s)',
|
|
196
|
+
'panel.delete.failed': 'Delete failed: {error}',
|
|
183
197
|
'panel.untitled': '(untitled)',
|
|
184
198
|
'panel.openSession': 'Open session',
|
|
185
199
|
'panel.footer.invoke': 'Open',
|
|
@@ -193,6 +207,10 @@ export const en: Dict = {
|
|
|
193
207
|
'sort.time': 'Time',
|
|
194
208
|
'sort.relevance.hint': 'Order by match strength (default).',
|
|
195
209
|
'sort.time.hint': 'Order by session last-activity, newest first; ties break on match strength.',
|
|
210
|
+
'group.merge': 'Merge',
|
|
211
|
+
'group.expand': 'Expand',
|
|
212
|
+
'group.merge.hint': 'Show only the strongest hit per session (default).',
|
|
213
|
+
'group.expand.hint': 'List every hit of a session as its own row (each row is a jump target).',
|
|
196
214
|
'type.user/message': 'User',
|
|
197
215
|
'type.assistant/message': 'Reply',
|
|
198
216
|
'type.tool/call': 'Tool call',
|
|
@@ -292,6 +310,11 @@ export const ja: Partial<Dict> = {
|
|
|
292
310
|
'panel.noContent': '一致するコンテンツがありません。',
|
|
293
311
|
'panel.contentHint': 'キーワードを入力してコンテンツを検索。',
|
|
294
312
|
'panel.noText': '(テキストなし)',
|
|
313
|
+
'panel.delete': '削除',
|
|
314
|
+
'panel.delete.confirm': '削除を確認',
|
|
315
|
+
'panel.delete.hint': 'このセッションを削除(不可逆、管理画面と同じ経路)',
|
|
316
|
+
'panel.delete.done': 'セッション {n} 件を削除しました',
|
|
317
|
+
'panel.delete.failed': '削除に失敗:{error}',
|
|
295
318
|
'panel.untitled': '(無題)',
|
|
296
319
|
'panel.openSession': 'セッションを開く',
|
|
297
320
|
'panel.footer.invoke': '開く',
|
|
@@ -305,6 +328,10 @@ export const ja: Partial<Dict> = {
|
|
|
305
328
|
'sort.time': '時間',
|
|
306
329
|
'sort.relevance.hint': '一致強度順に並べ替え(デフォルト)。',
|
|
307
330
|
'sort.time.hint': 'セッション最終活動日時の降順、同着時は一致強度順。',
|
|
331
|
+
'group.merge': 'まとめる',
|
|
332
|
+
'group.expand': '展開',
|
|
333
|
+
'group.merge.hint': 'セッションごとに最強の一致1件のみ表示(デフォルト)。',
|
|
334
|
+
'group.expand.hint': '同一セッションの複数一致を行ごとに展開(各行が個別のジャンプ先)。',
|
|
308
335
|
'type.user/message': 'ユーザー',
|
|
309
336
|
'type.assistant/message': '返信',
|
|
310
337
|
'type.tool/call': 'ツール呼び出し',
|
|
@@ -368,6 +395,11 @@ export const ko: Partial<Dict> = {
|
|
|
368
395
|
'panel.noContent': '일치하는 콘텐츠가 없습니다.',
|
|
369
396
|
'panel.contentHint': '키워드를 입력하여 콘텐츠를 검색하세요.',
|
|
370
397
|
'panel.noText': '(텍스트 없음)',
|
|
398
|
+
'panel.delete': '삭제',
|
|
399
|
+
'panel.delete.confirm': '삭제 확인',
|
|
400
|
+
'panel.delete.hint': '이 세션 삭제(되돌릴 수 없음, 관리 화면과 동일한 경로)',
|
|
401
|
+
'panel.delete.done': '세션 {n}개 삭제됨',
|
|
402
|
+
'panel.delete.failed': '삭제 실패: {error}',
|
|
371
403
|
'panel.untitled': '(제목 없음)',
|
|
372
404
|
'panel.openSession': '세션 열기',
|
|
373
405
|
'panel.footer.invoke': '열기',
|
|
@@ -381,6 +413,10 @@ export const ko: Partial<Dict> = {
|
|
|
381
413
|
'sort.time': '시간',
|
|
382
414
|
'sort.relevance.hint': '일치 강도순 정렬(기본값).',
|
|
383
415
|
'sort.time.hint': '세션 마지막 활동 시간 내림차순, 동률 시 일치 강도순.',
|
|
416
|
+
'group.merge': '병합',
|
|
417
|
+
'group.expand': '분할',
|
|
418
|
+
'group.merge.hint': '세션별 최강 일치 1건만 표시(기본값).',
|
|
419
|
+
'group.expand.hint': '같은 세션의 여러 일치를 각 행으로 표시(각 행이 개별 이동 대상).',
|
|
384
420
|
'type.user/message': '사용자',
|
|
385
421
|
'type.assistant/message': '답변',
|
|
386
422
|
'type.tool/call': '도구 호출',
|
|
@@ -444,6 +480,11 @@ export const fr: Partial<Dict> = {
|
|
|
444
480
|
'panel.noContent': 'Aucun contenu correspondant.',
|
|
445
481
|
'panel.contentHint': 'Saisissez des mots-clés pour rechercher dans le contenu.',
|
|
446
482
|
'panel.noText': '(pas de texte)',
|
|
483
|
+
'panel.delete': 'Supprimer',
|
|
484
|
+
'panel.delete.confirm': 'Confirmer la suppression',
|
|
485
|
+
'panel.delete.hint': 'Supprimer cette session (irréversible, même pipeline que la gestion)',
|
|
486
|
+
'panel.delete.done': '{n} session(s) supprimée(s)',
|
|
487
|
+
'panel.delete.failed': 'Échec de suppression : {error}',
|
|
447
488
|
'panel.untitled': '(sans titre)',
|
|
448
489
|
'panel.openSession': 'Ouvrir la session',
|
|
449
490
|
'panel.footer.invoke': 'Ouvrir',
|
|
@@ -457,6 +498,10 @@ export const fr: Partial<Dict> = {
|
|
|
457
498
|
'sort.time': 'Temps',
|
|
458
499
|
'sort.relevance.hint': 'Trier par force de correspondance (par défaut).',
|
|
459
500
|
'sort.time.hint': 'Trier par dernière activité, plus récent d\'abord ; à égalité, par force de correspondance.',
|
|
501
|
+
'group.merge': 'Fusionner',
|
|
502
|
+
'group.expand': 'Détailler',
|
|
503
|
+
'group.merge.hint': 'Une seule correspondance la plus forte par session (par défaut).',
|
|
504
|
+
'group.expand.hint': 'Afficher chaque correspondance de la session sur sa propre ligne (chacune est une cible de saut).',
|
|
460
505
|
'type.user/message': 'Utilisateur',
|
|
461
506
|
'type.assistant/message': 'Réponse',
|
|
462
507
|
'type.tool/call': 'Appel d\'outil',
|
|
@@ -520,6 +565,11 @@ export const de: Partial<Dict> = {
|
|
|
520
565
|
'panel.noContent': 'Kein passender Inhalt.',
|
|
521
566
|
'panel.contentHint': 'Geben Sie Stichwörter ein, um Inhalte zu durchsuchen.',
|
|
522
567
|
'panel.noText': '(kein Text)',
|
|
568
|
+
'panel.delete': 'Löschen',
|
|
569
|
+
'panel.delete.confirm': 'Löschen bestätigen',
|
|
570
|
+
'panel.delete.hint': 'Diese Sitzung löschen (unwiderruflich, gleiche Pipeline wie die Verwaltung)',
|
|
571
|
+
'panel.delete.done': '{n} Sitzung(en) gelöscht',
|
|
572
|
+
'panel.delete.failed': 'Löschen fehlgeschlagen: {error}',
|
|
523
573
|
'panel.untitled': '(unbenannt)',
|
|
524
574
|
'panel.openSession': 'Sitzung öffnen',
|
|
525
575
|
'panel.footer.invoke': 'Öffnen',
|
|
@@ -533,6 +583,10 @@ export const de: Partial<Dict> = {
|
|
|
533
583
|
'sort.time': 'Zeit',
|
|
534
584
|
'sort.relevance.hint': 'Nach Übereinstimmungsstärke sortieren (Standard).',
|
|
535
585
|
'sort.time.hint': 'Nach letzter Sitzungsaktivität, neueste zuerst; bei Gleichstand nach Übereinstimmungsstärke.',
|
|
586
|
+
'group.merge': 'Zusammen',
|
|
587
|
+
'group.expand': 'Aufklappen',
|
|
588
|
+
'group.merge.hint': 'Nur der stärkste Treffer je Sitzung (Standard).',
|
|
589
|
+
'group.expand.hint': 'Jeden Treffer einer Sitzung als eigene Zeile anzeigen (jede Zeile ist ein Sprungziel).',
|
|
536
590
|
'type.user/message': 'Benutzer',
|
|
537
591
|
'type.assistant/message': 'Antwort',
|
|
538
592
|
'type.tool/call': 'Werkzeugaufruf',
|
|
@@ -596,6 +650,11 @@ export const it: Partial<Dict> = {
|
|
|
596
650
|
'panel.noContent': 'Nessun contenuto corrispondente.',
|
|
597
651
|
'panel.contentHint': 'Inserisci parole chiave per cercare nel contenuto.',
|
|
598
652
|
'panel.noText': '(nessun testo)',
|
|
653
|
+
'panel.delete': 'Elimina',
|
|
654
|
+
'panel.delete.confirm': 'Conferma eliminazione',
|
|
655
|
+
'panel.delete.hint': 'Elimina questa sessione (irreversibile, stessa pipeline della gestione)',
|
|
656
|
+
'panel.delete.done': 'Eliminate {n} sessione/i',
|
|
657
|
+
'panel.delete.failed': 'Eliminazione non riuscita: {error}',
|
|
599
658
|
'panel.untitled': '(senza titolo)',
|
|
600
659
|
'panel.openSession': 'Apri sessione',
|
|
601
660
|
'panel.footer.invoke': 'Apri',
|
|
@@ -609,6 +668,10 @@ export const it: Partial<Dict> = {
|
|
|
609
668
|
'sort.time': 'Tempo',
|
|
610
669
|
'sort.relevance.hint': 'Ordina per forza di corrispondenza (predefinito).',
|
|
611
670
|
'sort.time.hint': 'Ordina per ultima attività, più recente prima; a parità, per forza di corrispondenza.',
|
|
671
|
+
'group.merge': 'Unisci',
|
|
672
|
+
'group.expand': 'Espandi',
|
|
673
|
+
'group.merge.hint': 'Mostra solo il risultato più forte per sessione (predefinito).',
|
|
674
|
+
'group.expand.hint': 'Mostra ogni corrispondenza della sessione su riga propria (ogni riga è una destinazione).',
|
|
612
675
|
'type.user/message': 'Utente',
|
|
613
676
|
'type.assistant/message': 'Risposta',
|
|
614
677
|
'type.tool/call': 'Chiamata strumento',
|
|
@@ -672,6 +735,11 @@ export const ru: Partial<Dict> = {
|
|
|
672
735
|
'panel.noContent': 'Нет подходящего содержимого.',
|
|
673
736
|
'panel.contentHint': 'Введите ключевые слова для поиска по содержимому.',
|
|
674
737
|
'panel.noText': '(без текста)',
|
|
738
|
+
'panel.delete': 'Удалить',
|
|
739
|
+
'panel.delete.confirm': 'Подтвердить удаление',
|
|
740
|
+
'panel.delete.hint': 'Удалить эту сессию (необратимо, тот же конвейер, что и в администраторе)',
|
|
741
|
+
'panel.delete.done': 'Удалено сессий: {n}',
|
|
742
|
+
'panel.delete.failed': 'Ошибка удаления: {error}',
|
|
675
743
|
'panel.untitled': '(без названия)',
|
|
676
744
|
'panel.openSession': 'Открыть сессию',
|
|
677
745
|
'panel.footer.invoke': 'Открыть',
|
|
@@ -685,6 +753,10 @@ export const ru: Partial<Dict> = {
|
|
|
685
753
|
'sort.time': 'Время',
|
|
686
754
|
'sort.relevance.hint': 'Сортировать по силе совпадения (по умолчанию).',
|
|
687
755
|
'sort.time.hint': 'Сортировать по последней активности, сначала новые; при равенстве — по силе совпадения.',
|
|
756
|
+
'group.merge': 'Объединить',
|
|
757
|
+
'group.expand': 'Развернуть',
|
|
758
|
+
'group.merge.hint': 'По одному сильнейшему совпадению на сессию (по умолчанию).',
|
|
759
|
+
'group.expand.hint': 'Каждое совпадение сессии — отдельной строкой (каждая строка — цель перехода).',
|
|
688
760
|
'type.user/message': 'Пользователь',
|
|
689
761
|
'type.assistant/message': 'Ответ',
|
|
690
762
|
'type.tool/call': 'Вызов инструмента',
|
|
@@ -748,6 +820,11 @@ export const es: Partial<Dict> = {
|
|
|
748
820
|
'panel.noContent': 'Sin contenido coincidente.',
|
|
749
821
|
'panel.contentHint': 'Escriba palabras clave para buscar contenido.',
|
|
750
822
|
'panel.noText': '(sin texto)',
|
|
823
|
+
'panel.delete': 'Eliminar',
|
|
824
|
+
'panel.delete.confirm': 'Confirmar borrado',
|
|
825
|
+
'panel.delete.hint': 'Eliminar esta sesión (irreversible, misma ruta que la gestión)',
|
|
826
|
+
'panel.delete.done': '{n} sesión(es) eliminadas',
|
|
827
|
+
'panel.delete.failed': 'Error de borrado: {error}',
|
|
751
828
|
'panel.untitled': '(sin título)',
|
|
752
829
|
'panel.openSession': 'Abrir sesión',
|
|
753
830
|
'panel.footer.invoke': 'Abrir',
|
|
@@ -761,6 +838,10 @@ export const es: Partial<Dict> = {
|
|
|
761
838
|
'sort.time': 'Tiempo',
|
|
762
839
|
'sort.relevance.hint': 'Ordenar por fuerza de coincidencia (predeterminado).',
|
|
763
840
|
'sort.time.hint': 'Ordenar por última actividad, más reciente primero; en caso de empate, por fuerza de coincidencia.',
|
|
841
|
+
'group.merge': 'Unir',
|
|
842
|
+
'group.expand': 'Desplegar',
|
|
843
|
+
'group.merge.hint': 'Solo la coincidencia más fuerte por sesión (predeterminado).',
|
|
844
|
+
'group.expand.hint': 'Mostrar cada coincidencia de la sesión en su propia fila (cada fila es un destino).',
|
|
764
845
|
'type.user/message': 'Usuario',
|
|
765
846
|
'type.assistant/message': 'Respuesta',
|
|
766
847
|
'type.tool/call': 'Llamada de herramienta',
|
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
}
|
|
@@ -35,6 +35,8 @@ function injectManageStyles(): () => void {
|
|
|
35
35
|
.dsws_batchBar{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
36
36
|
.dsws_batchInfo{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
37
37
|
.dsws_btnDanger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
|
|
38
|
+
.dsws_btnDangerSolid{background:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);color:#fff;font-weight:600}
|
|
39
|
+
.dsws_btnDangerSolid:hover{background:var(--dsw-alias-state-error-primary);filter:brightness(1.08);color:#fff}
|
|
38
40
|
.dsws_manageHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:8px 10px 0}
|
|
39
41
|
.dsws_chips{display:flex;align-items:center;gap:6px;padding:8px 10px 0;flex:none;flex-wrap:wrap}
|
|
40
42
|
.dsws_chip{height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:999px;padding:0 10px;font-size:12px;font-weight:500;line-height:22px;white-space:nowrap}
|
|
@@ -370,8 +372,10 @@ export function ManageConsole({ t, open }: { t?: ManageTranslate; open: (session
|
|
|
370
372
|
onClick: () => { runBatch('unarchive') },
|
|
371
373
|
}, translate(t, 'manage.batch.unarchive')),
|
|
372
374
|
createElement('button', {
|
|
373
|
-
key: 'purge', type: 'button',
|
|
374
|
-
|
|
375
|
+
key: 'purge', type: 'button',
|
|
376
|
+
// 两步确认的第二步:红底白字实心(对齐宿主原生危险按钮,用户定标
|
|
377
|
+
// 2026-10-20)——白字不赌主题令牌(tip 色在亮色主题下对比度不足)。
|
|
378
|
+
className: `dsws_actBtn${confirmPurge ? ' dsws_btnDangerSolid' : ' dsws_btnDanger'}`, disabled: batch.busy,
|
|
375
379
|
onClick: () => { runBatch('purge') },
|
|
376
380
|
}, confirmPurge ? translate(t, 'manage.batch.confirm') : translate(t, 'manage.batch.delete')),
|
|
377
381
|
batch.busy && createElement('span', { key: 'busy', className: 'dsws_batchInfo' }, translate(t, 'manage.batch.working')),
|