dqy08 commited on
Commit
b925659
·
1 Parent(s): 4bd7747

DAG新增tooltip选项;新增step-down模式;释放semantic query功能;支持递归归因(recursive attribution);其它小改进

Browse files
Files changed (39) hide show
  1. backend/api/client_activity.py +5 -1
  2. backend/completion_generator.py +1 -1
  3. backend/visit_stats.py +28 -1
  4. client/src/analysis.html +16 -4
  5. client/src/attribution.html +4 -2
  6. client/src/chat.html +7 -5
  7. client/src/css/_buttons.scss +6 -2
  8. client/src/css/_demo-list.scss +1 -1
  9. client/src/css/_responsive.scss +5 -2
  10. client/src/css/_semantic-analysis.scss +30 -0
  11. client/src/css/_tooltip-vars.scss +15 -0
  12. client/src/css/compare.scss +6 -1
  13. client/src/css/gen_attribute.scss +88 -44
  14. client/src/css/start.scss +44 -23
  15. client/src/demos/gen_attribute/CN->EN翻译.json +1 -1
  16. client/src/demos/gen_attribute/CoT | 苏州所在省的省会.json +0 -0
  17. client/src/demos/gen_attribute/Write a sonnet about love.json +1 -1
  18. client/src/demos/gen_attribute/写一首绝句,主题是春天.json +1 -1
  19. client/src/demos/gen_attribute/过拟合|李白 将进酒.json +1 -1
  20. client/src/gen_attribute.html +56 -17
  21. client/src/partials/settings-menu-analysis.html +0 -6
  22. client/src/ts/api/GLTR_API.ts +3 -0
  23. client/src/ts/attribution/genAttributeDagEdgeDisplay.ts +22 -6
  24. client/src/ts/attribution/genAttributeDagPreprocess.ts +9 -18
  25. client/src/ts/attribution/genAttributeDagTopkToken.ts +37 -0
  26. client/src/ts/attribution/genAttributeDagView.ts +844 -244
  27. client/src/ts/attribution/genAttributeDagViewLinearArcMode.ts +59 -6
  28. client/src/ts/demos/genAttributeBundledDemoManifest.generated.ts +1 -1
  29. client/src/ts/gen_attribute.ts +325 -52
  30. client/src/ts/lang/translations.ts +15 -1
  31. client/src/ts/storage/genAttributeRunCache.ts +47 -3
  32. client/src/ts/utils/clientActivityPing.ts +10 -0
  33. client/src/ts/utils/surprisalMath.ts +50 -3
  34. client/src/ts/utils/tokenDisplayUtils.ts +3 -3
  35. client/src/ts/utils/topkChartUtils.ts +2 -2
  36. client/src/ts/utils/visitStatsDialog.ts +35 -2
  37. client/src/ts/utils/visualizationUpdater.ts +18 -29
  38. client/src/ts/vis/ToolTip.ts +137 -97
  39. client/src/ts/vis/constants.ts +1 -1
backend/api/client_activity.py CHANGED
@@ -1,7 +1,7 @@
1
  from urllib.parse import unquote
2
 
3
  from backend.access_log import _log_request
4
- from backend.visit_stats import record_activity_report
5
 
6
 
7
  def _sparse_page_activity_log_cum(cum: int) -> bool:
@@ -38,6 +38,10 @@ def client_activity_report(activity_body=None):
38
  client_os = str(raw_os).strip() if raw_os is not None else None
39
 
40
  record_activity_report(page_key, dlt, cum, client_os)
 
 
 
 
41
  if _sparse_page_activity_log_cum(cum):
42
  _log_request(
43
  "📄 页面活跃",
 
1
  from urllib.parse import unquote
2
 
3
  from backend.access_log import _log_request
4
+ from backend.visit_stats import record_activity_report, record_gen_attr_opt_sec
5
 
6
 
7
  def _sparse_page_activity_log_cum(cum: int) -> bool:
 
38
  client_os = str(raw_os).strip() if raw_os is not None else None
39
 
40
  record_activity_report(page_key, dlt, cum, client_os)
41
+ if page_key == "gen_attribute.html":
42
+ raw_opts = d.get("page_opts")
43
+ if isinstance(raw_opts, dict):
44
+ record_gen_attr_opt_sec(dlt, {k: bool(v) for k, v in raw_opts.items() if isinstance(k, str)})
45
  if _sparse_page_activity_log_cum(cum):
46
  _log_request(
47
  "📄 页面活跃",
backend/completion_generator.py CHANGED
@@ -24,7 +24,7 @@ from backend.pred_topk_format import pred_topk_pairs_from_probs_1d
24
  from backend.runtime_config import DEFAULT_TOPK
25
 
26
  # 续写路径:prompt + 续写合计不得超过该 token 数(与语义分析 runtime 无关)。
27
- completion_max_token_length = 1000
28
 
29
  # 特殊 token 亦视为分析/展示内容,故不跳过。
30
  _COMPLETION_DECODE_SKIP_SPECIAL = False
 
24
  from backend.runtime_config import DEFAULT_TOPK
25
 
26
  # 续写路径:prompt + 续写合计不得超过该 token 数(与语义分析 runtime 无关)。
27
+ completion_max_token_length = 500
28
 
29
  # 特殊 token 亦视为分析/展示内容,故不跳过。
30
  _COMPLETION_DECODE_SKIP_SPECIAL = False
backend/visit_stats.py CHANGED
@@ -14,6 +14,7 @@ _WIN = {"page_loads": 0, "active_visits": 0}
14
  _PAGE_SEC = defaultdict(int)
15
  _API = defaultdict(int)
16
  _OS_REPORTS = defaultdict(int) # 与同页「首轮心跳」(delta_active_sec == total_active_sec) 对齐,仅凭该包附带 client_os 计一次
 
17
  _VALID_CLIENT_OS = frozenset({"ios", "android", "windows", "macos", "linux", "unknown"})
18
 
19
  # client/src/ts/utils/settingsMenuManager.ts handleVisitStatsClick:PAGE_ORDER / API_ORDER / OS_ORDER
@@ -36,6 +37,10 @@ _STATS_API_ORDER = (
36
  "prediction_attribute__analysis.html",
37
  )
38
  _STATS_OS_ORDER = ("ios", "android", "windows", "macos", "linux", "unknown")
 
 
 
 
39
 
40
  # RLock:_persist_tick 在已持锁时调用 _sample_locked_counters,同线程需可重入。
41
  _LOCK = threading.RLock()
@@ -190,7 +195,7 @@ def _increment_nonempty(h: dict) -> bool:
190
  """是否有尚未写入远端的任意增量。"""
191
  if h.get("page_loads") or h.get("active_visits"):
192
  return True
193
- if h.get("page_sec") or h.get("api") or h.get("os"):
194
  return True
195
  return False
196
 
@@ -214,6 +219,7 @@ def _apply_persist_success(total_rec: dict, committed_sample: dict) -> None:
214
  _subtract_defaultdict_int(_PAGE_SEC, committed_sample["session_page_sec"])
215
  _subtract_defaultdict_int(_API, committed_sample["session_api"])
216
  _subtract_defaultdict_int(_OS_REPORTS, committed_sample["session_os_reports"])
 
217
 
218
 
219
  def _load_base():
@@ -355,21 +361,35 @@ def bump_api(kind: str):
355
  _API[kind] += 1
356
 
357
 
 
 
 
 
 
 
 
 
 
 
358
  def _sample_locked_counters() -> dict:
359
  with _LOCK:
360
  bo = _base.get("os")
361
  base_os = dict(bo) if isinstance(bo, dict) else {}
 
 
362
  return {
363
  "sw_pl": _WIN["page_loads"],
364
  "sw_av": _WIN["active_visits"],
365
  "session_page_sec": dict(_PAGE_SEC),
366
  "session_api": dict(_API),
367
  "session_os_reports": dict(_OS_REPORTS),
 
368
  "bp": int(_base_int(_base, "page_loads")),
369
  "bav": _base_int(_base, "active_visits"),
370
  "base_page_sec": dict(_base.get("page_sec") or {}),
371
  "base_api": dict(_base.get("api") or {}),
372
  "base_os": base_os,
 
373
  "saved_at": _base.get("saved_at"),
374
  }
375
 
@@ -378,6 +398,7 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
378
  """(管理员 API 快照, stats_total 的 body 不含 saved_at, stats_delta 的 body)。"""
379
  sp, sa, so = s["session_page_sec"], s["session_api"], s["session_os_reports"]
380
  bpp, bpa, bpo = s["base_page_sec"], s["base_api"], s["base_os"]
 
381
 
382
  total_page_sec = {k: bpp.get(k, 0) + sp.get(k, 0) for k in set(bpp) | set(sp)}
383
  total_api = {k: bpa.get(k, 0) + sa.get(k, 0) for k in set(bpa) | set(sa)}
@@ -385,13 +406,16 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
385
  k: int(bpo.get(k, 0)) + int(so.get(k, 0))
386
  for k in set(bpo) | set(so)
387
  }
 
388
 
389
  total_page_sec = _ordered_str_int_map(_STATS_PAGE_ORDER, total_page_sec)
390
  total_api = _ordered_str_int_map(_STATS_API_ORDER, total_api)
391
  total_os = _ordered_str_int_map(_STATS_OS_ORDER, total_os)
 
392
  ord_pg = _ordered_str_int_map(_STATS_PAGE_ORDER, sp)
393
  ord_api = _ordered_str_int_map(_STATS_API_ORDER, sa)
394
  ord_os = _ordered_str_int_map(_STATS_OS_ORDER, so)
 
395
 
396
  tpl, tav = s["bp"] + s["sw_pl"], s["bav"] + s["sw_av"]
397
 
@@ -401,6 +425,7 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
401
  "os": total_os,
402
  "page_sec": total_page_sec,
403
  "api": total_api,
 
404
  "saved_at": s["saved_at"],
405
  }
406
  stats_body = {
@@ -409,6 +434,7 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
409
  "os": total_os,
410
  "page_sec": total_page_sec,
411
  "api": total_api,
 
412
  }
413
  delta_body = {
414
  "page_loads": s["sw_pl"],
@@ -416,6 +442,7 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
416
  "os": ord_os,
417
  "page_sec": ord_pg,
418
  "api": ord_api,
 
419
  }
420
  return public, stats_body, delta_body
421
 
 
14
  _PAGE_SEC = defaultdict(int)
15
  _API = defaultdict(int)
16
  _OS_REPORTS = defaultdict(int) # 与同页「首轮心跳」(delta_active_sec == total_active_sec) 对齐,仅凭该包附带 client_os 计一次
17
+ _GEN_ATTR_OPT_SEC = defaultdict(int) # gen_attribute.html 各非默认选项处于激活状态的活跃秒
18
  _VALID_CLIENT_OS = frozenset({"ios", "android", "windows", "macos", "linux", "unknown"})
19
 
20
  # client/src/ts/utils/settingsMenuManager.ts handleVisitStatsClick:PAGE_ORDER / API_ORDER / OS_ORDER
 
37
  "prediction_attribute__analysis.html",
38
  )
39
  _STATS_OS_ORDER = ("ios", "android", "windows", "macos", "linux", "unknown")
40
+ _STATS_GEN_ATTR_OPT_ORDER = (
41
+ "layout_linear_arc", "layout_step_down", "layout_spiral",
42
+ "propagated", "downstream", "token_tooltip",
43
+ )
44
 
45
  # RLock:_persist_tick 在已持锁时调用 _sample_locked_counters,同线程需可重入。
46
  _LOCK = threading.RLock()
 
195
  """是否有尚未写入远端的任意增量。"""
196
  if h.get("page_loads") or h.get("active_visits"):
197
  return True
198
+ if h.get("page_sec") or h.get("api") or h.get("os") or h.get("gen_attr_opt_sec"):
199
  return True
200
  return False
201
 
 
219
  _subtract_defaultdict_int(_PAGE_SEC, committed_sample["session_page_sec"])
220
  _subtract_defaultdict_int(_API, committed_sample["session_api"])
221
  _subtract_defaultdict_int(_OS_REPORTS, committed_sample["session_os_reports"])
222
+ _subtract_defaultdict_int(_GEN_ATTR_OPT_SEC, committed_sample["session_gen_attr_opt_sec"])
223
 
224
 
225
  def _load_base():
 
361
  _API[kind] += 1
362
 
363
 
364
+ def record_gen_attr_opt_sec(delta_sec: int, opts: dict[str, bool]) -> None:
365
+ """累计 gen_attribute.html 各非默认选项处于激活状态的活跃秒。"""
366
+ if delta_sec <= 0:
367
+ return
368
+ with _LOCK:
369
+ for k, v in opts.items():
370
+ if v:
371
+ _GEN_ATTR_OPT_SEC[k] += delta_sec
372
+
373
+
374
  def _sample_locked_counters() -> dict:
375
  with _LOCK:
376
  bo = _base.get("os")
377
  base_os = dict(bo) if isinstance(bo, dict) else {}
378
+ bgo = _base.get("gen_attr_opt_sec")
379
+ base_gen_attr_opt_sec = dict(bgo) if isinstance(bgo, dict) else {}
380
  return {
381
  "sw_pl": _WIN["page_loads"],
382
  "sw_av": _WIN["active_visits"],
383
  "session_page_sec": dict(_PAGE_SEC),
384
  "session_api": dict(_API),
385
  "session_os_reports": dict(_OS_REPORTS),
386
+ "session_gen_attr_opt_sec": dict(_GEN_ATTR_OPT_SEC),
387
  "bp": int(_base_int(_base, "page_loads")),
388
  "bav": _base_int(_base, "active_visits"),
389
  "base_page_sec": dict(_base.get("page_sec") or {}),
390
  "base_api": dict(_base.get("api") or {}),
391
  "base_os": base_os,
392
+ "base_gen_attr_opt_sec": base_gen_attr_opt_sec,
393
  "saved_at": _base.get("saved_at"),
394
  }
395
 
 
398
  """(管理员 API 快照, stats_total 的 body 不含 saved_at, stats_delta 的 body)。"""
399
  sp, sa, so = s["session_page_sec"], s["session_api"], s["session_os_reports"]
400
  bpp, bpa, bpo = s["base_page_sec"], s["base_api"], s["base_os"]
401
+ sg, bgo = s["session_gen_attr_opt_sec"], s["base_gen_attr_opt_sec"]
402
 
403
  total_page_sec = {k: bpp.get(k, 0) + sp.get(k, 0) for k in set(bpp) | set(sp)}
404
  total_api = {k: bpa.get(k, 0) + sa.get(k, 0) for k in set(bpa) | set(sa)}
 
406
  k: int(bpo.get(k, 0)) + int(so.get(k, 0))
407
  for k in set(bpo) | set(so)
408
  }
409
+ total_gen_attr_opt_sec = {k: bgo.get(k, 0) + sg.get(k, 0) for k in set(bgo) | set(sg)}
410
 
411
  total_page_sec = _ordered_str_int_map(_STATS_PAGE_ORDER, total_page_sec)
412
  total_api = _ordered_str_int_map(_STATS_API_ORDER, total_api)
413
  total_os = _ordered_str_int_map(_STATS_OS_ORDER, total_os)
414
+ total_gen_attr_opt_sec = _ordered_str_int_map(_STATS_GEN_ATTR_OPT_ORDER, total_gen_attr_opt_sec)
415
  ord_pg = _ordered_str_int_map(_STATS_PAGE_ORDER, sp)
416
  ord_api = _ordered_str_int_map(_STATS_API_ORDER, sa)
417
  ord_os = _ordered_str_int_map(_STATS_OS_ORDER, so)
418
+ ord_gen_attr_opt_sec = _ordered_str_int_map(_STATS_GEN_ATTR_OPT_ORDER, sg)
419
 
420
  tpl, tav = s["bp"] + s["sw_pl"], s["bav"] + s["sw_av"]
421
 
 
425
  "os": total_os,
426
  "page_sec": total_page_sec,
427
  "api": total_api,
428
+ "gen_attr_opt_sec": total_gen_attr_opt_sec,
429
  "saved_at": s["saved_at"],
430
  }
431
  stats_body = {
 
434
  "os": total_os,
435
  "page_sec": total_page_sec,
436
  "api": total_api,
437
+ "gen_attr_opt_sec": total_gen_attr_opt_sec,
438
  }
439
  delta_body = {
440
  "page_loads": s["sw_pl"],
 
442
  "os": ord_os,
443
  "page_sec": ord_pg,
444
  "api": ord_api,
445
+ "gen_attr_opt_sec": ord_gen_attr_opt_sec,
446
  }
447
  return public, stats_body, delta_body
448
 
client/src/analysis.html CHANGED
@@ -72,9 +72,21 @@
72
  <textarea id="test_text"></textarea>
73
  <div class="button-group">
74
  <div class="button-left">
75
- <button id="submit_text_btn" class="primary-btn" data-i18n>Analyze</button>
76
- <div class="loadersmall loader-small-container"></div>
77
- <span id="analyze_progress" class="analyze-progress"></span>
 
 
 
 
 
 
 
 
 
 
 
 
78
  </div>
79
  <div id="text_metrics" class="text-metrics">
80
  <div class="text-metrics-primary">
@@ -101,7 +113,7 @@
101
  <div class="semantic-analysis-controls">
102
  <div class="semantic-search-row">
103
  <div class="semantic-search-input-wrapper">
104
- <input type="text" id="semantic_search_input" class="semantic-search-input" placeholder="Enter query for semantic analysis">
105
  <button type="button" id="semantic_search_clear" class="semantic-search-clear demo-delete-btn" title="Clear" aria-label="Clear" data-i18n="title,aria-label">×</button>
106
  <ul id="semantic_search_history_dropdown" class="semantic-search-history-dropdown"></ul>
107
  </div>
 
72
  <textarea id="test_text"></textarea>
73
  <div class="button-group">
74
  <div class="button-left">
75
+ <div class="button-left-stack">
76
+ <div class="button-left-primary">
77
+ <button id="submit_text_btn" class="primary-btn" data-i18n>Analyze</button>
78
+ <div class="loadersmall loader-small-container"></div>
79
+ <span id="analyze_progress" class="analyze-progress"></span>
80
+ </div>
81
+ <div class="semantic-submode-row semantic-analysis-enable-row">
82
+ <span class="semantic-submode-group">
83
+ <label class="semantic-submode-label" for="semantic_analysis_toggle">
84
+ <input type="checkbox" id="semantic_analysis_toggle" />
85
+ <span data-i18n>Semantic Query(Beta)</span>
86
+ </label>
87
+ </span>
88
+ </div>
89
+ </div>
90
  </div>
91
  <div id="text_metrics" class="text-metrics">
92
  <div class="text-metrics-primary">
 
113
  <div class="semantic-analysis-controls">
114
  <div class="semantic-search-row">
115
  <div class="semantic-search-input-wrapper">
116
+ <input type="text" id="semantic_search_input" class="semantic-search-input" placeholder="Enter query question or topic" data-i18n="placeholder">
117
  <button type="button" id="semantic_search_clear" class="semantic-search-clear demo-delete-btn" title="Clear" aria-label="Clear" data-i18n="title,aria-label">×</button>
118
  <ul id="semantic_search_history_dropdown" class="semantic-search-history-dropdown"></ul>
119
  </div>
client/src/attribution.html CHANGED
@@ -89,8 +89,10 @@
89
  </div>
90
  <div class="button-group">
91
  <div class="button-left">
92
- <button type="button" id="analyze_btn" class="primary-btn inactive" disabled data-i18n>Analyze attribution</button>
93
- <div class="loadersmall loader-small-container"></div>
 
 
94
  </div>
95
  <div id="attribution_result_info" class="text-metrics is-hidden"></div>
96
  <div class="button-right">
 
89
  </div>
90
  <div class="button-group">
91
  <div class="button-left">
92
+ <div class="button-left-primary">
93
+ <button type="button" id="analyze_btn" class="primary-btn inactive" disabled data-i18n>Analyze attribution</button>
94
+ <div class="loadersmall loader-small-container"></div>
95
+ </div>
96
  </div>
97
  <div id="attribution_result_info" class="text-metrics is-hidden"></div>
98
  <div class="button-right">
client/src/chat.html CHANGED
@@ -118,12 +118,14 @@
118
  </div>
119
  <div class="button-group">
120
  <div class="button-left">
121
- <button type="button" id="submit_text_btn" class="primary-btn inactive" disabled data-i18n>Ask</button>
122
- <div class="generation-status-slot loader-small-container">
123
- <div class="loadersmall"></div>
124
- <span id="chat_complete_reason" class="generation-end-reason"></span>
 
 
 
125
  </div>
126
- <span id="analyze_progress" class="analyze-progress"></span>
127
  </div>
128
  <div id="text_metrics" class="text-metrics text-metrics-chat">
129
  <div id="metric_usage" class="text-metrics-secondary"></div>
 
118
  </div>
119
  <div class="button-group">
120
  <div class="button-left">
121
+ <div class="button-left-primary">
122
+ <button type="button" id="submit_text_btn" class="primary-btn inactive" disabled data-i18n>Ask</button>
123
+ <div class="generation-status-slot loader-small-container">
124
+ <div class="loadersmall"></div>
125
+ <span id="chat_complete_reason" class="generation-end-reason"></span>
126
+ </div>
127
+ <span id="analyze_progress" class="analyze-progress"></span>
128
  </div>
 
129
  </div>
130
  <div id="text_metrics" class="text-metrics text-metrics-chat">
131
  <div id="metric_usage" class="text-metrics-secondary"></div>
client/src/css/_buttons.scss CHANGED
@@ -62,11 +62,15 @@
62
  @extend %button-inactive;
63
  }
64
 
65
- &:hover:not(.inactive) {
 
 
 
 
66
  background-color: var(--text-action-btn-hover); // hover时浅灰色背景
67
  }
68
 
69
- &:active:not(.inactive) {
70
  background-color: var(--text-action-btn-hover); // active时更深的灰色背景
71
  opacity: 0.8;
72
  }
 
62
  @extend %button-inactive;
63
  }
64
 
65
+ &:disabled {
66
+ @extend %button-inactive;
67
+ }
68
+
69
+ &:hover:not(:disabled):not(.inactive) {
70
  background-color: var(--text-action-btn-hover); // hover时浅灰色背景
71
  }
72
 
73
+ &:active:not(:disabled):not(.inactive) {
74
  background-color: var(--text-action-btn-hover); // active时更深的灰色背景
75
  opacity: 0.8;
76
  }
client/src/css/_demo-list.scss CHANGED
@@ -35,7 +35,7 @@
35
  white-space: nowrap; // 防止文字换行
36
  background-color: var(--button-bg);
37
  color: var(--text-color);
38
- transition: background-color 0.3s ease, color 0.3s ease, border 0.2s ease, box-shadow 0.2s ease;
39
 
40
  &:hover {
41
  background-color: var(--button-hover-bg);
 
35
  white-space: nowrap; // 防止文字换行
36
  background-color: var(--button-bg);
37
  color: var(--text-color);
38
+ transition: none;
39
 
40
  &:hover {
41
  background-color: var(--button-hover-bg);
client/src/css/_responsive.scss CHANGED
@@ -207,7 +207,8 @@
207
  padding: 8px 20px 20px;
208
  overflow-y: auto;
209
  overflow-x: hidden; // 防止水平滚动,确保内容不会被遮挡
210
- border-right: 1px solid var(--border-color);
 
211
  background-color: var(--bg-color);
212
  box-sizing: border-box; // 确保padding包含在宽度计算中
213
  transition: background-color 0.3s ease, border-color 0.3s ease;
@@ -218,9 +219,11 @@
218
  }
219
  }
220
 
 
 
221
  .resizer {
222
  width: 8px;
223
- background: var(--resizer-bg);
224
  cursor: col-resize;
225
  position: relative;
226
  flex-shrink: 0;
 
207
  padding: 8px 20px 20px;
208
  overflow-y: auto;
209
  overflow-x: hidden; // 防止水平滚动,确保内容不会被遮挡
210
+ // 竖向分隔只做在 .resizer::before,避免与拖条中线叠成双线
211
+ border-right: none;
212
  background-color: var(--bg-color);
213
  box-sizing: border-box; // 确保padding包含在宽度计算中
214
  transition: background-color 0.3s ease, border-color 0.3s ease;
 
219
  }
220
  }
221
 
222
+ // 占位与命中区保持 8px(与 LayoutController / chatPanelLayout 的 −8 一致);
223
+ // 底色透明,仅用 ::before 画窄线,避免「视觉上收窄」时拖累拖拽热区。
224
  .resizer {
225
  width: 8px;
226
+ background: transparent;
227
  cursor: col-resize;
228
  position: relative;
229
  flex-shrink: 0;
client/src/css/_semantic-analysis.scss CHANGED
@@ -25,6 +25,14 @@
25
  &.semantic-submode-group-right {
26
  margin-left: auto;
27
  }
 
 
 
 
 
 
 
 
28
  }
29
 
30
  .semantic-submode-label {
@@ -53,6 +61,28 @@
53
  }
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  // 语义分析区(在 button-group 与 histogram 之间,仅在 semantic_analysis 模式下显示)
57
  .semantic-analysis-section {
58
  margin-top: 10px;
 
25
  &.semantic-submode-group-right {
26
  margin-left: auto;
27
  }
28
+
29
+ // 组内「主控」标签/下拉:比默认 submode 更醒目(仅直接子级,避免加粗单位后缀等嵌套 label)
30
+ &.semantic-submode-group--emphasis {
31
+ > .semantic-submode-label,
32
+ > .semantic-submode-select {
33
+ font-weight: 700;
34
+ }
35
+ }
36
  }
37
 
38
  .semantic-submode-label {
 
61
  }
62
  }
63
 
64
+ // Analyze 下方 Semantic Query 总开关:与 gen_attribute「Raw prompt mode」同款(未勾选整行灰色,勾选正文色)
65
+ .textarea-wrapper .semantic-submode-row.semantic-analysis-enable-row {
66
+ label.semantic-submode-label {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: 6px;
70
+ cursor: pointer;
71
+ user-select: none;
72
+ color: var(--text-primary, var(--text-color));
73
+
74
+ &:has(> input[type='checkbox']:not(:checked)) {
75
+ color: var(--text-muted);
76
+ }
77
+ }
78
+
79
+ input[type='checkbox'] {
80
+ cursor: pointer;
81
+ margin: 0;
82
+ flex-shrink: 0;
83
+ }
84
+ }
85
+
86
  // 语义分析区(在 button-group 与 histogram 之间,仅在 semantic_analysis 模式下显示)
87
  .semantic-analysis-section {
88
  margin-top: 10px;
client/src/css/_tooltip-vars.scss ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ToolTip 与其它入口共用的 CSS 变量(compare 仅打 compare.css,需与 start 共用此文件,避免重复字面量)
2
+ :root {
3
+ --tooltip-visible-opacity: 1;
4
+ }
5
+
6
+ // {@link ToolTipOptions.pointerInteractive} false:整块及子节点均不参与命中(子元素默认 `auto` 会覆盖父 none,必须用 `*`)
7
+ .tooltip.tooltip-no-pointer-hit {
8
+ pointer-events: none !important;
9
+ cursor: default !important;
10
+
11
+ &,
12
+ * {
13
+ pointer-events: none !important;
14
+ }
15
+ }
client/src/css/compare.scss CHANGED
@@ -3,6 +3,7 @@
3
  @use "lmf-readout" as lmf;
4
  @use "demo-list";
5
  @use "buttons";
 
6
 
7
  // 夜间模式:强制使用暗色滚动条
8
  :root[data-theme="dark"] {
@@ -366,6 +367,7 @@
366
  padding: 5px;
367
  background: var(--tooltip-bg);
368
  color: var(--text-color);
 
369
  transition-property: opacity, background-color, color;
370
  transition-duration: .2s;
371
  position: absolute; // 绝对定位
@@ -374,13 +376,16 @@
374
  cursor: pointer; // 添加指针样式,提示可点击
375
  user-select: none;
376
  -webkit-user-select: none;
 
 
 
 
377
  }
378
 
379
  // 全局tooltip容器
380
  #global_tooltip {
381
  position: fixed; // 使用fixed定位,相对于视口
382
  pointer-events: none; // 初始状态不接收事件
383
- opacity: 0;
384
  transition-property: background-color, color;
385
  }
386
 
 
3
  @use "lmf-readout" as lmf;
4
  @use "demo-list";
5
  @use "buttons";
6
+ @use "tooltip-vars";
7
 
8
  // 夜间模式:强制使用暗色滚动条
9
  :root[data-theme="dark"] {
 
367
  padding: 5px;
368
  background: var(--tooltip-bg);
369
  color: var(--text-color);
370
+ opacity: 0;
371
  transition-property: opacity, background-color, color;
372
  transition-duration: .2s;
373
  position: absolute; // 绝对定位
 
376
  cursor: pointer; // 添加指针样式,提示可点击
377
  user-select: none;
378
  -webkit-user-select: none;
379
+
380
+ &.tooltip-visible {
381
+ opacity: var(--tooltip-visible-opacity);
382
+ }
383
  }
384
 
385
  // 全局tooltip容器
386
  #global_tooltip {
387
  position: fixed; // 使用fixed定位,相对于视口
388
  pointer-events: none; // 初始状态不接收事件
 
389
  transition-property: background-color, color;
390
  }
391
 
client/src/css/gen_attribute.scss CHANGED
@@ -47,7 +47,6 @@ $gen-attr-option-row-gap: 12px;
47
  left: auto;
48
  right: 0;
49
  width: max-content;
50
- min-width: max(100%, 240px);
51
  max-width: min(100vw - 32px, 40rem);
52
  box-sizing: border-box;
53
  }
@@ -62,21 +61,15 @@ $gen-attr-option-row-gap: 12px;
62
  min-height: 0;
63
  width: 100%;
64
  box-sizing: border-box;
65
- padding: 20px;
66
  background: var(--text-area-bg);
67
  color: var(--text-color);
68
  overflow: visible;
69
  }
70
 
71
- @media (max-width: 767px) {
72
- #results.gen-attr-results-surface.LMF {
73
- padding: 15px 12px;
74
- }
75
- }
76
-
77
  // DAG:不可见测量层撑开高度,SVG 绝对定位叠在同栈内(坐标与 LMF 正文一致)
78
  // 初始 d3.zoom 缩放为 1/本变量,抵消 display-scale 在屏上的整体缩小感(见 genAttributeDagView)
79
- // 边色:`--dag-normal-line-color`、`--dag-highlight-line-color-in` / `out` 与节点描边 `--token-hover-outline` 同在 start.scss(见 genAttributeDagView)
80
  #results.gen-attr-results-surface.LMF .gen-attr-dag-stack {
81
  // compactness:dag界面紧凑程度,范围 [0.05, 1];1时和普通文本排版一致;由 JS 写入 inline style,0.5 为纯 CSS 降级兜底。
82
  --gen-attr-dag-display-scale: var(--gen-attr-dag-compactness, 0.5);
@@ -96,6 +89,32 @@ $gen-attr-option-row-gap: 12px;
96
  box-sizing: border-box;
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  // 勾选「Hide inactive edges」时隐藏未与焦点相邻的灰边(linkG 容器内的边);
100
  // 与焦点相邻的高亮边由 genAttributeDagView 运行时搬运到 .gen-attr-dag-links-front 中,不受影响。
101
  #results.gen-attr-results-surface.LMF.gen-attr-dag-hide-inactive-edges .gen-attr-dag-svg .gen-attr-dag-links {
@@ -115,27 +134,54 @@ $gen-attr-option-row-gap: 12px;
115
  white-space: pre;
116
  }
117
 
118
- // 节点框:默认无边框悬停/选描边与 start.scss `.token` 的 outline 2px 在屏上一致:
119
- // 几何按 --gen-attr-dag-display-scale 缩小后,genAttributeDagView 用 zoom 1/scale 拉回,故用户空间描边需 ×scale,
120
- // 使 2×s × (1/s) = 2(css px)。(s 可变时仍成立。)
121
- #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node > rect {
122
- stroke: none;
123
- stroke-width: 0;
124
- fill: var(--gen-attr-dag-generated-node-fill);
125
  }
126
 
127
- #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node--prompt > rect {
128
- fill: var(--gen-attr-dag-prompt-node-fill);
 
 
129
  }
130
 
131
- #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node--hover > rect,
132
- #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node--selected > rect {
133
- stroke: var(--token-hover-outline);
134
- stroke-width: calc(2 * var(--gen-attr-dag-display-scale, 1));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  }
136
 
137
- // 与 genAttributeDagView 中「仅 default(text-flow) 且选中时可拖」一致:可拖时提示 grab
138
- #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node--selected {
 
139
  cursor: grab;
140
  &:active {
141
  cursor: grabbing;
@@ -146,7 +192,11 @@ $gen-attr-option-row-gap: 12px;
146
  #results.gen-attr-results-surface.LMF
147
  .gen-attr-dag-stack.gen-attr-dag-no-node-drag-layout
148
  .gen-attr-dag-svg
149
- .gen-attr-dag-node--selected {
 
 
 
 
150
  cursor: default;
151
  &:active {
152
  cursor: default;
@@ -394,14 +444,22 @@ body.gen-attribute-page .input-section {
394
  margin-top: 10px;
395
  }
396
 
397
- // Start / Model 行与首行 DAG 参数(含 layout mode)之间略加大,避免按钮挤在
398
- .gen-attribute-page
399
- .input-section
400
- > .textarea-wrapper.chat-prompt-actions-row
401
- + .gen-attr-dag-measure-width-row {
402
  margin-top: 30px;
403
  }
404
 
 
 
 
 
 
 
 
 
 
 
 
405
  .attribution-exclude-prompt-patterns-header {
406
  flex-wrap: wrap;
407
 
@@ -441,20 +499,6 @@ body.gen-attribute-page .input-section {
441
  flex-wrap: nowrap;
442
  }
443
 
444
- .gen-attr-dag-measure-width-row .gen-attr-dag-layout-mode-group {
445
- .semantic-submode-label,
446
- .gen-attr-dag-layout-mode-select {
447
- font-weight: 700;
448
- }
449
- }
450
-
451
- .gen-attr-dag-measure-width-row .gen-attr-dag-replay-speed-row {
452
- > .semantic-submode-label,
453
- .gen-attr-dag-replay-mode-select {
454
- font-weight: 700;
455
- }
456
- }
457
-
458
  .gen-attr-dag-replay-speed-row {
459
  display: flex;
460
  flex-wrap: wrap;
 
47
  left: auto;
48
  right: 0;
49
  width: max-content;
 
50
  max-width: min(100vw - 32px, 40rem);
51
  box-sizing: border-box;
52
  }
 
61
  min-height: 0;
62
  width: 100%;
63
  box-sizing: border-box;
64
+ padding: 6px;
65
  background: var(--text-area-bg);
66
  color: var(--text-color);
67
  overflow: visible;
68
  }
69
 
 
 
 
 
 
 
70
  // DAG:不可见测量层撑开高度,SVG 绝对定位叠在同栈内(坐标与 LMF 正文一致)
71
  // 初始 d3.zoom 缩放为 1/本变量,抵消 display-scale 在屏上的整体缩小感(见 genAttributeDagView)
72
+ // 边色与节点追因/选中描边:`--dag-highlight-line-color-in`(accent 80%),见 start.scss。
73
  #results.gen-attr-results-surface.LMF .gen-attr-dag-stack {
74
  // compactness:dag界面紧凑程度,范围 [0.05, 1];1时和普通文本排版一致;由 JS 写入 inline style,0.5 为纯 CSS 降级兜底。
75
  --gen-attr-dag-display-scale: var(--gen-attr-dag-compactness, 0.5);
 
89
  box-sizing: border-box;
90
  }
91
 
92
+ // DAG Top‑K:贴在 #results 内侧右下(absolute);固定尺寸 HUD;Top‑K 区超出则在滚动层内滚动。
93
+ #results.gen-attr-results-surface.LMF > .tooltip.gen-attr-dag-topk-tooltip {
94
+ box-sizing: border-box;
95
+ // 与 #major_tooltip 一致:不参与 opacity 过渡,避免 reposition 后与底层叠影
96
+ transition-property: background-color, color;
97
+ max-width: none;
98
+ width: 14rem;
99
+ height: 300px;
100
+ display: flex;
101
+ flex-direction: column;
102
+ overflow: hidden;
103
+
104
+ .currentToken,
105
+ .myDetail {
106
+ flex-shrink: 0;
107
+ }
108
+
109
+ .gen-attr-dag-topk-tooltip-predictions-scroll {
110
+ flex: 1 1 auto;
111
+ min-height: 0;
112
+ margin-top: 6px;
113
+ overflow-x: hidden;
114
+ overflow-y: auto;
115
+ }
116
+ }
117
+
118
  // 勾选「Hide inactive edges」时隐藏未与焦点相邻的灰边(linkG 容器内的边);
119
  // 与焦点相邻的高亮边由 genAttributeDagView 运行时搬运到 .gen-attr-dag-links-front 中,不受影响。
120
  #results.gen-attr-results-surface.LMF.gen-attr-dag-hide-inactive-edges .gen-attr-dag-svg .gen-attr-dag-links {
 
134
  white-space: pre;
135
  }
136
 
137
+ // 视觉节点不接收指针事件由顶层 .gen-attr-dag-nodes-hit 承担(见 genAttributeDagView)。
138
+ #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-nodes .gen-attr-dag-node {
139
+ pointer-events: none;
 
 
 
 
140
  }
141
 
142
+ #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node-hit-target {
143
+ fill: transparent;
144
+ stroke: none;
145
+ pointer-events: all;
146
  }
147
 
148
+ // 节点 fill/stroke 分层:stroke rect 外扩见 syncNodeStrokeRects(屏上描边约 2px)。
149
+ #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node {
150
+ .gen-attr-dag-node-stroke {
151
+ fill: none;
152
+ stroke: none;
153
+ stroke-width: 0;
154
+ pointer-events: none;
155
+ }
156
+
157
+ .gen-attr-dag-node-fill {
158
+ stroke: none;
159
+ fill: var(--gen-attr-dag-generated-node-fill);
160
+ }
161
+
162
+ &--prompt .gen-attr-dag-node-fill {
163
+ fill: var(--gen-attr-dag-prompt-node-fill);
164
+ }
165
+
166
+ // 递归追因链上游节点描边(直接模式仅一跳,由蓝/红边表达);opacity 见 genAttributeDagView 中 stay→[0.3,1] 映射。
167
+ &--recursive-chain .gen-attr-dag-node-stroke {
168
+ stroke: var(--dag-highlight-line-color-in);
169
+ stroke-width: calc(2 * var(--gen-attr-dag-display-scale, 1));
170
+ stroke-opacity: var(--gen-attr-dag-node-recursive-share, 1);
171
+ }
172
+
173
+ // 选中/悬停 = 焦点(追因起点,nodeShare=1);置于 recursive-chain 之后以覆盖链上节点的 stroke-opacity
174
+ &--hover .gen-attr-dag-node-stroke,
175
+ &--selected .gen-attr-dag-node-stroke {
176
+ stroke: var(--dag-highlight-line-color-in);
177
+ stroke-width: calc(2 * var(--gen-attr-dag-display-scale, 1));
178
+ stroke-opacity: 1;
179
+ }
180
  }
181
 
182
+ // 与 genAttributeDagView 中「仅 default(text-flow) 且选中时可拖」一致:可拖时提示 grab(命中层与视觉层共用 class)
183
+ #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node--selected,
184
+ #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node-hit.gen-attr-dag-node--selected {
185
  cursor: grab;
186
  &:active {
187
  cursor: grabbing;
 
192
  #results.gen-attr-results-surface.LMF
193
  .gen-attr-dag-stack.gen-attr-dag-no-node-drag-layout
194
  .gen-attr-dag-svg
195
+ .gen-attr-dag-node--selected,
196
+ #results.gen-attr-results-surface.LMF
197
+ .gen-attr-dag-stack.gen-attr-dag-no-node-drag-layout
198
+ .gen-attr-dag-svg
199
+ .gen-attr-dag-node-hit.gen-attr-dag-node--selected {
200
  cursor: default;
201
  &:active {
202
  cursor: default;
 
444
  margin-top: 10px;
445
  }
446
 
447
+ // Start / Model 行与 Reset UI options 之间略加大;“重置”下方 DAG layout 行紧凑
448
+ .gen-attribute-page .input-section > .textarea-wrapper.chat-prompt-actions-row + .gen-attr-reset-ui-options-row {
 
 
 
449
  margin-top: 30px;
450
  }
451
 
452
+ .gen-attr-reset-ui-options-row {
453
+ display: flex;
454
+ flex-wrap: wrap;
455
+ align-items: center;
456
+ justify-content: flex-end;
457
+ }
458
+
459
+ .gen-attr-reset-ui-options-row + .gen-attr-dag-measure-width-row {
460
+ margin-top: 10px;
461
+ }
462
+
463
  .attribution-exclude-prompt-patterns-header {
464
  flex-wrap: wrap;
465
 
 
499
  flex-wrap: nowrap;
500
  }
501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  .gen-attr-dag-replay-speed-row {
503
  display: flex;
504
  flex-wrap: wrap;
client/src/css/start.scss CHANGED
@@ -12,6 +12,7 @@
12
  @use "semantic-analysis";
13
  @use "attribution-inspector";
14
  @use "attribution-sidebar";
 
15
 
16
  // CSS变量定义 - 日间模式(默认)
17
  :root {
@@ -60,6 +61,11 @@
60
  --bg-hover: #f5f5f5; // 弹窗内信息块/列表项背景(日间)
61
  --bg-hover-light: #f8f8f8; // 弹窗内列表项 hover(日间)
62
  --primary-color: #2196F3; // 主题色(链接、当前模型标题等)
 
 
 
 
 
63
  --text-disabled: #999; // 禁用态文本
64
  --text-area-bg: #fff; // 文本显示区域背景色
65
  --textarea-actions-button-row-min-height: 3.5rem; // textarea 下「按钮 + stats」行最小高度(analysis / chat / attribution / gen_attribute)
@@ -68,14 +74,14 @@
68
  --tooltip-text-selected: #933; // tooltip选中文本颜色
69
  --tooltip-text-detail: #666666; // tooltip详情文本颜色
70
  --tooltip-text-value: #333; // tooltip数值文本颜色
71
- --token-hover-shadow: rgba(42, 158, 255, 0.6); // token悬浮阴影颜色(日间模式)
72
- --token-hover-outline: #1e6fff; // token悬浮边框颜色
73
  // 与 rgba(128,128,128,0.252) 叠在 var(--text-area-bg) 上等效(实心,避免多线重叠加深)
74
  --dag-normal-line-color: #ddd;
75
- --dag-highlight-line-color-in: rgba(0, 94, 255, 0.8); // 入边:与 --token-hover-outline #1e6fff 同 hue,半透明与出边一致
76
  --dag-highlight-line-color-out: rgba(255, 71, 64, 0.5);
77
- --bin-highlight-outline: #1e6fff; // bin高亮边框(保持与悬浮一致)
78
- --bin-highlight-shadow: rgba(30, 111, 255, 0.65); // bin高亮阴影
79
  --avg-line-color: #8c8c8c; // 平均值参考线颜色(日间模式)
80
  --text-color-light: #c3c3c3; // 浅色文本颜色(主要用于夜间模式)
81
  --token-truncated-color: #888; // 被截断 token 的文本颜色(日间模式)
@@ -124,7 +130,11 @@ html {
124
  --hover-bg-color: #3a3a3a; // 菜单项悬浮背景色(夜间模式,更暗)
125
  --bg-hover: #2d2d2d; // 弹窗内信息块/列表项背景(夜间)
126
  --bg-hover-light: #353535; // 弹窗内列表项 hover(夜间)
127
- --primary-color: #5c8dff; // 主题色(夜间模式略亮)
 
 
 
 
128
  --text-disabled: #666; // 禁用态文本(夜间)
129
  --text-area-bg: #191919; // 夜间模式:文本显示区域背景色与主背景一致
130
  --tooltip-text-normal: #ccc; // tooltip普通文本颜色(夜间模式)
@@ -132,15 +142,13 @@ html {
132
  --tooltip-text-detail: #888; // tooltip详情文本颜色(夜间模式)
133
  --tooltip-text-value: var(--text-color-light); // tooltip数值文本颜色(夜间模式)
134
  --token-truncated-color: #888; // 被截断 token 的文本颜色(夜间模式)
135
- --token-hover-shadow: rgba(66, 165, 255, 0.8); // token悬浮阴影颜色(夜间模式,更亮更明显)
136
- --token-hover-outline: #5c8dff; // token悬浮边框颜色(夜间模式)
137
  --dag-normal-line-color: #333;
138
- // 出边数是无上限的,更可能重叠,所以透明度低一些,以免过亮
139
- // 入边数是有限的,所以透明度高一些,以免过暗
140
- --dag-highlight-line-color-in: rgba(92, 141, 255, 0.8); // 入边:与 --token-hover-outline #5c8dff 同 hue
141
  --dag-highlight-line-color-out: rgba(255, 71, 64, 0.5);
142
- --bin-highlight-outline: #5c8dff; // 夜间模式 bin 高亮边框
143
- --bin-highlight-shadow: rgba(92, 141, 255, 0.8); // 夜间模式 bin 高亮阴影
144
  --avg-line-color: #b0b0b0; // 平均值参考线颜色(夜间模式)
145
 
146
  // 夜间模式:LMF 区域使用 Light 字重(300)
@@ -213,12 +221,21 @@ textarea{
213
  flex-wrap: wrap; // 允许换行
214
 
215
  .button-left {
 
 
 
 
 
 
 
 
 
 
 
216
  display: flex;
217
  align-items: center;
218
  gap: 10px;
219
- flex-shrink: 0; // 防止左侧按钮被压缩
220
- position: relative; // 为绝对定位的进度消息提供定位上下文
221
-
222
  }
223
 
224
  .button-right {
@@ -645,7 +662,7 @@ select {
645
  // drop-shadow(0 0 2px rgba(30, 111, 255, 1))
646
  // drop-shadow(0 0 5px rgba(30, 111, 255, 0.85))
647
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
648
- stroke: rgba(30, 111, 255, 0.9);
649
  stroke-width: 2.5px;
650
  }
651
 
@@ -656,7 +673,7 @@ select {
656
  // drop-shadow(0 0 2px rgba(30, 111, 255, 1))
657
  // drop-shadow(0 0 5px rgba(30, 111, 255, 0.85))
658
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
659
- stroke: rgba(30, 111, 255, 0.9);
660
  stroke-width: 2.5px;
661
  }
662
  }
@@ -678,7 +695,7 @@ select {
678
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
679
  // 移动端备用方案:使用 stroke 作为发光效果(iOS Safari filter 支持不佳)
680
  // 使用 2.5px 区分于 bin-highlighted 的 1.5px
681
- stroke: rgba(30, 111, 255, 0.9);
682
  stroke-width: 2.5px;
683
  }
684
 
@@ -697,7 +714,7 @@ select {
697
  // drop-shadow(0 0 5px rgba(30, 111, 255, 0.85))
698
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
699
  // 覆盖 bin-highlighted 的 stroke,使用更粗的 stroke 表示同时悬浮
700
- stroke: rgba(30, 111, 255, 0.9);
701
  stroke-width: 2.5px;
702
  }
703
  }
@@ -771,6 +788,7 @@ select {
771
  padding: 5px;
772
  background: var(--tooltip-bg);
773
  color: var(--text-color);
 
774
  transition-property: opacity, background-color, color;
775
  transition-duration: .2s;
776
  position: absolute; // 相对于 #results 容器定位,与文本区域使用相同的坐标系统
@@ -783,6 +801,10 @@ select {
783
  max-width: min(16rem, calc(100vw - 24px));
784
  box-sizing: border-box;
785
  overflow-wrap: break-word;
 
 
 
 
786
  }
787
 
788
  // 当前token显示区域,使用等宽字体以便分辨连续的␣(字号继承 .tooltip 9pt,行高与历史一致)
@@ -981,7 +1003,7 @@ select {
981
  white-space: nowrap;
982
  line-height: 1.2;
983
  position: absolute; // 绝对定位,不占用正常布局空间
984
- left: 100%; // 定位在button-left的右侧
985
  margin-left: 30px; // loader宽度(10px) + loader的margin(10px) + 额外间距(10px)
986
  z-index: 10; // 确保在上层显示
987
 
@@ -1411,7 +1433,7 @@ select {
1411
  // 加载器小图标容器
1412
  .loader-small-container {
1413
  position: absolute; // 绝对定位,不占用正常布局空间
1414
- left: 100%; // 定位在button-left的右侧
1415
  margin-left: 10px; // 与左侧元素保持间距
1416
  z-index: 10; // 确保在上层显示
1417
  }
@@ -1433,7 +1455,6 @@ select {
1433
  #major_tooltip {
1434
  position: absolute;
1435
  pointer-events: none;
1436
- opacity: 0;
1437
  // 不参与 opacity 过渡:否则 hide 时 top/left 已归位而 opacity 仍 >0,会与 fixed 叠成视口角上鬼影
1438
  transition-property: background-color, color;
1439
  }
 
12
  @use "semantic-analysis";
13
  @use "attribution-inspector";
14
  @use "attribution-sidebar";
15
+ @use "tooltip-vars";
16
 
17
  // CSS变量定义 - 日间模式(默认)
18
  :root {
 
61
  --bg-hover: #f5f5f5; // 弹窗内信息块/列表项背景(日间)
62
  --bg-hover-light: #f8f8f8; // 弹窗内列表项 hover(日间)
63
  --primary-color: #2196F3; // 主题色(链接、当前模型标题等)
64
+ // 交互高亮蓝(token / bin / DAG 归因份额=1);与 --primary-color 日间 intentionally 区分
65
+ --accent-color: #1e6fff;
66
+ --accent-glow: color-mix(in srgb, var(--accent-color) 60%, transparent);
67
+ --accent-shadow: color-mix(in srgb, var(--accent-color) 65%, transparent);
68
+ --accent-stroke: color-mix(in srgb, var(--accent-color) 90%, transparent);
69
  --text-disabled: #999; // 禁用态文本
70
  --text-area-bg: #fff; // 文本显示区域背景色
71
  --textarea-actions-button-row-min-height: 3.5rem; // textarea 下「按钮 + stats」行最小高度(analysis / chat / attribution / gen_attribute)
 
74
  --tooltip-text-selected: #933; // tooltip选中文本颜色
75
  --tooltip-text-detail: #666666; // tooltip详情文本颜色
76
  --tooltip-text-value: #333; // tooltip数值文本颜色
77
+ --token-hover-shadow: var(--accent-glow);
78
+ --token-hover-outline: var(--accent-color);
79
  // 与 rgba(128,128,128,0.252) 叠在 var(--text-area-bg) 上等效(实心,避免多线重叠加深)
80
  --dag-normal-line-color: #ddd;
81
+ --dag-highlight-line-color-in: color-mix(in srgb, var(--accent-color) 80%, transparent);
82
  --dag-highlight-line-color-out: rgba(255, 71, 64, 0.5);
83
+ --bin-highlight-outline: var(--accent-color);
84
+ --bin-highlight-shadow: var(--accent-shadow);
85
  --avg-line-color: #8c8c8c; // 平均值参考线颜色(日间模式)
86
  --text-color-light: #c3c3c3; // 浅色文本颜色(主要用于夜间模式)
87
  --token-truncated-color: #888; // 被截断 token 的文本颜色(日间模式)
 
130
  --hover-bg-color: #3a3a3a; // 菜单项悬浮背景色(夜间模式,更暗)
131
  --bg-hover: #2d2d2d; // 弹窗内信息块/列表项背景(夜间)
132
  --bg-hover-light: #353535; // 弹窗内列表项 hover(夜间)
133
+ --accent-color: #5c8dff;
134
+ --accent-glow: color-mix(in srgb, var(--accent-color) 80%, transparent);
135
+ --accent-shadow: var(--accent-glow);
136
+ --accent-stroke: color-mix(in srgb, var(--accent-color) 90%, transparent);
137
+ --primary-color: var(--accent-color);
138
  --text-disabled: #666; // 禁用态文本(夜间)
139
  --text-area-bg: #191919; // 夜间模式:文本显示区域背景色与主背景一致
140
  --tooltip-text-normal: #ccc; // tooltip普通文本颜色(夜间模式)
 
142
  --tooltip-text-detail: #888; // tooltip详情文本颜色(夜间模式)
143
  --tooltip-text-value: var(--text-color-light); // tooltip数值文本颜色(夜间模式)
144
  --token-truncated-color: #888; // 被截断 token 的文本颜色(夜间模式)
145
+ --token-hover-shadow: var(--accent-glow);
146
+ --token-hover-outline: var(--accent-color);
147
  --dag-normal-line-color: #333;
148
+ --dag-highlight-line-color-in: color-mix(in srgb, var(--accent-color) 80%, transparent);
 
 
149
  --dag-highlight-line-color-out: rgba(255, 71, 64, 0.5);
150
+ --bin-highlight-outline: var(--accent-color);
151
+ --bin-highlight-shadow: var(--accent-shadow);
152
  --avg-line-color: #b0b0b0; // 平均值参考线颜色(夜间模式)
153
 
154
  // 夜间模式:LMF 区域使用 Light 字重(300)
 
221
  flex-wrap: wrap; // 允许换行
222
 
223
  .button-left {
224
+ flex-shrink: 0; // 防止左侧按钮被压缩
225
+ }
226
+
227
+ .button-left-stack {
228
+ display: flex;
229
+ flex-direction: column;
230
+ align-items: flex-start;
231
+ gap: 25px; // Analyze 行与下方 Semantic Query 开关间距(略大于常规行距)
232
+ }
233
+
234
+ .button-left-primary {
235
  display: flex;
236
  align-items: center;
237
  gap: 10px;
238
+ position: relative; // 为绝对定位的 loader / 进度消息提供定位上下文
 
 
239
  }
240
 
241
  .button-right {
 
662
  // drop-shadow(0 0 2px rgba(30, 111, 255, 1))
663
  // drop-shadow(0 0 5px rgba(30, 111, 255, 0.85))
664
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
665
+ stroke: var(--accent-stroke);
666
  stroke-width: 2.5px;
667
  }
668
 
 
673
  // drop-shadow(0 0 2px rgba(30, 111, 255, 1))
674
  // drop-shadow(0 0 5px rgba(30, 111, 255, 0.85))
675
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
676
+ stroke: var(--accent-stroke);
677
  stroke-width: 2.5px;
678
  }
679
  }
 
695
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
696
  // 移动端备用方案:使用 stroke 作为发光效果(iOS Safari filter 支持不佳)
697
  // 使用 2.5px 区分于 bin-highlighted 的 1.5px
698
+ stroke: var(--accent-stroke);
699
  stroke-width: 2.5px;
700
  }
701
 
 
714
  // drop-shadow(0 0 5px rgba(30, 111, 255, 0.85))
715
  // drop-shadow(0 0 10px rgba(30, 111, 255, 0.7));
716
  // 覆盖 bin-highlighted 的 stroke,使用更粗的 stroke 表示同时悬浮
717
+ stroke: var(--accent-stroke);
718
  stroke-width: 2.5px;
719
  }
720
  }
 
788
  padding: 5px;
789
  background: var(--tooltip-bg);
790
  color: var(--text-color);
791
+ opacity: 0;
792
  transition-property: opacity, background-color, color;
793
  transition-duration: .2s;
794
  position: absolute; // 相对于 #results 容器定位,与文本区域使用相同的坐标系统
 
801
  max-width: min(16rem, calc(100vw - 24px));
802
  box-sizing: border-box;
803
  overflow-wrap: break-word;
804
+
805
+ &.tooltip-visible {
806
+ opacity: var(--tooltip-visible-opacity);
807
+ }
808
  }
809
 
810
  // 当前token显示区域,使用等宽字体以便分辨连续的␣(字号继承 .tooltip 9pt,行高与历史一致)
 
1003
  white-space: nowrap;
1004
  line-height: 1.2;
1005
  position: absolute; // 绝对定位,不占用正常布局空间
1006
+ left: 100%; // 定位在 button-left-primary(Analyze 行)的右侧
1007
  margin-left: 30px; // loader宽度(10px) + loader的margin(10px) + 额外间距(10px)
1008
  z-index: 10; // 确保在上层显示
1009
 
 
1433
  // 加载器小图标容器
1434
  .loader-small-container {
1435
  position: absolute; // 绝对定位,不占用正常布局空间
1436
+ left: 100%; // 定位在 button-left-primary(Analyze 行)的右侧
1437
  margin-left: 10px; // 与左侧元素保持间距
1438
  z-index: 10; // 确保在上层显示
1439
  }
 
1455
  #major_tooltip {
1456
  position: absolute;
1457
  pointer-events: none;
 
1458
  // 不参与 opacity 过渡:否则 hide 时 top/left 已归位而 opacity 仍 >0,会与 fixed 叠成视口角上鬼影
1459
  transition-property: background-color, color;
1460
  }
client/src/demos/gen_attribute/CN->EN翻译.json CHANGED
@@ -13404,7 +13404,7 @@
13404
  "hideExcludedTokens": true,
13405
  "edgeTopPCoverage": 0.1,
13406
  "nodeCiVisualScaleEnabled": true,
13407
- "edgeWeakenHighSurprisalEnabled": true,
13408
  "hideInactiveEdges": false,
13409
  "replayPacingMode": "step",
13410
  "playbackTotalS": 7,
 
13404
  "hideExcludedTokens": true,
13405
  "edgeTopPCoverage": 0.1,
13406
  "nodeCiVisualScaleEnabled": true,
13407
+ "decayAttributionToHighSurprisalTargetEnabled": true,
13408
  "hideInactiveEdges": false,
13409
  "replayPacingMode": "step",
13410
  "playbackTotalS": 7,
client/src/demos/gen_attribute/CoT | 苏州所在省的省会.json ADDED
The diff for this file is too large to render. See raw diff
 
client/src/demos/gen_attribute/Write a sonnet about love.json CHANGED
@@ -117407,7 +117407,7 @@
117407
  "hideExcludedTokens": false,
117408
  "edgeTopPCoverage": 0.7,
117409
  "nodeCiVisualScaleEnabled": true,
117410
- "edgeWeakenHighSurprisalEnabled": true,
117411
  "hideInactiveEdges": false,
117412
  "replayPacingMode": "step",
117413
  "playbackTotalS": 7,
 
117407
  "hideExcludedTokens": false,
117408
  "edgeTopPCoverage": 0.7,
117409
  "nodeCiVisualScaleEnabled": true,
117410
+ "decayAttributionToHighSurprisalTargetEnabled": true,
117411
  "hideInactiveEdges": false,
117412
  "replayPacingMode": "step",
117413
  "playbackTotalS": 7,
client/src/demos/gen_attribute/写一首绝句,主题是春天.json CHANGED
@@ -15973,7 +15973,7 @@
15973
  "hideExcludedTokens": false,
15974
  "edgeTopPCoverage": 0.7,
15975
  "nodeCiVisualScaleEnabled": true,
15976
- "edgeWeakenHighSurprisalEnabled": true,
15977
  "hideInactiveEdges": false,
15978
  "replayPacingMode": "step",
15979
  "playbackTotalS": 7,
 
15973
  "hideExcludedTokens": false,
15974
  "edgeTopPCoverage": 0.7,
15975
  "nodeCiVisualScaleEnabled": true,
15976
+ "decayAttributionToHighSurprisalTargetEnabled": true,
15977
  "hideInactiveEdges": false,
15978
  "replayPacingMode": "step",
15979
  "playbackTotalS": 7,
client/src/demos/gen_attribute/过拟合|李白 将进酒.json CHANGED
@@ -154547,7 +154547,7 @@
154547
  "hideExcludedTokens": true,
154548
  "edgeTopPCoverage": 0.5,
154549
  "nodeCiVisualScaleEnabled": true,
154550
- "edgeWeakenHighSurprisalEnabled": true,
154551
  "hideInactiveEdges": false,
154552
  "replayPacingMode": "step",
154553
  "playbackTotalS": 7,
 
154547
  "hideExcludedTokens": true,
154548
  "edgeTopPCoverage": 0.5,
154549
  "nodeCiVisualScaleEnabled": true,
154550
+ "decayAttributionToHighSurprisalTargetEnabled": true,
154551
  "hideInactiveEdges": false,
154552
  "replayPacingMode": "step",
154553
  "playbackTotalS": 7,
client/src/gen_attribute.html CHANGED
@@ -174,12 +174,14 @@
174
  </div>
175
  <div class="button-group">
176
  <div class="button-left">
177
- <button type="button" id="gen_attr_submit_btn" class="primary-btn inactive" disabled>Start</button>
178
- <div class="generation-status-slot loader-small-container">
179
- <div class="loadersmall"></div>
180
- <span id="gen_attr_complete_reason" class="generation-end-reason"></span>
 
 
 
181
  </div>
182
- <span id="gen_attr_analyze_progress" class="analyze-progress"></span>
183
  </div>
184
  <div id="gen_attr_text_metrics" class="text-metrics text-metrics-chat">
185
  <div id="gen_attr_metric_usage" class="text-metrics-secondary"></div>
@@ -189,15 +191,24 @@
189
  </div>
190
  </div>
191
 
 
 
 
 
 
 
 
 
192
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
193
- <span class="semantic-submode-group gen-attr-dag-layout-mode-group">
194
  <label class="semantic-submode-label" for="gen_attr_dag_layout_mode">DAG layout mode</label>
195
  <select id="gen_attr_dag_layout_mode"
196
  class="semantic-submode-select gen-attr-dag-layout-mode-select"
197
- title="Choose DAG layout mode. 'text-flow' follows text layout geometry; 'linear-arc' uses fixed-order linear nodes with arc links; 'spiral' lays nodes on an Archimedean spiral (for fun)."
198
  data-i18n="title">
199
  <option value="text-flow">text-flow</option>
200
  <option value="linear-arc">linear-arc</option>
 
201
  <option value="spiral">spiral (for fun)</option>
202
  </select>
203
  </span>
@@ -220,7 +231,7 @@
220
  <label class="semantic-submode-label" for="gen_attr_dag_linear_arc_interval" data-i18n>Token distance</label>
221
  <input type="number" id="gen_attr_dag_linear_arc_interval" class="gen-attr-dag-measure-width-input"
222
  value="0" min="0" max="400" step="1"
223
- title="Horizontal gap (px) between the outer left/right edges of adjacent token nodes in linear-arc layout only. When idle, the DAG refits; during generation or DAG playback, the value is stored and applied on the next sync."
224
  data-i18n="title">
225
  <span class="semantic-submode-label">px</span>
226
  </span>
@@ -236,27 +247,35 @@
236
  </span>
237
  <span class="semantic-submode-group">
238
  <label class="semantic-submode-label">
239
- <input type="checkbox" id="gen_attr_dag_edge_weaken_high_surprisal" checked
240
- title="When checked, edges leading to high-surprisal (uncertain) target tokens are visually weakened by the mutual information ratio. Takes effect on next generation."
241
  data-i18n="title">
242
- Weaken attribution to high-surprisal target
243
  </label>
244
  </span>
245
  </div>
246
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
247
  <span class="semantic-submode-group">
248
- <label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Edge top-p coverage</label>
249
  <input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
250
  value="0.7" min="0.05" max="1" step="0.05"
251
  title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
252
  data-i18n="title">
253
  </span>
254
- <span class="semantic-submode-group">
255
  <label class="semantic-submode-label">
256
- <input type="checkbox" id="gen_attr_dag_hide_inactive_edges"
257
- title="When checked, gray DAG edges not adjacent to the hovered or selected node are hidden."
258
  data-i18n="title">
259
- Hide inactive edges
 
 
 
 
 
 
 
 
260
  </label>
261
  </span>
262
  </div>
@@ -315,7 +334,27 @@
315
  </span>
316
  </div>
317
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
318
- <span class="semantic-submode-group gen-attr-dag-replay-speed-row">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
320
  <select id="gen_attr_dag_replay_mode"
321
  class="semantic-submode-select gen-attr-dag-replay-mode-select"
 
174
  </div>
175
  <div class="button-group">
176
  <div class="button-left">
177
+ <div class="button-left-primary">
178
+ <button type="button" id="gen_attr_submit_btn" class="primary-btn inactive" disabled>Start</button>
179
+ <div class="generation-status-slot loader-small-container">
180
+ <div class="loadersmall"></div>
181
+ <span id="gen_attr_complete_reason" class="generation-end-reason"></span>
182
+ </div>
183
+ <span id="gen_attr_analyze_progress" class="analyze-progress"></span>
184
  </div>
 
185
  </div>
186
  <div id="gen_attr_text_metrics" class="text-metrics text-metrics-chat">
187
  <div id="gen_attr_metric_usage" class="text-metrics-secondary"></div>
 
191
  </div>
192
  </div>
193
 
194
+ <div class="gen-attr-reset-ui-options-row">
195
+ <button type="button" id="gen_attr_reset_ui_options_btn" class="text-action-btn"
196
+ title="Restore DAG options, replay speed, exclusions, etc. to defaults and clear saved preferences for those controls."
197
+ data-i18n="text,title">
198
+ Reset UI options
199
+ </button>
200
+ </div>
201
+
202
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
203
+ <span class="semantic-submode-group semantic-submode-group--emphasis">
204
  <label class="semantic-submode-label" for="gen_attr_dag_layout_mode">DAG layout mode</label>
205
  <select id="gen_attr_dag_layout_mode"
206
  class="semantic-submode-select gen-attr-dag-layout-mode-select"
207
+ title="Choose DAG layout mode. 'text-flow' follows text layout geometry; 'linear-arc' uses fixed-order linear nodes with arc links; 'linear-arc-step-down' is like linear-arc but drops each successive token vertically by CI (baseline = unscaled node height); 'spiral' lays nodes on an Archimedean spiral (for fun)."
208
  data-i18n="title">
209
  <option value="text-flow">text-flow</option>
210
  <option value="linear-arc">linear-arc</option>
211
+ <option value="linear-arc-step-down">step-down</option>
212
  <option value="spiral">spiral (for fun)</option>
213
  </select>
214
  </span>
 
231
  <label class="semantic-submode-label" for="gen_attr_dag_linear_arc_interval" data-i18n>Token distance</label>
232
  <input type="number" id="gen_attr_dag_linear_arc_interval" class="gen-attr-dag-measure-width-input"
233
  value="0" min="0" max="400" step="1"
234
+ title="Horizontal gap (px) between the outer left/right edges of adjacent token nodes in linear-arc / linear-arc-step-down layout only. When idle, the DAG refits; during generation or DAG playback, the value is stored and applied on the next sync."
235
  data-i18n="title">
236
  <span class="semantic-submode-label">px</span>
237
  </span>
 
247
  </span>
248
  <span class="semantic-submode-group">
249
  <label class="semantic-submode-label">
250
+ <input type="checkbox" id="gen_attr_dag_decay_attribution_high_surprisal" checked
251
+ title="When checked, recursive trace decays propagated attribution at high-surprisal generated tokens (MI discount), so surprising or teacher-forced tokens can act as sources. Unchecked: all generated tokens are transparent conduits; trace continues to prompt only."
252
  data-i18n="title">
253
+ Decay attribution to high-surprisal targets
254
  </label>
255
  </span>
256
  </div>
257
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
258
  <span class="semantic-submode-group">
259
+ <label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Attribution top-p coverage</label>
260
  <input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
261
  value="0.7" min="0.05" max="1" step="0.05"
262
  title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
263
  data-i18n="title">
264
  </span>
265
+ <span class="semantic-submode-group semantic-submode-group--emphasis">
266
  <label class="semantic-submode-label">
267
+ <input type="checkbox" id="gen_attr_dag_recursive_attribution"
268
+ title="Trace from the focused token back to information sources (not one hop). Sources: prompt; surprising or teacher-forced generated tokens (MI decay stops the chain). Conduits: high-confidence middle tokens—attribution passes through. Blue edges: propagated share; node ring: attribution stay (strong where explanation lands). Use with Decay attribution to high-surprisal targets. Off = direct mode (immediate predecessors only)."
269
  data-i18n="title">
270
+ Propagated attribution mode
271
+ </label>
272
+ </span>
273
+ <span class="semantic-submode-group" id="gen_attr_dag_show_downstream_influence_group">
274
+ <label class="semantic-submode-label">
275
+ <input type="checkbox" id="gen_attr_dag_show_downstream_influence"
276
+ title="When checked, direct attribution focus also shows outgoing edges from the selected or hovered token as downstream influence. Propagated attribution keeps showing upstream attribution chains only."
277
+ data-i18n="title">
278
+ Show downstream influence
279
  </label>
280
  </span>
281
  </div>
 
334
  </span>
335
  </div>
336
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
337
+ <span class="semantic-submode-group">
338
+ <label class="semantic-submode-label">
339
+ <input type="checkbox" id="gen_attr_dag_hide_inactive_edges"
340
+ title="When checked, gray DAG edges not adjacent to the hovered or selected node are hidden."
341
+ data-i18n="title">
342
+ Hide inactive edges
343
+ </label>
344
+ </span>
345
+ </div>
346
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row gen-attr-dag-show-topk-on-selected-row">
347
+ <span class="semantic-submode-group">
348
+ <label class="semantic-submode-label">
349
+ <input type="checkbox" id="gen_attr_dag_show_topk_on_selected"
350
+ title="When checked, selecting or hovering a token node shows token information in the results area."
351
+ data-i18n="title">
352
+ <span data-i18n>Show token tooltip</span>
353
+ </label>
354
+ </span>
355
+ </div>
356
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
357
+ <span class="semantic-submode-group gen-attr-dag-replay-speed-row semantic-submode-group--emphasis">
358
  <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
359
  <select id="gen_attr_dag_replay_mode"
360
  class="semantic-submode-select gen-attr-dag-replay-mode-select"
client/src/partials/settings-menu-analysis.html CHANGED
@@ -4,12 +4,6 @@
4
  <input type="checkbox" id="enable_minimap_toggle">
5
  </label>
6
  </div>
7
- <div id="semantic_analysis_item" class="settings-menu-item settings-menu-row">
8
- <span class="settings-menu-label">Semantic analysis (Beta):</span>
9
- <label class="settings-menu-control settings-menu-checkbox" style="cursor: pointer;">
10
- <input type="checkbox" id="semantic_analysis_toggle">
11
- </label>
12
- </div>
13
  <div id="token_render_style_item" class="settings-menu-item settings-menu-row" data-admin-only style="display: none;">
14
  <span class="settings-menu-label">Token render style:</span>
15
  <div id="token_render_style_dropdown" class="settings-menu-control settings-dropdown-in-menu"></div>
 
4
  <input type="checkbox" id="enable_minimap_toggle">
5
  </label>
6
  </div>
 
 
 
 
 
 
7
  <div id="token_render_style_item" class="settings-menu-item settings-menu-row" data-admin-only style="display: none;">
8
  <span class="settings-menu-label">Token render style:</span>
9
  <div id="token_render_style_dropdown" class="settings-menu-control settings-dropdown-in-menu"></div>
client/src/ts/api/GLTR_API.ts CHANGED
@@ -244,6 +244,7 @@ export class TextAnalysisAPI {
244
  os: Record<string, number>,
245
  page_sec: Record<string, number>,
246
  api: Record<string, number>,
 
247
  saved_at: string | null,
248
  process_start_at?: string | null,
249
  startup_base?: {
@@ -252,6 +253,7 @@ export class TextAnalysisAPI {
252
  page_sec?: Record<string, number>,
253
  api?: Record<string, number>,
254
  os?: Record<string, number>,
 
255
  },
256
  reset_base?: {
257
  page_loads?: number,
@@ -259,6 +261,7 @@ export class TextAnalysisAPI {
259
  page_sec?: Record<string, number>,
260
  api?: Record<string, number>,
261
  os?: Record<string, number>,
 
262
  },
263
  reset_at?: string | null,
264
  }> {
 
244
  os: Record<string, number>,
245
  page_sec: Record<string, number>,
246
  api: Record<string, number>,
247
+ gen_attr_opt_sec?: Record<string, number>,
248
  saved_at: string | null,
249
  process_start_at?: string | null,
250
  startup_base?: {
 
253
  page_sec?: Record<string, number>,
254
  api?: Record<string, number>,
255
  os?: Record<string, number>,
256
+ gen_attr_opt_sec?: Record<string, number>,
257
  },
258
  reset_base?: {
259
  page_loads?: number,
 
261
  page_sec?: Record<string, number>,
262
  api?: Record<string, number>,
263
  os?: Record<string, number>,
264
+ gen_attr_opt_sec?: Record<string, number>,
265
  },
266
  reset_at?: string | null,
267
  }> {
client/src/ts/attribution/genAttributeDagEdgeDisplay.ts CHANGED
@@ -1,8 +1,24 @@
1
  /**
2
- * DAG 最终 `stroke-opacity`(`normalizedScore × mutualInformationRatio`)的下限:
3
- * 的边不进入图中展示
4
- *
5
- * 与同数值在 `genAttributeDagPreprocess.ts` 池内前缀选取里 `relativeFloor = 常数 × topFrac` 复用:
6
- * max 归一后首条 `normalizedScore === 1`,故低于该相对份额的条目不可能在 MI≤1 下达到本阈值,属提前筛除。
7
  */
8
- export const DAG_EDGE_MIN_DISPLAY_OPACITY = 0.1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /**
2
+ * 剪枝:候选池内 max 归一后的 `normalizedScore` 低于该值不连边;
3
+ * decay 开启时再要求 `mutualInformationRatio × normalizedScore` 不低值。
4
+ * 与 {@link DAG_EDGE_RENDER_OPACITY_FLOOR} 独立;数值可分别调整。
 
 
5
  */
6
+ export const DAG_EDGE_MIN_NORMALIZED_SCORE = 0.1;
7
+
8
+ /**
9
+ * 焦点传播 / 高亮剪枝:传播后的节点份额、边份额、下游强度低于该阈值的边/节点不参与追因高亮。
10
+ */
11
+ export const DAG_MIN_ATTRIBUTION_SHARE = 0.01;
12
+
13
+ /**
14
+ * 归一后 `stroke-opacity` 的显示下限(`Math.max(本常数, scaled)`):弱边被抬高到此值,而非滤掉。
15
+ * 与 {@link DAG_EDGE_MIN_NORMALIZED_SCORE}(低于则剪枝)语义相反。
16
+ */
17
+ export const DAG_EDGE_RENDER_OPACITY_FLOOR = 0.1;
18
+
19
+ /**
20
+ * 递归链候选节点描边 `stroke-opacity` 下限:`stay / max(stay)` 线性映射到 `[本值, 1]`。
21
+ * 弱 stay 若直接当 opacity,在链上 max(stay) 较大时会接近 0、描边几乎看不见;抬高下限保留相对强弱对比。
22
+ * 与 {@link DAG_EDGE_RENDER_OPACITY_FLOOR}(边)独立。
23
+ */
24
+ export const DAG_NODE_STROKE_OPACITY_BASE = 0.3;
client/src/ts/attribution/genAttributeDagPreprocess.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
  import type { NodeAggregatedEntry } from './genAttributeDagIntervalResolve';
6
  import type { TokenGenStep } from './tokenGenAttributionRunner';
7
  import { getAttentionRawScore } from '../utils/semanticUtils';
8
- import { DAG_EDGE_MIN_DISPLAY_OPACITY } from './genAttributeDagEdgeDisplay';
9
 
10
  /** 与 DAG 节点 id 一致:来自 API `token_attribution` 几何(按 offset 去重,独立于 exclude/归一化)。 */
11
  export type PromptTokenSpan = {
@@ -58,38 +58,29 @@ function normalizeTopNPoolForDagSparse<T extends { score: number }>(tokens: T[])
58
  }
59
 
60
  /**
61
- * 在候选池已按 `score` 降序、池内归一保持该顺序的前提下,按遍历顺序取前缀,直到:
62
- * - 池内 L1 份额小于 {@link DAG_EDGE_MIN_DISPLAY_OPACITY}×首条份额(`relativeFloor`系数与最小展示透明度同值),
63
  * - 累计达到给定阈值(默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT};候选池内 Top-P,非整步全量 token 的分母)。
64
- * (池内份额与 `score` 单调一致,无需再排序。)
65
- *
66
- * `relativeFloor`:{@link normalizeTopNPoolForDagSparse} 后首条 `normalizedScore === 1`,且对正分条目有
67
- * `poolMassFrac_i / topFrac === normalizedScore_i`。故 `frac < β×topFrac` ⇔ `normalizedScore < β`;
68
- * 再乘互信息率(≤1)后不可能达到视图层最小 `stroke-opacity`,等于提前剔除注定画不出的边,与
69
- * {@link DAG_EDGE_MIN_DISPLAY_OPACITY} 在视图中的含义对齐。
70
  */
71
- function selectTokenAttributionByCumulativeShare<T extends { poolMassFrac: number }>(
72
  normalized: Array<T>,
73
  cumulativeShareThreshold: number,
74
  ): Array<T> {
75
  if (normalized.length === 0) return [];
76
-
77
- const topFrac = normalized[0]?.poolMassFrac ?? 0;
78
- if (!(topFrac > 0)) return [];
79
- const relativeFloor = DAG_EDGE_MIN_DISPLAY_OPACITY * topFrac;
80
 
81
  let cum = 0;
82
  const picked: Array<T> = [];
83
  for (const t of normalized) {
84
- const frac = t.poolMassFrac;
85
- if (!(frac > 0)) {
86
  break;
87
  }
88
- if (frac < relativeFloor) {
 
89
  break;
90
  }
91
  picked.push(t);
92
- cum += frac;
93
  if (cum >= cumulativeShareThreshold) {
94
  break;
95
  }
 
5
  import type { NodeAggregatedEntry } from './genAttributeDagIntervalResolve';
6
  import type { TokenGenStep } from './tokenGenAttributionRunner';
7
  import { getAttentionRawScore } from '../utils/semanticUtils';
8
+ import { DAG_EDGE_MIN_NORMALIZED_SCORE } from './genAttributeDagEdgeDisplay';
9
 
10
  /** 与 DAG 节点 id 一致:来自 API `token_attribution` 几何(按 offset 去重,独立于 exclude/归一化)。 */
11
  export type PromptTokenSpan = {
 
58
  }
59
 
60
  /**
61
+ * 在候选池已按 `score` 降序、池内 max 归一(`score` 即 `normalizedScore`)的前提下,按遍历顺序取前缀,直到:
62
+ * - `normalizedScore < {@link DAG_EDGE_MIN_NORMALIZED_SCORE}`,或
63
  * - 累计达到给定阈值(默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT};候选池内 Top-P,非整步全量 token 的分母)。
 
 
 
 
 
 
64
  */
65
+ function selectTokenAttributionByCumulativeShare<T extends { score: number; poolMassFrac: number }>(
66
  normalized: Array<T>,
67
  cumulativeShareThreshold: number,
68
  ): Array<T> {
69
  if (normalized.length === 0) return [];
70
+ if (!(normalized[0]!.poolMassFrac > 0)) return [];
 
 
 
71
 
72
  let cum = 0;
73
  const picked: Array<T> = [];
74
  for (const t of normalized) {
75
+ if (!(t.poolMassFrac > 0)) {
 
76
  break;
77
  }
78
+ const normalizedScore = t.score;
79
+ if (normalizedScore < DAG_EDGE_MIN_NORMALIZED_SCORE) {
80
  break;
81
  }
82
  picked.push(t);
83
+ cum += t.poolMassFrac;
84
  if (cum >= cumulativeShareThreshold) {
85
  break;
86
  }
client/src/ts/attribution/genAttributeDagTopkToken.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FrontendToken } from '../api/GLTR_API';
2
+ import type { TokenGenStep } from './tokenGenAttributionRunner';
3
+
4
+ /**
5
+ * 将单步生成归因结果转为与 {@link ToolTip} / Top‑K 条形图一致的 {@link FrontendToken}。
6
+ * `debug_info.topk_*` 来自 `/api/prediction-attribute`,与语义分析同源。
7
+ */
8
+ export function frontendTokenFromGenAttrStep(step: TokenGenStep): FrontendToken | null {
9
+ const raw = step.token;
10
+ if (!raw) return null;
11
+
12
+ const start = step.context.length;
13
+ const end = start + raw.length;
14
+ const prob = step.response.target_prob;
15
+ const real_topk =
16
+ prob != null && Number.isFinite(prob) ? ([0, prob] as [number, number]) : undefined;
17
+
18
+ const dbg = step.response.debug_info;
19
+ let pred_topk: [string, number][] = [];
20
+ if (dbg?.topk_tokens?.length && dbg?.topk_probs?.length) {
21
+ const n = Math.min(dbg.topk_tokens.length, dbg.topk_probs.length);
22
+ for (let i = 0; i < n; i++) {
23
+ const t = dbg.topk_tokens[i]!;
24
+ const p = dbg.topk_probs[i]!;
25
+ if (typeof t === 'string' && typeof p === 'number' && Number.isFinite(p)) {
26
+ pred_topk.push([t, p]);
27
+ }
28
+ }
29
+ }
30
+
31
+ return {
32
+ offset: [start, end],
33
+ raw,
34
+ pred_topk,
35
+ ...(real_topk !== undefined ? { real_topk } : {}),
36
+ };
37
+ }
client/src/ts/attribution/genAttributeDagView.ts CHANGED
@@ -1,6 +1,6 @@
1
  import * as d3 from 'd3';
2
  import { DirectedGraph } from 'graphology';
3
- import { calculateSurprisal, type D3Sel } from '../utils/Util';
4
  import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
5
  import {
6
  clampDagEdgeTopPCoverage,
@@ -10,10 +10,17 @@ import {
10
  phase2RankAndSparsify,
11
  type PromptTokenSpan,
12
  } from './genAttributeDagPreprocess';
13
- import { DAG_EDGE_MIN_DISPLAY_OPACITY } from './genAttributeDagEdgeDisplay';
 
 
 
 
 
14
  import {
15
  computeMutualInformationRatio,
16
  computeConditionalInformationRatio,
 
 
17
  FULL_CONFIDENCE_PROBABILITY_BASELINE,
18
  } from '../utils/surprisalMath';
19
  import { isOffsetSpanFullyExcluded } from './attributionDisplayModel';
@@ -23,8 +30,12 @@ import {
23
  type NodeInterval,
24
  type PieceEntry,
25
  } from './genAttributeDagIntervalResolve';
 
26
  import type { TokenGenStep } from './tokenGenAttributionRunner';
27
  import { createGenAttributeDagTextMeasure } from './genAttributeDagTextMeasure';
 
 
 
28
  import { formatTopkTooltipProbabilityPercent } from '../utils/topkChartUtils';
29
  import {
30
  CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT,
@@ -38,20 +49,22 @@ import {
38
  LINEAR_ARC_ADJACENT_GAP_MAX,
39
  LINEAR_ARC_ADJACENT_GAP_MIN,
40
  LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION,
 
41
  paintLinearArcLayout,
42
  } from './genAttributeDagViewLinearArcMode';
43
  import { paintTextFlowLayout } from './genAttributeDagViewTextFlowMode';
44
  import { paintSpiralLayout } from './genAttributeDagViewSpiralMode';
45
  import { tr } from '../lang/i18n-lite';
46
 
47
- /** 与 {@link ToolTip} 中 surprisal 数值格式一致(`.3g`) */
48
- const DAG_TITLE_SURPRISAL_FMT = d3.format('.3g');
49
-
50
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
51
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
52
 
53
- /** 节点布局模式:`text-flow` 按文字排版层几何;`linear-arc` 按节点插入序线性排布 + 弧线连边;`spiral` 螺旋排布。 */
54
- export type DagLayoutMode = 'text-flow' | 'linear-arc' | 'spiral';
 
 
 
 
55
 
56
  export const DAG_COMPACTNESS_DEFAULT = 0.5;
57
  /** 下限取小正数以满足 {@link readDisplayScaleFromCss}「必须为正」且不出现零宽度边线。 */
@@ -71,10 +84,16 @@ export function setDagNodeCiVisualScaleEnabled(enabled: boolean): void {
71
  dagNodeCiVisualScaleEnabled = enabled;
72
  }
73
 
74
- /** 高惊讶度目标边弱化开关;`false` 时 mutualInformationRatio 恒为 1(不弱化),下次 update() 起生效。 */
75
- let dagEdgeWeakenHighSurprisalEnabled = true;
76
- export function setDagEdgeWeakenHighSurprisalEnabled(enabled: boolean): void {
77
- dagEdgeWeakenHighSurprisalEnabled = enabled;
 
 
 
 
 
 
78
  }
79
 
80
  /**
@@ -83,30 +102,81 @@ export function setDagEdgeWeakenHighSurprisalEnabled(enabled: boolean): void {
83
  * {@link dagNodeCiVisualScaleEnabled} 为 false 时恒返回 1。
84
  */
85
  function dagGeneratedNodeCiVisualScale(targetProb: number | undefined): number {
86
- if (!dagNodeCiVisualScaleEnabled) return 1;
87
- if (targetProb !== undefined && Number.isFinite(targetProb) && targetProb > FULL_CONFIDENCE_PROBABILITY_BASELINE) return 1;
88
- return 1 + computeConditionalInformationRatio(targetProb);
89
  }
90
 
91
- /** 原生 `<title>` CI/MI 百分号展示,与 Top-K 概率列 {@link formatTopkTooltipProbabilityPercent} 同形。 */
92
- function formatCiMiRatiosLineForTooltip(ciRatio: number, miRatio: number): string {
 
 
 
93
  const ci = Number.isFinite(ciRatio) ? formatTopkTooltipProbabilityPercent(ciRatio) : String(ciRatio);
94
  const mi = Number.isFinite(miRatio) ? formatTopkTooltipProbabilityPercent(miRatio) : String(miRatio);
95
- return `CI/MI: ${ci} / ${mi}`;
96
  }
97
 
98
- /** 边原生 `<title>` 中互信息率 α 的展示(节点 title 改用 {@link formatCiMiRatiosLineForTooltip})。 */
99
- function formatMutualInformationRatioForTooltip(miRatio: number): string {
100
- if (!Number.isFinite(miRatio)) return String(miRatio);
 
 
101
  return formatTopkTooltipProbabilityPercent(miRatio);
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  export {
105
  clampLinearArcAdjacentGap,
106
  LINEAR_ARC_ADJACENT_GAP_DEFAULT,
107
  LINEAR_ARC_ADJACENT_GAP_MAX,
108
  LINEAR_ARC_ADJACENT_GAP_MIN,
109
  LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION,
 
110
  };
111
 
112
  /** 图中节点业务字段(与 graphology 节点 attributes 为同一对象) */
@@ -132,10 +202,17 @@ type DagNodeAttrs = {
132
  nodeH: number;
133
  /** CI 视觉缩放倍数 `1 + CI` ∈ [1, 2];prompt 节点为 `1`。供 CSS 字号变量使用。 */
134
  ciVisualScale: number;
 
 
 
 
 
135
  /** {@link visualizeSpecialChars}(DAG:仅「空格后是 [A-Za-z0-9]」保留空格,其余空格为 ·),建点后不变 */
136
  displayLabel: string;
137
- /** 原生 `<title>` 全文(与 `DISABLE_DAG_NODE_TOOLTIPS` 无关,便于切换时不必重算) */
138
- nativeTitleText: string;
 
 
139
  };
140
 
141
  type DagNode = DagNodeAttrs;
@@ -145,29 +222,164 @@ type DagLink = {
145
  target: string;
146
  /**
147
  * 候选池内 max 归一后的归因分,区间约 [0, 1];作为 `stroke-opacity` 的基项(再乘 {@link mutualInformationRatio})。
148
- * 池内稀疏化与建边前过滤均使用 {@link DAG_EDGE_MIN_DISPLAY_OPACITY}(见 genAttributeDagEdgeDisplay);条件为 {@link dagLinkStrokeOpacity} 不低于该阈值
149
  */
150
  normalizedScore?: number;
151
  /** 互信息率:仅作为本步入边的视觉透明度系数,不参与归因筛选。 */
152
  mutualInformationRatio?: number;
153
- /** 本步内:该边池内 L1 份额在「仅可见边」{@link DAG_EDGE_MIN_DISPLAY_OPACITY} 过滤后)占比;用于原生 title「Fan in share」 */
154
- scoreShare?: number;
155
  /** 与 `console.warn('[genAttributeDagView.align] …')` 正文一致(可多条,换行拼接) */
156
  alignmentNote?: string;
157
- /** 边创建时固定的 `<title>` 全文 */
158
- titleText: string;
159
  };
160
 
161
- /** 与 {@link refreshNodeLinkHighlight} 中边的 `stroke-opacity` 一致:`normalizedScore × mutualInformationRatio`(开关关闭时 MI 系数恒为 1)。 */
162
- function dagLinkStrokeOpacity(d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio'>): number {
163
- const mi = dagEdgeWeakenHighSurprisalEnabled ? (d.mutualInformationRatio ?? 1) : 1;
164
- return (d.normalizedScore ?? 1) * mi;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  }
166
 
167
  function dagLinkEndpointKey(source: string, target: string): string {
168
  return `${source}->${target}`;
169
  }
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  /**
172
  * 流式增量:任一端节点 span 完全落在排除区间内则删边(不重算 Top‑N,与全量重放可轻微不一致)。
173
  * 同步 graphology 与并行 `links`。
@@ -175,6 +387,7 @@ function dagLinkEndpointKey(source: string, target: string): string {
175
  function pruneDagLinksTouchingFullyExcludedNodes(
176
  graph: DirectedGraph<DagNodeAttrs>,
177
  links: DagLink[],
 
178
  intervals: [number, number][],
179
  ): void {
180
  if (intervals.length === 0) return;
@@ -205,11 +418,36 @@ function pruneDagLinksTouchingFullyExcludedNodes(
205
  links[write++] = link;
206
  }
207
  links.length = write;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  }
209
 
210
  const SVG_MIN_W = 320;
211
  const SVG_MIN_H = 280;
212
 
 
 
 
213
  /**
214
  * `.gen-attr-dag-stack` 布局尺寸(px),供 SVG width/height 与 `fitViewportToContent` 共用。
215
  * 用 offsetWidth/offsetHeight(布局流尺寸)而非 getBoundingClientRect,
@@ -224,7 +462,7 @@ function stackLayoutViewportPx(stackEl: HTMLElement): { w: number; h: number } {
224
 
225
  /** text-flow:在「抵消 display-scale」基准上的初始 zoom 倍率(d3 的 k) */
226
  const DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW = 2;
227
- /** linear-arc:同上 */
228
  const DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC = 4;
229
  /** spiral:同上 */
230
  const DAG_INITIAL_ZOOM_BOOST_SPIRAL = 2;
@@ -234,6 +472,7 @@ function dagInitialZoomBoost(mode: DagLayoutMode): number {
234
  case 'text-flow':
235
  return DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW;
236
  case 'linear-arc':
 
237
  return DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC;
238
  case 'spiral':
239
  return DAG_INITIAL_ZOOM_BOOST_SPIRAL;
@@ -253,19 +492,18 @@ const CSS_VAR_DAG_LINK_STROKE_WIDTH = '--gen-attr-dag-link-stroke-width';
253
 
254
  /** 与 {@link start.scss} `--dag-normal-line-color` 一致(普通边:线 stroke + 箭头 marker stroke) */
255
  const CSS_VAR_DAG_NORMAL_LINE_COLOR = '--dag-normal-line-color';
256
- /** 与 {@link start.scss} `--dag-highlight-line-color-in` 一致(入边:指向焦点) */
257
  const CSS_VAR_DAG_HIGHLIGHT_LINE_IN = '--dag-highlight-line-color-in';
258
  /** 与 {@link start.scss} `--dag-highlight-line-color-out` 一致(出边:从焦点出发) */
259
  const CSS_VAR_DAG_HIGHLIGHT_LINE_OUT = '--dag-highlight-line-color-out';
 
 
260
 
261
  /** 弱化:未排除的 prompt 无出边,或(prompt/生成区)邻域外且存在悬停/选中焦点时 */
262
  const DAG_NODE_WEAKEN_OPACITY = 0.5;
263
  /** 隐藏:节点 span 完全落在 exclude 规则命中区间内(prompt 与生成区各一套模式) */
264
  const DAG_NODE_HIDDEN_OPACITY = 0.1;
265
 
266
- /** 暂时关闭节点上的原生 `<title>` 悬浮提示;恢复时改为 `false`(边不受影响) */
267
- const DISABLE_DAG_NODE_TOOLTIPS = false;
268
-
269
  /**
270
  * 边端在矩形边界外侧的留白,相对测量层「1em」的比例(无单位);与箭头/描边衔接用。
271
  * 测量层与节点几何同源(lmf-readout-text),故随字号/CSS 变化而变。
@@ -316,6 +554,21 @@ function nodeRx(d: DagNode): number {
316
  return Math.min(d.nodeW / 2, d.nodeH / 2);
317
  }
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  export type GenAttributeDagHandle = {
320
  /**
321
  * 在首帧 `update`(第一步生成 token)之前调用一次:用全量 prompt token spans 建 prompt 层节点。
@@ -346,12 +599,16 @@ export type GenAttributeDagHandle = {
346
  reset(preserveUserViewport?: boolean): void;
347
  /**
348
  * zoom identity 后按内容适配视口;空图走默认缩放;`k` 上限 `k₀`(随当前布局模式的初始 zoom 倍率变化)。
349
- * - `text-flow`:`rootG.getBBox()`(含边)等比落入内框。
350
- * - `linear-arc`:仅按 `gen-attr-dag-nodes` 行宽定比,token 行相对内框竖直居中(弧不参与)。
351
  * 若 `layoutDirty` 为真则 no-op(仅已执行的 `syncSvgSize` 生效,不改 pan/zoom),但 `force` 为真时仍
352
  * fit 并清 dirty(例如刷新按钮的强制适配)。
353
  */
354
  fitViewportToContent(force?: boolean): void;
 
 
 
 
355
  /** 清除节点选中态(与点击画布空白等价);不改变图数据,生成结束后可调用以去掉末 token 描边 */
356
  clearNodeSelection(): void;
357
  /** DAG 步进重放:更新 ▶ / ⏸ 按钮文案(由页面在播放开始/结束/暂停时调用) */
@@ -366,7 +623,7 @@ export type GenAttributeDagHandle = {
366
  /** 切换 DAG 节点布局模式并立即重排现有节点/边。 */
367
  setLayoutMode(mode: DagLayoutMode): void;
368
  /**
369
- * linear-arc 下相邻节点矩形外侧边的水平间隙(px)。影响 linear-arc 几何;若在生成/播放中途调用且
370
  * `skipRefit` 为真,仅写入值,下一轮 `syncGraphToSvg`/空闲后再反映(与测量宽度语义一致)。
371
  */
372
  setLinearArcAdjacentGapPx(px: number, opts?: { skipRefit?: boolean }): void;
@@ -383,6 +640,12 @@ export type GenAttributeDagHandle = {
383
  * - `false`(默认):保留为低透明度({@link DAG_NODE_HIDDEN_OPACITY})占位。
384
  */
385
  setHideExcludedTokens(hide: boolean): void;
 
 
 
 
 
 
386
  /** prompt 层节点是否已注入(即 {@link setPromptTokenSpans} 至少成功添加过一个节点) */
387
  hasPromptSpans(): boolean;
388
  /** 移除 DAG 栈与刷新按钮(离开页面时调用) */
@@ -409,55 +672,50 @@ function formatNodeOffsetRange(id: string): string {
409
  return `[${a}, ${b})`;
410
  }
411
 
412
- function buildNodeNativeTitleText(
413
- d: Pick<DagNode, 'displayLabel' | 'id' | 'step'> & { targetProb?: number },
414
- ): string {
415
- const lines = [
416
- d.displayLabel,
417
- `Offset: ${formatNodeOffsetRange(d.id)}`,
418
- `Step: ${d.step}`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  ];
420
- const { targetProb } = d;
421
- if (targetProb !== undefined && Number.isFinite(targetProb)) {
422
- lines.push(`\nProb: ${formatTopkTooltipProbabilityPercent(targetProb)}`);
423
- lines.push(`Information: ${DAG_TITLE_SURPRISAL_FMT(calculateSurprisal(targetProb))} bits`);
424
- lines.push(
425
- formatCiMiRatiosLineForTooltip(
426
- computeConditionalInformationRatio(targetProb),
427
- computeMutualInformationRatio(targetProb),
428
- ),
429
- );
430
  }
431
- return lines.join('\n');
432
- }
433
-
434
- /** 建边时调用:端点已带 {@link DagNodeAttrs.displayLabel} */
435
- function buildLinkTitleText(
436
- d: Pick<DagLink, 'normalizedScore' | 'mutualInformationRatio' | 'scoreShare' | 'alignmentNote'>,
437
- src: DagNode,
438
- tgt: DagNode
439
- ): string {
440
- const s = d.normalizedScore ?? 1;
441
- const normStr = Number.isFinite(s) ? s.toFixed(3) : String(s);
442
- const opacity = dagLinkStrokeOpacity(d);
443
- const opacityStr = Number.isFinite(opacity) ? opacity.toFixed(3) : String(opacity);
444
 
445
  const metrics = [
446
- `Attribution score: ${normStr}`,
447
- `Target MI ratio: ${formatMutualInformationRatioForTooltip(d.mutualInformationRatio ?? 1)}`,
448
- `Link strength: ${opacityStr}`,
 
449
  ];
450
- const share = d.scoreShare;
451
- if (typeof share === 'number' && Number.isFinite(share) && share > 0) {
452
- metrics.push(`Fan in share: ${(share * 100).toFixed(1)}%`);
453
- }
454
- if (d.alignmentNote) {
455
- metrics.push(d.alignmentNote);
456
- }
457
 
458
  return [
459
- `From:\n${src.displayLabel}\nOffset: ${formatNodeOffsetRange(src.id)}`,
460
- `To:\n${tgt.displayLabel}\nOffset: ${formatNodeOffsetRange(tgt.id)}`,
461
  metrics.join('\n'),
462
  ].join('\n\n');
463
  }
@@ -480,45 +738,147 @@ function snapSubwordNode(node: DagNode, prev: DagNode | null): void {
480
  node.cx = prev.cx + (prev.nodeW + node.nodeW) / 2;
481
  }
482
 
483
- /** 焦点 + 一层入邻(直接祖先)+ 一层出邻(直接后代),用于选中/悬停高亮范围 */
484
- function oneHopNeighborhood(graph: DirectedGraph<DagNodeAttrs>, nodeId: string): Set<string> {
485
- const active = new Set<string>([nodeId]);
486
- graph.forEachInNeighbor(nodeId, (n) => {
487
- active.add(n);
488
- });
489
- graph.forEachOutNeighbor(nodeId, (n) => {
490
- active.add(n);
491
- });
492
- return active;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  }
494
 
495
- /** 边是否与焦点节点邻接(用于高亮边样式与 SVG 中置于灰边之上) */
496
- function dagLinkIncidentToFocus(
497
- graph: DirectedGraph<DagNodeAttrs>,
498
- focusId: string | null,
499
- d: DagLink
500
- ): boolean {
501
- if (!focusId) return false;
502
- const s = endpointNode(d.source, graph).id;
503
- const t = endpointNode(d.target, graph).id;
504
- return s === focusId || t === focusId;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  }
506
 
507
- /**
508
- * 邻接焦点时边的描边:从焦点出发 → 红;指向焦点 → 蓝(自环视为「出发」)。
509
- * 非邻接返回 `null`,调用方用默认边色。
510
- */
511
- function dagLinkHighlightStroke(
512
  graph: DirectedGraph<DagNodeAttrs>,
513
- focusId: string | null,
514
- d: DagLink
515
- ): string | null {
516
- if (!focusId) return null;
517
- const s = endpointNode(d.source, graph).id;
518
- const t = endpointNode(d.target, graph).id;
519
- if (s !== focusId && t !== focusId) return null;
520
- if (s === focusId) return `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_OUT})`;
521
- return `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_IN})`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  }
523
 
524
  /**
@@ -562,12 +922,18 @@ export type InitGenAttributeDagViewOptions = {
562
  /** DAG 节点布局模式;默认 `text-flow`。 */
563
  layoutMode?: DagLayoutMode;
564
  /**
565
- * linear-arc:相邻节点矩形外侧边的水平间隙(px),决定水平方向疏密;
566
  * 默认 {@link LINEAR_ARC_ADJACENT_GAP_DEFAULT}。
567
  */
568
  linearArcAdjacentGapPx?: number;
569
  /** 被 exclude 规则命中的节点是否完全隐藏(true)还是仅降至 {@link DAG_NODE_HIDDEN_OPACITY}(false,默认)。 */
570
  hideExcludedTokens?: boolean;
 
 
 
 
 
 
571
  /** 边 Top-P 覆盖阈值(候选池内累计份额);���认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT}。 */
572
  edgeTopPCoverage?: number;
573
  /** 进入/退出/切换全屏失败时(常见于移动端不支持元素全屏等)。不传则无提示。 */
@@ -597,6 +963,9 @@ export function initGenAttributeDagView(
597
  linearArcAdjacentGapPx = clampLinearArcAdjacentGap(iv);
598
  }
599
  let hideExcludedTokens: boolean = options?.hideExcludedTokens ?? false;
 
 
 
600
  let edgeTopPCoverage = clampDagEdgeTopPCoverage(
601
  options?.edgeTopPCoverage ?? DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
602
  );
@@ -624,6 +993,8 @@ export function initGenAttributeDagView(
624
  isBatching: () => false,
625
  reset: noop,
626
  fitViewportToContent: noop,
 
 
627
  clearNodeSelection: noop,
628
  setDagPlaybackPlaying: noop,
629
  setMeasureWidthPx: noop,
@@ -632,6 +1003,9 @@ export function initGenAttributeDagView(
632
  setDagCompactness: noop,
633
  setEdgeTopPCoverage: noop,
634
  setHideExcludedTokens: noop,
 
 
 
635
  hasPromptSpans: () => false,
636
  detach: noop,
637
  };
@@ -642,13 +1016,33 @@ export function initGenAttributeDagView(
642
  detachGenAttributeDagPanel.get(rootEl)?.();
643
  resultsRoot
644
  .selectAll(
645
- '.gen-attr-dag-stack, svg.gen-attr-dag-svg, button.gen-attr-dag-refresh, button.gen-attr-dag-play, button.gen-attr-dag-fullscreen'
646
  )
647
  .remove();
648
 
649
  const stack = resultsRoot.append('div').attr('class', 'gen-attr-dag-stack');
650
  const stackEl = stack.node() as HTMLElement;
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  /** 非 text-flow 时节点不可拖;用该类覆盖选中态的 grab 光标(linear-arc / spiral 等)。 */
653
  function syncStackLayoutDragUi(): void {
654
  stackEl.classList.toggle('gen-attr-dag-no-node-drag-layout', layoutMode !== 'text-flow');
@@ -697,6 +1091,7 @@ export function initGenAttributeDagView(
697
  function refreshDagScaleDerivedFromCss(): void {
698
  displayScale = readDisplayScaleFromCss(stackEl);
699
  linkEndInsetPx = linkEndInsetBaseAtUnitScalePx(measureRoot) * displayScale;
 
700
  }
701
 
702
  function setDagCompactness(c: number): void {
@@ -735,6 +1130,7 @@ export function initGenAttributeDagView(
735
  // 仅用户交互(滚轮/拖平移/双击)计入「改动布局」;程序触发的 transform
736
  // (init 初始缩放、`fitViewportToContent`)`sourceEvent === null`,不置 dirty。
737
  if (event.sourceEvent) layoutDirty = true;
 
738
  });
739
 
740
  function applyInitialDagZoom(): void {
@@ -750,14 +1146,37 @@ export function initGenAttributeDagView(
750
  const nodeG = rootG.append('g').attr('class', 'gen-attr-dag-nodes');
751
  /** 邻接焦点的高亮边:在节点层之后绘制,避免被节点遮挡 */
752
  const linkGFront = rootG.append('g').attr('class', 'gen-attr-dag-links-front');
 
 
753
 
754
  const graph = new DirectedGraph<DagNodeAttrs>();
755
  let nodes: DagNode[] = [];
 
 
756
  let links: DagLink[] = [];
 
 
 
 
757
  let stepProcessed = 0;
758
  let selectedId: string | null = null;
759
- /** 时焦点:与选中同款子图高亮;优先于 {@link selectedId}移出节后回落到选中态 */
760
  let hoveredId: string | null = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  /**
762
  * 与 {@link pruneDagLinksTouchingFullyExcludedNodes} / 预处理同源:全串上的 exclude 半开区间,
763
  * 供节点「隐藏」透明度判定({@link isOffsetSpanFullyExcluded})。在 {@link setPromptTokenSpans} 与每步
@@ -783,6 +1202,32 @@ export function initGenAttributeDagView(
783
  .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link')
784
  .data<DagLink>([], dagLinkDataKey);
785
  let nodeSel = nodeG.selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node').data<DagNode>([], (d) => d.id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
 
787
  function syncSvgSize(): void {
788
  const { w, h } = stackLayoutViewportPx(stackEl);
@@ -790,7 +1235,8 @@ export function initGenAttributeDagView(
790
  }
791
 
792
  function paint(): void {
793
- if (layoutMode === 'linear-arc') {
 
794
  const layoutNodes = hideExcludedTokens
795
  ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals))
796
  : nodes;
@@ -799,14 +1245,13 @@ export function initGenAttributeDagView(
799
  nodeSel,
800
  nodes: layoutNodes,
801
  adjacentGapPx: linearArcAdjacentGapPx,
 
802
  getLinkNodes: (d) => ({
803
  src: endpointNode(d.source, graph),
804
  tgt: endpointNode(d.target, graph),
805
  }),
806
  });
807
- return;
808
- }
809
- if (layoutMode === 'spiral') {
810
  const layoutNodes = hideExcludedTokens
811
  ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals))
812
  : nodes;
@@ -820,17 +1265,18 @@ export function initGenAttributeDagView(
820
  tgt: endpointNode(d.target, graph),
821
  }),
822
  });
823
- return;
 
 
 
 
 
 
 
 
 
824
  }
825
- paintTextFlowLayout({
826
- linkSel,
827
- nodeSel,
828
- linkEndInsetPx,
829
- getLinkNodes: (d) => ({
830
- src: endpointNode(d.source, graph),
831
- tgt: endpointNode(d.target, graph),
832
- }),
833
- });
834
  }
835
 
836
  let dragPointerOffset: { x: number; y: number } | null = null;
@@ -858,32 +1304,56 @@ export function initGenAttributeDagView(
858
  d.cx = x - offset.x;
859
  d.cy = y - offset.y;
860
  paint();
 
861
  })
862
  .on('end', () => {
863
  dragPointerOffset = null;
864
  });
865
 
866
- /**
867
- * 节点透明度:先按「一跳邻域」提亮为 1(选中先于悬停,邻域内一致);
868
- * 否则被 exclude 整段命中的为「隐藏」({@link DAG_NODE_HIDDEN_OPACITY})(prompt/生成区一致);
869
- * 其余节点:prompt 无出边时弱化;存在焦点时,所有邻域外非隐藏节点弱化(与原先焦点压暗一致)。
870
- * 边的高亮仍以悬停优先、否则选中为焦点({@link dagLinkIncidentToFocus})。
871
- */
872
  function refreshNodeLinkHighlight(): void {
873
- const focusId = hoveredId ?? selectedId;
874
- const selectedNbhd = selectedId ? oneHopNeighborhood(graph, selectedId) : null;
875
- const hoveredNbhd = hoveredId ? oneHopNeighborhood(graph, hoveredId) : null;
876
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
877
  nodeSel
878
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
879
  .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id)
880
- .style('display', (d) =>
881
- hideExcludedTokens && isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)
882
- ? 'none' : null
883
- )
884
  .attr('opacity', (d) => {
885
- if (selectedNbhd?.has(d.id)) return 1;
886
- if (hoveredNbhd?.has(d.id)) return 1;
 
 
887
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
888
  return hideExcludedTokens ? 0 : DAG_NODE_HIDDEN_OPACITY;
889
  }
@@ -891,36 +1361,134 @@ export function initGenAttributeDagView(
891
  const isPromptLeaf = hasGenTokens && d.step === -1 && graph.outDegree(d.id) === 0;
892
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
893
  return 1;
 
 
 
 
 
894
  });
895
- // 每条边独立 marker:线与箭头 path 同步 stroke / stroke-opacity。
896
- // normalizedScore 决定边内相对强弱(与 opacity 基项一致);互信息率只作为整步入边的视觉折扣。
897
- linkSel.each(function(d) {
898
- const op = dagLinkStrokeOpacity(d);
899
- const stroke =
900
- dagLinkHighlightStroke(graph, focusId, d) ?? `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`;
 
 
 
 
 
 
 
 
 
 
 
 
 
901
  const g = d3.select(this);
902
- g.select('path.gen-attr-dag-link-visible').attr('stroke', stroke).attr('stroke-opacity', op);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  linkMarkersDefs
904
  .select<SVGPathElement>(`#${dagLinkMarkerElementId(d.source, d.target)} path`)
905
  .attr('stroke', stroke)
906
- .attr('stroke-opacity', op);
907
- });
908
- // 灰边在 linkG、高亮边在 linkGFront(位于 nodeG 之后),既不被灰边也不被节点遮挡。
909
- // 同层内保持 DOM 插入顺序(= `links` push 顺序)即可,无需显式 sort:
910
- // - `links` 只 push、不重排;
911
- // - 新 `<g>` 由 d3 `enter().append` 追加在 `linkG` 末尾;
912
- // - 下面仅在父节点不一致时才 `appendChild`,避免白搬动导致末尾顺序被打乱。
913
- rootG.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function(d) {
914
- const incident = dagLinkIncidentToFocus(graph, focusId, d);
915
  const parent = incident ? linkGFront : linkG;
916
  const parentNode = parent.node()!;
917
  if (this.parentNode !== parentNode) {
918
  parentNode.appendChild(this as SVGGElement);
919
  }
920
  });
 
 
921
  }
922
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
923
  function setSelectedNodeId(id: string | null): void {
 
 
 
924
  selectedId = id;
925
  refreshNodeLinkHighlight();
926
  }
@@ -931,6 +1499,7 @@ export function initGenAttributeDagView(
931
 
932
  /** 将当前 `nodes` / `links` 同步到 SVG:join 新 DOM、`paint` 几何、`refreshNodeLinkHighlight` 样式。 */
933
  function syncGraphToSvg(): void {
 
934
  linkGFront.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function() {
935
  linkG.node()!.appendChild(this as SVGGElement);
936
  });
@@ -966,7 +1535,7 @@ export function initGenAttributeDagView(
966
  g.each(function(d: DagLink) {
967
  const el = d3.select(this);
968
  const mkId = dagLinkMarkerElementId(d.source, d.target);
969
- el.append('title').text(d.titleText);
970
  el.append('path')
971
  .attr('class', 'gen-attr-dag-link-visible')
972
  .attr('fill', 'none')
@@ -978,55 +1547,53 @@ export function initGenAttributeDagView(
978
  return g;
979
  });
980
  // 不在此处全量重置 marker `stroke-opacity`:紧接着的 {@link refreshNodeLinkHighlight} 会按边
981
- // 逐条写 `dagLinkStrokeOpacity`(与 `<title>` 中 Strength 同源),任何前值都会被覆盖,全量重置纯冗余。
982
 
983
  nodeSel = nodeG
984
  .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node')
985
  .data(nodes, (d) => d.id)
986
- .join(
987
- (enter) => {
988
- // 节点身份 append-only、几何(nodeW/nodeH)一旦建立不再变化(drag x/y,
989
- // paint 通过 transform 处理),故与几何相关的属性仅在 enter 写一次即可;
990
- // 同理 `--prompt` class 依据 step === -1,step 初始化后不变。
991
- const g = enter
992
- .append('g')
993
- .attr('class', 'gen-attr-dag-node')
994
- .style('--gen-attr-dag-node-ci-visual-scale', (d: DagNode) => String(d.ciVisualScale));
995
- g.classed('gen-attr-dag-node--prompt', (d: DagNode) => d.step === -1);
996
- if (!DISABLE_DAG_NODE_TOOLTIPS) {
997
- g.append('title').text((d: DagNode) => d.nativeTitleText);
998
- }
999
- g.append('rect')
1000
- .attr('x', 0)
1001
- .attr('y', 0)
1002
- .attr('width', (d: DagNode) => d.nodeW)
1003
- .attr('height', (d: DagNode) => d.nodeH)
1004
- .attr('rx', (d: DagNode) => nodeRx(d))
1005
- .attr('ry', (d: DagNode) => nodeRx(d));
1006
- g.append('text')
1007
- .attr('class', 'gen-attr-dag-node-text')
1008
- .attr('xml:space', 'preserve')
1009
- .attr('pointer-events', 'none')
1010
- .attr('text-anchor', 'middle')
1011
- .attr('dominant-baseline', 'central')
1012
- .attr('x', (d: DagNode) => d.nodeW / 2)
1013
- .attr('y', (d: DagNode) => d.nodeH / 2)
1014
- .text((d: DagNode) => d.displayLabel);
1015
- g.on('mouseenter', (_event, d) => {
1016
- hoveredId = d.id;
1017
- refreshNodeLinkHighlight();
1018
- });
1019
- g.on('mouseleave', () => {
1020
- hoveredId = null;
1021
- refreshNodeLinkHighlight();
1022
- });
1023
- g.on('click', (event, d) => {
1024
- event.stopPropagation();
1025
- setSelectedNodeId(selectedId === d.id ? null : d.id);
1026
- });
1027
- return g.call(drag);
1028
- }
1029
- );
1030
 
1031
  paint();
1032
  refreshNodeLinkHighlight();
@@ -1082,11 +1649,6 @@ export function initGenAttributeDagView(
1082
  nodeH: g.height * displayScale,
1083
  ciVisualScale: 1,
1084
  displayLabel,
1085
- nativeTitleText: buildNodeNativeTitleText({
1086
- displayLabel,
1087
- id: srcId,
1088
- step: -1,
1089
- }),
1090
  };
1091
  graph.addNode(srcId, srcNode);
1092
  nodes.push(srcNode);
@@ -1103,6 +1665,8 @@ export function initGenAttributeDagView(
1103
  getEffectiveExcludePromptPatternsText(),
1104
  getEffectiveExcludeGeneratedPatternsText(),
1105
  );
 
 
1106
  if (batchDepth === 0) syncGraphToSvg();
1107
  }
1108
 
@@ -1130,6 +1694,8 @@ export function initGenAttributeDagView(
1130
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
1131
  });
1132
  const ciVisualScale = dagGeneratedNodeCiVisualScale(response.target_prob);
 
 
1133
  const targetNode: DagNode = {
1134
  id: targetId,
1135
  label: token,
@@ -1141,16 +1707,15 @@ export function initGenAttributeDagView(
1141
  nodeW: g.width * displayScale * ciVisualScale,
1142
  nodeH: g.height * displayScale * ciVisualScale,
1143
  ciVisualScale,
 
1144
  displayLabel,
1145
- nativeTitleText: buildNodeNativeTitleText({
1146
- displayLabel,
1147
- id: targetId,
1148
- step: stepProcessed,
1149
- targetProb: response.target_prob,
1150
- }),
1151
  };
1152
  graph.addNode(targetId, targetNode);
1153
  nodes.push(targetNode);
 
 
1154
  snapSubwordNode(targetNode, nodes.length >= 2 ? nodes[nodes.length - 2]! : null);
1155
 
1156
  // align → exclude → rank:Top-N / β / cumP 在节点语义上工作(合并型「如下」/ 拆分型等)。
@@ -1173,15 +1738,14 @@ export function initGenAttributeDagView(
1173
  const selected = phase2RankAndSparsify(afterExclude, { cumulativeShare: edgeTopPCoverage });
1174
 
1175
  const mutualInformationRatio = computeMutualInformationRatio(response.target_prob);
1176
- // 仅保留可绘制的边;「Fan in share」的分母为下列可见边的池内 L1 份额之和(非完整 sparse 池)。
1177
- const selectedForDisplay = selected.filter(
1178
- (item) =>
1179
- dagLinkStrokeOpacity({
1180
- normalizedScore: item.score,
1181
- mutualInformationRatio,
1182
- }) >= DAG_EDGE_MIN_DISPLAY_OPACITY
1183
- );
1184
  const massSum = selectedForDisplay.reduce((acc, t) => acc + Math.max(0, t.poolMassFrac), 0);
 
1185
  for (const item of selectedForDisplay) {
1186
  const srcId = item.nodeId;
1187
  if (!graph.hasNode(srcId)) {
@@ -1202,19 +1766,19 @@ export function initGenAttributeDagView(
1202
  const edgeAttrs = {
1203
  normalizedScore: item.score,
1204
  mutualInformationRatio,
1205
- scoreShare: share,
1206
  ...(alignmentNote ? { alignmentNote } : {}),
1207
  };
1208
  graph.addEdge(srcId, targetId, edgeAttrs);
1209
- const srcAttrs = graph.getNodeAttributes(srcId) as DagNode;
1210
- const tgtAttrs = graph.getNodeAttributes(targetId) as DagNode;
1211
- links.push({
1212
  source: srcId,
1213
  target: targetId,
1214
  ...edgeAttrs,
1215
- titleText: buildLinkTitleText(edgeAttrs, srcAttrs, tgtAttrs),
1216
- });
 
1217
  }
 
1218
 
1219
  const excludeIntervals = collectGenAttrDagExcludeIntervals(
1220
  intervalCtx,
@@ -1223,10 +1787,10 @@ export function initGenAttributeDagView(
1223
  getEffectiveExcludeGeneratedPatternsText(),
1224
  );
1225
  dagExcludeIntervals = excludeIntervals;
1226
- pruneDagLinksTouchingFullyExcludedNodes(graph, links, excludeIntervals);
1227
 
1228
  stepProcessed++;
1229
- // 每步生成后:默认焦点为本步新生成的 token(与悬浮同款高亮有真实悬停时仍以 hoveredId 优先)
1230
  selectedId = targetId;
1231
  if (batchDepth === 0) {
1232
  syncGraphToSvg();
@@ -1242,19 +1806,27 @@ export function initGenAttributeDagView(
1242
  textMeasure.reset();
1243
  graph.clear();
1244
  nodes = [];
 
1245
  links = [];
 
 
1246
  stepProcessed = 0;
1247
  selectedId = null;
1248
  hoveredId = null;
 
1249
  linkMarkersDefs.selectAll('marker').remove();
1250
  linkG.selectAll('*').remove();
1251
  linkGFront.selectAll('*').remove();
1252
  nodeG.selectAll('*').remove();
 
1253
  dagExcludeIntervals = [];
1254
  linkSel = rootG
1255
  .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link')
1256
  .data<DagLink>([], dagLinkDataKey);
1257
  nodeSel = nodeG.selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node').data<DagNode>([], (d) => d.id);
 
 
 
1258
  layoutDirty = preserveUserViewport ? wasLayoutDirty : false;
1259
  userDraggedNodes = false;
1260
  }
@@ -1273,7 +1845,7 @@ export function initGenAttributeDagView(
1273
  const { w, h } = stackLayoutViewportPx(stackEl);
1274
  const innerW = Math.max(w - 2 * pad, 1);
1275
  const innerH = Math.max(h - 2 * pad, 1);
1276
- if (layoutMode === 'linear-arc') {
1277
  /** 仅用 token 行宽度定比;竖直按行中心居中(弧不参与 bbox → 不致上下抖) */
1278
  const bn = nodeG.node()!.getBBox();
1279
  const bw = Math.max(bn.width, 1e-6);
@@ -1309,14 +1881,17 @@ export function initGenAttributeDagView(
1309
  const ty = pad + halfH;
1310
  svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1311
  } else if (layoutMode === 'text-flow') {
1312
- /** 与原实现一致:`rootG` 整包 bbox + 宽高双约束顶对齐 */
 
 
 
1313
  const b = rootG.node()!.getBBox();
1314
  const bw = Math.max(b.width, 1e-6);
1315
  const bh = Math.max(b.height, 1e-6);
1316
- const kRaw = Math.min(innerW / bw, innerH / bh);
1317
  const k = Math.min(Number.isFinite(kRaw) && kRaw > 0 ? kRaw : k0, k0);
1318
- const tx = pad * 2 - k * b.x;
1319
- const ty = pad - k * b.y;
1320
  svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1321
  } else {
1322
  const _: never = layoutMode;
@@ -1381,7 +1956,7 @@ export function initGenAttributeDagView(
1381
  if (linearArcAdjacentGapPx === next) return;
1382
  linearArcAdjacentGapPx = next;
1383
  if (opts?.skipRefit || batchDepth > 0) return;
1384
- if (layoutMode !== 'linear-arc' || nodes.length === 0) return;
1385
  paint();
1386
  fitViewportToContent(true);
1387
  }
@@ -1395,6 +1970,25 @@ export function initGenAttributeDagView(
1395
  fitViewportToContent(true);
1396
  }
1397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1398
  const fullscreenBtn = resultsRoot
1399
  .append('button')
1400
  .attr('type', 'button')
@@ -1464,9 +2058,10 @@ export function initGenAttributeDagView(
1464
  ro.disconnect();
1465
  document.removeEventListener('fullscreenchange', refreshFullscreenChrome);
1466
  document.removeEventListener(CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT, refreshFullscreenChrome);
 
1467
  resultsRoot
1468
  .selectAll(
1469
- '.gen-attr-dag-stack, button.gen-attr-dag-refresh, button.gen-attr-dag-play, button.gen-attr-dag-fullscreen'
1470
  )
1471
  .remove();
1472
  detachGenAttributeDagPanel.delete(rootEl);
@@ -1482,6 +2077,8 @@ export function initGenAttributeDagView(
1482
  isBatching,
1483
  reset,
1484
  fitViewportToContent,
 
 
1485
  clearNodeSelection,
1486
  setDagPlaybackPlaying,
1487
  setMeasureWidthPx,
@@ -1490,6 +2087,9 @@ export function initGenAttributeDagView(
1490
  setDagCompactness,
1491
  setEdgeTopPCoverage,
1492
  setHideExcludedTokens,
 
 
 
1493
  hasPromptSpans: () => nodes.some((n) => n.step === -1),
1494
  detach,
1495
  };
 
1
  import * as d3 from 'd3';
2
  import { DirectedGraph } from 'graphology';
3
+ import type { D3Sel } from '../utils/Util';
4
  import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
5
  import {
6
  clampDagEdgeTopPCoverage,
 
10
  phase2RankAndSparsify,
11
  type PromptTokenSpan,
12
  } from './genAttributeDagPreprocess';
13
+ import {
14
+ DAG_EDGE_MIN_NORMALIZED_SCORE,
15
+ DAG_EDGE_RENDER_OPACITY_FLOOR,
16
+ DAG_MIN_ATTRIBUTION_SHARE,
17
+ DAG_NODE_STROKE_OPACITY_BASE,
18
+ } from './genAttributeDagEdgeDisplay';
19
  import {
20
  computeMutualInformationRatio,
21
  computeConditionalInformationRatio,
22
+ dagCiVisualScaleFromTargetProb,
23
+ dagPropagationMiRatio,
24
  FULL_CONFIDENCE_PROBABILITY_BASELINE,
25
  } from '../utils/surprisalMath';
26
  import { isOffsetSpanFullyExcluded } from './attributionDisplayModel';
 
30
  type NodeInterval,
31
  type PieceEntry,
32
  } from './genAttributeDagIntervalResolve';
33
+ import type { FrontendToken } from '../api/GLTR_API';
34
  import type { TokenGenStep } from './tokenGenAttributionRunner';
35
  import { createGenAttributeDagTextMeasure } from './genAttributeDagTextMeasure';
36
+ import { frontendTokenFromGenAttrStep } from './genAttributeDagTopkToken';
37
+ import { SimpleEventHandler } from '../utils/SimpleEventHandler';
38
+ import { ToolTip, type ToolTipUpdateAugment } from '../vis/ToolTip';
39
  import { formatTopkTooltipProbabilityPercent } from '../utils/topkChartUtils';
40
  import {
41
  CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT,
 
49
  LINEAR_ARC_ADJACENT_GAP_MAX,
50
  LINEAR_ARC_ADJACENT_GAP_MIN,
51
  LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION,
52
+ LINEAR_ARC_STEP_DOWN_DISTANCE_SCALE,
53
  paintLinearArcLayout,
54
  } from './genAttributeDagViewLinearArcMode';
55
  import { paintTextFlowLayout } from './genAttributeDagViewTextFlowMode';
56
  import { paintSpiralLayout } from './genAttributeDagViewSpiralMode';
57
  import { tr } from '../lang/i18n-lite';
58
 
 
 
 
59
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
60
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
61
 
62
+ /** 节点布局模式:`text-flow` 按文字排版层几何;`linear-arc` / `linear-arc-step-down` 为线性 + 弧线连边(后者按 CI 逐级下移);`spiral` 螺旋排布。 */
63
+ export type DagLayoutMode = 'text-flow' | 'linear-arc' | 'linear-arc-step-down' | 'spiral';
64
+
65
+ function isLinearArcFamilyLayout(mode: DagLayoutMode): mode is 'linear-arc' | 'linear-arc-step-down' {
66
+ return mode === 'linear-arc' || mode === 'linear-arc-step-down';
67
+ }
68
 
69
  export const DAG_COMPACTNESS_DEFAULT = 0.5;
70
  /** 下限取小正数以满足 {@link readDisplayScaleFromCss}「必须为正」且不出现零宽度边线。 */
 
84
  dagNodeCiVisualScaleEnabled = enabled;
85
  }
86
 
87
+ /**
88
+ * 「Decay attribution to high-surprisal targets」——递归归因的配套开关。
89
+ * 开启:沿链向上时,在高惊讶度(低置信 / teacher forcing)的**生成 token** 处用 MI 折扣传播预算,
90
+ * 使它们成为与 prompt 同类的「来源」,链在此变短。
91
+ * 关闭:所有生成 token 视为透明管道,预算不衰减,链只止于 prompt。
92
+ * `false` 时 `mutualInformationRatio` 仍按目标概率存储与展示,传播/边强度计算中 MI 系数恒为 1。
93
+ */
94
+ let dagDecayAttributionToHighSurprisalTargetEnabled = true;
95
+ export function setDagDecayAttributionToHighSurprisalTargetEnabled(enabled: boolean): void {
96
+ dagDecayAttributionToHighSurprisalTargetEnabled = enabled;
97
  }
98
 
99
  /**
 
102
  * {@link dagNodeCiVisualScaleEnabled} 为 false 时恒返回 1。
103
  */
104
  function dagGeneratedNodeCiVisualScale(targetProb: number | undefined): number {
105
+ return dagCiVisualScaleFromTargetProb(targetProb, dagNodeCiVisualScaleEnabled);
 
 
106
  }
107
 
108
+ /** DAG Top‑K tooltip CI/MI 数值格式原节点原生 title 一致({@link formatTopkTooltipProbabilityPercent}。 */
109
+ function dagCiMiTooltipRowForProb(targetProb: number | undefined): { label: string; value: string } | undefined {
110
+ if (targetProb === undefined || !Number.isFinite(targetProb)) return undefined;
111
+ const ciRatio = computeConditionalInformationRatio(targetProb);
112
+ const miRatio = computeMutualInformationRatio(targetProb);
113
  const ci = Number.isFinite(ciRatio) ? formatTopkTooltipProbabilityPercent(ciRatio) : String(ciRatio);
114
  const mi = Number.isFinite(miRatio) ? formatTopkTooltipProbabilityPercent(miRatio) : String(miRatio);
115
+ return { label: 'CI/MI:', value: `${ci} / ${mi}` };
116
  }
117
 
118
+ const TOOLTIP_NA = 'N/A';
119
+
120
+ /** 边原生 `<title>` 中互信息率 α 的展示。 */
121
+ function formatMutualInformationRatioForTooltip(miRatio: number | undefined): string {
122
+ if (miRatio === undefined || !Number.isFinite(miRatio)) return TOOLTIP_NA;
123
  return formatTopkTooltipProbabilityPercent(miRatio);
124
  }
125
 
126
+ function isPositiveFiniteShare(share: number | undefined): share is number {
127
+ return typeof share === 'number' && Number.isFinite(share) && share > 0;
128
+ }
129
+
130
+ /**
131
+ * 边级 MI 系数(直接归因强度、无焦点灰边)。
132
+ * 递归链上的传播折扣在节点级 {@link nodePropagationMiRatio},二者分工不同。
133
+ */
134
+ function effectiveMiRatio(miRatio: number | undefined): number | undefined {
135
+ if (!dagDecayAttributionToHighSurprisalTargetEnabled) return 1;
136
+ if (miRatio === undefined || !Number.isFinite(miRatio)) return undefined;
137
+ return miRatio;
138
+ }
139
+
140
+ function formatTooltipAttributionScore(normalizedScore: number | undefined): string {
141
+ if (normalizedScore === undefined || !Number.isFinite(normalizedScore)) return TOOLTIP_NA;
142
+ return normalizedScore.toFixed(3);
143
+ }
144
+
145
+ /** 直接归因份额的展示:L1 份额 × 目标真实 MI(与弱化开关无关,仅供读数)。 */
146
+ function formatTooltipDirectAttributionShare(
147
+ attributionShare: number | undefined,
148
+ miRatio: number | undefined,
149
+ ): string {
150
+ if (!isPositiveFiniteShare(attributionShare)) return TOOLTIP_NA;
151
+ if (miRatio === undefined || !Number.isFinite(miRatio)) return TOOLTIP_NA;
152
+ return formatTopkTooltipProbabilityPercent(attributionShare * miRatio);
153
+ }
154
+
155
+ function formatTooltipRecursiveAttributionShare(share: number | undefined): string {
156
+ if (share === undefined || !Number.isFinite(share)) return TOOLTIP_NA;
157
+ return formatAttributionSharePercentForTooltip(share);
158
+ }
159
+
160
+ /** 节点 tooltip 归因份额:低于 {@link DAG_MIN_ATTRIBUTION_SHARE} 时显示 `< x%`(x 为阈值,1 位有效数字)。 */
161
+ function formatAttributionSharePercentForTooltip(share: number): string {
162
+ const thresholdLabel = d3.format('.1g')(DAG_MIN_ATTRIBUTION_SHARE * 100) + '%';
163
+ if (!Number.isFinite(share) || share < DAG_MIN_ATTRIBUTION_SHARE) {
164
+ return `< ${thresholdLabel}`;
165
+ }
166
+ return formatTopkTooltipProbabilityPercent(share);
167
+ }
168
+
169
+ function formatTooltipLinkStrength(strength: number): string {
170
+ return Number.isFinite(strength) ? strength.toFixed(3) : TOOLTIP_NA;
171
+ }
172
+
173
  export {
174
  clampLinearArcAdjacentGap,
175
  LINEAR_ARC_ADJACENT_GAP_DEFAULT,
176
  LINEAR_ARC_ADJACENT_GAP_MAX,
177
  LINEAR_ARC_ADJACENT_GAP_MIN,
178
  LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION,
179
+ LINEAR_ARC_STEP_DOWN_DISTANCE_SCALE,
180
  };
181
 
182
  /** 图中节点业务字段(与 graphology 节点 attributes 为同一对象) */
 
202
  nodeH: number;
203
  /** CI 视觉缩放倍数 `1 + CI` ∈ [1, 2];prompt 节点为 `1`。供 CSS 字号变量使用。 */
204
  ciVisualScale: number;
205
+ /**
206
+ * 本步 {@link TokenGenStep} 的 `response.target_prob`(仅生成节点)。
207
+ * 下台阶等处用 {@link dagStepDownEffectiveCiRatio}(dagTargetProb)(高置信 p>p₁ 为 0;与「关闭 CI 视觉」无关);
208
+ */
209
+ dagTargetProb?: number;
210
  /** {@link visualizeSpecialChars}(DAG:仅「空格后是 [A-Za-z0-9]」保留空格,其余空格为 ·),建点后不变 */
211
  displayLabel: string;
212
+ /** 悬停 / 选中焦点时 Top‑K tooltip;仅生成节点(`step >= 0`) */
213
+ gltrTooltipToken?: FrontendToken;
214
+ /** 跟在 tooltip 内 log perplexity 行之后的 CI/MI;与 {@link dagCiMiTooltipRowForProb} 同源 */
215
+ dagCiMiTooltipRow?: { label: string; value: string };
216
  };
217
 
218
  type DagNode = DagNodeAttrs;
 
222
  target: string;
223
  /**
224
  * 候选池内 max 归一后的归因分,区间约 [0, 1];作为 `stroke-opacity` 的基项(再乘 {@link mutualInformationRatio})。
225
+ * 池内稀疏化与建边前过滤均使用 {@link DAG_EDGE_MIN_NORMALIZED_SCORE}(见 genAttributeDagEdgeDisplay)。
226
  */
227
  normalizedScore?: number;
228
  /** 互信息率:仅作为本步入边的视觉透明度系数,不参与归因筛选。 */
229
  mutualInformationRatio?: number;
230
+ /** 本步内:该边在可见入边池内 L1 份额(建边阈值过滤后归一,追因传播基本单位。 */
231
+ attributionShare?: number;
232
  /** 与 `console.warn('[genAttributeDagView.align] …')` 正文一致(可多条,换行拼接) */
233
  alignmentNote?: string;
 
 
234
  };
235
 
236
+ /**
237
+ * 该边的 attribution share:优先使用可见边池内的 L1 份额;无 attributionShare 时回退到 max-normalized score。
238
+ * max-normalized score 作为后备仅用于 attributionShare 尚未计算(如阈值过滤前)的场景。
239
+ */
240
+ function edgeAttributionShare(d: Pick<DagLink, 'attributionShare' | 'normalizedScore'>): number {
241
+ const share = d.attributionShare;
242
+ if (typeof share === 'number' && Number.isFinite(share) && share > 0) return share;
243
+ const s = d.normalizedScore ?? 1;
244
+ return Number.isFinite(s) ? Math.max(0, s) : 1;
245
+ }
246
+
247
+ /**
248
+ * 无焦点时的边渲染强度:attribution share × {@link effectiveMiRatio}。
249
+ * 「Decay attribution to high-surprisal targets」关闭时 MI 系数恒为 1(展示仍见 {@link formatMutualInformationRatioForTooltip})。
250
+ */
251
+ function directAttributionStrength(
252
+ d: Pick<DagLink, 'attributionShare' | 'normalizedScore' | 'mutualInformationRatio'>,
253
+ ): number {
254
+ const mi = effectiveMiRatio(d.mutualInformationRatio) ?? 1;
255
+ return edgeAttributionShare(d) * mi;
256
  }
257
 
258
  function dagLinkEndpointKey(source: string, target: string): string {
259
  return `${source}->${target}`;
260
  }
261
 
262
+ /** 节点 target 端 MI ratio(与 tooltip「Target MI ratio」同源;与 decay 开关无关)。 */
263
+ function nodeTargetMiRatio(node: DagNode): number {
264
+ return computeMutualInformationRatio(node.dagTargetProb);
265
+ }
266
+
267
+ function maxHighlightEdgeShare(sharesByKey: Map<string, number>): number {
268
+ let max = 0;
269
+ for (const share of sharesByKey.values()) {
270
+ if (share > max) max = share;
271
+ }
272
+ return max;
273
+ }
274
+
275
+ /**
276
+ * 池内 max 归一后的 `stroke-opacity`;最强边刻度为 {@link maxOpacity}(默认 1)。
277
+ * 按实际值计算后,最终不低于 {@link DAG_EDGE_RENDER_OPACITY_FLOOR},防止过淡不可见。
278
+ */
279
+ function normalizeEdgeRenderOpacity(share: number, maxShare: number, maxOpacity = 1): number {
280
+ if (!Number.isFinite(share) || share <= 0) return 0;
281
+ const cap = Number.isFinite(maxOpacity) && maxOpacity > 0 ? maxOpacity : 1;
282
+ const scaled =
283
+ !Number.isFinite(maxShare) || maxShare <= 0
284
+ ? Math.min(cap, share)
285
+ : Math.min(cap, (share / maxShare) * cap);
286
+ if (scaled <= 0) return 0;
287
+ return Math.max(DAG_EDGE_RENDER_OPACITY_FLOOR, scaled);
288
+ }
289
+
290
+ /**
291
+ * 候选归因节点描边透明度:池内 `stay / max(stay)` 线性映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`,
292
+ * 避免弱节点描边过淡、在 UI 里看不出来(见 {@link DAG_NODE_STROKE_OPACITY_BASE})。
293
+ */
294
+ function normalizeNodeStrokeRenderOpacity(share: number, maxShare: number): number {
295
+ if (!Number.isFinite(share) || share <= 0) return 0;
296
+ const scaled =
297
+ !Number.isFinite(maxShare) || maxShare <= 0
298
+ ? Math.min(1, share)
299
+ : Math.min(1, share / maxShare);
300
+ if (scaled <= 0) return 0;
301
+ return DAG_NODE_STROKE_OPACITY_BASE + scaled * (1 - DAG_NODE_STROKE_OPACITY_BASE);
302
+ }
303
+
304
+ /** 焦点在 target 时单条入边份额(直接模式一跳;灰边与此时蓝边共用)。 */
305
+ function perTargetIncomingEdgeShare(
306
+ link: Pick<DagLink, 'attributionShare' | 'normalizedScore'>,
307
+ targetNode: DagNode,
308
+ ): number {
309
+ const upstreamBudget = nodePropagationMiRatio(targetNode);
310
+ return Math.min(1, upstreamBudget * edgeAttributionShare(link));
311
+ }
312
+
313
+ /** 灰边 stroke-opacity:按各 target 入边池归一���与焦点在该 target 时的蓝边一致。 */
314
+ function buildGrayRenderStrengthByEdgeKey(
315
+ graph: DirectedGraph<DagNodeAttrs>,
316
+ incomingLinksByTarget: Map<string, DagLink[]>,
317
+ ): Map<string, number> {
318
+ const byKey = new Map<string, number>();
319
+ for (const [targetId, links] of incomingLinksByTarget) {
320
+ if (!graph.hasNode(targetId)) continue;
321
+ const targetNode = graph.getNodeAttributes(targetId) as DagNode;
322
+ // prompt 节点(step < 0)不应出现在 incomingLinksByTarget(仅 update() 中生成节点作为 target 时写入),
323
+ // 此处防御:nodePropagationMiRatio 对 prompt 返回 0,全组 share=0,跳过以节省迭代。
324
+ if (targetNode.step < 0) continue;
325
+ let maxShare = 0;
326
+ const rows: Array<{ key: string; share: number }> = [];
327
+ for (const link of links) {
328
+ if (!graph.hasEdge(link.source, link.target)) continue;
329
+ const srcId = endpointNode(link.source, graph).id;
330
+ const share = perTargetIncomingEdgeShare(link, targetNode);
331
+ if (share > maxShare) maxShare = share;
332
+ rows.push({ key: dagLinkEndpointKey(srcId, targetId), share });
333
+ }
334
+ for (const { key, share } of rows) {
335
+ byKey.set(key, normalizeEdgeRenderOpacity(share, maxShare));
336
+ }
337
+ }
338
+ return byKey;
339
+ }
340
+
341
+ /**
342
+ * 递归模式:焦点链上**上游**节点的描边 raw 强度(stay;不含焦点本身)。
343
+ * 直接模式仅一跳,由蓝/红高亮边表达,不画来源描边。
344
+ * 显示判定:stay ≥ {@link DAG_MIN_ATTRIBUTION_SHARE};描边透明度见 {@link buildNodeStrokeRenderStrengthById}。
345
+ */
346
+ function computeUpstreamNodeStrokeShareById(
347
+ nodeShareById: Map<string, number>,
348
+ graph: DirectedGraph<DagNodeAttrs>,
349
+ focusId: string,
350
+ ): Map<string, number> {
351
+ const byNodeId = new Map<string, number>();
352
+ for (const [nodeId, nodeShare] of nodeShareById) {
353
+ if (nodeId === focusId) continue;
354
+ const stay = nodeShare * (1 - nodePropagationMiRatio(graph.getNodeAttributes(nodeId) as DagNode));
355
+ if (stay >= DAG_MIN_ATTRIBUTION_SHARE) byNodeId.set(nodeId, stay);
356
+ }
357
+ return byNodeId;
358
+ }
359
+
360
+ /** 池内 max 归一后的 render 强度;{@link maxOpacity} 为链内最强边刻度(蓝入边见 {@link refreshNodeLinkHighlight},默认 1)。 */
361
+ function buildMaxNormalizedRenderStrengthByKey(
362
+ sharesByKey: Map<string, number>,
363
+ maxOpacity = 1,
364
+ ): Map<string, number> {
365
+ const maxShare = maxHighlightEdgeShare(sharesByKey);
366
+ const byKey = new Map<string, number>();
367
+ for (const [key, share] of sharesByKey) {
368
+ byKey.set(key, normalizeEdgeRenderOpacity(share, maxShare, maxOpacity));
369
+ }
370
+ return byKey;
371
+ }
372
+
373
+ /** 递归链候选节点描边强度:stay 池内 max 归一后映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`。 */
374
+ function buildNodeStrokeRenderStrengthById(stayByNodeId: Map<string, number>): Map<string, number> {
375
+ const maxShare = maxHighlightEdgeShare(stayByNodeId);
376
+ const byNodeId = new Map<string, number>();
377
+ for (const [nodeId, stay] of stayByNodeId) {
378
+ byNodeId.set(nodeId, normalizeNodeStrokeRenderOpacity(stay, maxShare));
379
+ }
380
+ return byNodeId;
381
+ }
382
+
383
  /**
384
  * 流式增量:任一端节点 span 完全落在排除区间内则删边(不重算 Top‑N,与全量重放可轻微不一致)。
385
  * 同步 graphology 与并行 `links`。
 
387
  function pruneDagLinksTouchingFullyExcludedNodes(
388
  graph: DirectedGraph<DagNodeAttrs>,
389
  links: DagLink[],
390
+ incomingLinksByTarget: Map<string, DagLink[]>,
391
  intervals: [number, number][],
392
  ): void {
393
  if (intervals.length === 0) return;
 
418
  links[write++] = link;
419
  }
420
  links.length = write;
421
+
422
+ for (const [targetId, incoming] of incomingLinksByTarget) {
423
+ if (incoming.length === 0) {
424
+ incomingLinksByTarget.delete(targetId);
425
+ continue;
426
+ }
427
+ let keep = 0;
428
+ for (const link of incoming) {
429
+ if (removedLinkKeys.has(dagLinkEndpointKey(link.source, link.target))) {
430
+ continue;
431
+ }
432
+ incoming[keep++] = link;
433
+ }
434
+ if (keep === 0) {
435
+ incomingLinksByTarget.delete(targetId);
436
+ continue;
437
+ }
438
+ incoming.length = keep;
439
+ if (!graph.hasNode(targetId)) {
440
+ incomingLinksByTarget.delete(targetId);
441
+ }
442
+ }
443
  }
444
 
445
  const SVG_MIN_W = 320;
446
  const SVG_MIN_H = 280;
447
 
448
+ /** text-flow:`fitViewportToContent` 四边对称边距(px)。 */
449
+ const DAG_TEXT_FLOW_FIT_PAD_PX = 24;
450
+
451
  /**
452
  * `.gen-attr-dag-stack` 布局尺寸(px),供 SVG width/height 与 `fitViewportToContent` 共用。
453
  * 用 offsetWidth/offsetHeight(布局流尺寸)而非 getBoundingClientRect,
 
462
 
463
  /** text-flow:在「抵消 display-scale」基准上的初始 zoom 倍率(d3 的 k) */
464
  const DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW = 2;
465
+ /** linear-arc / linear-arc-step-down:同上 */
466
  const DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC = 4;
467
  /** spiral:同上 */
468
  const DAG_INITIAL_ZOOM_BOOST_SPIRAL = 2;
 
472
  case 'text-flow':
473
  return DAG_INITIAL_ZOOM_BOOST_TEXT_FLOW;
474
  case 'linear-arc':
475
+ case 'linear-arc-step-down':
476
  return DAG_INITIAL_ZOOM_BOOST_LINEAR_ARC;
477
  case 'spiral':
478
  return DAG_INITIAL_ZOOM_BOOST_SPIRAL;
 
492
 
493
  /** 与 {@link start.scss} `--dag-normal-line-color` 一致(普通边:线 stroke + 箭头 marker stroke) */
494
  const CSS_VAR_DAG_NORMAL_LINE_COLOR = '--dag-normal-line-color';
495
+ /** 与 {@link start.scss} `--dag-highlight-line-color-in`(`--accent-color`)一致(入边:指向焦点) */
496
  const CSS_VAR_DAG_HIGHLIGHT_LINE_IN = '--dag-highlight-line-color-in';
497
  /** 与 {@link start.scss} `--dag-highlight-line-color-out` 一致(出边:从焦点出发) */
498
  const CSS_VAR_DAG_HIGHLIGHT_LINE_OUT = '--dag-highlight-line-color-out';
499
+ /** 与 {@link gen_attribute.scss} `.gen-attr-dag-node--recursive-chain` 中 `stroke-opacity` 一致(由 JS 写入 g 元素) */
500
+ const CSS_VAR_DAG_NODE_RECURSIVE_SHARE = '--gen-attr-dag-node-recursive-share';
501
 
502
  /** 弱化:未排除的 prompt 无出边,或(prompt/生成区)邻域外且存在悬停/选中焦点时 */
503
  const DAG_NODE_WEAKEN_OPACITY = 0.5;
504
  /** 隐藏:节点 span 完全落在 exclude 规则命中区间内(prompt 与生成区各一套模式) */
505
  const DAG_NODE_HIDDEN_OPACITY = 0.1;
506
 
 
 
 
507
  /**
508
  * 边端在矩形边界外侧的留白,相对测量层「1em」的比例(无单位);与箭头/描边衔接用。
509
  * 测量层与节点几何同源(lmf-readout-text),故随字号/CSS 变化而变。
 
554
  return Math.min(d.nodeW / 2, d.nodeH / 2);
555
  }
556
 
557
+ /** stroke rect 外扩 pad=displayScale,与 scss `stroke-width: calc(2 * display-scale)` 一致,描边不压 fill。 */
558
+ function syncNodeStrokeRects(
559
+ sel: d3.Selection<SVGGElement, DagNode, SVGGElement | null, unknown>,
560
+ displayScale: number,
561
+ ): void {
562
+ const p = displayScale;
563
+ sel.select('rect.gen-attr-dag-node-stroke')
564
+ .attr('x', -p)
565
+ .attr('y', -p)
566
+ .attr('width', (d) => d.nodeW + 2 * p)
567
+ .attr('height', (d) => d.nodeH + 2 * p)
568
+ .attr('rx', (d) => nodeRx(d) + p)
569
+ .attr('ry', (d) => nodeRx(d) + p);
570
+ }
571
+
572
  export type GenAttributeDagHandle = {
573
  /**
574
  * 在首帧 `update`(第一步生成 token)之前调用一次:用全量 prompt token spans 建 prompt 层节点。
 
599
  reset(preserveUserViewport?: boolean): void;
600
  /**
601
  * zoom identity 后按内容适配视口;空图走默认缩放;`k` 上限 `k₀`(随当前布局模式的初始 zoom 倍率变化)。
602
+ * - `text-flow`:`rootG.getBBox()`(含边)等比落入内框;四边对称各 {@link DAG_TEXT_FLOW_FIT_PAD_PX}px
603
+ * - `linear-arc` / `linear-arc-step-down`:仅按 `gen-attr-dag-nodes` 行宽定比,token 行相对内框竖直居中(弧不参与)。
604
  * 若 `layoutDirty` 为真则 no-op(仅已执行的 `syncSvgSize` 生效,不改 pan/zoom),但 `force` 为真时仍
605
  * fit 并清 dirty(例如刷新按钮的强制适配)。
606
  */
607
  fitViewportToContent(force?: boolean): void;
608
+ /** 当前选中节点 id;无选中为 `null`。 */
609
+ getSelectedNodeId(): string | null;
610
+ /** 设置选中节点(`null` 清除);节点须已存在于图中。 */
611
+ setSelectedNodeId(id: string | null): void;
612
  /** 清除节点选中态(与点击画布空白等价);不改变图数据,生成结束后可调用以去掉末 token 描边 */
613
  clearNodeSelection(): void;
614
  /** DAG 步进重放:更新 ▶ / ⏸ 按钮文案(由页面在播放开始/结束/暂停时调用) */
 
623
  /** 切换 DAG 节点布局模式并立即重排现有节点/边。 */
624
  setLayoutMode(mode: DagLayoutMode): void;
625
  /**
626
+ * linear-arc 家族下相邻节点矩形外侧边的水平间隙(px)。��影响该家族几何;若在生成/播放中途调用且
627
  * `skipRefit` 为真,仅写入值,下一轮 `syncGraphToSvg`/空闲后再反映(与测量宽度语义一致)。
628
  */
629
  setLinearArcAdjacentGapPx(px: number, opts?: { skipRefit?: boolean }): void;
 
640
  * - `false`(默认):保留为低透明度({@link DAG_NODE_HIDDEN_OPACITY})占位。
641
  */
642
  setHideExcludedTokens(hide: boolean): void;
643
+ /** 是否显示 token tooltip(UI: Show token tooltip;`showTokenInfoOnSelected`)。 */
644
+ setShowTokenInfoOnSelected(show: boolean): void;
645
+ /** 是否启用传播归因(UI: Propagated attribution mode;`recursiveAttributionEnabled`)。 */
646
+ setRecursiveAttributionEnabled(enabled: boolean): void;
647
+ /** 是否在直接归因焦点上额外展示从焦点出发的下游影响出边。 */
648
+ setShowDownstreamInfluence(show: boolean): void;
649
  /** prompt 层节点是否已注入(即 {@link setPromptTokenSpans} 至少成功添加过一个节点) */
650
  hasPromptSpans(): boolean;
651
  /** 移除 DAG 栈与刷新按钮(离开页面时调用) */
 
672
  return `[${a}, ${b})`;
673
  }
674
 
675
+ /**
676
+ * 边当前显示状态;在 {@link refreshNodeLinkHighlight} 中与 stroke 一并刷新 `<title>`。
677
+ *
678
+ * {@link recursiveAttributionShare} 为当前焦点下传播归因链上的份额(UI: Propagated;仅入边链;无焦点或不在链上为 undefined)。
679
+ * {@link linkStrength} 为 tooltip 用的原始强度;{@link renderStrength} 为写入 stroke-opacity 的值(直接模式灰边与蓝边同刻度)。
680
+ * 空行以上为建边后不变的直接归因指标。不用「opacity」命名:灰边与蓝/红高亮边在相同强度下 `stroke-opacity` 数值可相同,但肉眼对比度不同,
681
+ * 视觉效果由 stroke 颜色与透明度共同衍生,强度才是可比较的固定量。
682
+ */
683
+ type DagLinkTitleSnapshot = {
684
+ normalizedScore?: number;
685
+ mutualInformationRatio?: number;
686
+ attributionShare?: number;
687
+ alignmentNote?: string;
688
+ src: DagNode;
689
+ tgt: DagNode;
690
+ /** 递归链入边上的传播份额 edgeShare;不在链上时为 undefined(直接模式或无焦点)。 */
691
+ recursiveAttributionShare?: number;
692
+ linkStrength: number;
693
+ };
694
+
695
+ function buildLinkTitleText(snapshot: DagLinkTitleSnapshot): string {
696
+ // 建边后不变;空行以下随焦点/传播归因变化(Attribution share (Propagated)、Link strength)。
697
+ const staticMetrics = [
698
+ `Attribution score: ${formatTooltipAttributionScore(snapshot.normalizedScore)}`,
699
+ `Target MI ratio: ${formatMutualInformationRatioForTooltip(snapshot.mutualInformationRatio)}`,
700
+ `Attribution share (Adjacent): ${formatTooltipDirectAttributionShare(
701
+ snapshot.attributionShare,
702
+ snapshot.mutualInformationRatio,
703
+ )}`,
704
  ];
705
+ if (snapshot.alignmentNote) {
706
+ staticMetrics.push(snapshot.alignmentNote);
 
 
 
 
 
 
 
 
707
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
708
 
709
  const metrics = [
710
+ staticMetrics.join('\n'),
711
+ '',
712
+ `Attribution share (Propagated): ${formatTooltipRecursiveAttributionShare(snapshot.recursiveAttributionShare)}`,
713
+ `Link strength: ${formatTooltipLinkStrength(snapshot.linkStrength)}`,
714
  ];
 
 
 
 
 
 
 
715
 
716
  return [
717
+ `From:\n${snapshot.src.displayLabel}\nOffset: ${formatNodeOffsetRange(snapshot.src.id)}`,
718
+ `To:\n${snapshot.tgt.displayLabel}\nOffset: ${formatNodeOffsetRange(snapshot.tgt.id)}`,
719
  metrics.join('\n'),
720
  ].join('\n\n');
721
  }
 
738
  node.cx = prev.cx + (prev.nodeW + node.nodeW) / 2;
739
  }
740
 
741
+ /**
742
+ * 传播归因 vs 直接归因(设计理念)
743
+ *
744
+ * UI 称 Propagated attribution mode;代码标识 `recursiveAttribution*`(递归向上传播份额,二者同义)。
745
+ *
746
+ * - 直接归因:只看一跳前驱,回答“它直接依赖了谁”。
747
+ * - 传播归因:持续向上追溯,直到信息来源,回答“真正原因来自哪里”。
748
+ *
749
+ * 来源通常有两类:prompt,或低置信/高惊讶的生成 token(含 teacher forcing)。
750
+ * 高置信中间 token 更像传导节点,归因会继续穿过它。
751
+ *
752
+ * UI 语义:
753
+ * - 灰边:各 target 入边池内 max 归一(无焦点时的默认边);
754
+ * - 焦点蓝入边:链内 max 归一,最强边刻度为焦点 target 的实际 MI ratio;最终 opacity 不低于 {@link DAG_EDGE_RENDER_OPACITY_FLOOR};
755
+ * - 上游节点描边(仅传播归因):stay 池内 max 归一,映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`;直接模式一跳由边色表达,不描边。
756
+ * - 传播模式节点提亮与描边一致:仅焦点 + stay 达阈的上游(传导节点仅保留蓝边,不提亮)。
757
+ */
758
+ type FocusAttributionState = {
759
+ activeNodeIds: Set<string>;
760
+ /** 传播归因链上入边的份额(用于蓝边强度;`recursiveAttributionShare`)。 */
761
+ incomingEdgeShareByKey: Map<string, number>;
762
+ /** 仅直接模式:焦点出发的下游影响边。 */
763
+ downstreamEdgeStrengthByKey: Map<string, number>;
764
+ /** 链上各节点累计份额(用于计算节点停留量)。 */
765
+ nodeShareById: Map<string, number>;
766
+ };
767
+
768
+ /** 节点在递归传播中的传导系数:越低越像来源,越高越像传导节点。 */
769
+ function nodePropagationMiRatio(node: DagNode): number {
770
+ if (node.step < 0) return 0;
771
+ if (!dagDecayAttributionToHighSurprisalTargetEnabled) return 1;
772
+ return dagPropagationMiRatio(node.dagTargetProb);
773
  }
774
 
775
+ type DagLinkHighlightDisplay = {
776
+ stroke: string;
777
+ /** 写入 stroke-opacity(链内 max 归一;蓝入边最强边刻度见 {@link refreshNodeLinkHighlight},红出边/灰边为 1)。 */
778
+ renderStrength: number;
779
+ /** tooltip「Link strength」:原始强度,不做归一。 */
780
+ linkStrength: number;
781
+ recursiveAttributionShare?: number;
782
+ };
783
+
784
+ /** 焦点下边的视觉规则:传播归因看“向上原因链”,直接看“一跳关系 + 可选下游影响”。 */
785
+ function resolveDagLinkHighlightDisplay(
786
+ d: DagLink,
787
+ edgeKey: string,
788
+ focusState: FocusAttributionState | null,
789
+ recursiveAttributionEnabled: boolean,
790
+ grayRenderByKey: Map<string, number>,
791
+ incomingHighlightRenderByKey: Map<string, number>,
792
+ downstreamHighlightRenderByKey: Map<string, number>,
793
+ ): DagLinkHighlightDisplay {
794
+ const directStrength = directAttributionStrength(d);
795
+ const grayRender = grayRenderByKey.get(edgeKey) ?? directStrength;
796
+
797
+ if (focusState) {
798
+ const downstreamStrength = focusState.downstreamEdgeStrengthByKey.get(edgeKey);
799
+ if (downstreamStrength != null) {
800
+ return {
801
+ stroke: `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_OUT})`,
802
+ renderStrength: downstreamHighlightRenderByKey.get(edgeKey)!,
803
+ linkStrength: downstreamStrength,
804
+ };
805
+ }
806
+
807
+ const incomingShare = focusState.incomingEdgeShareByKey.get(edgeKey);
808
+ if (incomingShare != null) {
809
+ return {
810
+ stroke: `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_IN})`,
811
+ renderStrength: incomingHighlightRenderByKey.get(edgeKey)!,
812
+ linkStrength: incomingShare,
813
+ recursiveAttributionShare: recursiveAttributionEnabled ? incomingShare : undefined,
814
+ };
815
+ }
816
+ }
817
+
818
+ return {
819
+ stroke: `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`,
820
+ renderStrength: grayRender,
821
+ linkStrength: directStrength,
822
+ };
823
  }
824
 
825
+ function computeFocusAttributionState(
 
 
 
 
826
  graph: DirectedGraph<DagNodeAttrs>,
827
+ nodesSortedByStepDesc: DagNode[],
828
+ incomingLinksByTarget: Map<string, DagLink[]>,
829
+ focusId: string,
830
+ options: { maxIncomingDepth: number; includeDownstreamInfluence: boolean },
831
+ ): FocusAttributionState | null {
832
+ if (!graph.hasNode(focusId)) return null;
833
+
834
+ // 从焦点向上追溯:递归模式追到来源;直接模式仅保留一跳前驱。
835
+ const activeNodeIds = new Set<string>([focusId]);
836
+ const incomingEdgeShareByKey = new Map<string, number>();
837
+ const downstreamEdgeStrengthByKey = new Map<string, number>();
838
+ const nodeShareById = new Map<string, number>([[focusId, 1]]);
839
+ const remainingDepthByNodeId = new Map<string, number>([[focusId, options.maxIncomingDepth]]);
840
+
841
+ for (const node of nodesSortedByStepDesc) {
842
+ // min(1)/max(0) 仅防御,正常路径下 nodeShare ∈ (0, 1]。
843
+ const nodeShare = Math.min(1, Math.max(0, nodeShareById.get(node.id) ?? 0));
844
+ if (nodeShare <= 0) continue;
845
+ const remainingDepth = remainingDepthByNodeId.get(node.id) ?? 0;
846
+ if (remainingDepth <= 0) continue;
847
+
848
+ // 低系数节点更接近来源,高系数节点更接近传导。
849
+ const upstreamBudget = nodeShare * nodePropagationMiRatio(node);
850
+ if (upstreamBudget < DAG_MIN_ATTRIBUTION_SHARE) continue;
851
+
852
+ for (const link of incomingLinksByTarget.get(node.id) ?? []) {
853
+ if (!graph.hasEdge(link.source, link.target)) continue;
854
+ const srcId = endpointNode(link.source, graph).id;
855
+ // min(1) 仅防御:attributionShare L1 归一且 MI ≤ 1 保证乘积不超过 1。
856
+ const edgeShare = Math.min(1, upstreamBudget * edgeAttributionShare(link));
857
+ if (edgeShare < DAG_MIN_ATTRIBUTION_SHARE) continue;
858
+
859
+ incomingEdgeShareByKey.set(dagLinkEndpointKey(srcId, node.id), edgeShare);
860
+ activeNodeIds.add(srcId);
861
+ // min(1) 仅防御:nodeShare 从 1 出发,经 attributionShare 分配与 MI 衰减后各节点累积不超过 1。
862
+ nodeShareById.set(srcId, Math.min(1, (nodeShareById.get(srcId) ?? 0) + edgeShare));
863
+ remainingDepthByNodeId.set(
864
+ srcId,
865
+ Math.max(remainingDepthByNodeId.get(srcId) ?? 0, remainingDepth - 1),
866
+ );
867
+ }
868
+ }
869
+
870
+ // 直接模式可附带展示“焦点影响了谁”。
871
+ if (options.includeDownstreamInfluence) {
872
+ graph.forEachOutEdge(focusId, (_edgeId, edgeAttrs, srcId, tgtId) => {
873
+ const link = edgeAttrs as unknown as Pick<DagLink, 'attributionShare' | 'normalizedScore' | 'mutualInformationRatio'>;
874
+ const strength = directAttributionStrength(link);
875
+ if (strength < DAG_MIN_ATTRIBUTION_SHARE) return;
876
+ downstreamEdgeStrengthByKey.set(dagLinkEndpointKey(srcId, tgtId), strength);
877
+ activeNodeIds.add(tgtId);
878
+ });
879
+ }
880
+
881
+ return { activeNodeIds, incomingEdgeShareByKey, downstreamEdgeStrengthByKey, nodeShareById };
882
  }
883
 
884
  /**
 
922
  /** DAG 节点布局模式;默认 `text-flow`。 */
923
  layoutMode?: DagLayoutMode;
924
  /**
925
+ * linear-arc 家族:相邻节点矩形外侧边的水平间隙(px),决定水平方向疏密;
926
  * 默认 {@link LINEAR_ARC_ADJACENT_GAP_DEFAULT}。
927
  */
928
  linearArcAdjacentGapPx?: number;
929
  /** 被 exclude 规则命中的节点是否完全隐藏(true)还是仅降至 {@link DAG_NODE_HIDDEN_OPACITY}(false,默认)。 */
930
  hideExcludedTokens?: boolean;
931
+ /** 是否显示 token tooltip(UI: Show token tooltip;`showTokenInfoOnSelected`)。 */
932
+ showTokenInfoOnSelected?: boolean;
933
+ /** 传播归因(UI: Propagated attribution mode;`recursiveAttributionEnabled`);默认 `false`。 */
934
+ recursiveAttributionEnabled?: boolean;
935
+ /** 直接归因模式下是否展示从焦点出发的下游影响出边;默认 `false`。 */
936
+ showDownstreamInfluence?: boolean;
937
  /** 边 Top-P 覆盖阈值(候选池内累计份额);���认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT}。 */
938
  edgeTopPCoverage?: number;
939
  /** 进入/退出/切换全屏失败时(常见于移动端不支持元素全屏等)。不传则无提示。 */
 
963
  linearArcAdjacentGapPx = clampLinearArcAdjacentGap(iv);
964
  }
965
  let hideExcludedTokens: boolean = options?.hideExcludedTokens ?? false;
966
+ let showTokenInfoOnSelected: boolean = options?.showTokenInfoOnSelected ?? false;
967
+ let recursiveAttributionEnabled: boolean = options?.recursiveAttributionEnabled ?? false;
968
+ let showDownstreamInfluence: boolean = options?.showDownstreamInfluence ?? false;
969
  let edgeTopPCoverage = clampDagEdgeTopPCoverage(
970
  options?.edgeTopPCoverage ?? DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
971
  );
 
993
  isBatching: () => false,
994
  reset: noop,
995
  fitViewportToContent: noop,
996
+ getSelectedNodeId: () => null,
997
+ setSelectedNodeId: noop,
998
  clearNodeSelection: noop,
999
  setDagPlaybackPlaying: noop,
1000
  setMeasureWidthPx: noop,
 
1003
  setDagCompactness: noop,
1004
  setEdgeTopPCoverage: noop,
1005
  setHideExcludedTokens: noop,
1006
+ setShowTokenInfoOnSelected: noop,
1007
+ setRecursiveAttributionEnabled: noop,
1008
+ setShowDownstreamInfluence: noop,
1009
  hasPromptSpans: () => false,
1010
  detach: noop,
1011
  };
 
1016
  detachGenAttributeDagPanel.get(rootEl)?.();
1017
  resultsRoot
1018
  .selectAll(
1019
+ '.gen-attr-dag-stack, .gen-attr-dag-topk-tooltip, svg.gen-attr-dag-svg, button.gen-attr-dag-refresh, button.gen-attr-dag-play, button.gen-attr-dag-fullscreen'
1020
  )
1021
  .remove();
1022
 
1023
  const stack = resultsRoot.append('div').attr('class', 'gen-attr-dag-stack');
1024
  const stackEl = stack.node() as HTMLElement;
1025
 
1026
+ const dagTooltipEh = new SimpleEventHandler(stackEl);
1027
+ const dagTooltipRoot = resultsRoot.append('div').attr('class', 'tooltip gen-attr-dag-topk-tooltip');
1028
+ dagTooltipRoot.append('div').attr('class', 'currentToken');
1029
+ dagTooltipRoot.append('div').attr('class', 'myDetail');
1030
+ dagTooltipRoot
1031
+ .append('div')
1032
+ .attr('class', 'gen-attr-dag-topk-tooltip-predictions-scroll')
1033
+ .append('div')
1034
+ .attr('class', 'predictions predictions-table');
1035
+ const dagTopkToolTip = new ToolTip(dagTooltipRoot, dagTooltipEh, {
1036
+ surprisalRowLabel: tr('log perplexity:'),
1037
+ placement: 'parent-bottom-right',
1038
+ pointerInteractive: false,
1039
+ });
1040
+
1041
+ /** DAG Top‑K tooltip:挂载初期为 stub;{@link syncGenAttrDagTopkTooltipImpl} 在 {@link refreshNodeLinkHighlight} 定义之后赋值 */
1042
+ let syncGenAttrDagTopkTooltipImpl: () => void = () => {
1043
+ dagTopkToolTip.hideAndReset();
1044
+ };
1045
+
1046
  /** 非 text-flow 时节点不可拖;用该类覆盖选中态的 grab 光标(linear-arc / spiral 等)。 */
1047
  function syncStackLayoutDragUi(): void {
1048
  stackEl.classList.toggle('gen-attr-dag-no-node-drag-layout', layoutMode !== 'text-flow');
 
1091
  function refreshDagScaleDerivedFromCss(): void {
1092
  displayScale = readDisplayScaleFromCss(stackEl);
1093
  linkEndInsetPx = linkEndInsetBaseAtUnitScalePx(measureRoot) * displayScale;
1094
+ syncNodeStrokeRects(nodeSel, displayScale);
1095
  }
1096
 
1097
  function setDagCompactness(c: number): void {
 
1130
  // 仅用户交互(滚轮/拖平移/双击)计入「改动布局」;程序触发的 transform
1131
  // (init 初始缩放、`fitViewportToContent`)`sourceEvent === null`,不置 dirty。
1132
  if (event.sourceEvent) layoutDirty = true;
1133
+ syncGenAttrDagTopkTooltipImpl();
1134
  });
1135
 
1136
  function applyInitialDagZoom(): void {
 
1146
  const nodeG = rootG.append('g').attr('class', 'gen-attr-dag-nodes');
1147
  /** 邻接焦点的高亮边:在节点层之后绘制,避免被节点遮挡 */
1148
  const linkGFront = rootG.append('g').attr('class', 'gen-attr-dag-links-front');
1149
+ /** 与视觉节点同几何的透明命中层,置于 linkGFront 之上,避免蓝线挡住 hover/click */
1150
+ const nodeGHit = rootG.append('g').attr('class', 'gen-attr-dag-nodes-hit');
1151
 
1152
  const graph = new DirectedGraph<DagNodeAttrs>();
1153
  let nodes: DagNode[] = [];
1154
+ /** `nodes` 按 step 降序(新→旧→prompt)排列的副本,供 {@link computeFocusAttributionState} 使用,避免每次 hover 重新排序。 */
1155
+ let nodesSortedByStepDesc: DagNode[] = [];
1156
  let links: DagLink[] = [];
1157
+ /** 按 targetId 索引的入边列表,供 {@link computeFocusAttributionState} 使用,避免每次 hover O(N×E) 全扫描。 */
1158
+ const incomingLinksByTarget = new Map<string, DagLink[]>();
1159
+ /** 灰边渲染强度缓存;图结构变化({@link syncGraphToSvg})或 {@link reset} 时置 null 失效。 */
1160
+ let grayRenderCache: Map<string, number> | null = null;
1161
  let stepProcessed = 0;
1162
  let selectedId: string | null = null;
1163
+ /** 悬浮节点 id;无选中参与归因预览焦点,有选中时仅驱动 `--hover` 等样式不改归因焦点 */
1164
  let hoveredId: string | null = null;
1165
+ /** 最近一次 {@link refreshNodeLinkHighlight} 计算出的归因状态(基于 {@link effectiveFocusId});tooltip 用于展示归因份额 */
1166
+ let currentFocusState: FocusAttributionState | null = null;
1167
+
1168
+ /** 归因预览焦点:有选中则固定选中节点,否则随悬浮临时预览 */
1169
+ function effectiveFocusId(): string | null {
1170
+ return selectedId ?? hoveredId;
1171
+ }
1172
+
1173
+ /** tooltip 锚点:悬浮任意节点时展示该节点;无悬浮则展示焦点节点(选中 > 无) */
1174
+ function tooltipFocusId(): string | null {
1175
+ if (hoveredId != null) {
1176
+ return graph.hasNode(hoveredId) ? hoveredId : null;
1177
+ }
1178
+ return effectiveFocusId();
1179
+ }
1180
  /**
1181
  * 与 {@link pruneDagLinksTouchingFullyExcludedNodes} / 预处理同源:全串上的 exclude 半开区间,
1182
  * 供节点「隐藏」透明度判定({@link isOffsetSpanFullyExcluded})。在 {@link setPromptTokenSpans} 与每步
 
1202
  .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link')
1203
  .data<DagLink>([], dagLinkDataKey);
1204
  let nodeSel = nodeG.selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node').data<DagNode>([], (d) => d.id);
1205
+ let nodeHitSel = nodeGHit
1206
+ .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node-hit')
1207
+ .data<DagNode>([], (d) => d.id);
1208
+
1209
+ /** 与 {@link nodeSel} 同序同 transform(paint 各布局模式之后调用) */
1210
+ function syncNodeHitTransforms(): void {
1211
+ const visualNodes = nodeSel.nodes();
1212
+ nodeHitSel.attr('transform', (_d, i) => d3.select(visualNodes[i]).attr('transform'));
1213
+ }
1214
+
1215
+ function bindNodePointerHandlers(
1216
+ sel: d3.Selection<SVGGElement, DagNode, SVGGElement | null, unknown>,
1217
+ ): void {
1218
+ sel.on('mouseenter', (_event, d) => {
1219
+ hoveredId = d.id;
1220
+ refreshNodeLinkHighlight();
1221
+ })
1222
+ .on('mouseleave', () => {
1223
+ hoveredId = null;
1224
+ refreshNodeLinkHighlight();
1225
+ })
1226
+ .on('click', (event, d) => {
1227
+ event.stopPropagation();
1228
+ setSelectedNodeId(selectedId === d.id ? null : d.id);
1229
+ });
1230
+ }
1231
 
1232
  function syncSvgSize(): void {
1233
  const { w, h } = stackLayoutViewportPx(stackEl);
 
1235
  }
1236
 
1237
  function paint(): void {
1238
+ syncNodeStrokeRects(nodeSel, displayScale);
1239
+ if (layoutMode === 'linear-arc' || layoutMode === 'linear-arc-step-down') {
1240
  const layoutNodes = hideExcludedTokens
1241
  ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals))
1242
  : nodes;
 
1245
  nodeSel,
1246
  nodes: layoutNodes,
1247
  adjacentGapPx: linearArcAdjacentGapPx,
1248
+ variant: layoutMode === 'linear-arc-step-down' ? 'step-down' : 'flat',
1249
  getLinkNodes: (d) => ({
1250
  src: endpointNode(d.source, graph),
1251
  tgt: endpointNode(d.target, graph),
1252
  }),
1253
  });
1254
+ } else if (layoutMode === 'spiral') {
 
 
1255
  const layoutNodes = hideExcludedTokens
1256
  ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals))
1257
  : nodes;
 
1265
  tgt: endpointNode(d.target, graph),
1266
  }),
1267
  });
1268
+ } else {
1269
+ paintTextFlowLayout({
1270
+ linkSel,
1271
+ nodeSel,
1272
+ linkEndInsetPx,
1273
+ getLinkNodes: (d) => ({
1274
+ src: endpointNode(d.source, graph),
1275
+ tgt: endpointNode(d.target, graph),
1276
+ }),
1277
+ });
1278
  }
1279
+ syncNodeHitTransforms();
 
 
 
 
 
 
 
 
1280
  }
1281
 
1282
  let dragPointerOffset: { x: number; y: number } | null = null;
 
1304
  d.cx = x - offset.x;
1305
  d.cy = y - offset.y;
1306
  paint();
1307
+ syncGenAttrDagTopkTooltipImpl();
1308
  })
1309
  .on('end', () => {
1310
  dragPointerOffset = null;
1311
  });
1312
 
1313
+ /** 焦点高亮:递归强调来源链,直接强调一跳关系。 */
 
 
 
 
 
1314
  function refreshNodeLinkHighlight(): void {
1315
+ const focusId = effectiveFocusId();
1316
+ const focusState = focusId
1317
+ ? computeFocusAttributionState(graph, nodesSortedByStepDesc, incomingLinksByTarget, focusId, {
1318
+ maxIncomingDepth: recursiveAttributionEnabled ? Number.POSITIVE_INFINITY : 1,
1319
+ includeDownstreamInfluence: !recursiveAttributionEnabled && showDownstreamInfluence,
1320
+ })
1321
+ : null;
1322
+ currentFocusState = focusState;
1323
+ const focusNodeIds = focusState?.activeNodeIds ?? null;
1324
+ const nodeStrokeShareById =
1325
+ !recursiveAttributionEnabled || focusState == null || focusId == null
1326
+ ? null
1327
+ : computeUpstreamNodeStrokeShareById(focusState.nodeShareById, graph, focusId);
1328
+ const nodeStrokeRenderById =
1329
+ nodeStrokeShareById == null ? null : buildNodeStrokeRenderStrengthById(nodeStrokeShareById);
1330
+ const focusTargetMiRatio =
1331
+ focusId != null && graph.hasNode(focusId)
1332
+ ? nodeTargetMiRatio(graph.getNodeAttributes(focusId) as DagNode)
1333
+ : 1;
1334
+ const incomingHighlightRenderByKey =
1335
+ focusState == null
1336
+ ? new Map<string, number>()
1337
+ : buildMaxNormalizedRenderStrengthByKey(focusState.incomingEdgeShareByKey, focusTargetMiRatio);
1338
+ const downstreamHighlightRenderByKey =
1339
+ focusState == null
1340
+ ? new Map<string, number>()
1341
+ : buildMaxNormalizedRenderStrengthByKey(focusState.downstreamEdgeStrengthByKey);
1342
+ grayRenderCache ??= buildGrayRenderStrengthByEdgeKey(graph, incomingLinksByTarget);
1343
+ const grayRenderByKey = grayRenderCache;
1344
+ const nodeDisplay = (d: DagNode): string | null =>
1345
+ hideExcludedTokens && isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)
1346
+ ? 'none'
1347
+ : null;
1348
  nodeSel
1349
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
1350
  .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id)
1351
+ .style('display', nodeDisplay)
 
 
 
1352
  .attr('opacity', (d) => {
1353
+ const nodeFullyHighlighted = recursiveAttributionEnabled
1354
+ ? d.id === focusId || (nodeStrokeShareById?.has(d.id) ?? false)
1355
+ : (focusNodeIds?.has(d.id) ?? false);
1356
+ if (nodeFullyHighlighted) return 1;
1357
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
1358
  return hideExcludedTokens ? 0 : DAG_NODE_HIDDEN_OPACITY;
1359
  }
 
1361
  const isPromptLeaf = hasGenTokens && d.step === -1 && graph.outDegree(d.id) === 0;
1362
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
1363
  return 1;
1364
+ })
1365
+ .classed('gen-attr-dag-node--recursive-chain', (d) => nodeStrokeShareById?.has(d.id) ?? false)
1366
+ .style(CSS_VAR_DAG_NODE_RECURSIVE_SHARE, (d) => {
1367
+ const renderStrength = nodeStrokeRenderById?.get(d.id);
1368
+ return renderStrength != null ? String(renderStrength) : null;
1369
  });
1370
+ nodeHitSel
1371
+ .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
1372
+ .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id)
1373
+ .style('display', nodeDisplay);
1374
+ // 每条边:颜色/强度(见 resolveDagLinkHighlightDisplay)、`<title>` 一并刷新(含 linkGFront 高亮边)。
1375
+ rootG.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function(d) {
1376
+ const srcId = endpointNode(d.source, graph).id;
1377
+ const tgtId = endpointNode(d.target, graph).id;
1378
+ const edgeKey = dagLinkEndpointKey(srcId, tgtId);
1379
+ const { stroke, renderStrength, linkStrength, recursiveAttributionShare } =
1380
+ resolveDagLinkHighlightDisplay(
1381
+ d,
1382
+ edgeKey,
1383
+ focusState,
1384
+ recursiveAttributionEnabled,
1385
+ grayRenderByKey,
1386
+ incomingHighlightRenderByKey,
1387
+ downstreamHighlightRenderByKey,
1388
+ );
1389
  const g = d3.select(this);
1390
+ const srcAttrs = graph.getNodeAttributes(srcId) as DagNode;
1391
+ const tgtAttrs = graph.getNodeAttributes(tgtId) as DagNode;
1392
+ g.select('title').text(
1393
+ buildLinkTitleText({
1394
+ normalizedScore: d.normalizedScore,
1395
+ mutualInformationRatio: d.mutualInformationRatio,
1396
+ attributionShare: d.attributionShare,
1397
+ alignmentNote: d.alignmentNote,
1398
+ src: srcAttrs,
1399
+ tgt: tgtAttrs,
1400
+ recursiveAttributionShare,
1401
+ linkStrength,
1402
+ }),
1403
+ );
1404
+ g.select('path.gen-attr-dag-link-visible').attr('stroke', stroke).attr('stroke-opacity', renderStrength);
1405
  linkMarkersDefs
1406
  .select<SVGPathElement>(`#${dagLinkMarkerElementId(d.source, d.target)} path`)
1407
  .attr('stroke', stroke)
1408
+ .attr('stroke-opacity', renderStrength);
1409
+
1410
+ const incident =
1411
+ focusState != null &&
1412
+ (focusState.incomingEdgeShareByKey.has(edgeKey) ||
1413
+ focusState.downstreamEdgeStrengthByKey.has(edgeKey));
 
 
 
1414
  const parent = incident ? linkGFront : linkG;
1415
  const parentNode = parent.node()!;
1416
  if (this.parentNode !== parentNode) {
1417
  parentNode.appendChild(this as SVGGElement);
1418
  }
1419
  });
1420
+
1421
+ syncGenAttrDagTopkTooltipImpl();
1422
  }
1423
 
1424
+ syncGenAttrDagTopkTooltipImpl = (): void => {
1425
+ if (!showTokenInfoOnSelected) {
1426
+ dagTopkToolTip.hideAndReset();
1427
+ return;
1428
+ }
1429
+ const focusIdNext = tooltipFocusId();
1430
+ if (!focusIdNext || !graph.hasNode(focusIdNext)) {
1431
+ dagTopkToolTip.hideAndReset();
1432
+ return;
1433
+ }
1434
+ const attrs = graph.getNodeAttributes(focusIdNext) as DagNode;
1435
+ // 生成节点必须有 gltrTooltipToken;prompt 节点用 label 构造最简 token
1436
+ const isPromptNode = attrs.step < 0;
1437
+ if (!isPromptNode && !attrs.gltrTooltipToken) {
1438
+ dagTopkToolTip.hideAndReset();
1439
+ return;
1440
+ }
1441
+ const rect = nodeSel
1442
+ .filter((d: DagNode) => d.id === focusIdNext)
1443
+ .select<SVGRectElement>('rect.gen-attr-dag-node-fill')
1444
+ .node();
1445
+ if (!rect) {
1446
+ dagTopkToolTip.hideAndReset();
1447
+ return;
1448
+ }
1449
+ const tokenForTooltip: FrontendToken = attrs.gltrTooltipToken ?? {
1450
+ raw: attrs.label,
1451
+ offset: [attrs.start, attrs.end],
1452
+ pred_topk: [],
1453
+ };
1454
+
1455
+ // 构建 augment:归因份额行(token 下方最前)+ CI/MI 行(surprisal 之后)
1456
+ const rowsBeforeInfo: ToolTipUpdateAugment['rowsBeforeInfo'] = [];
1457
+ if (selectedId && hoveredId && currentFocusState && hoveredId !== selectedId && graph.hasNode(selectedId)) {
1458
+ const selectedStep = (graph.getNodeAttributes(selectedId) as DagNode).step;
1459
+ // 归因范围:选中 token 之前的所有 token(prompt 节点 step=-1,生成节点 step < selectedStep)
1460
+ const inAttributionRange =
1461
+ selectedStep >= 0 &&
1462
+ (attrs.step === -1 || (attrs.step >= 0 && attrs.step < selectedStep));
1463
+ if (inAttributionRange) {
1464
+ const share = currentFocusState.nodeShareById.get(hoveredId) ?? 0;
1465
+ if (recursiveAttributionEnabled) {
1466
+ const stay = share * (1 - nodePropagationMiRatio(attrs));
1467
+ rowsBeforeInfo.push(
1468
+ { label: tr('Attribution share (Total):'), value: formatAttributionSharePercentForTooltip(share) },
1469
+ { label: tr('Attribution share (Self):'), value: formatAttributionSharePercentForTooltip(stay) },
1470
+ );
1471
+ } else {
1472
+ rowsBeforeInfo.push({
1473
+ label: tr('Attribution share:'),
1474
+ value: formatAttributionSharePercentForTooltip(share),
1475
+ });
1476
+ }
1477
+ }
1478
+ }
1479
+ const rowsAfterSurprisal: ToolTipUpdateAugment['rowsAfterSurprisal'] =
1480
+ attrs.dagCiMiTooltipRow != null ? [attrs.dagCiMiTooltipRow] : [];
1481
+ const augment: ToolTipUpdateAugment | undefined =
1482
+ rowsBeforeInfo.length > 0 || rowsAfterSurprisal.length > 0
1483
+ ? { rowsBeforeInfo, rowsAfterSurprisal }
1484
+ : undefined;
1485
+ dagTopkToolTip.updateData({ tokenData: tokenForTooltip }, rect, augment);
1486
+ };
1487
+
1488
  function setSelectedNodeId(id: string | null): void {
1489
+ if (id != null && !graph.hasNode(id)) {
1490
+ throw new Error(`genAttributeDagView: unknown node id ${id}`);
1491
+ }
1492
  selectedId = id;
1493
  refreshNodeLinkHighlight();
1494
  }
 
1499
 
1500
  /** 将当前 `nodes` / `links` 同步到 SVG:join 新 DOM、`paint` 几何、`refreshNodeLinkHighlight` 样式。 */
1501
  function syncGraphToSvg(): void {
1502
+ grayRenderCache = null;
1503
  linkGFront.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function() {
1504
  linkG.node()!.appendChild(this as SVGGElement);
1505
  });
 
1535
  g.each(function(d: DagLink) {
1536
  const el = d3.select(this);
1537
  const mkId = dagLinkMarkerElementId(d.source, d.target);
1538
+ el.append('title');
1539
  el.append('path')
1540
  .attr('class', 'gen-attr-dag-link-visible')
1541
  .attr('fill', 'none')
 
1547
  return g;
1548
  });
1549
  // 不在此处全量重置 marker `stroke-opacity`:紧接着的 {@link refreshNodeLinkHighlight} 会按边
1550
+ // 逐条写 resolveDagLinkHighlightDisplay(与 `<title>` 中 Link strength 同源),任何前值都会被覆盖,全量重置纯冗余。
1551
 
1552
  nodeSel = nodeG
1553
  .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node')
1554
  .data(nodes, (d) => d.id)
1555
+ .join((enter) => {
1556
+ // 节点身份 append-only、几何(nodeW/nodeH)一旦建立不再变化(drag 仅改 x/y,
1557
+ // paint 通过 transform 处理),故与几何相关的属性 enter 写一次即可;
1558
+ // 同理 `--prompt` class 依据 step === -1,step 初始化后不变。
1559
+ const g = enter
1560
+ .append('g')
1561
+ .attr('class', 'gen-attr-dag-node')
1562
+ .style('--gen-attr-dag-node-ci-visual-scale', (d: DagNode) => String(d.ciVisualScale));
1563
+ g.classed('gen-attr-dag-node--prompt', (d: DagNode) => d.step === -1);
1564
+ g.append('rect').attr('class', 'gen-attr-dag-node-stroke');
1565
+ g.append('rect')
1566
+ .attr('class', 'gen-attr-dag-node-fill')
1567
+ .attr('width', (d: DagNode) => d.nodeW)
1568
+ .attr('height', (d: DagNode) => d.nodeH)
1569
+ .attr('rx', (d: DagNode) => nodeRx(d))
1570
+ .attr('ry', (d: DagNode) => nodeRx(d));
1571
+ g.append('text')
1572
+ .attr('class', 'gen-attr-dag-node-text')
1573
+ .attr('xml:space', 'preserve')
1574
+ .attr('pointer-events', 'none')
1575
+ .attr('text-anchor', 'middle')
1576
+ .attr('dominant-baseline', 'central')
1577
+ .attr('x', (d: DagNode) => d.nodeW / 2)
1578
+ .attr('y', (d: DagNode) => d.nodeH / 2)
1579
+ .text((d: DagNode) => d.displayLabel);
1580
+ return g;
1581
+ });
1582
+
1583
+ nodeHitSel = nodeGHit
1584
+ .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node-hit')
1585
+ .data(nodes, (d) => d.id)
1586
+ .join((enter) => {
1587
+ const g = enter.append('g').attr('class', 'gen-attr-dag-node-hit');
1588
+ g.append('rect')
1589
+ .attr('class', 'gen-attr-dag-node-hit-target')
1590
+ .attr('width', (d: DagNode) => d.nodeW)
1591
+ .attr('height', (d: DagNode) => d.nodeH)
1592
+ .attr('rx', (d: DagNode) => nodeRx(d))
1593
+ .attr('ry', (d: DagNode) => nodeRx(d));
1594
+ bindNodePointerHandlers(g);
1595
+ return g.call(drag);
1596
+ });
 
 
1597
 
1598
  paint();
1599
  refreshNodeLinkHighlight();
 
1649
  nodeH: g.height * displayScale,
1650
  ciVisualScale: 1,
1651
  displayLabel,
 
 
 
 
 
1652
  };
1653
  graph.addNode(srcId, srcNode);
1654
  nodes.push(srcNode);
 
1665
  getEffectiveExcludePromptPatternsText(),
1666
  getEffectiveExcludeGeneratedPatternsText(),
1667
  );
1668
+ // prompt 节点 step=-1 始终排在末尾;重建一次即可(setPromptTokenSpans 只调一次)。
1669
+ nodesSortedByStepDesc = [...nodes].sort((a, b) => b.step - a.step || b.start - a.start);
1670
  if (batchDepth === 0) syncGraphToSvg();
1671
  }
1672
 
 
1694
  spaceDotExceptBeforeAsciiLetterOrNumber: true,
1695
  });
1696
  const ciVisualScale = dagGeneratedNodeCiVisualScale(response.target_prob);
1697
+ const gltrTooltipToken = frontendTokenFromGenAttrStep(step);
1698
+ const dagCiMiTooltipRow = dagCiMiTooltipRowForProb(response.target_prob);
1699
  const targetNode: DagNode = {
1700
  id: targetId,
1701
  label: token,
 
1707
  nodeW: g.width * displayScale * ciVisualScale,
1708
  nodeH: g.height * displayScale * ciVisualScale,
1709
  ciVisualScale,
1710
+ dagTargetProb: response.target_prob,
1711
  displayLabel,
1712
+ ...(gltrTooltipToken != null ? { gltrTooltipToken } : {}),
1713
+ ...(dagCiMiTooltipRow != null ? { dagCiMiTooltipRow } : {}),
 
 
 
 
1714
  };
1715
  graph.addNode(targetId, targetNode);
1716
  nodes.push(targetNode);
1717
+ // 新 token 的 step 最大,直接放到排序列表最前面,无需重新全排序。
1718
+ nodesSortedByStepDesc.unshift(targetNode);
1719
  snapSubwordNode(targetNode, nodes.length >= 2 ? nodes[nodes.length - 2]! : null);
1720
 
1721
  // align → exclude → rank:Top-N / β / cumP 在节点语义上工作(合并型「如下」/ 拆分型等)。
 
1738
  const selected = phase2RankAndSparsify(afterExclude, { cumulativeShare: edgeTopPCoverage });
1739
 
1740
  const mutualInformationRatio = computeMutualInformationRatio(response.target_prob);
1741
+ const selectedForDisplay = selected.filter((item) => {
1742
+ const normalizedScore = item.score;
1743
+ const edgeVisibility =
1744
+ (dagDecayAttributionToHighSurprisalTargetEnabled ? mutualInformationRatio : 1) * normalizedScore;
1745
+ return edgeVisibility >= DAG_EDGE_MIN_NORMALIZED_SCORE;
1746
+ });
 
 
1747
  const massSum = selectedForDisplay.reduce((acc, t) => acc + Math.max(0, t.poolMassFrac), 0);
1748
+ const linksForTarget: DagLink[] = [];
1749
  for (const item of selectedForDisplay) {
1750
  const srcId = item.nodeId;
1751
  if (!graph.hasNode(srcId)) {
 
1766
  const edgeAttrs = {
1767
  normalizedScore: item.score,
1768
  mutualInformationRatio,
1769
+ attributionShare: share,
1770
  ...(alignmentNote ? { alignmentNote } : {}),
1771
  };
1772
  graph.addEdge(srcId, targetId, edgeAttrs);
1773
+ const newLink: DagLink = {
 
 
1774
  source: srcId,
1775
  target: targetId,
1776
  ...edgeAttrs,
1777
+ };
1778
+ links.push(newLink);
1779
+ linksForTarget.push(newLink);
1780
  }
1781
+ if (linksForTarget.length > 0) incomingLinksByTarget.set(targetId, linksForTarget);
1782
 
1783
  const excludeIntervals = collectGenAttrDagExcludeIntervals(
1784
  intervalCtx,
 
1787
  getEffectiveExcludeGeneratedPatternsText(),
1788
  );
1789
  dagExcludeIntervals = excludeIntervals;
1790
+ pruneDagLinksTouchingFullyExcludedNodes(graph, links, incomingLinksByTarget, excludeIntervals);
1791
 
1792
  stepProcessed++;
1793
+ // 每步生成后:默认选中本步新生成的 token;无其它选中悬浮可临时预览
1794
  selectedId = targetId;
1795
  if (batchDepth === 0) {
1796
  syncGraphToSvg();
 
1806
  textMeasure.reset();
1807
  graph.clear();
1808
  nodes = [];
1809
+ nodesSortedByStepDesc = [];
1810
  links = [];
1811
+ incomingLinksByTarget.clear();
1812
+ grayRenderCache = null;
1813
  stepProcessed = 0;
1814
  selectedId = null;
1815
  hoveredId = null;
1816
+ dagTopkToolTip.hideAndReset();
1817
  linkMarkersDefs.selectAll('marker').remove();
1818
  linkG.selectAll('*').remove();
1819
  linkGFront.selectAll('*').remove();
1820
  nodeG.selectAll('*').remove();
1821
+ nodeGHit.selectAll('*').remove();
1822
  dagExcludeIntervals = [];
1823
  linkSel = rootG
1824
  .selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link')
1825
  .data<DagLink>([], dagLinkDataKey);
1826
  nodeSel = nodeG.selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node').data<DagNode>([], (d) => d.id);
1827
+ nodeHitSel = nodeGHit
1828
+ .selectAll<SVGGElement, DagNode>('g.gen-attr-dag-node-hit')
1829
+ .data<DagNode>([], (d) => d.id);
1830
  layoutDirty = preserveUserViewport ? wasLayoutDirty : false;
1831
  userDraggedNodes = false;
1832
  }
 
1845
  const { w, h } = stackLayoutViewportPx(stackEl);
1846
  const innerW = Math.max(w - 2 * pad, 1);
1847
  const innerH = Math.max(h - 2 * pad, 1);
1848
+ if (isLinearArcFamilyLayout(layoutMode)) {
1849
  /** 仅用 token 行宽度定比;竖直按行中心居中(弧不参与 bbox → 不致上下抖) */
1850
  const bn = nodeG.node()!.getBBox();
1851
  const bw = Math.max(bn.width, 1e-6);
 
1881
  const ty = pad + halfH;
1882
  svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1883
  } else if (layoutMode === 'text-flow') {
1884
+ /** `rootG` 整包 bbox + 宽高双约束顶对齐 */
1885
+ const padTf = DAG_TEXT_FLOW_FIT_PAD_PX;
1886
+ const innerWTextFlow = Math.max(w - 2 * padTf, 1);
1887
+ const innerHTextFlow = Math.max(h - 2 * padTf, 1);
1888
  const b = rootG.node()!.getBBox();
1889
  const bw = Math.max(b.width, 1e-6);
1890
  const bh = Math.max(b.height, 1e-6);
1891
+ const kRaw = Math.min(innerWTextFlow / bw, innerHTextFlow / bh);
1892
  const k = Math.min(Number.isFinite(kRaw) && kRaw > 0 ? kRaw : k0, k0);
1893
+ const tx = padTf - k * b.x;
1894
+ const ty = padTf - k * b.y;
1895
  svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1896
  } else {
1897
  const _: never = layoutMode;
 
1956
  if (linearArcAdjacentGapPx === next) return;
1957
  linearArcAdjacentGapPx = next;
1958
  if (opts?.skipRefit || batchDepth > 0) return;
1959
+ if (!isLinearArcFamilyLayout(layoutMode) || nodes.length === 0) return;
1960
  paint();
1961
  fitViewportToContent(true);
1962
  }
 
1970
  fitViewportToContent(true);
1971
  }
1972
 
1973
+ function setShowTokenInfoOnSelected(show: boolean): void {
1974
+ if (showTokenInfoOnSelected === show) return;
1975
+ showTokenInfoOnSelected = show;
1976
+ syncGenAttrDagTopkTooltipImpl();
1977
+ }
1978
+
1979
+ /** 传播归因(UI: Propagated attribution mode;`recursiveAttributionEnabled`):向上追到来源;关闭则为直接归因(一跳)。 */
1980
+ function setRecursiveAttributionEnabled(enabled: boolean): void {
1981
+ if (recursiveAttributionEnabled === enabled) return;
1982
+ recursiveAttributionEnabled = enabled;
1983
+ refreshNodeLinkHighlight();
1984
+ }
1985
+
1986
+ function setShowDownstreamInfluence(show: boolean): void {
1987
+ if (showDownstreamInfluence === show) return;
1988
+ showDownstreamInfluence = show;
1989
+ refreshNodeLinkHighlight();
1990
+ }
1991
+
1992
  const fullscreenBtn = resultsRoot
1993
  .append('button')
1994
  .attr('type', 'button')
 
2058
  ro.disconnect();
2059
  document.removeEventListener('fullscreenchange', refreshFullscreenChrome);
2060
  document.removeEventListener(CSS_PSEUDO_FULLSCREEN_CHANGE_EVENT, refreshFullscreenChrome);
2061
+ dagTopkToolTip.dispose();
2062
  resultsRoot
2063
  .selectAll(
2064
+ '.gen-attr-dag-stack, .gen-attr-dag-topk-tooltip, button.gen-attr-dag-refresh, button.gen-attr-dag-play, button.gen-attr-dag-fullscreen'
2065
  )
2066
  .remove();
2067
  detachGenAttributeDagPanel.delete(rootEl);
 
2077
  isBatching,
2078
  reset,
2079
  fitViewportToContent,
2080
+ getSelectedNodeId: () => selectedId,
2081
+ setSelectedNodeId,
2082
  clearNodeSelection,
2083
  setDagPlaybackPlaying,
2084
  setMeasureWidthPx,
 
2087
  setDagCompactness,
2088
  setEdgeTopPCoverage,
2089
  setHideExcludedTokens,
2090
+ setShowTokenInfoOnSelected,
2091
+ setRecursiveAttributionEnabled,
2092
+ setShowDownstreamInfluence,
2093
  hasPromptSpans: () => nodes.some((n) => n.step === -1),
2094
  detach,
2095
  };
client/src/ts/attribution/genAttributeDagViewLinearArcMode.ts CHANGED
@@ -1,4 +1,5 @@
1
  import * as d3 from 'd3';
 
2
 
3
  /** linear-arc:相邻节点矩形水平方向「外侧边与边之间的空隙」(px,SVG 内部坐标) */
4
  export const LINEAR_ARC_ADJACENT_GAP_DEFAULT = 0;
@@ -30,6 +31,40 @@ type LinearArcNodeLike = { nodeW: number; nodeH: number; ciVisualScale: number }
30
  /** `step === -1` 表示 prompt(与 `genAttributeDagView` 中 `DagNode.step` 约定一致) */
31
  type LinearArcSteppedNode = LinearArcNodeLike & { step: number };
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  function computeNodeCenterXs(nodes: LinearArcSteppedNode[], adjacentGapPx: number): number[] {
34
  const xs: number[] = [];
35
  if (nodes.length === 0) return xs;
@@ -49,6 +84,8 @@ function computeNodeCenterXs(nodes: LinearArcSteppedNode[], adjacentGapPx: numbe
49
  *
50
  * `nodes` 为参与布局的可见节点子集(可能少于 `nodeSel` 绑定的全量节点);
51
  * 不在 `nodes` 中的节点(如被隐藏的 excluded 节点)transform 保持不变——调用方已将它们设为 `display:none`。
 
 
52
  */
53
  export function paintLinearArcLayout<
54
  LinkDatum,
@@ -59,8 +96,9 @@ export function paintLinearArcLayout<
59
  nodes: NodeDatum[];
60
  adjacentGapPx: number;
61
  getLinkNodes: (link: LinkDatum) => { src: NodeDatum; tgt: NodeDatum };
 
62
  }): void {
63
- const { linkSel, nodeSel, nodes, adjacentGapPx, getLinkNodes } = params;
64
 
65
  const centerXs = computeNodeCenterXs(nodes, adjacentGapPx);
66
 
@@ -70,23 +108,37 @@ export function paintLinearArcLayout<
70
  centerXByNode.set(nodes[i]!, centerXs[i]!);
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  const arcPathBetweenNodes = (src: NodeDatum, tgt: NodeDatum): string => {
74
  const srcCx = centerXByNode.get(src);
75
  const tgtCx = centerXByNode.get(tgt);
76
  if (srcCx === undefined || tgtCx === undefined) {
77
  throw new Error('paintLinearArcLayout: link endpoint not in linear node list');
78
  }
79
- // 用未放大的半高(nodeH / ciVisualScale / 2)定位弧端点,使所有节点顶部对齐同一 y 基线。
80
- const y = LINEAR_ARC_BASELINE_Y - src.nodeH / (2 * src.ciVisualScale);
81
  const dx = Math.abs(tgtCx - srcCx);
82
  const arcH = dx * 0.4;
83
- const upY = y - arcH;
84
  const t = Math.max(0, Math.min(1, LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION));
85
  const inset = t * (dx / 2);
86
  const dir = tgtCx >= srcCx ? 1 : -1;
87
  const p1x = srcCx + dir * inset;
88
  const p2x = tgtCx - dir * inset;
89
- return `M ${srcCx} ${y} C ${p1x} ${upY}, ${p2x} ${upY}, ${tgtCx} ${y}`;
90
  };
91
 
92
  linkSel.each(function(d) {
@@ -99,6 +151,7 @@ export function paintLinearArcLayout<
99
  nodeSel.attr('transform', (d) => {
100
  const cx = centerXByNode.get(d);
101
  if (cx === undefined) return null; // 不在布局列表中(已 display:none),不更新 transform
102
- return `translate(${cx - d.nodeW / 2},${LINEAR_ARC_BASELINE_Y - d.nodeH / 2})`;
 
103
  });
104
  }
 
1
  import * as d3 from 'd3';
2
+ import { dagStepDownEffectiveCiRatio } from '../utils/surprisalMath';
3
 
4
  /** linear-arc:相邻节点矩形水平方向「外侧边与边之间的空隙」(px,SVG 内部坐标) */
5
  export const LINEAR_ARC_ADJACENT_GAP_DEFAULT = 0;
 
31
  /** `step === -1` 表示 prompt(与 `genAttributeDagView` 中 `DagNode.step` 约定一致) */
32
  type LinearArcSteppedNode = LinearArcNodeLike & { step: number };
33
 
34
+ /** 下台阶布局:节点可带 `dagTargetProb`;有效 CI 为 {@link dagStepDownEffectiveCiRatio}(高置信 p>p₁ 为 0;与「关掉 CI 视觉」无关)。 */
35
+ export type LinearArcStepDownNode = LinearArcSteppedNode & { dagTargetProb?: number };
36
+
37
+ export type LinearArcPaintVariant = 'flat' | 'step-down';
38
+
39
+ /**
40
+ * 下台阶:每档竖直落差 = `LINEAR_ARC_STEP_DOWN_DISTANCE_SCALE × linearArcUnscaledNodeHeight × CI`。
41
+ * 仅代码可调;不对该系数做运行时 clamp。
42
+ */
43
+ export const LINEAR_ARC_STEP_DOWN_DISTANCE_SCALE = 1;
44
+
45
+ /** 未做 CI 视觉放大时的节点高度(SVG 坐标),作下台阶落差的 100% CI 基准 */
46
+ export function linearArcUnscaledNodeHeight(n: Pick<LinearArcNodeLike, 'nodeH' | 'ciVisualScale'>): number {
47
+ return n.nodeH / n.ciVisualScale;
48
+ }
49
+
50
+ /**
51
+ * 第 i 个节点相对首节点的累积下移:对每个 j≥1,加上
52
+ * `LINEAR_ARC_STEP_DOWN_DISTANCE_SCALE × linearArcUnscaledNodeHeight × CI`,
53
+ * CI 为 {@link dagStepDownEffectiveCiRatio}(dagTargetProb)。
54
+ */
55
+ export function computeLinearArcStepDownOffsetYs(nodes: LinearArcStepDownNode[]): number[] {
56
+ const offsetY: number[] = [];
57
+ let acc = 0;
58
+ for (let i = 0; i < nodes.length; i++) {
59
+ offsetY.push(acc);
60
+ const next = nodes[i + 1];
61
+ if (!next) continue;
62
+ const ratio = dagStepDownEffectiveCiRatio(next.dagTargetProb);
63
+ acc += LINEAR_ARC_STEP_DOWN_DISTANCE_SCALE * linearArcUnscaledNodeHeight(next) * ratio;
64
+ }
65
+ return offsetY;
66
+ }
67
+
68
  function computeNodeCenterXs(nodes: LinearArcSteppedNode[], adjacentGapPx: number): number[] {
69
  const xs: number[] = [];
70
  if (nodes.length === 0) return xs;
 
84
  *
85
  * `nodes` 为参与布局的可见节点子集(可能少于 `nodeSel` 绑定的全量节点);
86
  * 不在 `nodes` 中的节点(如被隐藏的 excluded 节点)transform 保持不变——调用方已将它们设为 `display:none`。
87
+ *
88
+ * `variant === 'step-down'`:竖直落差 × {@link LINEAR_ARC_STEP_DOWN_DISTANCE_SCALE} × {@link linearArcUnscaledNodeHeight} × {@link dagStepDownEffectiveCiRatio}。
89
  */
90
  export function paintLinearArcLayout<
91
  LinkDatum,
 
96
  nodes: NodeDatum[];
97
  adjacentGapPx: number;
98
  getLinkNodes: (link: LinkDatum) => { src: NodeDatum; tgt: NodeDatum };
99
+ variant?: LinearArcPaintVariant;
100
  }): void {
101
+ const { linkSel, nodeSel, nodes, adjacentGapPx, getLinkNodes, variant = 'flat' } = params;
102
 
103
  const centerXs = computeNodeCenterXs(nodes, adjacentGapPx);
104
 
 
108
  centerXByNode.set(nodes[i]!, centerXs[i]!);
109
  }
110
 
111
+ const offsetYs =
112
+ variant === 'step-down' ? computeLinearArcStepDownOffsetYs(nodes as LinearArcStepDownNode[]) : null;
113
+ const offsetYByNode = new Map<NodeDatum, number>();
114
+ if (offsetYs) {
115
+ for (let i = 0; i < nodes.length; i++) {
116
+ offsetYByNode.set(nodes[i]!, offsetYs[i]!);
117
+ }
118
+ }
119
+
120
+ const arcTopY = (n: NodeDatum): number => {
121
+ const oy = offsetYByNode.get(n) ?? 0;
122
+ return LINEAR_ARC_BASELINE_Y - n.nodeH / (2 * n.ciVisualScale) + oy;
123
+ };
124
+
125
  const arcPathBetweenNodes = (src: NodeDatum, tgt: NodeDatum): string => {
126
  const srcCx = centerXByNode.get(src);
127
  const tgtCx = centerXByNode.get(tgt);
128
  if (srcCx === undefined || tgtCx === undefined) {
129
  throw new Error('paintLinearArcLayout: link endpoint not in linear node list');
130
  }
131
+ const yStart = arcTopY(src);
132
+ const yEnd = arcTopY(tgt);
133
  const dx = Math.abs(tgtCx - srcCx);
134
  const arcH = dx * 0.4;
135
+ const upY = Math.min(yStart, yEnd) - arcH;
136
  const t = Math.max(0, Math.min(1, LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION));
137
  const inset = t * (dx / 2);
138
  const dir = tgtCx >= srcCx ? 1 : -1;
139
  const p1x = srcCx + dir * inset;
140
  const p2x = tgtCx - dir * inset;
141
+ return `M ${srcCx} ${yStart} C ${p1x} ${upY}, ${p2x} ${upY}, ${tgtCx} ${yEnd}`;
142
  };
143
 
144
  linkSel.each(function(d) {
 
151
  nodeSel.attr('transform', (d) => {
152
  const cx = centerXByNode.get(d);
153
  if (cx === undefined) return null; // 不在布局列表中(已 display:none),不更新 transform
154
+ const oy = offsetYByNode.get(d) ?? 0;
155
+ return `translate(${cx - d.nodeW / 2},${LINEAR_ARC_BASELINE_Y - d.nodeH / 2 + oy})`;
156
  });
157
  }
client/src/ts/demos/genAttributeBundledDemoManifest.generated.ts CHANGED
@@ -1,4 +1,4 @@
1
  /**
2
  * Generated by GenAttributeDemoManifestPlugin — do not edit.
3
  */
4
- export const GEN_ATTRIBUTE_BUNDLED_DEMO_SLUGS: readonly string[] = ["CN->EN翻译","Write a sonnet about love","写一首绝句,主题是春天","过拟合|李白 将进酒"];
 
1
  /**
2
  * Generated by GenAttributeDemoManifestPlugin — do not edit.
3
  */
4
+ export const GEN_ATTRIBUTE_BUNDLED_DEMO_SLUGS: readonly string[] = ["CN->EN翻译","CoT | 苏州所在省的省会","Write a sonnet about love","写一首绝句,主题是春天","过拟合|李白 将进酒"];
client/src/ts/gen_attribute.ts CHANGED
@@ -13,6 +13,7 @@ import { initChatPanelLayout } from './chat/chatPanelLayout';
13
  import { PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE } from './utils/panelSplitStorage';
14
  import { TextInputController } from './controllers/textInputController';
15
  import { initializeCommonApp } from './appInitializer';
 
16
  import { showAlertDialog } from './ui/dialog';
17
  import URLHandler from './utils/URLHandler';
18
  import { createToast } from './ui/toast';
@@ -26,7 +27,7 @@ import {
26
  import {
27
  initGenAttributeDagView,
28
  setDagNodeCiVisualScaleEnabled,
29
- setDagEdgeWeakenHighSurprisalEnabled,
30
  type DagLayoutMode,
31
  clampDagCompactness,
32
  clampLinearArcAdjacentGap,
@@ -106,9 +107,17 @@ const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playb
106
  const GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_replay_pacing_mode';
107
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_total_s';
108
  const GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY = 'info_radar_gen_attr_dag_node_ci_visual_scale';
109
- const GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY = 'info_radar_gen_attr_dag_edge_weaken_high_surprisal';
 
 
 
 
110
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
 
 
 
111
  const GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_excluded_tokens';
 
112
  const GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY =
113
  'info_radar_gen_attr_dag_linear_arc_adjacent_gap';
114
  const GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY = 'info_radar_gen_attr_dag_compactness';
@@ -136,6 +145,29 @@ const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT = 7;
136
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MIN = 1;
137
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MAX = 3600;
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  const GENERATE_BTN_LABEL = 'Start';
140
  const STOP_BTN_LABEL = 'Stop';
141
 
@@ -260,17 +292,24 @@ function readStoredDagReplayPacingMode(): DagReplayPacingMode {
260
  } catch {
261
  // ignore
262
  }
263
- return 'total';
264
  }
265
 
266
  function readStoredDagLayoutMode(): DagLayoutMode {
267
  try {
268
  const v = localStorage.getItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY);
269
- if (v === 'text-flow' || v === 'linear-arc' || v === 'spiral') return v;
 
 
 
 
 
 
 
270
  } catch {
271
  // ignore
272
  }
273
- return 'text-flow';
274
  }
275
 
276
  const apiPrefix = URLHandler.parameters['api'] || '';
@@ -375,36 +414,48 @@ function applyDagReplaySpeedUi(): void {
375
 
376
  function currentDagLayoutMode(): DagLayoutMode {
377
  const v = dagLayoutModeSelect?.value;
378
- if (v === 'linear-arc' || v === 'spiral') return v;
379
  return 'text-flow';
380
  }
381
 
382
  function applyDagLayoutModeUi(): void {
383
  const mode = currentDagLayoutMode();
384
  if (dagCompactnessGroup) {
385
- /** text-flow / spiral 均使用 display-scale 驱动的节点宽高与边回缩;linear-arc 不适用。 */
386
- dagCompactnessGroup.hidden = mode === 'linear-arc';
387
  }
388
  if (dagMeasureWidthGroup) {
389
  dagMeasureWidthGroup.hidden = mode !== 'text-flow';
390
  }
391
  if (dagLinearArcIntervalGroup) {
392
- dagLinearArcIntervalGroup.hidden = mode !== 'linear-arc';
393
  }
394
  }
395
 
396
  const dagHideExcludedTokensInput = document.getElementById(
397
  'gen_attr_dag_hide_excluded_tokens'
398
  ) as HTMLInputElement | null;
 
 
 
399
  const dagNodeCiVisualScaleInput = document.getElementById(
400
  'gen_attr_dag_node_ci_visual_scale'
401
  ) as HTMLInputElement | null;
402
- const dagEdgeWeakenHighSurprisalInput = document.getElementById(
403
- 'gen_attr_dag_edge_weaken_high_surprisal'
404
  ) as HTMLInputElement | null;
405
  const dagHideInactiveEdgesInput = document.getElementById(
406
  'gen_attr_dag_hide_inactive_edges'
407
  ) as HTMLInputElement | null;
 
 
 
 
 
 
 
 
 
408
  const genAttrExcludePromptPatternsTa = document.getElementById(
409
  'gen_attr_exclude_prompt_patterns'
410
  ) as HTMLTextAreaElement | null;
@@ -417,6 +468,9 @@ const genAttrExcludeGeneratedPatternsTa = document.getElementById(
417
  const genAttrExcludeGeneratedPatternsEnable = document.getElementById(
418
  'gen_attr_exclude_generated_patterns_enable'
419
  ) as HTMLInputElement | null;
 
 
 
420
  const completeReasonEl = d3.select('#gen_attr_complete_reason');
421
 
422
  function syncGenAttrExcludePatternTextareasDisabled(): void {
@@ -495,10 +549,11 @@ const genAttrResultsNode = genAttrResultsEl.node() as HTMLElement | null;
495
  function readStoredDagNodeCiVisualScale(): boolean {
496
  try {
497
  const v = localStorage.getItem(GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY);
498
- return v === null ? true : v === '1';
499
  } catch {
500
- return true;
501
  }
 
502
  }
503
  const initialDagNodeCiVisualScale = readStoredDagNodeCiVisualScale();
504
  if (dagNodeCiVisualScaleInput) dagNodeCiVisualScaleInput.checked = initialDagNodeCiVisualScale;
@@ -514,25 +569,30 @@ dagNodeCiVisualScaleInput?.addEventListener('change', () => {
514
  tryResetAndReplayDag();
515
  });
516
 
517
- function readStoredDagEdgeWeakenHighSurprisal(): boolean {
518
  try {
519
- const v = localStorage.getItem(GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY);
520
- return v === null ? true : v === '1';
 
 
521
  } catch {
522
- return true;
523
  }
 
 
 
 
 
524
  }
525
- const initialDagEdgeWeakenHighSurprisal = readStoredDagEdgeWeakenHighSurprisal();
526
- if (dagEdgeWeakenHighSurprisalInput) dagEdgeWeakenHighSurprisalInput.checked = initialDagEdgeWeakenHighSurprisal;
527
- setDagEdgeWeakenHighSurprisalEnabled(initialDagEdgeWeakenHighSurprisal);
528
- dagEdgeWeakenHighSurprisalInput?.addEventListener('change', () => {
529
- const enabled = dagEdgeWeakenHighSurprisalInput.checked;
530
  try {
531
- localStorage.setItem(GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY, enabled ? '1' : '0');
532
  } catch {
533
  /* ignore */
534
  }
535
- setDagEdgeWeakenHighSurprisalEnabled(enabled);
536
  tryResetAndReplayDag();
537
  });
538
 
@@ -542,10 +602,12 @@ function applyDagHideInactiveEdges(hide: boolean): void {
542
  }
543
  function readStoredDagHideInactiveEdges(): boolean {
544
  try {
545
- return localStorage.getItem(GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY) === '1';
 
546
  } catch {
547
- return false;
548
  }
 
549
  }
550
  const initialDagHideInactiveEdges = readStoredDagHideInactiveEdges();
551
  if (dagHideInactiveEdgesInput) dagHideInactiveEdgesInput.checked = initialDagHideInactiveEdges;
@@ -560,15 +622,82 @@ dagHideInactiveEdgesInput?.addEventListener('change', () => {
560
  applyDagHideInactiveEdges(hide);
561
  });
562
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  function readStoredDagHideExcludedTokens(): boolean {
564
  try {
565
- return localStorage.getItem(GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY) === '1';
 
566
  } catch {
567
- return false;
568
  }
 
569
  }
570
  const initialDagHideExcludedTokens = readStoredDagHideExcludedTokens();
571
  if (dagHideExcludedTokensInput) dagHideExcludedTokensInput.checked = initialDagHideExcludedTokens;
 
 
 
 
 
 
 
 
 
 
 
572
  dagHideExcludedTokensInput?.addEventListener('change', () => {
573
  const hide = dagHideExcludedTokensInput.checked;
574
  try {
@@ -579,6 +708,28 @@ dagHideExcludedTokensInput?.addEventListener('change', () => {
579
  dagHandle.setHideExcludedTokens(hide);
580
  });
581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  modelVariantSelect?.addEventListener('change', () => {
583
  try {
584
  localStorage.setItem(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, currentModelVariant());
@@ -998,6 +1149,9 @@ const dagHandle = initGenAttributeDagView(d3.select('#results'), {
998
  dagCompactness: initialDagCompactness,
999
  linearArcAdjacentGapPx: initialDagLinearArcGap,
1000
  hideExcludedTokens: initialDagHideExcludedTokens,
 
 
 
1001
  edgeTopPCoverage: initialDagEdgeTopPCoverage,
1002
  onFullscreenError: (message) => showToast(message, 'error'),
1003
  getEffectiveExcludePromptPatternsText: genAttrEffectiveExcludePromptPatternsText,
@@ -1024,16 +1178,25 @@ function isDagBusy(): boolean {
1024
  return inFlight || dagPlaybackTimer !== null || dagLastTokenDwellTimer !== null;
1025
  }
1026
 
1027
- /** 非忙状态下 reset + replay + fit,供各设置项切换后复用。忙时为 no-op。 */
1028
- function tryResetAndReplayDag(): void {
 
 
 
1029
  if (isDagBusy()) return;
 
 
1030
  const h = runnerHandle;
1031
  dagHandle.reset();
1032
  if (h && h.tokenCount > 0) {
1033
  replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
1034
  }
1035
  dagHandle.fitViewportToContent();
1036
- dagHandle.clearNodeSelection();
 
 
 
 
1037
  }
1038
 
1039
  dagMeasureWidthInput?.addEventListener('change', () => {
@@ -1127,8 +1290,12 @@ function readGenAttrDemoUiOptionsFromControls(): GenAttrDemoUiOptions {
1127
  hideExcludedTokens: dagHideExcludedTokensInput?.checked ?? false,
1128
  edgeTopPCoverage,
1129
  nodeCiVisualScaleEnabled: dagNodeCiVisualScaleInput?.checked ?? true,
1130
- edgeWeakenHighSurprisalEnabled: dagEdgeWeakenHighSurprisalInput?.checked ?? true,
 
1131
  hideInactiveEdges: dagHideInactiveEdgesInput?.checked ?? false,
 
 
 
1132
  replayPacingMode: currentDagReplayPacingMode(),
1133
  playbackTotalS,
1134
  playbackStepMs,
@@ -1136,9 +1303,31 @@ function readGenAttrDemoUiOptionsFromControls(): GenAttrDemoUiOptions {
1136
  excludePromptPatternsText: genAttrExcludePromptPatternsTa?.value ?? '',
1137
  excludeGeneratedPatternsEnabled: genAttrExcludeGeneratedPatternsEnable?.checked ?? true,
1138
  excludeGeneratedPatternsText: genAttrExcludeGeneratedPatternsTa?.value ?? '',
 
1139
  };
1140
  }
1141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1142
  /**
1143
  * 从 `demoUiOptions` 还原排除控件(仅 DOM);与施加 DAG demo 不回写 GEN_ATTR_LS 的策略一致,`replay` 读当前控件生效。
1144
  */
@@ -1172,12 +1361,8 @@ function applyGenAttrExcludePatternsFromDemoUiSnap(snap: Partial<GenAttrDemoUiOp
1172
  syncGenAttrExcludePatternTextareasDisabled();
1173
  }
1174
 
1175
- /**
1176
- * 按记录中的 `demoUiOptions` 还原 DAG 面板与排除控件(后者仅 DOM)。
1177
- */
1178
- function applyGenAttrDemoUiOptionsFromRecord(rec: GenAttrCachedRun): void {
1179
- const snap = rec.demoUiOptions;
1180
- if (!snap) return;
1181
  const mode = snap.layoutMode;
1182
  if (mode) {
1183
  if (dagLayoutModeSelect) {
@@ -1215,14 +1400,36 @@ function applyGenAttrDemoUiOptionsFromRecord(rec: GenAttrCachedRun): void {
1215
  if (dagNodeCiVisualScaleInput) dagNodeCiVisualScaleInput.checked = snap.nodeCiVisualScaleEnabled;
1216
  setDagNodeCiVisualScaleEnabled(snap.nodeCiVisualScaleEnabled);
1217
  }
1218
- if (snap.edgeWeakenHighSurprisalEnabled !== undefined) {
1219
- if (dagEdgeWeakenHighSurprisalInput) dagEdgeWeakenHighSurprisalInput.checked = snap.edgeWeakenHighSurprisalEnabled;
1220
- setDagEdgeWeakenHighSurprisalEnabled(snap.edgeWeakenHighSurprisalEnabled);
 
 
 
 
 
1221
  }
1222
  if (snap.hideInactiveEdges !== undefined) {
1223
  if (dagHideInactiveEdgesInput) dagHideInactiveEdgesInput.checked = snap.hideInactiveEdges;
1224
  applyDagHideInactiveEdges(snap.hideInactiveEdges);
1225
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1226
  if (snap.replayPacingMode !== undefined) {
1227
  if (dagReplayModeSelect) dagReplayModeSelect.value = snap.replayPacingMode;
1228
  applyDagReplaySpeedUi();
@@ -1237,8 +1444,80 @@ function applyGenAttrDemoUiOptionsFromRecord(rec: GenAttrCachedRun): void {
1237
  }
1238
 
1239
  applyGenAttrExcludePatternsFromDemoUiSnap(snap);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1240
  }
1241
 
 
 
 
 
 
 
 
 
 
 
 
1242
  window.addEventListener('pagehide', (ev) => {
1243
  if (ev.persisted) return;
1244
  dagHandle.detach();
@@ -1247,9 +1526,7 @@ window.addEventListener('pagehide', (ev) => {
1247
  function onExcludePatternsEffectiveChange(): void {
1248
  const h = runnerHandle;
1249
  if (!h || h.tokenCount === 0) return;
1250
- dagHandle.reset();
1251
- replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
1252
- dagHandle.clearNodeSelection();
1253
  }
1254
 
1255
  function bindExcludePatternControls(
@@ -1576,6 +1853,7 @@ async function applyGenAttrCachedRun(
1576
  stopDagPlayback();
1577
  dagHandle.reset();
1578
  applyGenAttrDemoUiOptionsFromRecord(rec);
 
1579
  runnerHandle = createHydratedTokenGenHandle(rec.steps);
1580
  lastRunInitialContext = rec.initialContext;
1581
  lastRunInputSnapshot = getInputSnapshotForRun();
@@ -1585,7 +1863,7 @@ async function applyGenAttrCachedRun(
1585
  currentRunPromptSpans = replayPromptSpans;
1586
  replayRunnerStepsIntoDag(runnerHandle, replayPromptSpans);
1587
  dagHandle.fitViewportToContent();
1588
- dagHandle.clearNodeSelection();
1589
  const n = runnerHandle.tokenCount;
1590
  setGenAttrUsageMetric(initialPromptTokensFromFirstStep(rec.steps[0]!), n);
1591
  if (validateMetricsElements(metricModel) && n > 0) {
@@ -1993,13 +2271,8 @@ submitBtn.on('click', () => {
1993
  function refreshDagForThemeChange(): void {
1994
  stopDagPlayback();
1995
  const h = runnerHandle;
1996
- if (!h || h.tokenCount === 0) {
1997
- return;
1998
- }
1999
- dagHandle.reset();
2000
- replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
2001
- dagHandle.fitViewportToContent();
2002
- dagHandle.clearNodeSelection();
2003
  }
2004
 
2005
  const themeManager = initThemeManager(
 
13
  import { PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE } from './utils/panelSplitStorage';
14
  import { TextInputController } from './controllers/textInputController';
15
  import { initializeCommonApp } from './appInitializer';
16
+ import { setPageOptsGetter } from './utils/clientActivityPing';
17
  import { showAlertDialog } from './ui/dialog';
18
  import URLHandler from './utils/URLHandler';
19
  import { createToast } from './ui/toast';
 
27
  import {
28
  initGenAttributeDagView,
29
  setDagNodeCiVisualScaleEnabled,
30
+ setDagDecayAttributionToHighSurprisalTargetEnabled,
31
  type DagLayoutMode,
32
  clampDagCompactness,
33
  clampLinearArcAdjacentGap,
 
107
  const GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_replay_pacing_mode';
108
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_total_s';
109
  const GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY = 'info_radar_gen_attr_dag_node_ci_visual_scale';
110
+ const GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY =
111
+ 'info_radar_gen_attr_dag_decay_attribution_high_surprisal';
112
+ /** @deprecated 读取迁移用 */
113
+ const GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY_LEGACY =
114
+ 'info_radar_gen_attr_dag_edge_weaken_high_surprisal';
115
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
116
+ const GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY =
117
+ 'info_radar_gen_attr_dag_show_downstream_influence';
118
+ const GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY = 'info_radar_gen_attr_dag_recursive_attribution';
119
  const GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_excluded_tokens';
120
+ const GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY = 'info_radar_gen_attr_dag_show_topk_on_selected';
121
  const GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY =
122
  'info_radar_gen_attr_dag_linear_arc_adjacent_gap';
123
  const GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY = 'info_radar_gen_attr_dag_compactness';
 
145
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MIN = 1;
146
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MAX = 3600;
147
 
148
+ /** 与无 demoUiOptions 本地缓存时「读出默认」对齐,供重置与可读性单一的来源 */
149
+ const DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS: GenAttrDemoUiOptions = {
150
+ layoutMode: 'text-flow',
151
+ measureWidthPx: GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT,
152
+ dagCompactness: DAG_COMPACTNESS_DEFAULT,
153
+ linearArcAdjacentGapPx: LINEAR_ARC_ADJACENT_GAP_DEFAULT,
154
+ hideExcludedTokens: false,
155
+ edgeTopPCoverage: DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
156
+ nodeCiVisualScaleEnabled: true,
157
+ decayAttributionToHighSurprisalTargetEnabled: true,
158
+ hideInactiveEdges: false,
159
+ showDownstreamInfluence: false,
160
+ recursiveAttributionEnabled: false,
161
+ showTokenInfoOnSelected: false,
162
+ replayPacingMode: 'total',
163
+ playbackTotalS: GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT,
164
+ playbackStepMs: GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT,
165
+ excludePromptPatternsEnabled: true,
166
+ excludePromptPatternsText: DEFAULT_EXCLUDE_PROMPT_PATTERNS_TEXT,
167
+ excludeGeneratedPatternsEnabled: true,
168
+ excludeGeneratedPatternsText: DEFAULT_EXCLUDE_GENERATED_PATTERNS_TEXT,
169
+ };
170
+
171
  const GENERATE_BTN_LABEL = 'Start';
172
  const STOP_BTN_LABEL = 'Stop';
173
 
 
292
  } catch {
293
  // ignore
294
  }
295
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.replayPacingMode;
296
  }
297
 
298
  function readStoredDagLayoutMode(): DagLayoutMode {
299
  try {
300
  const v = localStorage.getItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY);
301
+ if (
302
+ v === 'text-flow' ||
303
+ v === 'linear-arc' ||
304
+ v === 'linear-arc-step-down' ||
305
+ v === 'spiral'
306
+ ) {
307
+ return v;
308
+ }
309
  } catch {
310
  // ignore
311
  }
312
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.layoutMode;
313
  }
314
 
315
  const apiPrefix = URLHandler.parameters['api'] || '';
 
414
 
415
  function currentDagLayoutMode(): DagLayoutMode {
416
  const v = dagLayoutModeSelect?.value;
417
+ if (v === 'linear-arc' || v === 'linear-arc-step-down' || v === 'spiral') return v;
418
  return 'text-flow';
419
  }
420
 
421
  function applyDagLayoutModeUi(): void {
422
  const mode = currentDagLayoutMode();
423
  if (dagCompactnessGroup) {
424
+ /** text-flow / spiral 均使用 display-scale 驱动的节点宽高与边回缩;linear-arc 家族不适用。 */
425
+ dagCompactnessGroup.hidden = mode === 'linear-arc' || mode === 'linear-arc-step-down';
426
  }
427
  if (dagMeasureWidthGroup) {
428
  dagMeasureWidthGroup.hidden = mode !== 'text-flow';
429
  }
430
  if (dagLinearArcIntervalGroup) {
431
+ dagLinearArcIntervalGroup.hidden = mode !== 'linear-arc' && mode !== 'linear-arc-step-down';
432
  }
433
  }
434
 
435
  const dagHideExcludedTokensInput = document.getElementById(
436
  'gen_attr_dag_hide_excluded_tokens'
437
  ) as HTMLInputElement | null;
438
+ const dagShowTopkOnSelectedInput = document.getElementById(
439
+ 'gen_attr_dag_show_topk_on_selected'
440
+ ) as HTMLInputElement | null;
441
  const dagNodeCiVisualScaleInput = document.getElementById(
442
  'gen_attr_dag_node_ci_visual_scale'
443
  ) as HTMLInputElement | null;
444
+ const dagDecayAttributionHighSurprisalInput = document.getElementById(
445
+ 'gen_attr_dag_decay_attribution_high_surprisal'
446
  ) as HTMLInputElement | null;
447
  const dagHideInactiveEdgesInput = document.getElementById(
448
  'gen_attr_dag_hide_inactive_edges'
449
  ) as HTMLInputElement | null;
450
+ const dagShowDownstreamInfluenceInput = document.getElementById(
451
+ 'gen_attr_dag_show_downstream_influence'
452
+ ) as HTMLInputElement | null;
453
+ const dagShowDownstreamInfluenceGroup = document.getElementById(
454
+ 'gen_attr_dag_show_downstream_influence_group'
455
+ );
456
+ const dagRecursiveAttributionInput = document.getElementById(
457
+ 'gen_attr_dag_recursive_attribution'
458
+ ) as HTMLInputElement | null;
459
  const genAttrExcludePromptPatternsTa = document.getElementById(
460
  'gen_attr_exclude_prompt_patterns'
461
  ) as HTMLTextAreaElement | null;
 
468
  const genAttrExcludeGeneratedPatternsEnable = document.getElementById(
469
  'gen_attr_exclude_generated_patterns_enable'
470
  ) as HTMLInputElement | null;
471
+ const genAttrResetUiOptionsBtn = document.getElementById(
472
+ 'gen_attr_reset_ui_options_btn',
473
+ ) as HTMLButtonElement | null;
474
  const completeReasonEl = d3.select('#gen_attr_complete_reason');
475
 
476
  function syncGenAttrExcludePatternTextareasDisabled(): void {
 
549
  function readStoredDagNodeCiVisualScale(): boolean {
550
  try {
551
  const v = localStorage.getItem(GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY);
552
+ if (v !== null) return v === '1';
553
  } catch {
554
+ // ignore
555
  }
556
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.nodeCiVisualScaleEnabled;
557
  }
558
  const initialDagNodeCiVisualScale = readStoredDagNodeCiVisualScale();
559
  if (dagNodeCiVisualScaleInput) dagNodeCiVisualScaleInput.checked = initialDagNodeCiVisualScale;
 
569
  tryResetAndReplayDag();
570
  });
571
 
572
+ function readStoredDagDecayAttributionToHighSurprisalTarget(): boolean {
573
  try {
574
+ const v = localStorage.getItem(GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY);
575
+ if (v !== null) return v === '1';
576
+ const legacy = localStorage.getItem(GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY_LEGACY);
577
+ if (legacy !== null) return legacy === '1';
578
  } catch {
579
+ // ignore
580
  }
581
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.decayAttributionToHighSurprisalTargetEnabled;
582
+ }
583
+ const initialDagDecayAttributionHighSurprisal = readStoredDagDecayAttributionToHighSurprisalTarget();
584
+ if (dagDecayAttributionHighSurprisalInput) {
585
+ dagDecayAttributionHighSurprisalInput.checked = initialDagDecayAttributionHighSurprisal;
586
  }
587
+ setDagDecayAttributionToHighSurprisalTargetEnabled(initialDagDecayAttributionHighSurprisal);
588
+ dagDecayAttributionHighSurprisalInput?.addEventListener('change', () => {
589
+ const enabled = dagDecayAttributionHighSurprisalInput.checked;
 
 
590
  try {
591
+ localStorage.setItem(GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY, enabled ? '1' : '0');
592
  } catch {
593
  /* ignore */
594
  }
595
+ setDagDecayAttributionToHighSurprisalTargetEnabled(enabled);
596
  tryResetAndReplayDag();
597
  });
598
 
 
602
  }
603
  function readStoredDagHideInactiveEdges(): boolean {
604
  try {
605
+ const v = localStorage.getItem(GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY);
606
+ if (v !== null) return v === '1';
607
  } catch {
608
+ // ignore
609
  }
610
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.hideInactiveEdges;
611
  }
612
  const initialDagHideInactiveEdges = readStoredDagHideInactiveEdges();
613
  if (dagHideInactiveEdgesInput) dagHideInactiveEdgesInput.checked = initialDagHideInactiveEdges;
 
622
  applyDagHideInactiveEdges(hide);
623
  });
624
 
625
+ function readStoredDagShowDownstreamInfluence(): boolean {
626
+ try {
627
+ const v = localStorage.getItem(GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY);
628
+ if (v !== null) return v === '1';
629
+ } catch {
630
+ // ignore
631
+ }
632
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.showDownstreamInfluence;
633
+ }
634
+ const initialDagShowDownstreamInfluence = readStoredDagShowDownstreamInfluence();
635
+ if (dagShowDownstreamInfluenceInput) {
636
+ dagShowDownstreamInfluenceInput.checked = initialDagShowDownstreamInfluence;
637
+ }
638
+ dagShowDownstreamInfluenceInput?.addEventListener('change', () => {
639
+ const show = dagShowDownstreamInfluenceInput.checked;
640
+ try {
641
+ localStorage.setItem(GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY, show ? '1' : '0');
642
+ } catch {
643
+ /* ignore */
644
+ }
645
+ dagHandle.setShowDownstreamInfluence(show);
646
+ });
647
+
648
+ /** 传播归因(UI: Propagated attribution mode;`recursiveAttributionEnabled`)开启时隐藏 downstream influence 选项(该模式仅展示上游链)。 */
649
+ function applyDagDownstreamInfluenceUi(): void {
650
+ const recursive = dagRecursiveAttributionInput?.checked ?? false;
651
+ if (dagShowDownstreamInfluenceGroup) {
652
+ dagShowDownstreamInfluenceGroup.hidden = recursive;
653
+ }
654
+ }
655
+
656
+ function readStoredDagRecursiveAttribution(): boolean {
657
+ try {
658
+ const v = localStorage.getItem(GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY);
659
+ if (v !== null) return v === '1';
660
+ } catch {
661
+ // ignore
662
+ }
663
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.recursiveAttributionEnabled;
664
+ }
665
+ const initialDagRecursiveAttribution = readStoredDagRecursiveAttribution();
666
+ if (dagRecursiveAttributionInput) dagRecursiveAttributionInput.checked = initialDagRecursiveAttribution;
667
+ applyDagDownstreamInfluenceUi();
668
+ dagRecursiveAttributionInput?.addEventListener('change', () => {
669
+ const enabled = dagRecursiveAttributionInput.checked;
670
+ try {
671
+ localStorage.setItem(GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY, enabled ? '1' : '0');
672
+ } catch {
673
+ /* ignore */
674
+ }
675
+ applyDagDownstreamInfluenceUi();
676
+ dagHandle.setRecursiveAttributionEnabled(enabled);
677
+ });
678
+
679
  function readStoredDagHideExcludedTokens(): boolean {
680
  try {
681
+ const v = localStorage.getItem(GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY);
682
+ if (v !== null) return v === '1';
683
  } catch {
684
+ // ignore
685
  }
686
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.hideExcludedTokens;
687
  }
688
  const initialDagHideExcludedTokens = readStoredDagHideExcludedTokens();
689
  if (dagHideExcludedTokensInput) dagHideExcludedTokensInput.checked = initialDagHideExcludedTokens;
690
+ function readStoredDagShowTopkOnSelected(): boolean {
691
+ try {
692
+ const v = localStorage.getItem(GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY);
693
+ if (v !== null) return v === '1';
694
+ } catch {
695
+ // ignore
696
+ }
697
+ return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.showTokenInfoOnSelected;
698
+ }
699
+ const initialDagShowTopkOnSelected = readStoredDagShowTopkOnSelected();
700
+ if (dagShowTopkOnSelectedInput) dagShowTopkOnSelectedInput.checked = initialDagShowTopkOnSelected;
701
  dagHideExcludedTokensInput?.addEventListener('change', () => {
702
  const hide = dagHideExcludedTokensInput.checked;
703
  try {
 
708
  dagHandle.setHideExcludedTokens(hide);
709
  });
710
 
711
+ dagShowTopkOnSelectedInput?.addEventListener('change', () => {
712
+ const show = dagShowTopkOnSelectedInput.checked;
713
+ try {
714
+ localStorage.setItem(GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY, show ? '1' : '0');
715
+ } catch {
716
+ /* ignore */
717
+ }
718
+ dagHandle.setShowTokenInfoOnSelected(show);
719
+ });
720
+
721
+ setPageOptsGetter(() => {
722
+ const mode = currentDagLayoutMode();
723
+ return {
724
+ layout_linear_arc: mode === 'linear-arc',
725
+ layout_step_down: mode === 'linear-arc-step-down',
726
+ layout_spiral: mode === 'spiral',
727
+ propagated: dagRecursiveAttributionInput?.checked ?? false,
728
+ downstream: dagShowDownstreamInfluenceInput?.checked ?? false,
729
+ token_tooltip: dagShowTopkOnSelectedInput?.checked ?? false,
730
+ };
731
+ });
732
+
733
  modelVariantSelect?.addEventListener('change', () => {
734
  try {
735
  localStorage.setItem(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, currentModelVariant());
 
1149
  dagCompactness: initialDagCompactness,
1150
  linearArcAdjacentGapPx: initialDagLinearArcGap,
1151
  hideExcludedTokens: initialDagHideExcludedTokens,
1152
+ showTokenInfoOnSelected: initialDagShowTopkOnSelected,
1153
+ showDownstreamInfluence: initialDagShowDownstreamInfluence,
1154
+ recursiveAttributionEnabled: initialDagRecursiveAttribution,
1155
  edgeTopPCoverage: initialDagEdgeTopPCoverage,
1156
  onFullscreenError: (message) => showToast(message, 'error'),
1157
  getEffectiveExcludePromptPatternsText: genAttrEffectiveExcludePromptPatternsText,
 
1178
  return inFlight || dagPlaybackTimer !== null || dagLastTokenDwellTimer !== null;
1179
  }
1180
 
1181
+ /**
1182
+ * 非忙状态下 reset + replay + fit,供各设置项切换后复用。忙时为 no-op。
1183
+ * 默认保留 DAG 选中节点;整页重置 UI 等场景传 `preserveNodeSelection: false`。
1184
+ */
1185
+ function tryResetAndReplayDag(opts?: { preserveNodeSelection?: boolean }): void {
1186
  if (isDagBusy()) return;
1187
+ const preserveSelection = opts?.preserveNodeSelection !== false;
1188
+ const preservedSelectedId = preserveSelection ? dagHandle.getSelectedNodeId() : null;
1189
  const h = runnerHandle;
1190
  dagHandle.reset();
1191
  if (h && h.tokenCount > 0) {
1192
  replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
1193
  }
1194
  dagHandle.fitViewportToContent();
1195
+ if (preservedSelectedId != null) {
1196
+ dagHandle.setSelectedNodeId(preservedSelectedId);
1197
+ } else {
1198
+ dagHandle.clearNodeSelection();
1199
+ }
1200
  }
1201
 
1202
  dagMeasureWidthInput?.addEventListener('change', () => {
 
1290
  hideExcludedTokens: dagHideExcludedTokensInput?.checked ?? false,
1291
  edgeTopPCoverage,
1292
  nodeCiVisualScaleEnabled: dagNodeCiVisualScaleInput?.checked ?? true,
1293
+ decayAttributionToHighSurprisalTargetEnabled:
1294
+ dagDecayAttributionHighSurprisalInput?.checked ?? true,
1295
  hideInactiveEdges: dagHideInactiveEdgesInput?.checked ?? false,
1296
+ showDownstreamInfluence: dagShowDownstreamInfluenceInput?.checked ?? false,
1297
+ recursiveAttributionEnabled: dagRecursiveAttributionInput?.checked ?? false,
1298
+ showTokenInfoOnSelected: dagShowTopkOnSelectedInput?.checked ?? false,
1299
  replayPacingMode: currentDagReplayPacingMode(),
1300
  playbackTotalS,
1301
  playbackStepMs,
 
1303
  excludePromptPatternsText: genAttrExcludePromptPatternsTa?.value ?? '',
1304
  excludeGeneratedPatternsEnabled: genAttrExcludeGeneratedPatternsEnable?.checked ?? true,
1305
  excludeGeneratedPatternsText: genAttrExcludeGeneratedPatternsTa?.value ?? '',
1306
+ selectedNodeId: dagHandle.getSelectedNodeId(),
1307
  };
1308
  }
1309
 
1310
+ function genAttrDemoUiOptionsMatchesDefaults(current: GenAttrDemoUiOptions): boolean {
1311
+ const base = DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS;
1312
+ for (const key of Object.keys(base) as (keyof GenAttrDemoUiOptions)[]) {
1313
+ const c = current[key];
1314
+ const b = base[key];
1315
+ if (typeof c === 'number' && typeof b === 'number') {
1316
+ if (Math.abs(c - b) >= 1e-6) return false;
1317
+ } else if (c !== b) {
1318
+ return false;
1319
+ }
1320
+ }
1321
+ return true;
1322
+ }
1323
+
1324
+ function syncGenAttrResetUiOptionsButtonState(): void {
1325
+ if (!genAttrResetUiOptionsBtn) return;
1326
+ genAttrResetUiOptionsBtn.disabled = genAttrDemoUiOptionsMatchesDefaults(
1327
+ readGenAttrDemoUiOptionsFromControls(),
1328
+ );
1329
+ }
1330
+
1331
  /**
1332
  * 从 `demoUiOptions` 还原排除控件(仅 DOM);与施加 DAG demo 不回写 GEN_ATTR_LS 的策略一致,`replay` 读当前控件生效。
1333
  */
 
1361
  syncGenAttrExcludePatternTextareasDisabled();
1362
  }
1363
 
1364
+ /** 按 `demoUiOptions` 逐项还原 DAG 面板与排除控件(后者仅 DOM);未覆盖字段不改变。 */
1365
+ function applyGenAttrDemoUiOptionsSnap(snap: Partial<GenAttrDemoUiOptions>): void {
 
 
 
 
1366
  const mode = snap.layoutMode;
1367
  if (mode) {
1368
  if (dagLayoutModeSelect) {
 
1400
  if (dagNodeCiVisualScaleInput) dagNodeCiVisualScaleInput.checked = snap.nodeCiVisualScaleEnabled;
1401
  setDagNodeCiVisualScaleEnabled(snap.nodeCiVisualScaleEnabled);
1402
  }
1403
+ const decayAttributionHighSurprisal =
1404
+ snap.decayAttributionToHighSurprisalTargetEnabled ??
1405
+ (snap as { edgeWeakenHighSurprisalEnabled?: boolean }).edgeWeakenHighSurprisalEnabled;
1406
+ if (decayAttributionHighSurprisal !== undefined) {
1407
+ if (dagDecayAttributionHighSurprisalInput) {
1408
+ dagDecayAttributionHighSurprisalInput.checked = decayAttributionHighSurprisal;
1409
+ }
1410
+ setDagDecayAttributionToHighSurprisalTargetEnabled(decayAttributionHighSurprisal);
1411
  }
1412
  if (snap.hideInactiveEdges !== undefined) {
1413
  if (dagHideInactiveEdgesInput) dagHideInactiveEdgesInput.checked = snap.hideInactiveEdges;
1414
  applyDagHideInactiveEdges(snap.hideInactiveEdges);
1415
  }
1416
+ if (snap.showDownstreamInfluence !== undefined) {
1417
+ if (dagShowDownstreamInfluenceInput) {
1418
+ dagShowDownstreamInfluenceInput.checked = snap.showDownstreamInfluence;
1419
+ }
1420
+ dagHandle.setShowDownstreamInfluence(snap.showDownstreamInfluence);
1421
+ }
1422
+ if (snap.recursiveAttributionEnabled !== undefined) {
1423
+ if (dagRecursiveAttributionInput) {
1424
+ dagRecursiveAttributionInput.checked = snap.recursiveAttributionEnabled;
1425
+ }
1426
+ applyDagDownstreamInfluenceUi();
1427
+ dagHandle.setRecursiveAttributionEnabled(snap.recursiveAttributionEnabled);
1428
+ }
1429
+ if (snap.showTokenInfoOnSelected !== undefined) {
1430
+ if (dagShowTopkOnSelectedInput) dagShowTopkOnSelectedInput.checked = snap.showTokenInfoOnSelected;
1431
+ dagHandle.setShowTokenInfoOnSelected(snap.showTokenInfoOnSelected);
1432
+ }
1433
  if (snap.replayPacingMode !== undefined) {
1434
  if (dagReplayModeSelect) dagReplayModeSelect.value = snap.replayPacingMode;
1435
  applyDagReplaySpeedUi();
 
1444
  }
1445
 
1446
  applyGenAttrExcludePatternsFromDemoUiSnap(snap);
1447
+ syncGenAttrResetUiOptionsButtonState();
1448
+ }
1449
+
1450
+ /** replay 完成后按 `demoUiOptions.selectedNodeId` 恢复 DAG 焦点;无效或缺失则清除选中。 */
1451
+ function restoreGenAttrDagFocusFromDemoUiOptions(snap: Partial<GenAttrDemoUiOptions> | undefined): void {
1452
+ const focusId = snap?.selectedNodeId;
1453
+ if (typeof focusId === 'string' && focusId.length > 0) {
1454
+ try {
1455
+ dagHandle.setSelectedNodeId(focusId);
1456
+ return;
1457
+ } catch {
1458
+ /* demo 快照与当前图不一致时忽略 */
1459
+ }
1460
+ }
1461
+ dagHandle.clearNodeSelection();
1462
+ }
1463
+
1464
+ function applyGenAttrDemoUiOptionsFromRecord(rec: GenAttrCachedRun): void {
1465
+ if (!rec.demoUiOptions) return;
1466
+ applyGenAttrDemoUiOptionsSnap(rec.demoUiOptions);
1467
+ }
1468
+
1469
+ /** Gen Attribute demo-UI scope:与 {@link readGenAttrDemoUiOptionsFromControls} / IndexedDB demo 快照一致(不含 Model、Max tokens、prompt 正文)。 */
1470
+ const GEN_ATTR_DEMO_UI_LOCAL_STORAGE_KEYS: readonly string[] = [
1471
+ GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY,
1472
+ GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY,
1473
+ GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY,
1474
+ GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY,
1475
+ GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY,
1476
+ GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY,
1477
+ GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY,
1478
+ GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY,
1479
+ GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY,
1480
+ GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY,
1481
+ GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY,
1482
+ GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY,
1483
+ GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY,
1484
+ GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY,
1485
+ GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY,
1486
+ GEN_ATTR_EXCLUDE_PROMPT_PATTERNS_STORAGE_KEY,
1487
+ GEN_ATTR_EXCLUDE_PROMPT_PATTERNS_ENABLED_STORAGE_KEY,
1488
+ GEN_ATTR_EXCLUDE_GENERATED_PATTERNS_STORAGE_KEY,
1489
+ GEN_ATTR_EXCLUDE_GENERATED_PATTERNS_ENABLED_STORAGE_KEY,
1490
+ ];
1491
+
1492
+ function removeGenAttrDemoUiOptionsFromLocalStorage(): void {
1493
+ try {
1494
+ for (const k of GEN_ATTR_DEMO_UI_LOCAL_STORAGE_KEYS) {
1495
+ localStorage.removeItem(k);
1496
+ }
1497
+ } catch {
1498
+ /* ignore */
1499
+ }
1500
+ }
1501
+
1502
+ /** 重置「DAG 演示用 UI」:清 LS 后以 {@link DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS} 全量套用。 */
1503
+ function resetGenAttrDemoUiOptionsToDefaults(): void {
1504
+ stopDagPlayback();
1505
+ removeGenAttrDemoUiOptionsFromLocalStorage();
1506
+ applyGenAttrDemoUiOptionsSnap(DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS);
1507
+ tryResetAndReplayDag({ preserveNodeSelection: false });
1508
  }
1509
 
1510
+ genAttrResetUiOptionsBtn?.addEventListener('click', resetGenAttrDemoUiOptionsToDefaults);
1511
+
1512
+ (() => {
1513
+ const panel = document.querySelector('.gen-attribute-page .input-section');
1514
+ if (!panel) return;
1515
+ const sync = () => syncGenAttrResetUiOptionsButtonState();
1516
+ panel.addEventListener('change', sync);
1517
+ panel.addEventListener('input', sync);
1518
+ sync();
1519
+ })();
1520
+
1521
  window.addEventListener('pagehide', (ev) => {
1522
  if (ev.persisted) return;
1523
  dagHandle.detach();
 
1526
  function onExcludePatternsEffectiveChange(): void {
1527
  const h = runnerHandle;
1528
  if (!h || h.tokenCount === 0) return;
1529
+ tryResetAndReplayDag();
 
 
1530
  }
1531
 
1532
  function bindExcludePatternControls(
 
1853
  stopDagPlayback();
1854
  dagHandle.reset();
1855
  applyGenAttrDemoUiOptionsFromRecord(rec);
1856
+ syncGenAttrResetUiOptionsButtonState();
1857
  runnerHandle = createHydratedTokenGenHandle(rec.steps);
1858
  lastRunInitialContext = rec.initialContext;
1859
  lastRunInputSnapshot = getInputSnapshotForRun();
 
1863
  currentRunPromptSpans = replayPromptSpans;
1864
  replayRunnerStepsIntoDag(runnerHandle, replayPromptSpans);
1865
  dagHandle.fitViewportToContent();
1866
+ restoreGenAttrDagFocusFromDemoUiOptions(rec.demoUiOptions);
1867
  const n = runnerHandle.tokenCount;
1868
  setGenAttrUsageMetric(initialPromptTokensFromFirstStep(rec.steps[0]!), n);
1869
  if (validateMetricsElements(metricModel) && n > 0) {
 
2271
  function refreshDagForThemeChange(): void {
2272
  stopDagPlayback();
2273
  const h = runnerHandle;
2274
+ if (!h || h.tokenCount === 0) return;
2275
+ tryResetAndReplayDag();
 
 
 
 
 
2276
  }
2277
 
2278
  const themeManager = initThemeManager(
client/src/ts/lang/translations.ts CHANGED
@@ -52,11 +52,17 @@ export const translations: Translations = {
52
  'Coverage 指每一步在 Top-N 候选池内的累计质量份额(先入池、池内归一后按强度排序再累加)。数值越大保留的 DAG 入边越多;分母仅为该候选池,不是该步 API 返回的全部归因 token。',
53
  'When checked, gray DAG edges not adjacent to the hovered or selected node are hidden.':
54
  '勾选后,DAG 中未与当前悬浮/选中节点相邻的灰色边将被隐藏。',
 
 
 
 
55
  'Width (px) of the invisible measurement layer used for DAG layout. Only this width affects wrapping and node positions. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh.':
56
  'DAG 节点几何所基于的不可见测量层宽度(px)。只有测量层宽度会影响节点折行/位置。修改后:稳态下自动按新宽度重放并 fit;若正在生成或 DAG 播放中,仅更新设置,下次刷新/生成时生效。',
57
  'Token distance': 'Token distance 间距',
 
 
58
  'Horizontal gap (px) between the outer left/right edges of adjacent token nodes in linear-arc layout only. When idle, the DAG refits; during generation or DAG playback, the value is stored and applied on the next sync.':
59
- '仅 linear-arc 布局下生效:相邻 token 节点矩形外侧边之间的水平间隙(px)。修改后:稳态下立即重绘并 fit;若正在生成或 DAG 播放中,仅写入存储,下一轮同步时再反映。',
60
  'Compactness': 'DAG 紧凑度',
61
  'Scales DAG node boxes and labels relative to the measurement layer; 1 matches full readout scale. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh.':
62
  '相对测量层缩放 DAG 节点框与标签;1 与正文阅读比例一致。修改后:稳态下自动重放并 fit;若正在生成或 DAG 播放中,仅更新设置,下次运行或刷新时生效。',
@@ -115,6 +121,9 @@ export const translations: Translations = {
115
  'Cached result not found (link may be expired)': '未找到该条归因缓存(分享链接可能已过期)',
116
  'Cached run not found': '未找到该条生成缓存,可能已被删除',
117
  'Cached run not found (link may be expired)': '未找到该条生成缓存(分享链接可能已过期)',
 
 
 
118
  'Cached history': '历史缓存',
119
  'Cached demos': '示例缓存',
120
  'Demo not found': '未找到该示例缓存',
@@ -305,6 +314,9 @@ export const translations: Translations = {
305
  'log perplexity:': '对数困惑度:',
306
  'Top-k data not available.': '未提供 Top-k 数据。',
307
  'UTF-8 size:': 'UTF-8大小:',
 
 
 
308
 
309
  // ========== 统计信息 ==========
310
  'bytes': '字节',
@@ -386,6 +398,8 @@ export const translations: Translations = {
386
  '无法使用加密API(crypto.subtle),保存到本地缓存功能不可用。',
387
  'URL text extraction failed': 'URL 文本提取失败',
388
  'Semantic analysis failed': '语义分析失败',
 
 
389
  'Tokenizer results inconsistent: semantic and info-density token boundaries differ.': 'Tokenizer 结果不一致:语义分析与信息密度的 token 边界存在差异,属预期外情况。',
390
  'No data to analyze, please analyze text first': '没有可分析的数据,请先分析文本',
391
  'User cancelled file selection': '用户取消了文件选择',
 
52
  'Coverage 指每一步在 Top-N 候选池内的累计质量份额(先入池、池内归一后按强度排序再累加)。数值越大保留的 DAG 入边越多;分母仅为该候选池,不是该步 API 返回的全部归因 token。',
53
  'When checked, gray DAG edges not adjacent to the hovered or selected node are hidden.':
54
  '勾选后,DAG 中未与当前悬浮/选中节点相邻的灰色边将被隐藏。',
55
+ 'Show token tooltip':
56
+ '显示 token 提示',
57
+ 'When checked, selecting or hovering a token node shows token information in the results area.':
58
+ '勾选后,选中或悬浮 token 节点时,在右侧结果区展示该 token 的信息(信息量、预测分布、归因份额等)。',
59
  'Width (px) of the invisible measurement layer used for DAG layout. Only this width affects wrapping and node positions. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh.':
60
  'DAG 节点几何所基于的不可见测量层宽度(px)。只有测量层宽度会影响节点折行/位置。修改后:稳态下自动按新宽度重放并 fit;若正在生成或 DAG 播放中,仅更新设置,下次刷新/生成时生效。',
61
  'Token distance': 'Token distance 间距',
62
+ 'Horizontal gap (px) between the outer left/right edges of adjacent token nodes in linear-arc / linear-arc-step-down layout only. When idle, the DAG refits; during generation or DAG playback, the value is stored and applied on the next sync.':
63
+ '仅 linear-arc / linear-arc-step-down 布局下生效:相邻 token 节点矩形外侧边之间的水平间隙(px)。修改后:稳态下立即重绘并 fit;若正在生成或 DAG 播放中,仅写入存储,下一轮同步时再反映。',
64
  'Horizontal gap (px) between the outer left/right edges of adjacent token nodes in linear-arc layout only. When idle, the DAG refits; during generation or DAG playback, the value is stored and applied on the next sync.':
65
+ '仅 linear-arc / linear-arc-step-down 布局下生效:相邻 token 节点矩形外侧边之间的水平间隙(px)。修改后:稳态下立即重绘并 fit;若正在生成或 DAG 播放中,仅写入存储,下一轮同步时再反映。',
66
  'Compactness': 'DAG 紧凑度',
67
  'Scales DAG node boxes and labels relative to the measurement layer; 1 matches full readout scale. When idle, changes replay and fit automatically; during generation or DAG playback, the setting updates for the next run or refresh.':
68
  '相对测量层缩放 DAG 节点框与标签;1 与正文阅读比例一致。修改后:稳态下自动重放并 fit;若正在生成或 DAG 播放中,仅更新设置,下次运行或刷新时生效。',
 
121
  'Cached result not found (link may be expired)': '未找到该条归因缓存(分享链接可能已过期)',
122
  'Cached run not found': '未找到该条生成缓存,可能已被删除',
123
  'Cached run not found (link may be expired)': '未找到该条生成缓存(分享链接可能已过期)',
124
+ 'Reset UI options': '重置界面选项',
125
+ 'Restore DAG options, replay speed, exclusions, etc. to defaults and clear saved preferences for those controls.':
126
+ '将 DAG 参数、回放速度、排除正则等恢复为默认值,并清除这些控件的本地保存项。',
127
  'Cached history': '历史缓存',
128
  'Cached demos': '示例缓存',
129
  'Demo not found': '未找到该示例缓存',
 
314
  'log perplexity:': '对数困惑度:',
315
  'Top-k data not available.': '未提供 Top-k 数据。',
316
  'UTF-8 size:': 'UTF-8大小:',
317
+ 'Attribution share:': '归因份额:',
318
+ 'Attribution share (Total):': '归因份额(总计):',
319
+ 'Attribution share (Self):': '归因份额(自身):',
320
 
321
  // ========== 统计信息 ==========
322
  'bytes': '字节',
 
398
  '无法使用加密API(crypto.subtle),保存到本地缓存功能不可用。',
399
  'URL text extraction failed': 'URL 文本提取失败',
400
  'Semantic analysis failed': '语义分析失败',
401
+ 'Semantic Query(Beta)': '语义查询(Beta)',
402
+ 'Enter query question or topic': '请输入查询问题或主题',
403
  'Tokenizer results inconsistent: semantic and info-density token boundaries differ.': 'Tokenizer 结果不一致:语义分析与信息密度的 token 边界存在差异,属预期外情况。',
404
  'No data to analyze, please analyze text first': '没有可分析的数据,请先分析文本',
405
  'User cancelled file selection': '用户取消了文件选择',
client/src/ts/storage/genAttributeRunCache.ts CHANGED
@@ -67,8 +67,13 @@ export type GenAttrDemoUiOptions = {
67
  hideExcludedTokens: boolean;
68
  edgeTopPCoverage: number;
69
  nodeCiVisualScaleEnabled: boolean;
70
- edgeWeakenHighSurprisalEnabled: boolean;
71
  hideInactiveEdges: boolean;
 
 
 
 
 
72
  replayPacingMode: 'total' | 'step';
73
  playbackTotalS: number;
74
  playbackStepMs: number;
@@ -78,6 +83,8 @@ export type GenAttrDemoUiOptions = {
78
  /** 排除生成 token 归因:使能与正则文本(`info_radar_gen_attr_exclude_generated_*`)。 */
79
  excludeGeneratedPatternsEnabled: boolean;
80
  excludeGeneratedPatternsText: string;
 
 
81
  };
82
 
83
  /** 单条记录 JSON:内容字段 + 可选 `demoUiOptions`(仅导出 demo 写入)。 */
@@ -193,7 +200,12 @@ function isValidGenAttrRunDraftPayload(v: unknown): boolean {
193
  }
194
 
195
  function isDagLayoutModePayload(v: unknown): v is DagLayoutMode {
196
- return v === 'text-flow' || v === 'linear-arc' || v === 'spiral';
 
 
 
 
 
197
  }
198
 
199
  function isValidDemoUiOptionsPayload(v: unknown): v is Partial<GenAttrDemoUiOptions> {
@@ -222,10 +234,35 @@ function isValidDemoUiOptionsPayload(v: unknown): v is Partial<GenAttrDemoUiOpti
222
  if (d.nodeCiVisualScaleEnabled !== undefined && typeof d.nodeCiVisualScaleEnabled !== 'boolean') {
223
  return false;
224
  }
225
- if (d.edgeWeakenHighSurprisalEnabled !== undefined && typeof d.edgeWeakenHighSurprisalEnabled !== 'boolean') {
 
 
 
 
 
 
 
226
  return false;
227
  }
228
  if (d.hideInactiveEdges !== undefined && typeof d.hideInactiveEdges !== 'boolean') return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  if (d.replayPacingMode !== undefined && d.replayPacingMode !== 'total' && d.replayPacingMode !== 'step') {
230
  return false;
231
  }
@@ -256,6 +293,13 @@ function isValidDemoUiOptionsPayload(v: unknown): v is Partial<GenAttrDemoUiOpti
256
  ) {
257
  return false;
258
  }
 
 
 
 
 
 
 
259
  return true;
260
  }
261
 
 
67
  hideExcludedTokens: boolean;
68
  edgeTopPCoverage: number;
69
  nodeCiVisualScaleEnabled: boolean;
70
+ decayAttributionToHighSurprisalTargetEnabled: boolean;
71
  hideInactiveEdges: boolean;
72
+ showDownstreamInfluence: boolean;
73
+ /** 传播归因(UI: Propagated attribution mode;与 `recursiveAttribution*` 同义)。 */
74
+ recursiveAttributionEnabled: boolean;
75
+ /** 是否显示 token tooltip(UI: Show token tooltip;`showTokenInfoOnSelected`)。 */
76
+ showTokenInfoOnSelected: boolean;
77
  replayPacingMode: 'total' | 'step';
78
  playbackTotalS: number;
79
  playbackStepMs: number;
 
83
  /** 排除生成 token 归因:使能与正则文本(`info_radar_gen_attr_exclude_generated_*`)。 */
84
  excludeGeneratedPatternsEnabled: boolean;
85
  excludeGeneratedPatternsText: string;
86
+ /** DAG 选中节点(offset id:`"${start}_${end}"`);无选中时为 `null`。 */
87
+ selectedNodeId?: string | null;
88
  };
89
 
90
  /** 单条记录 JSON:内容字段 + 可选 `demoUiOptions`(仅导出 demo 写入)。 */
 
200
  }
201
 
202
  function isDagLayoutModePayload(v: unknown): v is DagLayoutMode {
203
+ return (
204
+ v === 'text-flow' ||
205
+ v === 'linear-arc' ||
206
+ v === 'linear-arc-step-down' ||
207
+ v === 'spiral'
208
+ );
209
  }
210
 
211
  function isValidDemoUiOptionsPayload(v: unknown): v is Partial<GenAttrDemoUiOptions> {
 
234
  if (d.nodeCiVisualScaleEnabled !== undefined && typeof d.nodeCiVisualScaleEnabled !== 'boolean') {
235
  return false;
236
  }
237
+ if (
238
+ d.decayAttributionToHighSurprisalTargetEnabled !== undefined &&
239
+ typeof d.decayAttributionToHighSurprisalTargetEnabled !== 'boolean'
240
+ ) {
241
+ return false;
242
+ }
243
+ const legacyDecay = (d as { edgeWeakenHighSurprisalEnabled?: unknown }).edgeWeakenHighSurprisalEnabled;
244
+ if (legacyDecay !== undefined && typeof legacyDecay !== 'boolean') {
245
  return false;
246
  }
247
  if (d.hideInactiveEdges !== undefined && typeof d.hideInactiveEdges !== 'boolean') return false;
248
+ if (
249
+ d.showDownstreamInfluence !== undefined &&
250
+ typeof d.showDownstreamInfluence !== 'boolean'
251
+ ) {
252
+ return false;
253
+ }
254
+ if (
255
+ d.recursiveAttributionEnabled !== undefined &&
256
+ typeof d.recursiveAttributionEnabled !== 'boolean'
257
+ ) {
258
+ return false;
259
+ }
260
+ if (
261
+ d.showTokenInfoOnSelected !== undefined &&
262
+ typeof d.showTokenInfoOnSelected !== 'boolean'
263
+ ) {
264
+ return false;
265
+ }
266
  if (d.replayPacingMode !== undefined && d.replayPacingMode !== 'total' && d.replayPacingMode !== 'step') {
267
  return false;
268
  }
 
293
  ) {
294
  return false;
295
  }
296
+ if (
297
+ d.selectedNodeId !== undefined &&
298
+ d.selectedNodeId !== null &&
299
+ typeof d.selectedNodeId !== 'string'
300
+ ) {
301
+ return false;
302
+ }
303
  return true;
304
  }
305
 
client/src/ts/utils/clientActivityPing.ts CHANGED
@@ -3,6 +3,12 @@ import { AdminManager } from './adminManager';
3
 
4
  const S = 10, FIRST = 2;
5
 
 
 
 
 
 
 
6
  export type ReportedClientOs = 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown';
7
 
8
  /** UA 粗略归类;仅在首轮心跳(cum === FIRST)顺带上报一次。 */
@@ -39,6 +45,10 @@ export function initClientActivityPing(apiPrefix: string | null | undefined): vo
39
  delta_active_sec,
40
  };
41
  if (cum === FIRST) payload.client_os = detectInitialClientOs();
 
 
 
 
42
  const body = JSON.stringify(payload);
43
  void fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true })
44
  .then((r) => { if (r.ok) reportedTotal = cum; })
 
3
 
4
  const S = 10, FIRST = 2;
5
 
6
+ let _pageOptsGetter: (() => Record<string, boolean>) | undefined;
7
+ /** gen_attribute.html 等页面注册当前选项状态,供心跳上报时附带。 */
8
+ export function setPageOptsGetter(fn: () => Record<string, boolean>): void {
9
+ _pageOptsGetter = fn;
10
+ }
11
+
12
  export type ReportedClientOs = 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown';
13
 
14
  /** UA 粗略归类;仅在首轮心跳(cum === FIRST)顺带上报一次。 */
 
45
  delta_active_sec,
46
  };
47
  if (cum === FIRST) payload.client_os = detectInitialClientOs();
48
+ if (_pageOptsGetter) {
49
+ const active = Object.fromEntries(Object.entries(_pageOptsGetter()).filter(([, v]) => v));
50
+ if (Object.keys(active).length > 0) payload.page_opts = active;
51
+ }
52
  const body = JSON.stringify(payload);
53
  void fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true })
54
  .then((r) => { if (r.ok) reportedTotal = cum; })
client/src/ts/utils/surprisalMath.ts CHANGED
@@ -14,12 +14,18 @@ export const ZERO_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -18;
14
  export const REFERENCE_MAX_SURPRISAL_BITS = Math.log2(1 / ZERO_CONFIDENCE_PROBABILITY_BASELINE);
15
 
16
  /**
17
- * 全信心概率阈值 p₁:surprisal 低于对应 bit 数时视作模型已充分自信,视觉上不放大节点。
18
- * 此处为 3 bit,对应概率 > 1/8(约 12.5%)。
 
 
 
 
 
 
19
  */
20
  export const FULL_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -3;
21
 
22
- /** 与 p₁ 对应的 surprisal 界(bit);低于此值的节点 ciVisualScale 截断为 1×。 */
23
  export const REFERENCE_NO_SURPRISAL_BITS = Math.log2(1 / FULL_CONFIDENCE_PROBABILITY_BASELINE);
24
 
25
  function clamp01(n: number): number {
@@ -49,3 +55,44 @@ export function computeConditionalInformationRatio(targetProb: number | undefine
49
  if (!Number.isFinite(targetProb) || targetProb <= 0) return 1;
50
  return clamp01(-Math.log2(targetProb) / REFERENCE_MAX_SURPRISAL_BITS);
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  export const REFERENCE_MAX_SURPRISAL_BITS = Math.log2(1 / ZERO_CONFIDENCE_PROBABILITY_BASELINE);
15
 
16
  /**
17
+ * 全信心概率阈值 p₁(3 bit,p > 1/8):surprisal 足够低时视充分自信、DAG 传导节点(非信息来源)
18
+ *
19
+ * 硬截断仅经本文件三个 `dag*` 函数进入 Generate & Attribute DAG:
20
+ * - {@link dagCiVisualScaleFromTargetProb} → `genAttributeDagView`:生成节点框/标签不放大(1×)
21
+ * - {@link dagStepDownEffectiveCiRatio} → `genAttributeDagViewLinearArcMode`:`linear-arc-step-down` 无竖直台阶
22
+ * - {@link dagPropagationMiRatio} → `genAttributeDagView` `nodePropagationMiRatio`:递归链满额传导、无 stay 描边
23
+ *
24
+ * 不用于 tooltip 与边上的 CI/MI 展示(仍 {@link computeMutualInformationRatio} / {@link computeConditionalInformationRatio})。
25
  */
26
  export const FULL_CONFIDENCE_PROBABILITY_BASELINE = 2 ** -3;
27
 
28
+ /** 与 p₁ 对应的 surprisal 界(bit);surprisal 此值即满足「充分自信」截断条件。 */
29
  export const REFERENCE_NO_SURPRISAL_BITS = Math.log2(1 / FULL_CONFIDENCE_PROBABILITY_BASELINE);
30
 
31
  function clamp01(n: number): number {
 
55
  if (!Number.isFinite(targetProb) || targetProb <= 0) return 1;
56
  return clamp01(-Math.log2(targetProb) / REFERENCE_MAX_SURPRISAL_BITS);
57
  }
58
+
59
+ /**
60
+ * DAG 生成节点 CI 视觉缩放倍数(约 `[1, 2]`):语义为 `1 +` 有效 CI。
61
+ * `ciVisualScaleEnabled === false` 或 `p > {@link FULL_CONFIDENCE_PROBABILITY_BASELINE}` 时为 `1×`;
62
+ * 否则为 `1 + {@link computeConditionalInformationRatio}(p)`。
63
+ */
64
+ export function dagCiVisualScaleFromTargetProb(
65
+ targetProb: number | undefined,
66
+ ciVisualScaleEnabled: boolean
67
+ ): number {
68
+ if (!ciVisualScaleEnabled) return 1;
69
+ if (targetProb !== undefined && Number.isFinite(targetProb) && targetProb > FULL_CONFIDENCE_PROBABILITY_BASELINE) {
70
+ return 1;
71
+ }
72
+ return 1 + computeConditionalInformationRatio(targetProb);
73
+ }
74
+
75
+ /**
76
+ * 仅用于 DAG「下台阶」布局的有效 CI(`[0,1]`):与 {@link computeConditionalInformationRatio} 同源,
77
+ * 但 `p > {@link FULL_CONFIDENCE_PROBABILITY_BASELINE}` 时为 0(与节点「高置信 1×」截断一致)。
78
+ *
79
+ * 不受「关闭 CI 视觉放大」开关影响——该开关只缩节点框,不应关掉按不确定度的竖直落差。
80
+ */
81
+ export function dagStepDownEffectiveCiRatio(targetProb: number | undefined): number {
82
+ if (targetProb !== undefined && Number.isFinite(targetProb) && targetProb > FULL_CONFIDENCE_PROBABILITY_BASELINE) {
83
+ return 0;
84
+ }
85
+ return computeConditionalInformationRatio(targetProb);
86
+ }
87
+
88
+ /**
89
+ * DAG 递归归因链的传导系数(`[0,1]`):与 {@link computeMutualInformationRatio} 同源,
90
+ * 但 `p > {@link FULL_CONFIDENCE_PROBABILITY_BASELINE}` 时截断为 1(纯传导,不衰减预算、不留 stay)。
91
+ * 与节点视觉(不放大)、下台阶(不下沉)的「充分自信」语义保持一致。
92
+ */
93
+ export function dagPropagationMiRatio(targetProb: number | undefined): number {
94
+ if (targetProb !== undefined && Number.isFinite(targetProb) && targetProb > FULL_CONFIDENCE_PROBABILITY_BASELINE) {
95
+ return 1;
96
+ }
97
+ return computeMutualInformationRatio(targetProb);
98
+ }
client/src/ts/utils/tokenDisplayUtils.ts CHANGED
@@ -80,7 +80,7 @@ function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialChars
80
  } else if (inBracket) {
81
  processed.push(char);
82
  } else {
83
- // 保留的空格不能走下方「不可打印 → U+」分支,否则会变成 [U+0020]
84
  if (char === ' ') {
85
  processed.push(char);
86
  } else if (isPrintableChar(char)) {
@@ -88,8 +88,8 @@ function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialChars
88
  } else {
89
  const codePoint = char.codePointAt(0);
90
  if (codePoint !== undefined) {
91
- const hexCode = codePoint.toString(16).toUpperCase().padStart(4, '0');
92
- processed.push(`[U+${hexCode}]`);
93
  } else {
94
  processed.push(char);
95
  }
 
80
  } else if (inBracket) {
81
  processed.push(char);
82
  } else {
83
+ // 保留的空格不能走下方「不可打印 → 码点」分支,否则会变成 [0020]
84
  if (char === ' ') {
85
  processed.push(char);
86
  } else if (isPrintableChar(char)) {
 
88
  } else {
89
  const codePoint = char.codePointAt(0);
90
  if (codePoint !== undefined) {
91
+ const hexCode = codePoint.toString(16).toLowerCase().padStart(4, '0');
92
+ processed.push(`[${hexCode}]`);
93
  } else {
94
  processed.push(char);
95
  }
client/src/ts/utils/topkChartUtils.ts CHANGED
@@ -123,8 +123,8 @@ export function renderTopkChartHtml(
123
  const maxBar = options?.maxBarWidth ?? MAX_BAR_WIDTH;
124
  const numF = options?.numFormat ?? formatTopkTooltipProbabilityPercent;
125
 
126
- const maxProb = data[0]?.prob ?? 1;
127
- const scale = d3.scaleLinear().domain([0, maxProb]).range([0, maxBar]);
128
  const barCellW = options?.barCellWidth ?? 110;
129
 
130
  const pickable = options?.interactivePickable === true;
 
123
  const maxBar = options?.maxBarWidth ?? MAX_BAR_WIDTH;
124
  const numF = options?.numFormat ?? formatTopkTooltipProbabilityPercent;
125
 
126
+ /** 条形满宽对应概率 100%(1),与显示的百分比刻度一致 */
127
+ const scale = d3.scaleLinear().domain([0, 1]).range([0, maxBar]);
128
  const barCellW = options?.barCellWidth ?? 110;
129
 
130
  const pickable = options?.interactivePickable === true;
client/src/ts/utils/visitStatsDialog.ts CHANGED
@@ -28,6 +28,22 @@ const API_ORDER = [
28
 
29
  const OS_ORDER = ['ios', 'android', 'windows', 'macos', 'linux', 'unknown'] as const;
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  type VisitStatsRow = NonNullable<Awaited<ReturnType<TextAnalysisAPI['getVisitStats']>>>;
32
 
33
  function orderedKeysGt0(primary: readonly string[], rec: Record<string, number>): string[] {
@@ -74,7 +90,8 @@ function visitStatsHtml(data: VisitStatsRow): string {
74
  const pg = data.page_sec ?? {};
75
  const ap = data.api ?? {};
76
  const os = data.os ?? {};
77
- const fmtTotal = (v: number) => (Object.keys(sb).length > 0 ? String(v) : 'unknown');
 
78
  const linesJoined = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => {
79
  if (!keys.length) return ['(none)'];
80
  return keys.map((k) => {
@@ -84,7 +101,6 @@ function visitStatsHtml(data: VisitStatsRow): string {
84
  };
85
  const linesJoinedPageSec = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => {
86
  if (!keys.length) return ['(none)'];
87
- const hasBase = Object.keys(sb).length > 0;
88
  return keys.map((k) => {
89
  const v = cur[k] ?? 0;
90
  const main = hasBase ? formatDurationSec(v) : 'unknown';
@@ -92,6 +108,20 @@ function visitStatsHtml(data: VisitStatsRow): string {
92
  });
93
  };
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  return [
96
  `Last delta reset: ${esc(data.reset_at ? new Date(data.reset_at).toLocaleString() : 'unknown')}`,
97
  `Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`,
@@ -108,6 +138,9 @@ function visitStatsHtml(data: VisitStatsRow): string {
108
  '',
109
  '[API]',
110
  ...linesJoined(orderedKeysGt0(API_ORDER, ap), ap, sb.api ?? {}),
 
 
 
111
  ].join('\n');
112
  }
113
 
 
28
 
29
  const OS_ORDER = ['ios', 'android', 'windows', 'macos', 'linux', 'unknown'] as const;
30
 
31
+ const GEN_ATTR_OPT_ORDER = [
32
+ 'layout_linear_arc', 'layout_step_down', 'layout_spiral',
33
+ 'propagated',
34
+ 'downstream', 'token_tooltip',
35
+ ] as const;
36
+
37
+ /** gen_attribute.html UI 原文;key 与上报/存储一致 */
38
+ const GEN_ATTR_OPT_LABELS: Record<(typeof GEN_ATTR_OPT_ORDER)[number], string> = {
39
+ propagated: 'Propagated attribution mode',
40
+ layout_linear_arc: 'DAG layout mode/linear_arc',
41
+ layout_step_down: 'DAG layout mode/step-down',
42
+ layout_spiral: 'DAG layout mode/spiral',
43
+ downstream: 'Show downstream influence',
44
+ token_tooltip: 'Show token tooltip',
45
+ };
46
+
47
  type VisitStatsRow = NonNullable<Awaited<ReturnType<TextAnalysisAPI['getVisitStats']>>>;
48
 
49
  function orderedKeysGt0(primary: readonly string[], rec: Record<string, number>): string[] {
 
90
  const pg = data.page_sec ?? {};
91
  const ap = data.api ?? {};
92
  const os = data.os ?? {};
93
+ const hasBase = Object.keys(sb).length > 0;
94
+ const fmtTotal = (v: number) => (hasBase ? String(v) : 'unknown');
95
  const linesJoined = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => {
96
  if (!keys.length) return ['(none)'];
97
  return keys.map((k) => {
 
101
  };
102
  const linesJoinedPageSec = (keys: string[], cur: Record<string, number>, base: Record<string, number>): string[] => {
103
  if (!keys.length) return ['(none)'];
 
104
  return keys.map((k) => {
105
  const v = cur[k] ?? 0;
106
  const main = hasBase ? formatDurationSec(v) : 'unknown';
 
108
  });
109
  };
110
 
111
+ const genAttrOpts = data.gen_attr_opt_sec ?? {};
112
+ const genAttrTotalSec = pg['gen_attribute.html'] ?? 0;
113
+ const genAttrOptKeys = orderedKeysGt0(GEN_ATTR_OPT_ORDER, genAttrOpts);
114
+ const genAttrOptLines: string[] = genAttrOptKeys.length > 0 && genAttrTotalSec > 0
115
+ ? genAttrOptKeys.map((k) => {
116
+ const v = genAttrOpts[k] ?? 0;
117
+ const pct = Math.round(v / genAttrTotalSec * 100);
118
+ const main = hasBase ? `${formatDurationSec(v)} (${pct}%)` : 'unknown';
119
+ const bv = (sb.gen_attr_opt_sec ?? {})[k] ?? 0;
120
+ const label = GEN_ATTR_OPT_LABELS[k as (typeof GEN_ATTR_OPT_ORDER)[number]] ?? k;
121
+ return `${esc(label)}: ${main}${deltaSuffixDuration(v - bv)}`;
122
+ })
123
+ : ['(none)'];
124
+
125
  return [
126
  `Last delta reset: ${esc(data.reset_at ? new Date(data.reset_at).toLocaleString() : 'unknown')}`,
127
  `Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`,
 
138
  '',
139
  '[API]',
140
  ...linesJoined(orderedKeysGt0(API_ORDER, ap), ap, sb.api ?? {}),
141
+ '',
142
+ '[gen_attribute options (% active time)]',
143
+ ...genAttrOptLines,
144
  ].join('\n');
145
  }
146
 
client/src/ts/utils/visualizationUpdater.ts CHANGED
@@ -272,16 +272,23 @@ export class VisualizationUpdater {
272
 
273
  const infoText = this.currentState.infoDensityData?.request?.text ?? '';
274
  const semText = this.currentState.semanticData?.text ?? '';
 
275
 
276
  let showInfoDensity = false;
277
  let showSemantic = false;
278
 
279
  if (mode === 'infoDensity') {
280
- showInfoDensity = true;
281
- showSemantic = hasSemanticData(this.currentState.semanticData) && semText === text;
 
 
 
 
282
  } else {
283
  showSemantic = true;
284
- showInfoDensity = !!this.currentState.infoDensityData && infoText === text;
 
 
285
  }
286
 
287
  if (tokenHistogramItem) tokenHistogramItem.style.display = showInfoDensity ? '' : 'none';
@@ -327,18 +334,20 @@ export class VisualizationUpdater {
327
 
328
  /**
329
  * 重新渲染直方图(内部方法)
330
- * 仅信息密度:只显示 token/surprisal progress;仅语义:只显示 raw score normed;联合:全部显示
331
  * @param skipLmfUpdate 为 true 时跳过 lmf.update(主题切换时由 rerenderOnThemeChange 统一重绘,避免竞态)
332
  */
333
  private updateVisualizationInternal(skipLmfUpdate = false): void {
334
  const hasInfoDensity = !!this.currentState.infoDensityData;
335
  const displayResult = this.computeDisplayResult();
 
 
336
 
337
  const tokenHistogramItem = document.getElementById('token_histogram_item');
338
  const surprisalProgressItem = document.getElementById('surprisal_progress_item');
339
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
340
 
341
- if (hasInfoDensity) {
342
  const currentSurprisals = this.currentState.currentSurprisals;
343
  const currentTokenAvg = this.currentState.currentTokenAvg;
344
  const currentTokenP90 = this.currentState.currentTokenP90;
@@ -375,7 +384,6 @@ export class VisualizationUpdater {
375
 
376
  const rawScoresNormed = displayResult?.rawScoresNormed;
377
  const validRawScoresNormed = rawScoresNormed?.filter((s) => typeof s === 'number' && isFinite(s));
378
- const sem = this.currentState.semanticData;
379
  const signalFitResult = sem?.signalFitResult ?? null;
380
  const chunkInfos = sem?.chunkInfos;
381
  const isChunkMode = (chunkInfos?.length ?? 0) > 0;
@@ -614,7 +622,6 @@ export class VisualizationUpdater {
614
  this.deps.highlightController.updateCurrentData(null);
615
  d3.select('#all_result').style('opacity', 0);
616
  this.updateSemanticDebugInfo();
617
- this.syncDigitsMergeUi();
618
  }
619
 
620
  /**
@@ -627,24 +634,10 @@ export class VisualizationUpdater {
627
  const matchScoreProgressItem = document.getElementById('match_score_progress_item');
628
  if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none';
629
  this.updateSemanticDebugInfo();
630
- this.syncDigitsMergeUi();
631
  }
632
 
633
  /**
634
- * 分块语义结果无法在客户端切换 digit 合并方式,禁开关避免与信息密度边界一致
635
- */
636
- private syncDigitsMergeUi(): void {
637
- const el = document.getElementById('enable_digits_merge_toggle') as HTMLInputElement | null;
638
- if (!el) return;
639
- const disabled = !!this.currentState.semanticData?.chunkInfos?.length;
640
- el.disabled = disabled;
641
- el.title = disabled
642
- ? 'Chunked semantic analysis locks digit merge; clear semantic data or use non-chunked mode to toggle.'
643
- : '';
644
- }
645
-
646
- /**
647
- * digit merge 开关变化时:从 originalTokens / API attention 重算合并并刷新文本与图表
648
  */
649
  public applyDigitsMergeSetting(): void {
650
  const digitMerge = getDigitsMergeEnabled();
@@ -678,7 +671,6 @@ export class VisualizationUpdater {
678
  this.currentState.currentTokenAvg = computeAverage(mergedSurprisals);
679
  this.currentState.currentTokenP90 = computeP90(mergedSurprisals);
680
  }
681
- this.syncDigitsMergeUi();
682
  let displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>;
683
  try {
684
  displayResult = this.computeDisplayResult();
@@ -707,7 +699,7 @@ export class VisualizationUpdater {
707
  if (el) el.style.display = enabled ? '' : 'none';
708
  this.deps.lmf.updateOptions({ semanticAnalysisMode: enabled }, false);
709
  if (!enabled) {
710
- // 关闭时清除语义数据、直方、debug 信息(不重渲染,避免重复渲染信息密度)
711
  this.currentState.semanticData = null;
712
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
713
  if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none';
@@ -720,10 +712,9 @@ export class VisualizationUpdater {
720
  d3.select('#all_result').style('opacity', 0);
721
  this.deps.appStateManager.updateState({ hasValidData: false });
722
  }
723
- // 关闭语义模式后立刻按当前数据重绘,确保语义着色和相关图表不残留
724
- this.updateVisualizationInternal(false);
725
  }
726
- this.syncDigitsMergeUi();
 
727
  // 语义分析配置影响 Upload/Save 的 dataReadyForSave 条件,需始终更新按钮状态
728
  this.deps.appStateManager.updateButtonStates();
729
  }
@@ -881,7 +872,6 @@ export class VisualizationUpdater {
881
  this.deps.appStateManager.updateState({ hasValidData: true });
882
 
883
  this.syncSemanticUiFromConfig();
884
- this.syncDigitsMergeUi();
885
  }
886
 
887
  /**
@@ -986,7 +976,6 @@ export class VisualizationUpdater {
986
  this.updateVisualizationInternal();
987
 
988
  this.updateSemanticDebugInfo(res.debug_info);
989
- this.syncDigitsMergeUi();
990
  return true;
991
  }
992
 
 
272
 
273
  const infoText = this.currentState.infoDensityData?.request?.text ?? '';
274
  const semText = this.currentState.semanticData?.text ?? '';
275
+ const semanticQueryOn = getSemanticAnalysisEnabled();
276
 
277
  let showInfoDensity = false;
278
  let showSemantic = false;
279
 
280
  if (mode === 'infoDensity') {
281
+ /** Semantic Query 勾选时统计区不出现信息密度图占位 */
282
+ showInfoDensity = !semanticQueryOn;
283
+ showSemantic =
284
+ semanticQueryOn &&
285
+ hasSemanticData(this.currentState.semanticData) &&
286
+ semText === text;
287
  } else {
288
  showSemantic = true;
289
+ showInfoDensity =
290
+ !semanticQueryOn &&
291
+ !!(this.currentState.infoDensityData && infoText === text);
292
  }
293
 
294
  if (tokenHistogramItem) tokenHistogramItem.style.display = showInfoDensity ? '' : 'none';
 
334
 
335
  /**
336
  * 重新渲染直方图(内部方法)
337
+ * Semantic Query 勾选:语义相关图;未勾选:有信息密度数据时显示 token + surprisal
338
  * @param skipLmfUpdate 为 true 时跳过 lmf.update(主题切换时由 rerenderOnThemeChange 统一重绘,避免竞态)
339
  */
340
  private updateVisualizationInternal(skipLmfUpdate = false): void {
341
  const hasInfoDensity = !!this.currentState.infoDensityData;
342
  const displayResult = this.computeDisplayResult();
343
+ const sem = this.currentState.semanticData;
344
+ const showInfoDensityCharts = hasInfoDensity && !getSemanticAnalysisEnabled();
345
 
346
  const tokenHistogramItem = document.getElementById('token_histogram_item');
347
  const surprisalProgressItem = document.getElementById('surprisal_progress_item');
348
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
349
 
350
+ if (showInfoDensityCharts) {
351
  const currentSurprisals = this.currentState.currentSurprisals;
352
  const currentTokenAvg = this.currentState.currentTokenAvg;
353
  const currentTokenP90 = this.currentState.currentTokenP90;
 
384
 
385
  const rawScoresNormed = displayResult?.rawScoresNormed;
386
  const validRawScoresNormed = rawScoresNormed?.filter((s) => typeof s === 'number' && isFinite(s));
 
387
  const signalFitResult = sem?.signalFitResult ?? null;
388
  const chunkInfos = sem?.chunkInfos;
389
  const isChunkMode = (chunkInfos?.length ?? 0) > 0;
 
622
  this.deps.highlightController.updateCurrentData(null);
623
  d3.select('#all_result').style('opacity', 0);
624
  this.updateSemanticDebugInfo();
 
625
  }
626
 
627
  /**
 
634
  const matchScoreProgressItem = document.getElementById('match_score_progress_item');
635
  if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none';
636
  this.updateSemanticDebugInfo();
 
637
  }
638
 
639
  /**
640
+ * digit merge 户偏好变化时:对信息密度与整段语义从可重算数据源刷新;分块语义无副本则保持当前展示
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  */
642
  public applyDigitsMergeSetting(): void {
643
  const digitMerge = getDigitsMergeEnabled();
 
671
  this.currentState.currentTokenAvg = computeAverage(mergedSurprisals);
672
  this.currentState.currentTokenP90 = computeP90(mergedSurprisals);
673
  }
 
674
  let displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>;
675
  try {
676
  displayResult = this.computeDisplayResult();
 
699
  if (el) el.style.display = enabled ? '' : 'none';
700
  this.deps.lmf.updateOptions({ semanticAnalysisMode: enabled }, false);
701
  if (!enabled) {
702
+ // 关闭时清除语义数据;统计由下方 updateVisualizationInternal 统一刷新
703
  this.currentState.semanticData = null;
704
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
705
  if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none';
 
712
  d3.select('#all_result').style('opacity', 0);
713
  this.deps.appStateManager.updateState({ hasValidData: false });
714
  }
 
 
715
  }
716
+ /** 勾选 / 关闭 Semantic Query 后立即刷新统计图显隐(与 getSemanticAnalysisEnabled 一致) */
717
+ this.updateVisualizationInternal(false);
718
  // 语义分析配置影响 Upload/Save 的 dataReadyForSave 条件,需始终更新按钮状态
719
  this.deps.appStateManager.updateButtonStates();
720
  }
 
872
  this.deps.appStateManager.updateState({ hasValidData: true });
873
 
874
  this.syncSemanticUiFromConfig();
 
875
  }
876
 
877
  /**
 
976
  this.updateVisualizationInternal();
977
 
978
  this.updateSemanticDebugInfo(res.debug_info);
 
979
  return true;
980
  }
981
 
client/src/ts/vis/ToolTip.ts CHANGED
@@ -13,9 +13,30 @@ import {
13
 
14
  const SEPARATOR = '─────────────';
15
 
 
 
 
16
  export type ToolTipOptions = {
17
  /** 真实 top-k 下 surprisal 行的标签(默认「信息量」) */
18
  surprisalRowLabel?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  };
20
 
21
  type DetailField = { label: string; value: string; valueColor?: boolean };
@@ -43,15 +64,30 @@ export class ToolTip {
43
 
44
  // 防抖:pending 的更新任务
45
  private pendingUpdate: number | null = null;
46
- private pendingData: { ri: GLTR_RenderItem; event?: MouseEvent } | null = null;
 
 
 
 
47
 
48
  // 主题监听器
49
  private themeObserver: MutationObserver | null = null;
50
 
51
  private readonly surprisalRowLabel: string;
 
 
52
 
53
  constructor(private parent: D3Sel, private eh: SimpleEventHandler, options?: ToolTipOptions) {
54
  this.surprisalRowLabel = options?.surprisalRowLabel ?? tr('information:');
 
 
 
 
 
 
 
 
 
55
  this._init();
56
  this._setupThemeObserver();
57
  this._updateThemeColors();
@@ -62,20 +98,19 @@ export class ToolTip {
62
  this.predictions = this.parent.select('.predictions');
63
  this.myDetail = this.parent.select('.myDetail');
64
  this.currentToken = this.parent.select('.currentToken');
65
-
66
- // 添加点击事件:点击 tooltip 任意位置关闭
67
- this.parent.on('click', (event) => {
68
- event.stopPropagation(); // 阻止事件冒泡,避免触发下方元素
69
- event.preventDefault(); // 阻止默认行为
70
- this.visibility = false;
71
- });
72
-
73
- // 移动端触摸事件
74
- this.parent.on('touchstart', (event) => {
75
- event.stopPropagation(); // 阻止事件冒泡,避免触发 body 的 touchstart
76
- event.preventDefault(); // 阻止默认行为
77
- this.visibility = false;
78
- });
79
  }
80
 
81
  /**
@@ -140,59 +175,23 @@ export class ToolTip {
140
  }
141
 
142
  /**
143
- * 从event.target向上查找SVG rect元素,优先找到包含鼠位置的rect
144
- * 解决事件绑定在group上时,target可能是group而不是rect的问题
145
- * @param target 事件目标元素
146
- * @param mouseX 鼠标X坐标
147
- * @param mouseY 鼠标Y坐标
148
- * @returns 找到的SVG rect元素,如果没找到则返回null
149
  */
150
- private _findTokenRect(target: EventTarget | null, mouseX: number, mouseY: number): SVGRectElement | null {
151
- if (!target) return null;
152
-
153
- let element = target as Element;
154
-
155
- // 如果target本身就是rect,直接返回
156
- if (element instanceof SVGRectElement) {
157
- return element;
158
- }
159
-
160
- // 如果target是group,查找包含鼠标位置的rect
161
  if (element instanceof SVGGElement) {
162
- const rects = element.querySelectorAll('rect');
163
- // 优先查找包含鼠标位置的rect
164
- for (const rect of rects) {
165
- const rectBounds = rect.getBoundingClientRect();
166
- if (mouseX >= rectBounds.left && mouseX <= rectBounds.right &&
167
- mouseY >= rectBounds.top && mouseY <= rectBounds.bottom) {
168
- return rect;
169
- }
170
- }
171
- // 如果没找到包含鼠标的rect,返回第一个rect(fallback)
172
- return rects[0] || null;
173
  }
174
-
175
- // 如果target是其他元素,向上查找parent
176
- let parent = element.parentElement;
177
- while (parent) {
178
- if (parent instanceof SVGRectElement) {
179
- return parent;
180
  }
181
- if (parent instanceof SVGGElement) {
182
- // 在group中查找包含鼠标位置的rect
183
- const rects = parent.querySelectorAll('rect');
184
- for (const rect of rects) {
185
- const rectBounds = rect.getBoundingClientRect();
186
- if (mouseX >= rectBounds.left && mouseX <= rectBounds.right &&
187
- mouseY >= rectBounds.top && mouseY <= rectBounds.bottom) {
188
- return rect;
189
- }
190
- }
191
- return rects[0] || null;
192
- }
193
- parent = parent.parentElement;
194
  }
195
-
196
  return null;
197
  }
198
 
@@ -222,37 +221,71 @@ export class ToolTip {
222
  this.pendingData = null;
223
  this.visibility = false;
224
  if (node) {
225
- node.style.top = '0px';
226
- node.style.left = '0px';
 
 
 
 
227
  }
228
  }
229
 
 
 
 
 
 
 
 
 
 
230
  set visibility(vis: boolean) {
 
231
  if (vis == true) {
232
- this.parent.style('opacity', 1);
233
- this.parent.style('pointer-events', 'auto'); // 显示时允许点击
 
234
  } else {
 
235
  this.parent.style('opacity', 0);
236
  this.parent.style('pointer-events', 'none'); // 关闭时禁止点击,让事件穿透
237
  }
238
  }
239
 
240
 
241
- updateData(ri: GLTR_RenderItem, event?: MouseEvent) {
 
 
 
 
 
 
 
242
  // 防抖:取消之前的更新任务
243
  if (this.pendingUpdate !== null) {
244
  cancelAnimationFrame(this.pendingUpdate);
245
  }
246
 
 
 
 
 
 
 
 
247
  // 保存最新的数据
248
- this.pendingData = { ri, event };
249
 
250
  // 先将 tooltip 移到屏幕外,避免在位置计算完成前显示在旧位置
251
  // 这可以解决 iOS Safari 上触摸时的抖动问题:
252
  // 如果旧位置在触摸点下方,会触发 tooltip 的 touchstart 导致关闭
253
  const node = this.parent.node() as HTMLElement;
254
  if (node) {
255
- node.style.left = '-9999px';
 
 
 
 
256
  }
257
  this.visibility = true;
258
 
@@ -261,14 +294,14 @@ export class ToolTip {
261
  this.pendingUpdate = null;
262
  if (!this.pendingData) return;
263
 
264
- const { ri: currentRi, event: currentEvent } = this.pendingData;
265
  this.pendingData = null;
266
 
267
  // 更新内容
268
- this._updateContent(currentRi);
269
 
270
  // 立即计算位置(DOM已更新,getBoundingClientRect 能获取准确值)
271
- this._updatePosition(currentEvent);
272
  });
273
  }
274
 
@@ -276,7 +309,7 @@ export class ToolTip {
276
  * 更新tooltip内容
277
  * 统一结构:语义区块(上) + 分隔线 + 信息密度区块(下,含汇总指标 + top-k 表格)
278
  */
279
- private _updateContent(ri: GLTR_RenderItem): void {
280
  const { selectedColor, detailColor, valueColor } = this.themeColors;
281
 
282
  // 更新当前token显示(第一行)
@@ -296,40 +329,47 @@ export class ToolTip {
296
  (s.chunkIndex !== undefined && s.chunkMatchDegree !== undefined));
297
  const { hasRealTopk } = getFrontendTokenTopkState(tokenData);
298
 
299
- // 1. 构建语义区块(pw score = raw_score_normed × P_pw × matchDegree,P_pw: x≤threshold 为 0,x>threshold 为 1;分块用 chunkMatchDegree,非分块用 full_match_degree
300
- const semanticRows: string[] = [];
 
301
  if (hasSemantic && s) {
302
- if (s.pwScore !== undefined) semanticRows.push(renderField({ label: tr('pw score:'), value: this.numF(s.pwScore) }, detailColor, valueColor));
303
- if (s.signalProb !== undefined) semanticRows.push(renderField({ label: tr('signal probability:'), value: this.numF(s.signalProb) }, detailColor, valueColor));
304
- if (s.rawScoreNormed !== undefined) semanticRows.push(renderField({ label: tr('raw score normed:'), value: this.numF(s.rawScoreNormed) }, detailColor, valueColor));
305
- if (s.rawScore !== undefined) semanticRows.push(renderField({ label: tr('raw score:'), value: d3.format('.6f')(s.rawScore), valueColor: false }, detailColor, valueColor));
306
  if (s.chunkIndex !== undefined && s.chunkMatchDegree !== undefined) {
307
- semanticRows.push(renderField({
308
  label: `chunk #${s.chunkIndex} match score:`,
309
  value: (s.chunkMatchDegree * 100).toFixed(1) + '%'
310
  }, detailColor, valueColor));
311
  }
312
  }
 
 
 
313
 
314
- // 2. 构建信息密度区块(汇总指标)
315
  const infoRows: string[] = [];
316
  if (hasRealTopk) {
317
  const prob = tokenData.real_topk![1];
318
  const surprisal = calculateSurprisal(prob);
319
  const isClassic = getTokenRenderStyle() === 'classic';
320
- infoRows.push(renderField({ label: this.surprisalRowLabel, value: `${this.significantF(surprisal)} bits` }, detailColor, valueColor));
321
  if (!isClassic) {
322
  const informationDensity = calculateSurprisalDensity(tokenData);
323
  const utf8Size = new TextEncoder().encode(tokenData.raw).length;
324
- infoRows.unshift(renderField({ label: tr('information density:'), value: `${this.significantF(informationDensity)} ${tr('bits/Byte')}` }, detailColor, valueColor));
325
- infoRows.splice(1, 0, renderField({ label: tr('UTF-8 size:'), value: `${utf8Size} ${tr('bytes')}`, valueColor: false }, detailColor, valueColor));
326
  }
 
 
 
 
327
  }
328
 
329
- // 3. 合并 myDetail:语义 + 分隔线(仅当两区块都有时) + 信息
330
  const detailParts: string[] = [];
331
- if (semanticRows.length) detailParts.push(semanticRows.join('<br/>'));
332
- if (semanticRows.length && infoRows.length) detailParts.push(`<span style="color:${detailColor}">${SEPARATOR}</span>`);
333
  if (infoRows.length) detailParts.push(infoRows.join('<br/>'));
334
  this.myDetail.html(detailParts.join('<br/>'));
335
 
@@ -343,12 +383,17 @@ export class ToolTip {
343
  }
344
 
345
  /**
346
- * 更新tooltip位置
347
  */
348
- private _updatePosition(event?: MouseEvent): void {
349
  const tooltipNode = this.parent.node() as HTMLElement;
350
  if (!tooltipNode) return;
351
 
 
 
 
 
 
352
  // 获取视口信息(用于边界检查)
353
  const viewport = this._getViewportInfo();
354
 
@@ -374,15 +419,10 @@ export class ToolTip {
374
  anchorRect = anchor.getBoundingClientRect();
375
  }
376
 
377
- if (!event) {
378
- throw new Error('[ToolTip] 更新位置需要 pointer 事件(缺少 MouseEvent)');
379
- }
380
- const mouseX = event.clientX;
381
- const mouseY = event.clientY;
382
- const tokenRectElement = this._findTokenRect(event.target, mouseX, mouseY);
383
  if (!tokenRectElement) {
384
  throw new Error(
385
- '[ToolTip] 无法从 event.target 解析到 token 的 SVG rect,请检查 GLTR 事件目标与 DOM 结构'
386
  );
387
  }
388
 
 
13
 
14
  const SEPARATOR = '─────────────';
15
 
16
+ /** 贴在定位包含块角落时的留白(px);{@link ToolTipOptions.placement} `parent-bottom-right` 使用 */
17
+ const CORNER_INSET_PX = 0;
18
+
19
  export type ToolTipOptions = {
20
  /** 真实 top-k 下 surprisal 行的标签(默认「信息量」) */
21
  surprisalRowLabel?: string;
22
+ /**
23
+ * `parent-bottom-right`:`position:absolute`,贴在定位包含块(`offsetParent`)右下角(DAG Top‑K 作为 `#results` 直接子节点时即为 results 内侧右下)。
24
+ * 该 HUD 模式不依赖锚点几何;面板应由页面 CSS(如 `.gen-attr-dag-topk-tooltip`)约束宽高与内部滚动,而非按内容 shrink-wrap。
25
+ * 默认 `anchor`:沿用原有相对 token rect 的定位。
26
+ */
27
+ placement?: 'anchor' | 'parent-bottom-right';
28
+ /**
29
+ * false:面板不参与命中测试(`pointer-events: none`),避免盖住底层 SVG 时在节点上反复 `mouseleave`/闪动;
30
+ * 同时不注册点击/触摸收起,且不接收内部滚动交互。
31
+ */
32
+ pointerInteractive?: boolean;
33
+ };
34
+
35
+ /** {@link ToolTip.updateData} 可选增补(如 DAG:CI/MI 行紧跟 surprisal 之后) */
36
+ export type ToolTipUpdateAugment = {
37
+ /** 在 surprisal / 信息密度行之前渲染(紧跟 token 文字,位于所有 info 行上方) */
38
+ rowsBeforeInfo?: Array<{ label: string; value: string; valueColor?: boolean }>;
39
+ rowsAfterSurprisal?: Array<{ label: string; value: string; valueColor?: boolean }>;
40
  };
41
 
42
  type DetailField = { label: string; value: string; valueColor?: boolean };
 
64
 
65
  // 防抖:pending 的更新任务
66
  private pendingUpdate: number | null = null;
67
+ private pendingData: {
68
+ ri: GLTR_RenderItem;
69
+ anchorTarget: EventTarget | null;
70
+ augment?: ToolTipUpdateAugment;
71
+ } | null = null;
72
 
73
  // 主题监听器
74
  private themeObserver: MutationObserver | null = null;
75
 
76
  private readonly surprisalRowLabel: string;
77
+ private readonly placement: NonNullable<ToolTipOptions['placement']>;
78
+ private readonly pointerInteractive: boolean;
79
 
80
  constructor(private parent: D3Sel, private eh: SimpleEventHandler, options?: ToolTipOptions) {
81
  this.surprisalRowLabel = options?.surprisalRowLabel ?? tr('information:');
82
+ this.placement = options?.placement ?? 'anchor';
83
+ this.pointerInteractive = options?.pointerInteractive ?? true;
84
+ if (!this.pointerInteractive) {
85
+ this.parent.classed('tooltip-no-pointer-hit', true);
86
+ }
87
+ const el = this.parent.node() as HTMLElement | null;
88
+ if (el && this.placement === 'parent-bottom-right') {
89
+ el.style.position = 'absolute';
90
+ }
91
  this._init();
92
  this._setupThemeObserver();
93
  this._updateThemeColors();
 
98
  this.predictions = this.parent.select('.predictions');
99
  this.myDetail = this.parent.select('.myDetail');
100
  this.currentToken = this.parent.select('.currentToken');
101
+
102
+ if (this.pointerInteractive) {
103
+ this.parent.on('click', (event) => {
104
+ event.stopPropagation();
105
+ event.preventDefault();
106
+ this.visibility = false;
107
+ });
108
+ this.parent.on('touchstart', (event) => {
109
+ event.stopPropagation();
110
+ event.preventDefault();
111
+ this.visibility = false;
112
+ });
113
+ }
 
114
  }
115
 
116
  /**
 
175
  }
176
 
177
  /**
178
+ * 从事件目标解析用于定位的 SVG rect(与指针坐无关):rect 自身、或容器 `g` 内首个 `rect`、或向上追溯。
 
 
 
 
 
179
  */
180
+ private _resolveAnchorRectElement(target: EventTarget | null): SVGRectElement | null {
181
+ if (!target || !(target instanceof Element)) return null;
182
+ let element: Element | null = target;
183
+ if (element instanceof SVGRectElement) return element;
 
 
 
 
 
 
 
184
  if (element instanceof SVGGElement) {
185
+ return element.querySelector('rect');
 
 
 
 
 
 
 
 
 
 
186
  }
187
+ while (element) {
188
+ if (element instanceof SVGRectElement) return element;
189
+ if (element instanceof SVGGElement) {
190
+ const r = element.querySelector('rect');
191
+ if (r) return r;
 
192
  }
193
+ element = element.parentElement;
 
 
 
 
 
 
 
 
 
 
 
 
194
  }
 
195
  return null;
196
  }
197
 
 
221
  this.pendingData = null;
222
  this.visibility = false;
223
  if (node) {
224
+ if (this.placement === 'parent-bottom-right') {
225
+ this._placeParentBottomRight(node);
226
+ } else {
227
+ node.style.top = '0px';
228
+ node.style.left = '0px';
229
+ }
230
  }
231
  }
232
 
233
+ /** 固定在 offsetParent(含隐藏态占位)右下角 */
234
+ private _placeParentBottomRight(node: HTMLElement): void {
235
+ node.style.position = 'absolute';
236
+ node.style.right = `${CORNER_INSET_PX}px`;
237
+ node.style.left = 'auto';
238
+ node.style.top = 'auto';
239
+ node.style.bottom = `${CORNER_INSET_PX}px`;
240
+ }
241
+
242
  set visibility(vis: boolean) {
243
+ const node = this.parent.node() as HTMLElement | null;
244
  if (vis == true) {
245
+ node?.classList.add('tooltip-visible');
246
+ node?.style.removeProperty('opacity');
247
+ this.parent.style('pointer-events', this.pointerInteractive ? 'auto' : 'none');
248
  } else {
249
+ node?.classList.remove('tooltip-visible');
250
  this.parent.style('opacity', 0);
251
  this.parent.style('pointer-events', 'none'); // 关闭时禁止点击,让事件穿透
252
  }
253
  }
254
 
255
 
256
+ /**
257
+ * @param eventOrAnchor 指针事件(使用 `target`)或直接传入用作锚点的元素(如 SVG `rect` / `g`)
258
+ */
259
+ updateData(
260
+ ri: GLTR_RenderItem,
261
+ eventOrAnchor?: MouseEvent | TouchEvent | Element | null,
262
+ augment?: ToolTipUpdateAugment
263
+ ) {
264
  // 防抖:取消之前的更新任务
265
  if (this.pendingUpdate !== null) {
266
  cancelAnimationFrame(this.pendingUpdate);
267
  }
268
 
269
+ const anchorTarget =
270
+ eventOrAnchor instanceof Element
271
+ ? eventOrAnchor
272
+ : eventOrAnchor && 'target' in eventOrAnchor
273
+ ? (eventOrAnchor.target as EventTarget | null)
274
+ : null;
275
+
276
  // 保存最新的数据
277
+ this.pendingData = { ri, anchorTarget, augment };
278
 
279
  // 先将 tooltip 移到屏幕外,避免在位置计算完成前显示在旧位置
280
  // 这可以解决 iOS Safari 上触摸时的抖动问题:
281
  // 如果旧位置在触摸点下方,会触发 tooltip 的 touchstart 导致关闭
282
  const node = this.parent.node() as HTMLElement;
283
  if (node) {
284
+ if (this.placement === 'parent-bottom-right') {
285
+ this._placeParentBottomRight(node);
286
+ } else {
287
+ node.style.left = '-9999px';
288
+ }
289
  }
290
  this.visibility = true;
291
 
 
294
  this.pendingUpdate = null;
295
  if (!this.pendingData) return;
296
 
297
+ const { ri: currentRi, anchorTarget: at, augment } = this.pendingData;
298
  this.pendingData = null;
299
 
300
  // 更新内容
301
+ this._updateContent(currentRi, augment);
302
 
303
  // 立即计算位置(DOM已更新,getBoundingClientRect 能获取准确值)
304
+ this._updatePosition(at);
305
  });
306
  }
307
 
 
309
  * 更新tooltip内容
310
  * 统一结构:语义区块(上) + 分隔线 + 信息密度区块(下,含汇总指标 + top-k 表格)
311
  */
312
+ private _updateContent(ri: GLTR_RenderItem, augment?: ToolTipUpdateAugment): void {
313
  const { selectedColor, detailColor, valueColor } = this.themeColors;
314
 
315
  // 更新当前token显示(第一行)
 
329
  (s.chunkIndex !== undefined && s.chunkMatchDegree !== undefined));
330
  const { hasRealTopk } = getFrontendTokenTopkState(tokenData);
331
 
332
+ // 1. 构建区块:语义行 + rowsBeforeInfo(DAG 归因份额等附加行
333
+ // 二者在视觉上同属"token 语义信息",与下方信息密度区块以分隔线隔开
334
+ const topRows: string[] = [];
335
  if (hasSemantic && s) {
336
+ if (s.pwScore !== undefined) topRows.push(renderField({ label: tr('pw score:'), value: this.numF(s.pwScore) }, detailColor, valueColor));
337
+ if (s.signalProb !== undefined) topRows.push(renderField({ label: tr('signal probability:'), value: this.numF(s.signalProb) }, detailColor, valueColor));
338
+ if (s.rawScoreNormed !== undefined) topRows.push(renderField({ label: tr('raw score normed:'), value: this.numF(s.rawScoreNormed) }, detailColor, valueColor));
339
+ if (s.rawScore !== undefined) topRows.push(renderField({ label: tr('raw score:'), value: d3.format('.6f')(s.rawScore), valueColor: false }, detailColor, valueColor));
340
  if (s.chunkIndex !== undefined && s.chunkMatchDegree !== undefined) {
341
+ topRows.push(renderField({
342
  label: `chunk #${s.chunkIndex} match score:`,
343
  value: (s.chunkMatchDegree * 100).toFixed(1) + '%'
344
  }, detailColor, valueColor));
345
  }
346
  }
347
+ for (const f of augment?.rowsBeforeInfo ?? []) {
348
+ topRows.push(renderField(f, detailColor, valueColor));
349
+ }
350
 
351
+ // 2. 构建信息密度区块:按明确顺序追加,避免脆弱的 unshift/splice
352
  const infoRows: string[] = [];
353
  if (hasRealTopk) {
354
  const prob = tokenData.real_topk![1];
355
  const surprisal = calculateSurprisal(prob);
356
  const isClassic = getTokenRenderStyle() === 'classic';
 
357
  if (!isClassic) {
358
  const informationDensity = calculateSurprisalDensity(tokenData);
359
  const utf8Size = new TextEncoder().encode(tokenData.raw).length;
360
+ infoRows.push(renderField({ label: tr('information density:'), value: `${this.significantF(informationDensity)} ${tr('bits/Byte')}` }, detailColor, valueColor));
361
+ infoRows.push(renderField({ label: tr('UTF-8 size:'), value: `${utf8Size} ${tr('bytes')}`, valueColor: false }, detailColor, valueColor));
362
  }
363
+ infoRows.push(renderField({ label: this.surprisalRowLabel, value: `${this.significantF(surprisal)} bits` }, detailColor, valueColor));
364
+ }
365
+ for (const f of augment?.rowsAfterSurprisal ?? []) {
366
+ infoRows.push(renderField(f, detailColor, valueColor));
367
  }
368
 
369
+ // 3. 合并 myDetail:上区块 + 分隔线(仅当两区块都有时) + 信息密度区块
370
  const detailParts: string[] = [];
371
+ if (topRows.length) detailParts.push(topRows.join('<br/>'));
372
+ if (topRows.length && infoRows.length) detailParts.push(`<span style="color:${detailColor}">${SEPARATOR}</span>`);
373
  if (infoRows.length) detailParts.push(infoRows.join('<br/>'));
374
  this.myDetail.html(detailParts.join('<br/>'));
375
 
 
383
  }
384
 
385
  /**
386
+ * 更新tooltip位置(相对锚点元素几何,不依赖指针坐标)
387
  */
388
+ private _updatePosition(anchorTarget: EventTarget | null): void {
389
  const tooltipNode = this.parent.node() as HTMLElement;
390
  if (!tooltipNode) return;
391
 
392
+ if (this.placement === 'parent-bottom-right') {
393
+ this._placeParentBottomRight(tooltipNode);
394
+ return;
395
+ }
396
+
397
  // 获取视口信息(用于边界检查)
398
  const viewport = this._getViewportInfo();
399
 
 
419
  anchorRect = anchor.getBoundingClientRect();
420
  }
421
 
422
+ const tokenRectElement = this._resolveAnchorRectElement(anchorTarget);
 
 
 
 
 
423
  if (!tokenRectElement) {
424
  throw new Error(
425
+ '[ToolTip] 无法从锚点解析到 SVG rect,请传入 token rect、g 或其它含 rect 的祖先元素'
426
  );
427
  }
428
 
client/src/ts/vis/constants.ts CHANGED
@@ -13,7 +13,7 @@ export const HIGHLIGHT_CONSTANTS = {
13
  /** chunk 字符半开区间下划线(由 DOM Range 推算,不依赖 token rect) */
14
  INTERVAL_UNDERLINE_CLASS: 'chunk-interval-underline',
15
  /** 高亮边框颜色(CSS变量) */
16
- HIGHLIGHT_COLOR: 'var(--bin-highlight-outline, #1e6fff)',
17
  /** 边框宽度 */
18
  BORDER_WIDTH: '1.5',
19
  /** 下划线宽度 */
 
13
  /** chunk 字符半开区间下划线(由 DOM Range 推算,不依赖 token rect) */
14
  INTERVAL_UNDERLINE_CLASS: 'chunk-interval-underline',
15
  /** 高亮边框颜色(CSS变量) */
16
+ HIGHLIGHT_COLOR: 'var(--accent-color, #1e6fff)',
17
  /** 边框宽度 */
18
  BORDER_WIDTH: '1.5',
19
  /** 下划线宽度 */