dqy08 commited on
Commit
b704fe2
·
1 Parent(s): 53e5b08

DAG增加linear-arc布局模式;增加DAG用户自定义选项;UI改进

Browse files
backend/access_log.py CHANGED
@@ -61,9 +61,14 @@ def log_page_load(path: str):
61
  _log_request("📄 页面访问", f"path={combined!r}")
62
 
63
 
64
- def log_demo_file(path: str):
65
- """记录demo文件请求"""
66
- _log_request("🎯 demo文件", f"file='{path}'")
 
 
 
 
 
67
 
68
 
69
  def log_analyze_request(text: str, stream_mode: bool = False, client_ip: str = None):
 
61
  _log_request("📄 页面访问", f"path={combined!r}")
62
 
63
 
64
+ def log_json_demo(path: str):
65
+ """记录从 client/dist 拉取的 .json(如打包的 gen_attribute demo"""
66
+ _log_request("🎯 json demo", f"file='{path}'")
67
+
68
+
69
+ def log_cached_demo(path: str):
70
+ """记录从数据目录 /demo/ 拉取的服务端 demo"""
71
+ _log_request("🎯 cached demo", f"file='{path}'")
72
 
73
 
74
  def log_analyze_request(text: str, stream_mode: bool = False, client_ip: str = None):
backend/api/static.py CHANGED
@@ -6,7 +6,7 @@ from urllib.parse import unquote
6
  from flask import Response, redirect, abort, request
7
  from werkzeug.utils import safe_join
8
 
9
- from backend.access_log import log_page_load, log_demo_file
10
 
11
 
12
  def _read_static_file(directory: str, path: str) -> Response:
@@ -42,7 +42,7 @@ def register_static_routes(app):
42
  if path.endswith('.html'):
43
  log_page_load(path)
44
  if path.endswith('.json'):
45
- log_demo_file(path)
46
  return _read_static_file('client/dist', path)
47
 
48
  @app.route('/demo/<path:path>')
@@ -50,7 +50,7 @@ def register_static_routes(app):
50
  """serves all demo files from the demo dir to ``/demo/<path:path>``"""
51
  from backend.app_context import get_data_dir
52
  data_dir = get_data_dir()
53
- log_demo_file(path)
54
  try:
55
  decoded_path = unquote(path)
56
  return _read_static_file(str(data_dir), decoded_path)
 
6
  from flask import Response, redirect, abort, request
7
  from werkzeug.utils import safe_join
8
 
9
+ from backend.access_log import log_cached_demo, log_json_demo, log_page_load
10
 
11
 
12
  def _read_static_file(directory: str, path: str) -> Response:
 
42
  if path.endswith('.html'):
43
  log_page_load(path)
44
  if path.endswith('.json'):
45
+ log_json_demo(path)
46
  return _read_static_file('client/dist', path)
47
 
48
  @app.route('/demo/<path:path>')
 
50
  """serves all demo files from the demo dir to ``/demo/<path:path>``"""
51
  from backend.app_context import get_data_dir
52
  data_dir = get_data_dir()
53
+ log_cached_demo(path)
54
  try:
55
  decoded_path = unquote(path)
56
  return _read_static_file(str(data_dir), decoded_path)
backend/visit_stats.py CHANGED
@@ -15,6 +15,23 @@ _api = defaultdict(int)
15
  _ip_os: dict[str, str] = {}
16
  _VALID_CLIENT_OS = frozenset({"ios", "android", "windows", "macos", "linux", "unknown"})
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  def _is_page_route_request() -> bool:
20
  """与 static 中首页重定向、HTML 页面下发一致,不把 /api、静态 js/css 等算进访问 IP。"""
@@ -54,13 +71,22 @@ def print_visit_summary():
54
  with _LOCK:
55
  h = (time.monotonic() - _t0) / 3600
56
  n_ip, n_act = len(_seen), len(_active)
57
- pg = [f" {k}: {v}" for k, v in sorted(_page_sec.items(), key=lambda kv: (-kv[1], kv[0]))]
58
  apis = dict(_api)
59
  os_cnt = defaultdict(int)
60
  for aip in _active:
61
  o = _ip_os.get(aip)
62
  if o:
63
  os_cnt[o] += 1
 
 
 
 
 
 
 
 
 
 
64
  os_order = ("ios", "android", "windows", "macos", "linux", "unknown")
65
  os_pg = [f" {k}: {os_cnt[k]}" for k in os_order if os_cnt[k]]
66
  now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
@@ -68,8 +94,9 @@ def print_visit_summary():
68
  body = [f"========== [访问统计] {now} ==========",
69
  f"进程约 {h:.2f}h | 页面访问IP:{n_ip} | 真实活跃IP:{n_act}", "--- 活跃IP中OS统计 ---",
70
  *(os_pg or [" (尚无)"]), "--- 页面活跃时间统计(秒) ---",
71
- *(pg or [" (尚无)"]), "--- API调用统计 ---",
72
- *[f" {k}: {apis.get(k, 0)}" for k in ("analyze", "analyze_semantic", "prediction_attribute", "chat")],
 
73
  "=" * 42]
74
  print("\n".join(body), flush=True)
75
 
 
15
  _ip_os: dict[str, str] = {}
16
  _VALID_CLIENT_OS = frozenset({"ios", "android", "windows", "macos", "linux", "unknown"})
17
 
18
+ # 与 webpack 顶层 HTML(client_activity 上报的 pathname 末段 *.html)一致;无上报也打印秒数 0
19
+ _SUMMARY_HTML_PAGES = (
20
+ "index.html",
21
+ "analysis.html",
22
+ "compare.html",
23
+ "chat.html",
24
+ "attribution.html",
25
+ "gen_attribute.html",
26
+ )
27
+
28
+ _SUMMARY_API_KINDS = (
29
+ "analyze",
30
+ "analyze_semantic",
31
+ "chat",
32
+ "prediction_attribute",
33
+ )
34
+
35
 
36
  def _is_page_route_request() -> bool:
37
  """与 static 中首页重定向、HTML 页面下发一致,不把 /api、静态 js/css 等算进访问 IP。"""
 
71
  with _LOCK:
72
  h = (time.monotonic() - _t0) / 3600
73
  n_ip, n_act = len(_seen), len(_active)
 
74
  apis = dict(_api)
75
  os_cnt = defaultdict(int)
76
  for aip in _active:
77
  o = _ip_os.get(aip)
78
  if o:
79
  os_cnt[o] += 1
80
+ known_pages = frozenset(_SUMMARY_HTML_PAGES)
81
+ merged_sec = {k: _page_sec.get(k, 0) for k in _SUMMARY_HTML_PAGES}
82
+ for pk, secs in _page_sec.items():
83
+ if pk not in known_pages:
84
+ merged_sec[pk] = secs
85
+ pg = [f" {k}: {merged_sec[k]}" for k in _SUMMARY_HTML_PAGES]
86
+ pg.extend(
87
+ f" {k}: {merged_sec[k]}"
88
+ for k in sorted(k for k in merged_sec if k not in known_pages)
89
+ )
90
  os_order = ("ios", "android", "windows", "macos", "linux", "unknown")
91
  os_pg = [f" {k}: {os_cnt[k]}" for k in os_order if os_cnt[k]]
92
  now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
 
94
  body = [f"========== [访问统计] {now} ==========",
95
  f"进程约 {h:.2f}h | 页面访问IP:{n_ip} | 真实活跃IP:{n_act}", "--- 活跃IP中OS统计 ---",
96
  *(os_pg or [" (尚无)"]), "--- 页面活跃时间统计(秒) ---",
97
+ *pg,
98
+ "--- API调用统计 ---",
99
+ *[f" {k}: {apis.get(k, 0)}" for k in _SUMMARY_API_KINDS],
100
  "=" * 42]
101
  print("\n".join(body), flush=True)
102
 
client/src/content/page-meta.json CHANGED
@@ -4,6 +4,7 @@
4
  "home": {
5
  "title": "Info Lens",
6
  "subtitle": "A toolbox for exploring the informational nature of LLMs and language",
 
7
  "formula": "LLM × Linguistics × Information Theory"
8
  },
9
  "analysis": {
 
4
  "home": {
5
  "title": "Info Lens",
6
  "subtitle": "A toolbox for exploring the informational nature of LLMs and language",
7
+ "heartline": "A ❤️ would mean a lot!",
8
  "formula": "LLM × Linguistics × Information Theory"
9
  },
10
  "analysis": {
client/src/css/_semantic-analysis.scss CHANGED
@@ -16,6 +16,12 @@
16
  align-items: center;
17
  gap: 6px;
18
 
 
 
 
 
 
 
19
  &.semantic-submode-group-right {
20
  margin-left: auto;
21
  }
 
16
  align-items: center;
17
  gap: 6px;
18
 
19
+ // `.semantic-submode-row .semantic-submode-group`(两档类)特异性高于 UA 里单独的 `[hidden]`,
20
+ // 会把本应隐藏的组仍排成 flex;配合 TS 的 element.hidden 需强制盖住自身 flex。
21
+ &[hidden] {
22
+ display: none !important;
23
+ }
24
+
25
  &.semantic-submode-group-right {
26
  margin-left: auto;
27
  }
client/src/css/gen_attribute.scss CHANGED
@@ -77,9 +77,8 @@
77
  // 初始 d3.zoom 缩放为 1/本变量,抵消 display-scale 在屏上的整体缩小感(见 genAttributeDagView)
78
  // 边色:`--dag-normal-line-color`、`--dag-highlight-line-color-in` / `out` 与节点描边 `--token-hover-outline` 同在 start.scss(见 genAttributeDagView)。
79
  #results.gen-attr-results-surface.LMF .gen-attr-dag-stack {
80
- // compactness:dag界面紧凑程度,范围 [0, 1];1时和普通文本排版一致,最紧凑;0.5为默认
81
- --gen-attr-dag-compactness: 0.5;
82
- --gen-attr-dag-display-scale: var(--gen-attr-dag-compactness);
83
  --gen-attr-dag-node-text-font-scale: 0.9;
84
  --gen-attr-dag-link-stroke-width: calc(2px * var(--gen-attr-dag-compactness));
85
  // --gen-attr-dag-prompt-node-fill: rgba(221, 179, 120, 0.29);
@@ -133,7 +132,7 @@
133
  stroke-width: calc(2 * var(--gen-attr-dag-display-scale, 1));
134
  }
135
 
136
- // 与 genAttributeDagView 中「仅选中节点可拖」一致:可拖时提示 grab
137
  #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node--selected {
138
  cursor: grab;
139
  &:active {
@@ -141,6 +140,17 @@
141
  }
142
  }
143
 
 
 
 
 
 
 
 
 
 
 
 
144
  // 默认 width:100%(随容器)仅作回退;genAttributeDagView 会在 init 与 `setMeasureWidthPx`
145
  // 时以 inline style 写入固定像素宽度。测量层宽度是节点几何(折行位置)的唯一输入,
146
  // 固定后 resize/全屏等容器变化不再影响节点布局,只影响 fit 的视口缩放/平移。
@@ -270,9 +280,12 @@
270
  opacity: 1;
271
  }
272
 
273
- // Max tokens 输入框:紧凑数字输入
274
- .gen-attr-max-tokens-input {
275
- width: 64px;
 
 
 
276
  padding: 3px 6px;
277
  font-size: inherit;
278
  border: 1px solid var(--input-border);
@@ -280,6 +293,9 @@
280
  background: var(--input-bg);
281
  color: var(--text-color);
282
  text-align: right;
 
 
 
283
  }
284
 
285
  // 与 Chat 共用的 .generation-status-slot 见 _generation-status.scss
@@ -329,20 +345,14 @@
329
  }
330
  }
331
 
332
- // DAG 测量层宽度:与 .gen-attr-max-tokens-input 同形
333
  .gen-attr-dag-measure-width-row {
334
  margin-top: 8px;
 
335
  }
336
 
337
- .gen-attr-dag-measure-width-input {
338
- width: 72px;
339
- padding: 3px 6px;
340
- font-size: inherit;
341
- border: 1px solid var(--input-border);
342
- border-radius: 4px;
343
- background: var(--input-bg);
344
- color: var(--text-color);
345
- text-align: right;
346
  }
347
 
348
  .gen-attr-dag-replay-speed-row {
@@ -352,9 +362,12 @@
352
  gap: 6px 10px;
353
  }
354
 
355
- select.gen-attr-dag-replay-mode-select {
356
- width: auto;
357
- min-width: 7.5rem;
 
 
 
358
  text-align: left;
359
  }
360
 
 
77
  // 初始 d3.zoom 缩放为 1/本变量,抵消 display-scale 在屏上的整体缩小感(见 genAttributeDagView)
78
  // 边色:`--dag-normal-line-color`、`--dag-highlight-line-color-in` / `out` 与节点描边 `--token-hover-outline` 同在 start.scss(见 genAttributeDagView)。
79
  #results.gen-attr-results-surface.LMF .gen-attr-dag-stack {
80
+ // compactness:dag界面紧凑程度,范围 [0.05, 1];1时和普通文本排版一致;由 JS 写入 inline style,0.5 纯 CSS 降级兜底
81
+ --gen-attr-dag-display-scale: var(--gen-attr-dag-compactness, 0.5);
 
82
  --gen-attr-dag-node-text-font-scale: 0.9;
83
  --gen-attr-dag-link-stroke-width: calc(2px * var(--gen-attr-dag-compactness));
84
  // --gen-attr-dag-prompt-node-fill: rgba(221, 179, 120, 0.29);
 
132
  stroke-width: calc(2 * var(--gen-attr-dag-display-scale, 1));
133
  }
134
 
135
+ // 与 genAttributeDagView 中「仅 default(text-flow) 且选中可拖」一致:可拖时提示 grab
136
  #results.gen-attr-results-surface.LMF .gen-attr-dag-svg .gen-attr-dag-node--selected {
137
  cursor: grab;
138
  &:active {
 
140
  }
141
  }
142
 
143
+ // linear-arc 布局禁拖:不显示 grab(由 .gen-attr-dag-stack 上的 class 标记)
144
+ #results.gen-attr-results-surface.LMF
145
+ .gen-attr-dag-stack.gen-attr-dag-linear-arc-layout
146
+ .gen-attr-dag-svg
147
+ .gen-attr-dag-node--selected {
148
+ cursor: default;
149
+ &:active {
150
+ cursor: default;
151
+ }
152
+ }
153
+
154
  // 默认 width:100%(随容器)仅作回退;genAttributeDagView 会在 init 与 `setMeasureWidthPx`
155
  // 时以 inline style 写入固定像素宽度。测量层宽度是节点几何(折行位置)的唯一输入,
156
  // 固定后 resize/全屏等容器变化不再影响节点布局,只影响 fit 的视口缩放/平移。
 
280
  opacity: 1;
281
  }
282
 
283
+ // 紧凑数字输入(Max tokens、DAG Text width / compactness / 回放时长等共用)
284
+ .gen-attr-max-tokens-input,
285
+ .gen-attr-dag-measure-width-input {
286
+ box-sizing: border-box;
287
+ flex: 0 0 auto;
288
+ min-width: 0;
289
  padding: 3px 6px;
290
  font-size: inherit;
291
  border: 1px solid var(--input-border);
 
293
  background: var(--input-bg);
294
  color: var(--text-color);
295
  text-align: right;
296
+ // 沿用原先约 4.5–4.75rem 目标,但以 border-box 计满宽(先前 content-box + padding/border 会额外变宽)
297
+ inline-size: 4rem;
298
+ max-inline-size: 100%;
299
  }
300
 
301
  // 与 Chat 共用的 .generation-status-slot 见 _generation-status.scss
 
345
  }
346
  }
347
 
348
+ // DAG 参数行(Text width / compactness 等);允许换行,避免单行被撑得过宽
349
  .gen-attr-dag-measure-width-row {
350
  margin-top: 8px;
351
+ flex-wrap: wrap;
352
  }
353
 
354
+ .gen-attr-dag-measure-width-row .gen-attr-dag-layout-mode-group {
355
+ margin-right: 12px;
 
 
 
 
 
 
 
356
  }
357
 
358
  .gen-attr-dag-replay-speed-row {
 
362
  gap: 6px 10px;
363
  }
364
 
365
+ // DAG 两行下拉:仅按英文选项收合宽度(不专门为译文留宽)。
366
+ .semantic-submode-row select.gen-attr-dag-layout-mode-select,
367
+ .semantic-submode-row select.gen-attr-dag-replay-mode-select {
368
+ width: fit-content;
369
+ min-width: unset;
370
+ max-width: none;
371
  text-align: left;
372
  }
373
 
client/src/css/home.scss CHANGED
@@ -88,13 +88,24 @@ html[data-theme='dark'] body.nav-landing-page {
88
  line-height: var(--nav-landing-subtitle-line-height);
89
  }
90
 
 
 
 
 
 
 
 
 
 
 
91
  .nav-landing-hint {
92
- margin: 2rem 0 0;
93
  text-align: center;
94
  font-size: 0.8125rem;
 
95
  line-height: 1.45;
96
  color: var(--text-muted, #666);
97
- opacity: 0.82;
98
  letter-spacing: 0.02em;
99
  }
100
 
 
88
  line-height: var(--nav-landing-subtitle-line-height);
89
  }
90
 
91
+ .nav-landing-heartline {
92
+ margin: 2rem 0 0.35rem;
93
+ text-align: center;
94
+ font-size: 0.875rem;
95
+ line-height: 1.45;
96
+ color: var(--text-color, #1a1a1a);
97
+ opacity: 0.88;
98
+ letter-spacing: 0.02em;
99
+ }
100
+
101
  .nav-landing-hint {
102
+ margin: 0;
103
  text-align: center;
104
  font-size: 0.8125rem;
105
+ font-style: italic;
106
  line-height: 1.45;
107
  color: var(--text-muted, #666);
108
+ opacity: 0.72;
109
  letter-spacing: 0.02em;
110
  }
111
 
client/src/gen_attribute.html CHANGED
@@ -190,6 +190,16 @@
190
  data-i18n="placeholder,title"></textarea>
191
  </div>
192
 
 
 
 
 
 
 
 
 
 
 
193
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
194
  <span class="semantic-submode-group">
195
  <label class="semantic-submode-label">
@@ -202,6 +212,32 @@
202
  </div>
203
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
204
  <span class="semantic-submode-group">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  <label class="semantic-submode-label" for="gen_attr_dag_measure_width">Text width</label>
206
  <input type="number" id="gen_attr_dag_measure_width" class="gen-attr-dag-measure-width-input"
207
  value="500" min="200" max="4000" step="10"
@@ -209,15 +245,24 @@
209
  data-i18n="title">
210
  <span class="semantic-submode-label">px</span>
211
  </span>
 
 
 
 
 
 
 
 
212
  </div>
213
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
214
  <span class="semantic-submode-group gen-attr-dag-replay-speed-row">
215
  <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
216
- <select id="gen_attr_dag_replay_mode" class="gen-attr-dag-replay-mode-select gen-attr-dag-measure-width-input"
 
217
  title="Total duration vs. fixed delay per step. Waits shrink if a step is slow; long idle realigns the beat."
218
  data-i18n="title">
219
- <option value="total" data-i18n>Total time</option>
220
- <option value="step" data-i18n>Step time</option>
221
  </select>
222
  <span id="gen_attr_dag_replay_total_wrap" class="gen-attr-dag-replay-value-wrap">
223
  <input type="number" id="gen_attr_dag_playback_total_s" class="gen-attr-dag-measure-width-input"
 
190
  data-i18n="placeholder,title"></textarea>
191
  </div>
192
 
193
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
194
+ <span class="semantic-submode-group">
195
+ <label class="semantic-submode-label">
196
+ <input type="checkbox" id="gen_attr_dag_hide_excluded_tokens"
197
+ title="When checked, tokens fully covered by an exclude pattern are hidden from the DAG (linear-arc: also excluded from layout)."
198
+ data-i18n="title">
199
+ <span data-i18n>Hide excluded tokens</span>
200
+ </label>
201
+ </span>
202
+ </div>
203
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
204
  <span class="semantic-submode-group">
205
  <label class="semantic-submode-label">
 
212
  </div>
213
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
214
  <span class="semantic-submode-group">
215
+ <label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Edge top-p coverage</label>
216
+ <input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
217
+ value="0.7" min="0.05" max="1" step="0.05"
218
+ 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."
219
+ data-i18n="title">
220
+ </span>
221
+ </div>
222
+ <div class="gen-attr-dag-measure-width-row semantic-submode-row">
223
+ <span class="semantic-submode-group gen-attr-dag-layout-mode-group">
224
+ <label class="semantic-submode-label" for="gen_attr_dag_layout_mode">DAG layout mode</label>
225
+ <select id="gen_attr_dag_layout_mode"
226
+ class="semantic-submode-select gen-attr-dag-layout-mode-select"
227
+ title="Choose DAG layout mode. 'text-flow' follows text layout geometry; 'linear-arc' uses fixed-order linear nodes with arc links."
228
+ data-i18n="title">
229
+ <option value="text-flow">text-flow</option>
230
+ <option value="linear-arc">linear-arc</option>
231
+ </select>
232
+ </span>
233
+ <span class="semantic-submode-group" id="gen_attr_dag_compactness_group">
234
+ <label class="semantic-submode-label" for="gen_attr_dag_compactness" data-i18n>Compactness</label>
235
+ <input type="number" id="gen_attr_dag_compactness" class="gen-attr-dag-measure-width-input"
236
+ value="0.5" min="0.05" max="1" step="0.05"
237
+ title="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."
238
+ data-i18n="title">
239
+ </span>
240
+ <span class="semantic-submode-group" id="gen_attr_dag_measure_width_group">
241
  <label class="semantic-submode-label" for="gen_attr_dag_measure_width">Text width</label>
242
  <input type="number" id="gen_attr_dag_measure_width" class="gen-attr-dag-measure-width-input"
243
  value="500" min="200" max="4000" step="10"
 
245
  data-i18n="title">
246
  <span class="semantic-submode-label">px</span>
247
  </span>
248
+ <span class="semantic-submode-group" id="gen_attr_dag_linear_arc_interval_group" hidden>
249
+ <label class="semantic-submode-label" for="gen_attr_dag_linear_arc_interval" data-i18n>Token distance</label>
250
+ <input type="number" id="gen_attr_dag_linear_arc_interval" class="gen-attr-dag-measure-width-input"
251
+ value="0" min="0" max="400" step="1"
252
+ 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."
253
+ data-i18n="title">
254
+ <span class="semantic-submode-label">px</span>
255
+ </span>
256
  </div>
257
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
258
  <span class="semantic-submode-group gen-attr-dag-replay-speed-row">
259
  <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
260
+ <select id="gen_attr_dag_replay_mode"
261
+ class="semantic-submode-select gen-attr-dag-replay-mode-select"
262
  title="Total duration vs. fixed delay per step. Waits shrink if a step is slow; long idle realigns the beat."
263
  data-i18n="title">
264
+ <option value="total" data-i18n>total time</option>
265
+ <option value="step" data-i18n>step time</option>
266
  </select>
267
  <span id="gen_attr_dag_replay_total_wrap" class="gen-attr-dag-replay-value-wrap">
268
  <input type="number" id="gen_attr_dag_playback_total_s" class="gen-attr-dag-measure-width-input"
client/src/index.html CHANGED
@@ -33,6 +33,7 @@
33
  <a class="nav-landing-card nav-landing-card--compact" href="attribution.html" target="_blank" rel="noopener" data-nav-page="attribution" data-i18n="title"></a>
34
  <a class="nav-landing-card nav-landing-card--compact" href="chat.html" target="_blank" rel="noopener" data-nav-page="chat" data-i18n="title"></a>
35
  </nav>
 
36
  <p class="nav-landing-hint" data-page-formula data-i18n></p>
37
  </main>
38
 
 
33
  <a class="nav-landing-card nav-landing-card--compact" href="attribution.html" target="_blank" rel="noopener" data-nav-page="attribution" data-i18n="title"></a>
34
  <a class="nav-landing-card nav-landing-card--compact" href="chat.html" target="_blank" rel="noopener" data-nav-page="chat" data-i18n="title"></a>
35
  </nav>
36
+ <p class="nav-landing-heartline" data-page-heartline data-i18n></p>
37
  <p class="nav-landing-hint" data-page-formula data-i18n></p>
38
  </main>
39
 
client/src/scripts/injectPageMetaIntoHtml.js CHANGED
@@ -50,7 +50,7 @@ function injectDataPageBlock(html, attrToken, text) {
50
  /**
51
  * @param {string} html
52
  * @param {string} pageKey
53
- * @param {{ pages: Record<string, { title: string, subtitle: string, href?: string, formula?: string }>, navPageKeys: string[] }} doc
54
  * @returns {string}
55
  */
56
  function injectPageMeta(html, pageKey, doc) {
@@ -65,6 +65,13 @@ function injectPageMeta(html, pageKey, doc) {
65
  html = injectDataPageBlock(html, 'data-page-title', meta.title);
66
  html = injectDataPageBlock(html, 'data-page-subtitle', meta.subtitle);
67
 
 
 
 
 
 
 
 
68
  const formulaElRe = /<([a-z][a-z0-9]*)([^>]*\bdata-page-formula\b[^>]*)>([\s\S]*?)<\/\1>/gi;
69
  if (meta.formula) {
70
  html = html.replace(formulaElRe, (_m, tag, attrs) => `<${tag}${attrs}>${escapeHtmlText(meta.formula)}</${tag}>`);
 
50
  /**
51
  * @param {string} html
52
  * @param {string} pageKey
53
+ * @param {{ pages: Record<string, { title: string, subtitle: string, href?: string, heartline?: string, formula?: string }>, navPageKeys: string[] }} doc
54
  * @returns {string}
55
  */
56
  function injectPageMeta(html, pageKey, doc) {
 
65
  html = injectDataPageBlock(html, 'data-page-title', meta.title);
66
  html = injectDataPageBlock(html, 'data-page-subtitle', meta.subtitle);
67
 
68
+ const heartlineElRe = /<([a-z][a-z0-9]*)([^>]*\bdata-page-heartline\b[^>]*)>([\s\S]*?)<\/\1>/gi;
69
+ if (meta.heartline) {
70
+ html = html.replace(heartlineElRe, (_m, tag, attrs) => `<${tag}${attrs}>${escapeHtmlText(meta.heartline)}</${tag}>`);
71
+ } else {
72
+ html = html.replace(heartlineElRe, '');
73
+ }
74
+
75
  const formulaElRe = /<([a-z][a-z0-9]*)([^>]*\bdata-page-formula\b[^>]*)>([\s\S]*?)<\/\1>/gi;
76
  if (meta.formula) {
77
  html = html.replace(formulaElRe, (_m, tag, attrs) => `<${tag}${attrs}>${escapeHtmlText(meta.formula)}</${tag}>`);
client/src/ts/attribution.ts CHANGED
@@ -10,6 +10,7 @@ import { initI18n, tr, trf } from './lang/i18n-lite';
10
  import { AdminManager } from './utils/adminManager';
11
  import { SettingsMenuManager } from './utils/settingsMenuManager';
12
  import { initChatPanelLayout } from './chat/chatPanelLayout';
 
13
  import { TextInputController } from './controllers/textInputController';
14
  import { initializeCommonApp } from './appInitializer';
15
  import { showAlertDialog } from './ui/dialog';
@@ -378,7 +379,7 @@ void runContentUrlHydrate({
378
  },
379
  });
380
 
381
- initChatPanelLayout();
382
 
383
  const themeManager = initThemeManager(
384
  {
 
10
  import { AdminManager } from './utils/adminManager';
11
  import { SettingsMenuManager } from './utils/settingsMenuManager';
12
  import { initChatPanelLayout } from './chat/chatPanelLayout';
13
+ import { PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION } from './utils/panelSplitStorage';
14
  import { TextInputController } from './controllers/textInputController';
15
  import { initializeCommonApp } from './appInitializer';
16
  import { showAlertDialog } from './ui/dialog';
 
379
  },
380
  });
381
 
382
+ initChatPanelLayout({ storageKey: PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION });
383
 
384
  const themeManager = initThemeManager(
385
  {
client/src/ts/attribution/genAttributeDagPreprocess.ts CHANGED
@@ -19,9 +19,17 @@ export type PromptTokenSpan = {
19
  /** 每步在 exclude 之后按 `score` 降序取前 N 条作为候选池,避免长上下文长尾稀释。 */
20
  // 经验值,最后能筛选出大概一半的归因数
21
  const DAG_EDGE_TOP_N = 10;
22
- // todo: 用户配置归因力度
23
- /** 候选池内累计份额阈值Top-P,用于保留主要解释力。 */
24
- const DAG_EDGE_CUMULATIVE_SHARE = 0.7;
 
 
 
 
 
 
 
 
25
  /** 候选池内相对最强条目的下限系数:池内 L1 份额小于该比例×首条份额时停止。 */
26
  // topShare 的线有最大的透明度,所以这里对应的是最小的透明度是最大透明度的比例
27
  const DAG_EDGE_RELATIVE_TOP_SHARE_FLOOR_BETA = 0.1;
@@ -57,11 +65,12 @@ function normalizeTopNPoolForDagSparse<T extends { score: number }>(tokens: T[])
57
  /**
58
  * 在候选池已按 `score` 降序、池内归一保持该顺序的前提下,按遍历顺序取前缀,直到:
59
  * - 池内 L1 份额小于 β×首条份额(分布形状截断),或
60
- * - 累计达到 {@link DAG_EDGE_CUMULATIVE_SHARE}候选池内 Top-P,非整步全量 token 的分母)。
61
  * (池内份额与 `score` 单调一致,无需再排序。)
62
  */
63
  function selectTokenAttributionByCumulativeShare<T extends { poolMassFrac: number }>(
64
  normalized: Array<T>,
 
65
  ): Array<T> {
66
  if (normalized.length === 0) return [];
67
 
@@ -81,7 +90,7 @@ function selectTokenAttributionByCumulativeShare<T extends { poolMassFrac: numbe
81
  }
82
  picked.push(t);
83
  cum += frac;
84
- if (cum >= DAG_EDGE_CUMULATIVE_SHARE) {
85
  break;
86
  }
87
  }
@@ -152,16 +161,17 @@ export function excludeNodeAggregatedEntries(
152
  });
153
  }
154
 
155
- /**
156
- * 预处理阶段 2(展示单元级,纯函数):Top-N 候选池 → 池内 max 归一 & L1 份额 → β 截断 & cumulative Top-P。
157
- * 输入为「按节点聚合后的条目」(带 `nodeId`);所有额外字段会透传到输出,
158
- * 输出 `score` 为池内 max 归一后的强度,`poolMassFrac` 为池内 L1 份额(供下游 `scoreShare`)。
159
- */
160
  export function phase2RankAndSparsify<T extends { score: number }>(
161
  entries: T[],
 
162
  ): Array<T & { score: number; rawScore: number; poolMassFrac: number }> {
163
  if (!entries.length) return [];
164
  const topNPool = selectTopNByScore(entries, DAG_EDGE_TOP_N);
165
  const normalized = normalizeTopNPoolForDagSparse(topNPool);
166
- return selectTokenAttributionByCumulativeShare(normalized);
 
 
 
 
167
  }
 
19
  /** 每步在 exclude 之后按 `score` 降序取前 N 条作为候选池,避免长上下文长尾稀释。 */
20
  // 经验值,最后能筛选出大概一半的归因数
21
  const DAG_EDGE_TOP_N = 10;
22
+
23
+ /** DAG 边 Top-P:候选池内累计份额默认上限{@link phase2RankAndSparsify})。 */
24
+ export const DAG_EDGE_TOP_P_COVERAGE_DEFAULT = 0.7;
25
+ const DAG_EDGE_TOP_P_COVERAGE_MIN = 0.05;
26
+ const DAG_EDGE_TOP_P_COVERAGE_MAX = 1;
27
+
28
+ export function clampDagEdgeTopPCoverage(n: number): number {
29
+ if (!Number.isFinite(n)) return DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
30
+ return Math.min(DAG_EDGE_TOP_P_COVERAGE_MAX, Math.max(DAG_EDGE_TOP_P_COVERAGE_MIN, n));
31
+ }
32
+
33
  /** 候选池内相对最强条目的下限系数:池内 L1 份额小于该比例×首条份额时停止。 */
34
  // topShare 的线有最大的透明度,所以这里对应的是最小的透明度是最大透明度的比例
35
  const DAG_EDGE_RELATIVE_TOP_SHARE_FLOOR_BETA = 0.1;
 
65
  /**
66
  * 在候选池已按 `score` 降序、池内归一保持该顺序的前提下,按遍历顺序取前缀,直到:
67
  * - 池内 L1 份额小于 β×首条份额(分布形状截断),或
68
+ * - 累计达到给定阈值(默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT}候选池内 Top-P,非整步全量 token 的分母)。
69
  * (池内份额与 `score` 单调一致,无需再排序。)
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
 
 
90
  }
91
  picked.push(t);
92
  cum += frac;
93
+ if (cum >= cumulativeShareThreshold) {
94
  break;
95
  }
96
  }
 
161
  });
162
  }
163
 
164
+ /** Top-N 候选池 → 池内归一 → β 截断与累计 Top-P;`cumulativeShare` 未传用 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT}。 */
 
 
 
 
165
  export function phase2RankAndSparsify<T extends { score: number }>(
166
  entries: T[],
167
+ options?: { cumulativeShare?: number },
168
  ): Array<T & { score: number; rawScore: number; poolMassFrac: number }> {
169
  if (!entries.length) return [];
170
  const topNPool = selectTopNByScore(entries, DAG_EDGE_TOP_N);
171
  const normalized = normalizeTopNPoolForDagSparse(topNPool);
172
+ const threshold =
173
+ options?.cumulativeShare !== undefined
174
+ ? clampDagEdgeTopPCoverage(options.cumulativeShare)
175
+ : DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
176
+ return selectTokenAttributionByCumulativeShare(normalized, threshold);
177
  }
client/src/ts/attribution/genAttributeDagTextMeasure.ts CHANGED
@@ -2,6 +2,7 @@ import type { FrontendAnalyzeResult, FrontendToken } from '../api/GLTR_API';
2
  import { TokenPositionCalculator } from '../vis/TokenPositionCalculator';
3
  import { ZERO_WIDTH_FRAGMENT_PLACEHOLDER_PX } from '../vis/types';
4
  import type { TokenFragmentRect } from '../vis/types';
 
5
  import type { PromptTokenSpan } from './genAttributeDagPreprocess';
6
 
7
  export type GenAttrDagTokenGeom = {
@@ -25,6 +26,27 @@ function fragmentsForToken(
25
  return parts;
26
  }
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  /** 纯换行 token 的零宽/占位 fragment 本身就是它的几何语义。 */
29
  function isLineBreakOnlyToken(raw: string): boolean {
30
  return /^[\n\r\u0085\u2028\u2029]+$/.test(raw);
@@ -65,10 +87,11 @@ function geomFromTokenFragments(frags: TokenFragmentRect[], raw: string): GenAtt
65
  const geomFrags = fragmentsForDagGeom(frags, raw);
66
  const first = geomFrags[0]!;
67
  const hFirst = Math.max(first.height, 1);
68
- const widthSum = Math.max(
69
- geomFrags.reduce((s, f) => s + widthForDagGeom(f), 0),
70
- 1
71
- );
 
72
  return {
73
  originX: first.x,
74
  originY: first.y,
 
2
  import { TokenPositionCalculator } from '../vis/TokenPositionCalculator';
3
  import { ZERO_WIDTH_FRAGMENT_PLACEHOLDER_PX } from '../vis/types';
4
  import type { TokenFragmentRect } from '../vis/types';
5
+ import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
6
  import type { PromptTokenSpan } from './genAttributeDagPreprocess';
7
 
8
  export type GenAttrDagTokenGeom = {
 
26
  return parts;
27
  }
28
 
29
+ /**
30
+ * raw 中含有在 visualizeSpecialChars 里会展开成更长标签的特殊字符
31
+ * (控制字符 / 全角空格等),此时 displayLabel 比 raw 宽,需要最小宽保底。
32
+ */
33
+ function hasExpandingSpecialChar(raw: string): boolean {
34
+ return /[\x00-\x1f\x7f\u0085\u2028\u2029\u3000]/.test(raw);
35
+ }
36
+
37
+ /**
38
+ * 估算 visualizeSpecialChars 后的标签宽度下限:
39
+ * 直接按「显示字符数 × 常数」估算,简单稳定。
40
+ */
41
+ function estimateExpandedLabelWidthFloorPx(raw: string): number {
42
+ const APPROX_CHAR_WIDTH_PX = 10;
43
+ const displayLabel = visualizeSpecialChars(raw, {
44
+ spaceDotExceptBeforeAsciiLetterOrNumber: true,
45
+ });
46
+ const displayLen = Array.from(displayLabel).length;
47
+ return Math.max(displayLen * APPROX_CHAR_WIDTH_PX, 1);
48
+ }
49
+
50
  /** 纯换行 token 的零宽/占位 fragment 本身就是它的几何语义。 */
51
  function isLineBreakOnlyToken(raw: string): boolean {
52
  return /^[\n\r\u0085\u2028\u2029]+$/.test(raw);
 
87
  const geomFrags = fragmentsForDagGeom(frags, raw);
88
  const first = geomFrags[0]!;
89
  const hFirst = Math.max(first.height, 1);
90
+ const geomWidthSum = geomFrags.reduce((s, f) => s + widthForDagGeom(f), 0);
91
+ const expandedFloor = hasExpandingSpecialChar(raw)
92
+ ? estimateExpandedLabelWidthFloorPx(raw)
93
+ : 1;
94
+ const widthSum = Math.max(geomWidthSum, expandedFloor);
95
  return {
96
  originX: first.x,
97
  originY: first.y,
client/src/ts/attribution/genAttributeDagView.ts CHANGED
@@ -3,7 +3,9 @@ import { DirectedGraph } from 'graphology';
3
  import type { D3Sel } from '../utils/Util';
4
  import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
5
  import {
 
6
  collectGenAttrDagExcludeIntervals,
 
7
  excludeNodeAggregatedEntries,
8
  phase2RankAndSparsify,
9
  type PromptTokenSpan,
@@ -23,11 +25,41 @@ import {
23
  detachDagPseudoFullscreenIfPresent,
24
  runDagFullscreenToggleWithPseudoWorkaround,
25
  } from './genAttributeDagFullscreenWorkaround';
 
 
 
 
 
 
 
 
 
26
  import { tr } from '../lang/i18n-lite';
27
 
28
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
29
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  /** 图中节点业务字段(与 graphology 节点 attributes 为同一对象) */
32
  type DagNodeAttrs = {
33
  id: string;
@@ -127,6 +159,8 @@ function stackLayoutViewportPx(stackEl: HTMLElement): { w: number; h: number } {
127
  /** 在「抵消 display-scale」的基准上再放大,作为 DAG 默认初始视图(d3 zoom 的 k) */
128
  const DAG_INITIAL_ZOOM_BOOST = 1.5;
129
 
 
 
130
  /** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-display-scale` 一致 */
131
  const CSS_VAR_DISPLAY_SCALE = '--gen-attr-dag-display-scale';
132
  /** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-link-stroke-width` 一致 */
@@ -153,6 +187,13 @@ const DISABLE_DAG_NODE_TOOLTIPS = true;
153
  */
154
  const LINK_END_INSET_PER_EM = 0.1;
155
 
 
 
 
 
 
 
 
156
  /** 每条边独立 marker 的 document id(节点 id 为 `start_end`,与另一节点组合唯一) */
157
  function dagLinkMarkerElementId(source: string, target: string): string {
158
  const s = source.replace(/[^0-9_]/g, '_');
@@ -219,7 +260,9 @@ export type GenAttributeDagHandle = {
219
  */
220
  reset(preserveUserViewport?: boolean): void;
221
  /**
222
- * zoom identity 后对 `rootG` 做 `getBBox()` 适配视口;空图走默认缩放;`k` 上限 `k₀`。
 
 
223
  * 若 `layoutDirty` 为真则 no-op(仅已执行的 `syncSvgSize` 生效,不改 pan/zoom),但 `force` 为真时仍
224
  * fit 并清 dirty(例如刷新按钮的强制适配)。
225
  */
@@ -235,6 +278,26 @@ export type GenAttributeDagHandle = {
235
  * 传 `null` 恢复样式表默认(`100%`,跟随容器)。
236
  */
237
  setMeasureWidthPx(widthPx: number | null): void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  /** 移除 DAG 栈与刷新按钮(离开页面时调用) */
239
  detach(): void;
240
  };
@@ -371,8 +434,12 @@ export type InitGenAttributeDagViewOptions = {
371
  /** 点击 DAG 刷新时:在内部先按需 `fitViewportToContent`、再 `reset` 之后调用,用于重放(视口沿用 fit 结果)。 */
372
  onDagRefresh?: () => void;
373
  /**
374
- * 写入 `.gen-attr-dag-stack` 的 `--gen-attr-dag-display-scale`(矩形与节点文字同缩放)。
375
- * 未设置时沿用样式表(`--gen-attr-dag-display-scale` `--gen-attr-dag-compactness` 同源)。
 
 
 
 
376
  */
377
  displayScale?: number;
378
  /**
@@ -381,6 +448,17 @@ export type InitGenAttributeDagViewOptions = {
381
  * 「resize 只 refit 旧几何、刷新才重测几何」的结构性不一致。未设置时沿用样式表 `100%`(跟随容器)。
382
  */
383
  measureWidthPx?: number;
 
 
 
 
 
 
 
 
 
 
 
384
  /** 进入/退出/切换全屏失败时(常见于移动端不支持元素全屏等)。不传则无提示。 */
385
  onFullscreenError?: (message: string) => void;
386
  };
@@ -392,6 +470,19 @@ export function initGenAttributeDagView(
392
  const onDagRefresh = options?.onDagRefresh;
393
  const onDagPlaybackToggle = options?.onDagPlaybackToggle;
394
  const onFullscreenError = options?.onFullscreenError;
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
  function reportFullscreenFailure(err: unknown): void {
397
  if (!onFullscreenError) return;
@@ -419,6 +510,11 @@ export function initGenAttributeDagView(
419
  clearNodeSelection: noop,
420
  setDagPlaybackPlaying: noop,
421
  setMeasureWidthPx: noop,
 
 
 
 
 
422
  detach: noop,
423
  };
424
  }
@@ -433,14 +529,22 @@ export function initGenAttributeDagView(
433
  const stack = resultsRoot.append('div').attr('class', 'gen-attr-dag-stack');
434
  const stackEl = stack.node() as HTMLElement;
435
 
436
- if (options?.displayScale !== undefined) {
437
- const s = options.displayScale;
438
- if (!Number.isFinite(s) || s <= 0) {
439
- throw new Error('genAttributeDagView: displayScale must be a finite positive number');
440
- }
441
- stackEl.style.setProperty(CSS_VAR_DISPLAY_SCALE, String(s));
 
 
 
 
 
 
 
442
  }
443
 
 
444
  const measureRoot = stack
445
  .append('div')
446
  .attr('class', 'gen-attr-dag-measure-layer')
@@ -464,11 +568,26 @@ export function initGenAttributeDagView(
464
  const textMeasure = createGenAttributeDagTextMeasure(measureRoot);
465
 
466
  /**
467
- * 一次 session 内 `--gen-attr-dag-display-scale` 与测量层字号不外部未提供运行时改写入口
468
- * 故 init 时读一次即可,热路径不再触发 `getComputedStyle`(否则 `paint`/每步 `update` 都会强制样式计算)
469
  */
470
- const displayScale = readDisplayScaleFromCss(stackEl);
471
- const linkEndInsetPx = linkEndInsetBaseAtUnitScalePx(measureRoot) * displayScale;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
 
473
  const svg = stack.append('svg').attr('class', 'gen-attr-dag-svg');
474
 
@@ -553,72 +672,45 @@ export function initGenAttributeDagView(
553
  svg.attr('width', w).attr('height', h);
554
  }
555
 
556
- function nodeCenter(n: DagNode): { cx: number; cy: number } {
557
- return { cx: n.x + n.nodeW / 2, cy: n.y + n.nodeH / 2 };
558
- }
559
-
560
- /** 轴对齐矩形(半宽 hw、半高 hh)中心沿单位向量 (ux,uy) 到边界的距离 */
561
- function distCenterToRectEdgeAlongRay(hw: number, hh: number, ux: number, uy: number): number {
562
- const ax = Math.abs(ux);
563
- const ay = Math.abs(uy);
564
- let t = Infinity;
565
- if (ax > 1e-12) t = Math.min(t, hw / ax);
566
- if (ay > 1e-12) t = Math.min(t, hh / ay);
567
- return Number.isFinite(t) ? t : 0;
568
- }
569
-
570
- /**
571
- * 沿两节点中心连线画边:端点从各中心沿该线走到矩形边界,再可选向外 `outsideInset`。
572
- * 若边界交点越过彼此(重叠/极近),退化为两中心直连且不加外向留白。
573
- */
574
- function linkSegmentThroughNodeRects(
575
- src: DagNode,
576
- tgt: DagNode,
577
- outsideInset: number
578
- ): { x1: number; y1: number; x2: number; y2: number } {
579
- const a = nodeCenter(src);
580
- const b = nodeCenter(tgt);
581
- const dx = b.cx - a.cx;
582
- const dy = b.cy - a.cy;
583
- const L = Math.hypot(dx, dy);
584
- if (L < 1e-9) return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
585
- const ux = dx / L;
586
- const uy = dy / L;
587
- const tA = distCenterToRectEdgeAlongRay(src.nodeW / 2, src.nodeH / 2, ux, uy);
588
- const tB = distCenterToRectEdgeAlongRay(tgt.nodeW / 2, tgt.nodeH / 2, ux, uy);
589
- const eps = 1e-6;
590
- let g = outsideInset;
591
- if (tA + tB + 2 * g >= L - eps) g = 0;
592
- if (tA + tB + 2 * g >= L - eps) {
593
- return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
594
- }
595
- return {
596
- x1: a.cx + (tA + g) * ux,
597
- y1: a.cy + (tA + g) * uy,
598
- x2: b.cx - (tB + g) * ux,
599
- y2: b.cy - (tB + g) * uy,
600
- };
601
- }
602
-
603
  function paint(): void {
604
- linkSel.each(function(d) {
605
- const src = endpointNode(d.source, graph);
606
- const tgt = endpointNode(d.target, graph);
607
- const seg = linkSegmentThroughNodeRects(src, tgt, linkEndInsetPx);
608
- d3.select(this)
609
- .selectAll('line')
610
- .attr('x1', seg.x1)
611
- .attr('y1', seg.y1)
612
- .attr('x2', seg.x2)
613
- .attr('y2', seg.y2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  });
615
- nodeSel.attr('transform', (d) => `translate(${d.x},${d.y})`);
616
  }
617
 
618
  const drag = d3
619
  .drag<SVGGElement, DagNode>()
620
  // 与 d3 默认 filter 一致,并仅在「当前节点已单击选中」时允许拖动手势生效,减少误拖
621
- .filter((event, d) => !event.ctrlKey && !event.button && selectedId === d.id)
 
 
 
 
 
 
 
622
  .on('start', (event) => {
623
  event.sourceEvent?.stopPropagation();
624
  })
@@ -645,11 +737,15 @@ export function initGenAttributeDagView(
645
  nodeSel
646
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
647
  .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id)
 
 
 
 
648
  .attr('opacity', (d) => {
649
  if (selectedNbhd?.has(d.id)) return 1;
650
  if (hoveredNbhd?.has(d.id)) return 1;
651
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
652
- return DAG_NODE_HIDDEN_OPACITY;
653
  }
654
  const isPromptLeaf = d.step === -1 && graph.outDegree(d.id) === 0;
655
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
@@ -662,7 +758,7 @@ export function initGenAttributeDagView(
662
  const stroke =
663
  dagLinkHighlightStroke(graph, focusId, d) ?? `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`;
664
  const g = d3.select(this);
665
- g.select('line.gen-attr-dag-link-visible').attr('stroke', stroke).attr('stroke-opacity', op);
666
  linkMarkersDefs
667
  .select<SVGPathElement>(`#${dagLinkMarkerElementId(d.source, d.target)} path`)
668
  .attr('stroke', stroke)
@@ -704,18 +800,18 @@ export function initGenAttributeDagView(
704
  const m = enter
705
  .append('marker')
706
  .attr('id', (d) => dagLinkMarkerElementId(d.source, d.target))
707
- .attr('viewBox', '0 -5 10 10')
708
- .attr('refX', 8)
709
  .attr('refY', 0)
710
- .attr('markerWidth', 4)
711
- .attr('markerHeight', 4)
712
  .attr('orient', 'auto');
713
  m.append('path')
714
- .attr('d', 'M0,-5 L10,0 L0,5')
715
  .attr('fill', 'none')
716
  .attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
717
- .attr('stroke-width', `var(${CSS_VAR_DAG_LINK_STROKE_WIDTH})`)
718
- .attr('vector-effect', 'non-scaling-stroke')
719
  .attr('stroke-linecap', 'round')
720
  .attr('stroke-linejoin', 'round');
721
  return m;
@@ -730,8 +826,9 @@ export function initGenAttributeDagView(
730
  const el = d3.select(this);
731
  const mkId = dagLinkMarkerElementId(d.source, d.target);
732
  el.append('title').text(d.titleText);
733
- el.append('line')
734
  .attr('class', 'gen-attr-dag-link-visible')
 
735
  .attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
736
  .attr('stroke-width', `var(${CSS_VAR_DAG_LINK_STROKE_WIDTH})`)
737
  .attr('pointer-events', 'stroke')
@@ -914,7 +1011,7 @@ export function initGenAttributeDagView(
914
  targetToken: token,
915
  });
916
  const afterExclude = excludeNodeAggregatedEntries(step, aggregated, excludeIntervalContext);
917
- const selected = phase2RankAndSparsify(afterExclude);
918
 
919
  const massSum = selected.reduce((acc, t) => acc + Math.max(0, t.poolMassFrac), 0);
920
  for (const item of selected) {
@@ -998,18 +1095,34 @@ export function initGenAttributeDagView(
998
  applyInitialDagZoom();
999
  } else {
1000
  svg.call(zoomBehavior.transform, d3.zoomIdentity);
1001
- const b = rootG.node()!.getBBox();
1002
- const bw = Math.max(b.width, 1e-6);
1003
- const bh = Math.max(b.height, 1e-6);
1004
  const pad = 12;
1005
  const { w, h } = stackLayoutViewportPx(stackEl);
1006
  const innerW = Math.max(w - 2 * pad, 1);
1007
  const innerH = Math.max(h - 2 * pad, 1);
1008
- const kRaw = Math.min(innerW / bw, innerH / bh);
1009
- const k = Math.min(Number.isFinite(kRaw) && kRaw > 0 ? kRaw : k0, k0);
1010
- const tx = pad * 2 - k * b.x;
1011
- const ty = pad - k * b.y;
1012
- svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1013
  }
1014
  // 任何成功 fit(含 RO 自动 fit、refresh)都回到默认视图语义,下个 dirty 周期重新起算。
1015
  layoutDirty = false;
@@ -1052,6 +1165,37 @@ export function initGenAttributeDagView(
1052
  playBtn.text(playing ? '⏸' : '▶').attr('title', playing ? 'Pause' : 'Play');
1053
  }
1054
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1055
  const fullscreenBtn = resultsRoot
1056
  .append('button')
1057
  .attr('type', 'button')
@@ -1142,6 +1286,11 @@ export function initGenAttributeDagView(
1142
  clearNodeSelection,
1143
  setDagPlaybackPlaying,
1144
  setMeasureWidthPx,
 
 
 
 
 
1145
  detach,
1146
  };
1147
  }
 
3
  import type { D3Sel } from '../utils/Util';
4
  import { visualizeSpecialChars } from '../utils/tokenDisplayUtils';
5
  import {
6
+ clampDagEdgeTopPCoverage,
7
  collectGenAttrDagExcludeIntervals,
8
+ DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
9
  excludeNodeAggregatedEntries,
10
  phase2RankAndSparsify,
11
  type PromptTokenSpan,
 
25
  detachDagPseudoFullscreenIfPresent,
26
  runDagFullscreenToggleWithPseudoWorkaround,
27
  } from './genAttributeDagFullscreenWorkaround';
28
+ import {
29
+ clampLinearArcAdjacentGap,
30
+ LINEAR_ARC_ADJACENT_GAP_DEFAULT,
31
+ LINEAR_ARC_ADJACENT_GAP_MAX,
32
+ LINEAR_ARC_ADJACENT_GAP_MIN,
33
+ LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION,
34
+ paintLinearArcLayout,
35
+ } from './genAttributeDagViewLinearArcMode';
36
+ import { paintTextFlowLayout } from './genAttributeDagViewTextFlowMode';
37
  import { tr } from '../lang/i18n-lite';
38
 
39
  /** 再次挂载前执行上一轮 detach(当前为空操作,保留扩展点) */
40
  const detachGenAttributeDagPanel = new WeakMap<HTMLElement, () => void>();
41
 
42
+ /** 节点布局模式:`text-flow` 按文字排版层几何;`linear-arc` 按节点插入序线性排布 + 弧线连边。 */
43
+ export type DagLayoutMode = 'text-flow' | 'linear-arc';
44
+
45
+ export const DAG_COMPACTNESS_DEFAULT = 0.5;
46
+ /** 下限取小正数以满足 {@link readDisplayScaleFromCss}「必须为正」且不出现零宽度边线。 */
47
+ export const DAG_COMPACTNESS_MIN = 0.05;
48
+ export const DAG_COMPACTNESS_MAX = 1;
49
+
50
+ export function clampDagCompactness(n: number): number {
51
+ if (!Number.isFinite(n)) return DAG_COMPACTNESS_DEFAULT;
52
+ return Math.min(DAG_COMPACTNESS_MAX, Math.max(DAG_COMPACTNESS_MIN, n));
53
+ }
54
+
55
+ export {
56
+ clampLinearArcAdjacentGap,
57
+ LINEAR_ARC_ADJACENT_GAP_DEFAULT,
58
+ LINEAR_ARC_ADJACENT_GAP_MAX,
59
+ LINEAR_ARC_ADJACENT_GAP_MIN,
60
+ LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION,
61
+ };
62
+
63
  /** 图中节点业务字段(与 graphology 节点 attributes 为同一对象) */
64
  type DagNodeAttrs = {
65
  id: string;
 
159
  /** 在「抵消 display-scale」的基准上再放大,作为 DAG 默认初始视图(d3 zoom 的 k) */
160
  const DAG_INITIAL_ZOOM_BOOST = 1.5;
161
 
162
+ /** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-compactness` 一致(display-scale/link 线粗等同源派生) */
163
+ const CSS_VAR_DAG_COMPACTNESS = '--gen-attr-dag-compactness';
164
  /** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-display-scale` 一致 */
165
  const CSS_VAR_DISPLAY_SCALE = '--gen-attr-dag-display-scale';
166
  /** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-link-stroke-width` 一致 */
 
187
  */
188
  const LINK_END_INSET_PER_EM = 0.1;
189
 
190
+ /** 箭头 marker 的 viewBox 半高(viewBox = `0 -H W 2H`) */
191
+ const MARKER_HALF_H = 5;
192
+ /** 箭头 marker 的 viewBox 宽(同时是 path 尖端 x 坐标) */
193
+ const MARKER_VW = 10;
194
+ /** 箭头 marker 渲染尺寸(markerWidth = markerHeight,单位为 markerUnits=strokeWidth) */
195
+ const MARKER_SIZE = 4;
196
+
197
  /** 每条边独立 marker 的 document id(节点 id 为 `start_end`,与另一节点组合唯一) */
198
  function dagLinkMarkerElementId(source: string, target: string): string {
199
  const s = source.replace(/[^0-9_]/g, '_');
 
260
  */
261
  reset(preserveUserViewport?: boolean): void;
262
  /**
263
+ * zoom identity 后按内容适配视口;空图走默认缩放;`k` 上限 `k₀`。
264
+ * - `text-flow`:`rootG.getBBox()`(含边)等比落入内框。
265
+ * - `linear-arc`:仅按 `gen-attr-dag-nodes` 行宽定比,token 行相对内框竖直居中(弧不参与)。
266
  * 若 `layoutDirty` 为真则 no-op(仅已执行的 `syncSvgSize` 生效,不改 pan/zoom),但 `force` 为真时仍
267
  * fit 并清 dirty(例如刷新按钮的强制适配)。
268
  */
 
278
  * 传 `null` 恢复样式表默认(`100%`,跟随容器)。
279
  */
280
  setMeasureWidthPx(widthPx: number | null): void;
281
+ /** 切换 DAG 节点布局模式并立即重排现有节点/边。 */
282
+ setLayoutMode(mode: DagLayoutMode): void;
283
+ /**
284
+ * linear-arc 下相邻节点矩形外侧边的水平间隙(px)。仅影响 linear-arc 几何;若在生成/播放中途调用且
285
+ * `skipRefit` 为真,仅写入值,下一轮 `syncGraphToSvg`/空闲后再反映(与测量宽度语义一致)。
286
+ */
287
+ setLinearArcAdjacentGapPx(px: number, opts?: { skipRefit?: boolean }): void;
288
+ /**
289
+ * 写入 `--gen-attr-dag-compactness`(与样式表中 display-scale / 边线粗等同源派生)。
290
+ * 已有节点的 `nodeW`/`nodeH` 仍为建点时的缩放结果;调用方在需要一致几何时应 `reset` 后重放。
291
+ */
292
+ setDagCompactness(c: number): void;
293
+ /** 更新边 Top-P 覆盖阈值;要重算当前 DAG 须 reset 后重放。 */
294
+ setEdgeTopPCoverage(coverage: number): void;
295
+ /**
296
+ * 切换 excluded 节点的隐藏模式:
297
+ * - `true`:完全隐藏(`display:none`);linear-arc 下同时不参与布局。
298
+ * - `false`(默认):保留为低透明度({@link DAG_NODE_HIDDEN_OPACITY})占位。
299
+ */
300
+ setHideExcludedTokens(hide: boolean): void;
301
  /** 移除 DAG 栈与刷新按钮(离开页面时调用) */
302
  detach(): void;
303
  };
 
434
  /** 点击 DAG 刷新时:在内部先按需 `fitViewportToContent`、再 `reset` 之后调用,用于重放(视口沿用 fit 结果)。 */
435
  onDagRefresh?: () => void;
436
  /**
437
+ * 写入 `.gen-attr-dag-stack` 的 `--gen-attr-dag-compactness`(矩形与节点文字、边线粗等缩放基准)。
438
+ * 未设置时沿用样式表默认值 {@link DAG_COMPACTNESS_DEFAULT})。
439
+ */
440
+ dagCompactness?: number;
441
+ /**
442
+ * @deprecated 与 {@link dagCompactness} 同义;二者择一,若同时传入则抛错。
443
  */
444
  displayScale?: number;
445
  /**
 
448
  * 「resize 只 refit 旧几何、刷新才重测几何」的结构性不一致。未设置时沿用样式表 `100%`(跟随容器)。
449
  */
450
  measureWidthPx?: number;
451
+ /** DAG 节点布局模式;默认 `text-flow`。 */
452
+ layoutMode?: DagLayoutMode;
453
+ /**
454
+ * linear-arc:相邻节点矩形外侧边的水平间隙(px),决定水平方向疏密;
455
+ * 默认 {@link LINEAR_ARC_ADJACENT_GAP_DEFAULT}。
456
+ */
457
+ linearArcAdjacentGapPx?: number;
458
+ /** 被 exclude 规则命中的节点是否完全隐藏(true)还是仅降至 {@link DAG_NODE_HIDDEN_OPACITY}(false,默认)。 */
459
+ hideExcludedTokens?: boolean;
460
+ /** 边 Top-P 覆盖阈值(候选池内累计份额);默认 {@link DAG_EDGE_TOP_P_COVERAGE_DEFAULT}。 */
461
+ edgeTopPCoverage?: number;
462
  /** 进入/退出/切换全屏失败时(常见于移动端不支持元素全屏等)。不传则无提示。 */
463
  onFullscreenError?: (message: string) => void;
464
  };
 
470
  const onDagRefresh = options?.onDagRefresh;
471
  const onDagPlaybackToggle = options?.onDagPlaybackToggle;
472
  const onFullscreenError = options?.onFullscreenError;
473
+ let layoutMode: DagLayoutMode = options?.layoutMode ?? 'text-flow';
474
+ let linearArcAdjacentGapPx = LINEAR_ARC_ADJACENT_GAP_DEFAULT;
475
+ if (options?.linearArcAdjacentGapPx !== undefined) {
476
+ const iv = options.linearArcAdjacentGapPx;
477
+ if (!Number.isFinite(iv)) {
478
+ throw new Error('genAttributeDagView: linearArcAdjacentGapPx must be finite');
479
+ }
480
+ linearArcAdjacentGapPx = clampLinearArcAdjacentGap(iv);
481
+ }
482
+ let hideExcludedTokens: boolean = options?.hideExcludedTokens ?? false;
483
+ let edgeTopPCoverage = clampDagEdgeTopPCoverage(
484
+ options?.edgeTopPCoverage ?? DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
485
+ );
486
 
487
  function reportFullscreenFailure(err: unknown): void {
488
  if (!onFullscreenError) return;
 
510
  clearNodeSelection: noop,
511
  setDagPlaybackPlaying: noop,
512
  setMeasureWidthPx: noop,
513
+ setLayoutMode: noop,
514
+ setLinearArcAdjacentGapPx: noop,
515
+ setDagCompactness: noop,
516
+ setEdgeTopPCoverage: noop,
517
+ setHideExcludedTokens: noop,
518
  detach: noop,
519
  };
520
  }
 
529
  const stack = resultsRoot.append('div').attr('class', 'gen-attr-dag-stack');
530
  const stackEl = stack.node() as HTMLElement;
531
 
532
+ /** linear-arc 下禁用节点拖拽;同时用该类覆盖「选中 grab」光标。 */
533
+ function syncStackLayoutDragUi(): void {
534
+ stackEl.classList.toggle('gen-attr-dag-linear-arc-layout', layoutMode === 'linear-arc');
535
+ }
536
+ syncStackLayoutDragUi();
537
+
538
+ if (options?.dagCompactness !== undefined && options?.displayScale !== undefined) {
539
+ throw new Error('genAttributeDagView: pass only one of dagCompactness or displayScale');
540
+ }
541
+ const compactnessInit = options?.dagCompactness ?? options?.displayScale;
542
+ if (compactnessInit !== undefined) {
543
+ const c = clampDagCompactness(compactnessInit);
544
+ stackEl.style.setProperty(CSS_VAR_DAG_COMPACTNESS, String(c));
545
  }
546
 
547
+
548
  const measureRoot = stack
549
  .append('div')
550
  .attr('class', 'gen-attr-dag-measure-layer')
 
568
  const textMeasure = createGenAttributeDagTextMeasure(measureRoot);
569
 
570
  /**
571
+ * `--gen-attr-dag-display-scale` 一致;`setDagCompactness` 更新并同步 `linkEndInsetPx`
572
+ * 热路径不 `getComputedStyle`,仅在该 setter init 时刷新
573
  */
574
+ let displayScale = readDisplayScaleFromCss(stackEl);
575
+ let linkEndInsetPx = linkEndInsetBaseAtUnitScalePx(measureRoot) * displayScale;
576
+
577
+ function refreshDagScaleDerivedFromCss(): void {
578
+ displayScale = readDisplayScaleFromCss(stackEl);
579
+ linkEndInsetPx = linkEndInsetBaseAtUnitScalePx(measureRoot) * displayScale;
580
+ }
581
+
582
+ function setDagCompactness(c: number): void {
583
+ const v = clampDagCompactness(c);
584
+ stackEl.style.setProperty(CSS_VAR_DAG_COMPACTNESS, String(v));
585
+ refreshDagScaleDerivedFromCss();
586
+ }
587
+
588
+ function setEdgeTopPCoverage(coverage: number): void {
589
+ edgeTopPCoverage = clampDagEdgeTopPCoverage(coverage);
590
+ }
591
 
592
  const svg = stack.append('svg').attr('class', 'gen-attr-dag-svg');
593
 
 
672
  svg.attr('width', w).attr('height', h);
673
  }
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  function paint(): void {
676
+ if (layoutMode === 'linear-arc') {
677
+ const layoutNodes = hideExcludedTokens
678
+ ? nodes.filter((n) => !isOffsetSpanFullyExcluded(n.start, n.end, dagExcludeIntervals))
679
+ : nodes;
680
+ paintLinearArcLayout({
681
+ linkSel,
682
+ nodeSel,
683
+ nodes: layoutNodes,
684
+ adjacentGapPx: linearArcAdjacentGapPx,
685
+ getLinkNodes: (d) => ({
686
+ src: endpointNode(d.source, graph),
687
+ tgt: endpointNode(d.target, graph),
688
+ }),
689
+ });
690
+ return;
691
+ }
692
+ paintTextFlowLayout({
693
+ linkSel,
694
+ nodeSel,
695
+ linkEndInsetPx,
696
+ getLinkNodes: (d) => ({
697
+ src: endpointNode(d.source, graph),
698
+ tgt: endpointNode(d.target, graph),
699
+ }),
700
  });
 
701
  }
702
 
703
  const drag = d3
704
  .drag<SVGGElement, DagNode>()
705
  // 与 d3 默认 filter 一致,并仅在「当前节点已单击选中」时允许拖动手势生效,减少误拖
706
+ // text-flow(UI default)支持拖拽;linear-arc 下禁拖
707
+ .filter(
708
+ (event, d) =>
709
+ !event.ctrlKey &&
710
+ !event.button &&
711
+ selectedId === d.id &&
712
+ layoutMode === 'text-flow'
713
+ )
714
  .on('start', (event) => {
715
  event.sourceEvent?.stopPropagation();
716
  })
 
737
  nodeSel
738
  .classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
739
  .classed('gen-attr-dag-node--selected', (d) => selectedId === d.id)
740
+ .style('display', (d) =>
741
+ hideExcludedTokens && isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)
742
+ ? 'none' : null
743
+ )
744
  .attr('opacity', (d) => {
745
  if (selectedNbhd?.has(d.id)) return 1;
746
  if (hoveredNbhd?.has(d.id)) return 1;
747
  if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
748
+ return hideExcludedTokens ? 0 : DAG_NODE_HIDDEN_OPACITY;
749
  }
750
  const isPromptLeaf = d.step === -1 && graph.outDegree(d.id) === 0;
751
  if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
 
758
  const stroke =
759
  dagLinkHighlightStroke(graph, focusId, d) ?? `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`;
760
  const g = d3.select(this);
761
+ g.select('path.gen-attr-dag-link-visible').attr('stroke', stroke).attr('stroke-opacity', op);
762
  linkMarkersDefs
763
  .select<SVGPathElement>(`#${dagLinkMarkerElementId(d.source, d.target)} path`)
764
  .attr('stroke', stroke)
 
800
  const m = enter
801
  .append('marker')
802
  .attr('id', (d) => dagLinkMarkerElementId(d.source, d.target))
803
+ .attr('viewBox', `0 -${MARKER_HALF_H} ${MARKER_VW} ${MARKER_HALF_H * 2}`)
804
+ .attr('refX', MARKER_VW * 0.8)
805
  .attr('refY', 0)
806
+ .attr('markerWidth', MARKER_SIZE)
807
+ .attr('markerHeight', MARKER_SIZE)
808
  .attr('orient', 'auto');
809
  m.append('path')
810
+ .attr('d', `M0,-${MARKER_HALF_H} L${MARKER_VW},0 L0,${MARKER_HALF_H}`)
811
  .attr('fill', 'none')
812
  .attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
813
+ // markerUnits=strokeWidth 坐标系下,viewBox宽/marker尺寸 = 1× 线宽
814
+ .attr('stroke-width', MARKER_VW / MARKER_SIZE)
815
  .attr('stroke-linecap', 'round')
816
  .attr('stroke-linejoin', 'round');
817
  return m;
 
826
  const el = d3.select(this);
827
  const mkId = dagLinkMarkerElementId(d.source, d.target);
828
  el.append('title').text(d.titleText);
829
+ el.append('path')
830
  .attr('class', 'gen-attr-dag-link-visible')
831
+ .attr('fill', 'none')
832
  .attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
833
  .attr('stroke-width', `var(${CSS_VAR_DAG_LINK_STROKE_WIDTH})`)
834
  .attr('pointer-events', 'stroke')
 
1011
  targetToken: token,
1012
  });
1013
  const afterExclude = excludeNodeAggregatedEntries(step, aggregated, excludeIntervalContext);
1014
+ const selected = phase2RankAndSparsify(afterExclude, { cumulativeShare: edgeTopPCoverage });
1015
 
1016
  const massSum = selected.reduce((acc, t) => acc + Math.max(0, t.poolMassFrac), 0);
1017
  for (const item of selected) {
 
1095
  applyInitialDagZoom();
1096
  } else {
1097
  svg.call(zoomBehavior.transform, d3.zoomIdentity);
 
 
 
1098
  const pad = 12;
1099
  const { w, h } = stackLayoutViewportPx(stackEl);
1100
  const innerW = Math.max(w - 2 * pad, 1);
1101
  const innerH = Math.max(h - 2 * pad, 1);
1102
+ if (layoutMode === 'linear-arc') {
1103
+ /** 仅用 token 行宽度定比;竖直按行中心居中(弧不参与 bbox 不致上下抖) */
1104
+ const bn = nodeG.node()!.getBBox();
1105
+ const bw = Math.max(bn.width, 1e-6);
1106
+ const kRaw = innerW / bw;
1107
+ const k = Math.min(Number.isFinite(kRaw) && kRaw > 0 ? kRaw : k0, k0);
1108
+ const tx = pad * 2 - k * bn.x;
1109
+ const rowMidY = bn.y + bn.height / 2;
1110
+ const ty = pad + innerH / 2 - k * rowMidY;
1111
+ svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1112
+ } else if (layoutMode === 'text-flow') {
1113
+ /** 与原实现一致:`rootG` 整包 bbox + 宽高双约束顶对齐 */
1114
+ const b = rootG.node()!.getBBox();
1115
+ const bw = Math.max(b.width, 1e-6);
1116
+ const bh = Math.max(b.height, 1e-6);
1117
+ const kRaw = Math.min(innerW / bw, innerH / bh);
1118
+ const k = Math.min(Number.isFinite(kRaw) && kRaw > 0 ? kRaw : k0, k0);
1119
+ const tx = pad * 2 - k * b.x;
1120
+ const ty = pad - k * b.y;
1121
+ svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(k));
1122
+ } else {
1123
+ const _: never = layoutMode;
1124
+ throw new Error(`genAttributeDagView: unsupported layoutMode for fit (${String(_)})`);
1125
+ }
1126
  }
1127
  // 任何成功 fit(含 RO 自动 fit、refresh)都回到默认视图语义,下个 dirty 周期重新起算。
1128
  layoutDirty = false;
 
1165
  playBtn.text(playing ? '⏸' : '▶').attr('title', playing ? 'Pause' : 'Play');
1166
  }
1167
 
1168
+ function setLayoutMode(mode: DagLayoutMode): void {
1169
+ if (layoutMode === mode) return;
1170
+ layoutMode = mode;
1171
+ syncStackLayoutDragUi();
1172
+ if (batchDepth > 0) return;
1173
+ syncGraphToSvg();
1174
+ fitViewportToContent(true);
1175
+ }
1176
+
1177
+ function setLinearArcAdjacentGapPx(px: number, opts?: { skipRefit?: boolean }): void {
1178
+ if (!Number.isFinite(px)) {
1179
+ throw new Error('genAttributeDagView: linear arc adjacent node gap must be finite');
1180
+ }
1181
+ const next = clampLinearArcAdjacentGap(px);
1182
+ if (linearArcAdjacentGapPx === next) return;
1183
+ linearArcAdjacentGapPx = next;
1184
+ if (opts?.skipRefit || batchDepth > 0) return;
1185
+ if (layoutMode !== 'linear-arc' || nodes.length === 0) return;
1186
+ paint();
1187
+ fitViewportToContent(true);
1188
+ }
1189
+
1190
+ function setHideExcludedTokens(hide: boolean): void {
1191
+ if (hideExcludedTokens === hide) return;
1192
+ hideExcludedTokens = hide;
1193
+ if (batchDepth > 0 || nodes.length === 0) return;
1194
+ paint();
1195
+ refreshNodeLinkHighlight();
1196
+ fitViewportToContent(true);
1197
+ }
1198
+
1199
  const fullscreenBtn = resultsRoot
1200
  .append('button')
1201
  .attr('type', 'button')
 
1286
  clearNodeSelection,
1287
  setDagPlaybackPlaying,
1288
  setMeasureWidthPx,
1289
+ setLayoutMode,
1290
+ setLinearArcAdjacentGapPx,
1291
+ setDagCompactness,
1292
+ setEdgeTopPCoverage,
1293
+ setHideExcludedTokens,
1294
  detach,
1295
  };
1296
  }
client/src/ts/attribution/genAttributeDagViewLinearArcMode.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as d3 from 'd3';
2
+
3
+ /** linear-arc:相邻节点矩形水平方向「外侧边与边之间的空隙」(px,SVG 内部坐标) */
4
+ export const LINEAR_ARC_ADJACENT_GAP_DEFAULT = 0;
5
+ export const LINEAR_ARC_ADJACENT_GAP_MIN = 0;
6
+ export const LINEAR_ARC_ADJACENT_GAP_MAX = 400;
7
+
8
+ /** prompt→生成 首邻:在 `adjacentGapPx` 之上多出的水平空隙(节点已有 `step`,仅此一处判断) */
9
+ const LINEAR_ARC_PROMPT_GEN_EXTRA_GAP_PX = 12;
10
+
11
+ /**
12
+ * 连边的三次贝塞尔:P1/P2 沿水平边方向向对端内收,相对半跨距的比例,[0,1]。
13
+ * 0 表示控制点与端点同竖线(切线竖直向上);1 表示收到跨度中点(最圆)。
14
+ */
15
+ export const LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION = 0.25;
16
+
17
+ /** 首节点中心 x(与 translate(cx - w/2) 一致) */
18
+ const LINEAR_ARC_FIRST_CENTER_X = 20;
19
+ const LINEAR_ARC_BASELINE_Y = 0;
20
+
21
+ export function clampLinearArcAdjacentGap(px: number): number {
22
+ return Math.max(
23
+ LINEAR_ARC_ADJACENT_GAP_MIN,
24
+ Math.min(LINEAR_ARC_ADJACENT_GAP_MAX, Math.round(px))
25
+ );
26
+ }
27
+
28
+ type LinearArcNodeLike = { nodeW: number };
29
+
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;
36
+ xs.push(LINEAR_ARC_FIRST_CENTER_X);
37
+ for (let i = 1; i < nodes.length; i++) {
38
+ const prev = nodes[i - 1]!;
39
+ const curr = nodes[i]!;
40
+ const gap =
41
+ adjacentGapPx +
42
+ (prev.step === -1 && curr.step !== -1 ? LINEAR_ARC_PROMPT_GEN_EXTRA_GAP_PX : 0);
43
+ xs.push(xs[i - 1]! + prev.nodeW / 2 + gap + curr.nodeW / 2);
44
+ }
45
+ return xs;
46
+ }
47
+
48
+ /** linear-arc 模式:节点线性排布,边使用顶部向上弧线。
49
+ *
50
+ * `nodes` 为参与布局的可见节点子集(可能少于 `nodeSel` 绑定的全量节点);
51
+ * 不在 `nodes` 中的节点(如被隐藏的 excluded 节点)transform 保持不变——调用方已将它们设为 `display:none`。
52
+ */
53
+ export function paintLinearArcLayout<
54
+ LinkDatum,
55
+ NodeDatum extends LinearArcSteppedNode,
56
+ >(params: {
57
+ linkSel: d3.Selection<SVGGElement, LinkDatum, SVGGElement, unknown>;
58
+ nodeSel: d3.Selection<SVGGElement, NodeDatum, SVGGElement, unknown>;
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
+
67
+ // Map datum → centerX:支持 nodeSel 含超出 nodes 范围的节点(如被隐藏的节点)。
68
+ const centerXByNode = new Map<NodeDatum, number>();
69
+ for (let i = 0; i < nodes.length; i++) {
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
+ const y = LINEAR_ARC_BASELINE_Y;
80
+ const dx = Math.abs(tgtCx - srcCx);
81
+ const arcH = dx * 0.4;
82
+ const upY = y - arcH;
83
+ const t = Math.max(0, Math.min(1, LINEAR_ARC_BEZIER_HANDLE_INSET_FRACTION));
84
+ const inset = t * (dx / 2);
85
+ const dir = tgtCx >= srcCx ? 1 : -1;
86
+ const p1x = srcCx + dir * inset;
87
+ const p2x = tgtCx - dir * inset;
88
+ return `M ${srcCx} ${y} C ${p1x} ${upY}, ${p2x} ${upY}, ${tgtCx} ${y}`;
89
+ };
90
+
91
+ linkSel.each(function(d) {
92
+ const { src, tgt } = getLinkNodes(d);
93
+ d3.select(this)
94
+ .selectAll('path.gen-attr-dag-link-visible')
95
+ .attr('d', arcPathBetweenNodes(src, tgt));
96
+ });
97
+
98
+ nodeSel.attr('transform', (d) => {
99
+ const cx = centerXByNode.get(d);
100
+ if (cx === undefined) return null; // 不在布局列表中(已 display:none),不更新 transform
101
+ return `translate(${cx - d.nodeW / 2},${LINEAR_ARC_BASELINE_Y})`;
102
+ });
103
+ }
client/src/ts/attribution/genAttributeDagViewTextFlowMode.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as d3 from 'd3';
2
+
3
+ type TextFlowNodeLike = {
4
+ x: number;
5
+ y: number;
6
+ nodeW: number;
7
+ nodeH: number;
8
+ };
9
+
10
+ function nodeCenter(n: TextFlowNodeLike): { cx: number; cy: number } {
11
+ return { cx: n.x + n.nodeW / 2, cy: n.y + n.nodeH / 2 };
12
+ }
13
+
14
+ /** 轴对齐矩形(半宽 hw、半高 hh)中心沿单位向量 (ux,uy) 到边界的距离。 */
15
+ function distCenterToRectEdgeAlongRay(hw: number, hh: number, ux: number, uy: number): number {
16
+ const ax = Math.abs(ux);
17
+ const ay = Math.abs(uy);
18
+ let t = Infinity;
19
+ if (ax > 1e-12) t = Math.min(t, hw / ax);
20
+ if (ay > 1e-12) t = Math.min(t, hh / ay);
21
+ return Number.isFinite(t) ? t : 0;
22
+ }
23
+
24
+ /** text-flow 模式边几何:从两节点矩形边界连线,必要时退化为中心直连。 */
25
+ function linkSegmentThroughNodeRects(
26
+ src: TextFlowNodeLike,
27
+ tgt: TextFlowNodeLike,
28
+ outsideInset: number
29
+ ): { x1: number; y1: number; x2: number; y2: number } {
30
+ const a = nodeCenter(src);
31
+ const b = nodeCenter(tgt);
32
+ const dx = b.cx - a.cx;
33
+ const dy = b.cy - a.cy;
34
+ const L = Math.hypot(dx, dy);
35
+ if (L < 1e-9) return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
36
+ const ux = dx / L;
37
+ const uy = dy / L;
38
+ const tA = distCenterToRectEdgeAlongRay(src.nodeW / 2, src.nodeH / 2, ux, uy);
39
+ const tB = distCenterToRectEdgeAlongRay(tgt.nodeW / 2, tgt.nodeH / 2, ux, uy);
40
+ const eps = 1e-6;
41
+ let g = outsideInset;
42
+ if (tA + tB + 2 * g >= L - eps) g = 0;
43
+ if (tA + tB + 2 * g >= L - eps) {
44
+ return { x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy };
45
+ }
46
+ return {
47
+ x1: a.cx + (tA + g) * ux,
48
+ y1: a.cy + (tA + g) * uy,
49
+ x2: b.cx - (tB + g) * ux,
50
+ y2: b.cy - (tB + g) * uy,
51
+ };
52
+ }
53
+
54
+ /** text-flow 模式:节点使用测量层坐标,边按节点矩形几何连接。 */
55
+ export function paintTextFlowLayout<LinkDatum, NodeDatum extends TextFlowNodeLike>(params: {
56
+ linkSel: d3.Selection<SVGGElement, LinkDatum, SVGGElement, unknown>;
57
+ nodeSel: d3.Selection<SVGGElement, NodeDatum, SVGGElement, unknown>;
58
+ linkEndInsetPx: number;
59
+ getLinkNodes: (link: LinkDatum) => { src: NodeDatum; tgt: NodeDatum };
60
+ }): void {
61
+ const { linkSel, nodeSel, linkEndInsetPx, getLinkNodes } = params;
62
+ linkSel.each(function(d) {
63
+ const { src, tgt } = getLinkNodes(d);
64
+ const seg = linkSegmentThroughNodeRects(src, tgt, linkEndInsetPx);
65
+ d3.select(this)
66
+ .selectAll('path.gen-attr-dag-link-visible')
67
+ .attr('d', `M ${seg.x1} ${seg.y1} L ${seg.x2} ${seg.y2}`);
68
+ });
69
+ nodeSel.attr('transform', (d) => `translate(${d.x},${d.y})`);
70
+ }
client/src/ts/chat.ts CHANGED
@@ -10,6 +10,7 @@ import { AdminManager } from './utils/adminManager';
10
  import { SettingsMenuManager } from './utils/settingsMenuManager';
11
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from './utils/cachedHistoryUi';
12
  import { initChatPanelLayout } from './chat/chatPanelLayout';
 
13
  import { TextInputController } from './controllers/textInputController';
14
  import { initializeCommonApp } from './appInitializer';
15
  import { showAlertDialog } from './ui/dialog';
@@ -684,7 +685,7 @@ initQueryHistoryDropdown({
684
  applyHistoryOnHover: true
685
  });
686
 
687
- initChatPanelLayout();
688
 
689
  const chatCopyFulltextBtn = document.getElementById('chat_copy_fulltext_btn');
690
  if (chatCopyFulltextBtn) {
 
10
  import { SettingsMenuManager } from './utils/settingsMenuManager';
11
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from './utils/cachedHistoryUi';
12
  import { initChatPanelLayout } from './chat/chatPanelLayout';
13
+ import { PANEL_SPLIT_STORAGE_KEY_CHAT } from './utils/panelSplitStorage';
14
  import { TextInputController } from './controllers/textInputController';
15
  import { initializeCommonApp } from './appInitializer';
16
  import { showAlertDialog } from './ui/dialog';
 
685
  applyHistoryOnHover: true
686
  });
687
 
688
+ initChatPanelLayout({ storageKey: PANEL_SPLIT_STORAGE_KEY_CHAT });
689
 
690
  const chatCopyFulltextBtn = document.getElementById('chat_copy_fulltext_btn');
691
  if (chatCopyFulltextBtn) {
client/src/ts/chat/chatPanelLayout.ts CHANGED
@@ -1,17 +1,24 @@
1
  import * as d3 from 'd3';
2
  import { isNarrowScreen } from '../utils/responsive';
 
 
 
 
 
 
3
 
4
  /**
5
- * Chat 页专用:左右分栏拖拽与窗口尺寸同步,不含侧栏逻辑,不影响首页 LayoutController
6
  */
7
- export function initChatPanelLayout(): void {
8
  const resizer = d3.select('#resizer');
9
  const leftPanel = d3.select('.left_panel');
10
  if (resizer.empty() || leftPanel.empty()) {
11
  return;
12
  }
13
 
14
- let leftPanelRatio = 0.5;
 
15
  let isResizing = false;
16
  let startX = 0;
17
  let startWidth = 0;
@@ -86,6 +93,8 @@ export function initChatPanelLayout(): void {
86
  }
87
  isResizing = false;
88
 
 
 
89
  d3.select('body').style('cursor', null).style('user-select', null);
90
 
91
  d3.select(window)
 
1
  import * as d3 from 'd3';
2
  import { isNarrowScreen } from '../utils/responsive';
3
+ import { readPanelSplitRatio, writePanelSplitRatio } from '../utils/panelSplitStorage';
4
+
5
+ export type ChatPanelLayoutOptions = {
6
+ /** 各页面独立 key,用于 localStorage 持久化分栏比例 */
7
+ storageKey: string;
8
+ };
9
 
10
  /**
11
+ * Chat / 归因 / gen_attribute 等:左右分栏拖拽与窗口尺寸同步,不含侧栏逻辑。
12
  */
13
+ export function initChatPanelLayout(options: ChatPanelLayoutOptions): void {
14
  const resizer = d3.select('#resizer');
15
  const leftPanel = d3.select('.left_panel');
16
  if (resizer.empty() || leftPanel.empty()) {
17
  return;
18
  }
19
 
20
+ const { storageKey } = options;
21
+ let leftPanelRatio = readPanelSplitRatio(storageKey);
22
  let isResizing = false;
23
  let startX = 0;
24
  let startWidth = 0;
 
93
  }
94
  isResizing = false;
95
 
96
+ writePanelSplitRatio(storageKey, leftPanelRatio);
97
+
98
  d3.select('body').style('cursor', null).style('user-select', null);
99
 
100
  d3.select(window)
client/src/ts/controllers/layoutController.ts CHANGED
@@ -1,5 +1,6 @@
1
  import * as d3 from 'd3';
2
  import { isNarrowScreen } from '../utils/responsive';
 
3
 
4
  export type LayoutState = {
5
  sidebar: {
@@ -14,6 +15,8 @@ export type LayoutControllerOptions = {
14
  sidebarBtn: d3.Selection<any, unknown, any, any>;
15
  onSidebarToggle?: (visible: boolean) => void;
16
  onLayoutChange?: () => void;
 
 
17
  };
18
 
19
  export class LayoutController {
@@ -21,10 +24,14 @@ export class LayoutController {
21
  private isResizing = false;
22
  private startX = 0;
23
  private startWidth = 0;
24
- private leftPanelRatio = 0.5; // 左侧面板的比例,默认50%
25
 
26
  constructor(options: LayoutControllerOptions) {
27
  this.options = options;
 
 
 
 
28
  this.initialize();
29
  }
30
 
@@ -169,9 +176,14 @@ export class LayoutController {
169
 
170
  private handleMouseUp(): void {
171
  if (!this.isResizing) return;
172
-
173
  this.isResizing = false;
174
-
 
 
 
 
 
175
  d3.select('body')
176
  .style('cursor', 'default')
177
  .style('user-select', 'auto');
 
1
  import * as d3 from 'd3';
2
  import { isNarrowScreen } from '../utils/responsive';
3
+ import { readPanelSplitRatio, writePanelSplitRatio } from '../utils/panelSplitStorage';
4
 
5
  export type LayoutState = {
6
  sidebar: {
 
15
  sidebarBtn: d3.Selection<any, unknown, any, any>;
16
  onSidebarToggle?: (visible: boolean) => void;
17
  onLayoutChange?: () => void;
18
+ /** 若设置,则从 localStorage 恢复分栏比例,并在用户拖动分割条结束后写回 */
19
+ panelSplitStorageKey?: string;
20
  };
21
 
22
  export class LayoutController {
 
24
  private isResizing = false;
25
  private startX = 0;
26
  private startWidth = 0;
27
+ private leftPanelRatio = 0.5;
28
 
29
  constructor(options: LayoutControllerOptions) {
30
  this.options = options;
31
+ const sk = options.panelSplitStorageKey;
32
+ if (sk) {
33
+ this.leftPanelRatio = readPanelSplitRatio(sk);
34
+ }
35
  this.initialize();
36
  }
37
 
 
176
 
177
  private handleMouseUp(): void {
178
  if (!this.isResizing) return;
179
+
180
  this.isResizing = false;
181
+
182
+ const sk = this.options.panelSplitStorageKey;
183
+ if (sk) {
184
+ writePanelSplitRatio(sk, this.leftPanelRatio);
185
+ }
186
+
187
  d3.select('body')
188
  .style('cursor', 'default')
189
  .style('user-select', 'auto');
client/src/ts/gen_attribute.ts CHANGED
@@ -10,14 +10,26 @@ import { initI18n, tr } from './lang/i18n-lite';
10
  import { AdminManager } from './utils/adminManager';
11
  import { SettingsMenuManager } from './utils/settingsMenuManager';
12
  import { initChatPanelLayout } from './chat/chatPanelLayout';
 
13
  import { TextInputController } from './controllers/textInputController';
14
  import { initializeCommonApp } from './appInitializer';
15
  import { showAlertDialog } from './ui/dialog';
16
  import URLHandler from './utils/URLHandler';
17
  import { createToast } from './ui/toast';
18
  import type { PredictionAttributeModelVariant } from './attribution/attributionResultCache';
19
- import { extractPromptTokenSpans } from './attribution/genAttributeDagPreprocess';
20
- import { initGenAttributeDagView } from './attribution/genAttributeDagView';
 
 
 
 
 
 
 
 
 
 
 
21
  import {
22
  createHydratedTokenGenHandle,
23
  startTokenGenAttribution,
@@ -77,10 +89,16 @@ const GEN_ATTR_MODEL_VARIANT_STORAGE_KEY = 'info_radar_gen_attr_model_variant';
77
  const GEN_ATTR_MAX_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_max_tokens';
78
  const GEN_ATTR_MAX_TOKENS_DEFAULT = 100;
79
  const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
 
80
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
81
  const GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_replay_pacing_mode';
82
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_total_s';
83
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
 
 
 
 
 
84
 
85
  /** 步进回放节奏:`total`=整段剩余回放总时长内均分间隔;`step`=固定每步间隔(ms)。 */
86
  type DagReplayPacingMode = 'total' | 'step';
@@ -139,6 +157,39 @@ function readStoredDagMeasureWidth(): number {
139
  return GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT;
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  function clampDagPlaybackStepMs(n: number): number {
143
  return Math.max(
144
  GEN_ATTR_DAG_PLAYBACK_STEP_MS_MIN,
@@ -185,6 +236,16 @@ function readStoredDagReplayPacingMode(): DagReplayPacingMode {
185
  return 'total';
186
  }
187
 
 
 
 
 
 
 
 
 
 
 
188
  const apiPrefix = URLHandler.parameters['api'] || '';
189
  const bodyElement = d3.select('body').node() as Element;
190
  const { totalSurprisalFormat, api } = initializeCommonApp(apiPrefix, bodyElement);
@@ -237,9 +298,22 @@ const genAttrResultsEl = d3.select('#results.gen-attr-results-surface');
237
 
238
  const modelVariantSelect = document.getElementById('gen_attr_model_variant') as HTMLSelectElement | null;
239
  const maxTokensInput = document.getElementById('gen_attr_max_tokens') as HTMLInputElement | null;
 
 
 
240
  const dagMeasureWidthInput = document.getElementById(
241
  'gen_attr_dag_measure_width'
242
  ) as HTMLInputElement | null;
 
 
 
 
 
 
 
 
 
 
243
  /** 步进回放:固定间隔(ms)或总时长(s),由 {@link DagReplayPacingMode} 选择。 */
244
  const dagPlaybackStepMsInput = document.getElementById(
245
  'gen_attr_dag_playback_step_ms'
@@ -265,6 +339,26 @@ function applyDagReplaySpeedUi(): void {
265
  if (dagReplayStepWrap) dagReplayStepWrap.hidden = mode !== 'step';
266
  }
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  const dagHideInactiveEdgesInput = document.getElementById(
269
  'gen_attr_dag_hide_inactive_edges'
270
  ) as HTMLInputElement | null;
@@ -272,8 +366,17 @@ const completeReasonEl = d3.select('#gen_attr_complete_reason');
272
 
273
  if (modelVariantSelect) modelVariantSelect.value = readStoredModelVariant();
274
  if (maxTokensInput) maxTokensInput.value = String(readStoredMaxTokens());
 
 
 
275
  const initialDagMeasureWidth = readStoredDagMeasureWidth();
276
  if (dagMeasureWidthInput) dagMeasureWidthInput.value = String(initialDagMeasureWidth);
 
 
 
 
 
 
277
 
278
  // DAG 回放节奏:步长 / 总时长 / 模式下拉 — 自 localStorage 恢复后再同步展示哪块输入
279
  const initialDagPlaybackStepMs = readStoredDagPlaybackStepMs();
@@ -309,6 +412,25 @@ dagHideInactiveEdgesInput?.addEventListener('change', () => {
309
  applyDagHideInactiveEdges(hide);
310
  });
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  modelVariantSelect?.addEventListener('change', () => {
313
  try {
314
  localStorage.setItem(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, currentModelVariant());
@@ -635,10 +757,26 @@ const dagHandle = initGenAttributeDagView(d3.select('#results'), {
635
  if (!h) return;
636
  replayRunnerStepsIntoDag(h);
637
  },
 
638
  measureWidthPx: initialDagMeasureWidth,
 
 
 
 
639
  onFullscreenError: (message) => showToast(message, 'error'),
640
  });
641
 
 
 
 
 
 
 
 
 
 
 
 
642
  /**
643
  * DAG 是否处于「不方便」状态:流式生成中或 DAG 播放中(含末 token dwell)。
644
  * 这些状态下改测量宽度只更新设置、不触发重绘,避免打断正在进行的流程/定时器状态机;
@@ -669,6 +807,63 @@ dagMeasureWidthInput?.addEventListener('change', () => {
669
  dagHandle.fitViewportToContent();
670
  dagHandle.clearNodeSelection();
671
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
  window.addEventListener('pagehide', (ev) => {
673
  if (ev.persisted) return;
674
  dagHandle.detach();
@@ -1296,4 +1491,4 @@ exportDemoBtn?.addEventListener('click', () => {
1296
  void exportJsonFile(payload, `genattr-${Date.now()}.json`);
1297
  });
1298
 
1299
- initChatPanelLayout();
 
10
  import { AdminManager } from './utils/adminManager';
11
  import { SettingsMenuManager } from './utils/settingsMenuManager';
12
  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';
19
  import type { PredictionAttributeModelVariant } from './attribution/attributionResultCache';
20
+ import {
21
+ clampDagEdgeTopPCoverage,
22
+ DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
23
+ extractPromptTokenSpans,
24
+ } from './attribution/genAttributeDagPreprocess';
25
+ import {
26
+ initGenAttributeDagView,
27
+ type DagLayoutMode,
28
+ clampDagCompactness,
29
+ clampLinearArcAdjacentGap,
30
+ DAG_COMPACTNESS_DEFAULT,
31
+ LINEAR_ARC_ADJACENT_GAP_DEFAULT,
32
+ } from './attribution/genAttributeDagView';
33
  import {
34
  createHydratedTokenGenHandle,
35
  startTokenGenAttribution,
 
89
  const GEN_ATTR_MAX_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_max_tokens';
90
  const GEN_ATTR_MAX_TOKENS_DEFAULT = 100;
91
  const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
92
+ const GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_layout_mode';
93
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
94
  const GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_replay_pacing_mode';
95
  const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_total_s';
96
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
97
+ const GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_excluded_tokens';
98
+ const GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY =
99
+ 'info_radar_gen_attr_dag_linear_arc_adjacent_gap';
100
+ const GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY = 'info_radar_gen_attr_dag_compactness';
101
+ const GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY = 'info_radar_gen_attr_dag_edge_top_p_coverage';
102
 
103
  /** 步进回放节奏:`total`=整段剩余回放总时长内均分间隔;`step`=固定每步间隔(ms)。 */
104
  type DagReplayPacingMode = 'total' | 'step';
 
157
  return GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT;
158
  }
159
 
160
+ function readStoredDagCompactness(): number {
161
+ try {
162
+ const v = localStorage.getItem(GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY);
163
+ const n = v !== null ? parseFloat(v) : NaN;
164
+ if (Number.isFinite(n)) return clampDagCompactness(n);
165
+ } catch {
166
+ // ignore
167
+ }
168
+ return DAG_COMPACTNESS_DEFAULT;
169
+ }
170
+
171
+ function readStoredDagEdgeTopPCoverage(): number {
172
+ try {
173
+ const v = localStorage.getItem(GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY);
174
+ const n = v !== null ? parseFloat(v) : NaN;
175
+ if (Number.isFinite(n)) return clampDagEdgeTopPCoverage(n);
176
+ } catch {
177
+ // ignore
178
+ }
179
+ return DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
180
+ }
181
+
182
+ function readStoredDagLinearArcAdjacentGap(): number {
183
+ try {
184
+ const v = localStorage.getItem(GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY);
185
+ const n = v !== null ? parseInt(v, 10) : NaN;
186
+ if (Number.isFinite(n)) return clampLinearArcAdjacentGap(n);
187
+ } catch {
188
+ // ignore
189
+ }
190
+ return LINEAR_ARC_ADJACENT_GAP_DEFAULT;
191
+ }
192
+
193
  function clampDagPlaybackStepMs(n: number): number {
194
  return Math.max(
195
  GEN_ATTR_DAG_PLAYBACK_STEP_MS_MIN,
 
236
  return 'total';
237
  }
238
 
239
+ function readStoredDagLayoutMode(): DagLayoutMode {
240
+ try {
241
+ const v = localStorage.getItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY);
242
+ if (v === 'text-flow' || v === 'linear-arc') return v;
243
+ } catch {
244
+ // ignore
245
+ }
246
+ return 'text-flow';
247
+ }
248
+
249
  const apiPrefix = URLHandler.parameters['api'] || '';
250
  const bodyElement = d3.select('body').node() as Element;
251
  const { totalSurprisalFormat, api } = initializeCommonApp(apiPrefix, bodyElement);
 
298
 
299
  const modelVariantSelect = document.getElementById('gen_attr_model_variant') as HTMLSelectElement | null;
300
  const maxTokensInput = document.getElementById('gen_attr_max_tokens') as HTMLInputElement | null;
301
+ const dagLayoutModeSelect = document.getElementById('gen_attr_dag_layout_mode') as HTMLSelectElement | null;
302
+ const dagMeasureWidthGroup = document.getElementById('gen_attr_dag_measure_width_group');
303
+ const dagCompactnessGroup = document.getElementById('gen_attr_dag_compactness_group');
304
  const dagMeasureWidthInput = document.getElementById(
305
  'gen_attr_dag_measure_width'
306
  ) as HTMLInputElement | null;
307
+ const dagLinearArcIntervalGroup = document.getElementById('gen_attr_dag_linear_arc_interval_group');
308
+ const dagLinearArcIntervalInput = document.getElementById(
309
+ 'gen_attr_dag_linear_arc_interval'
310
+ ) as HTMLInputElement | null;
311
+ const dagCompactnessInput = document.getElementById(
312
+ 'gen_attr_dag_compactness'
313
+ ) as HTMLInputElement | null;
314
+ const dagEdgeTopPCoverageInput = document.getElementById(
315
+ 'gen_attr_dag_edge_top_p_coverage'
316
+ ) as HTMLInputElement | null;
317
  /** 步进回放:固定间隔(ms)或总时长(s),由 {@link DagReplayPacingMode} 选择。 */
318
  const dagPlaybackStepMsInput = document.getElementById(
319
  'gen_attr_dag_playback_step_ms'
 
339
  if (dagReplayStepWrap) dagReplayStepWrap.hidden = mode !== 'step';
340
  }
341
 
342
+ function currentDagLayoutMode(): DagLayoutMode {
343
+ return dagLayoutModeSelect?.value === 'linear-arc' ? 'linear-arc' : 'text-flow';
344
+ }
345
+
346
+ function applyDagLayoutModeUi(): void {
347
+ const textFlow = currentDagLayoutMode() === 'text-flow';
348
+ if (dagCompactnessGroup) {
349
+ dagCompactnessGroup.hidden = !textFlow;
350
+ }
351
+ if (dagMeasureWidthGroup) {
352
+ dagMeasureWidthGroup.hidden = !textFlow;
353
+ }
354
+ if (dagLinearArcIntervalGroup) {
355
+ dagLinearArcIntervalGroup.hidden = textFlow;
356
+ }
357
+ }
358
+
359
+ const dagHideExcludedTokensInput = document.getElementById(
360
+ 'gen_attr_dag_hide_excluded_tokens'
361
+ ) as HTMLInputElement | null;
362
  const dagHideInactiveEdgesInput = document.getElementById(
363
  'gen_attr_dag_hide_inactive_edges'
364
  ) as HTMLInputElement | null;
 
366
 
367
  if (modelVariantSelect) modelVariantSelect.value = readStoredModelVariant();
368
  if (maxTokensInput) maxTokensInput.value = String(readStoredMaxTokens());
369
+ const initialDagLayoutMode = readStoredDagLayoutMode();
370
+ if (dagLayoutModeSelect) dagLayoutModeSelect.value = initialDagLayoutMode;
371
+ applyDagLayoutModeUi();
372
  const initialDagMeasureWidth = readStoredDagMeasureWidth();
373
  if (dagMeasureWidthInput) dagMeasureWidthInput.value = String(initialDagMeasureWidth);
374
+ const initialDagCompactness = readStoredDagCompactness();
375
+ if (dagCompactnessInput) dagCompactnessInput.value = String(initialDagCompactness);
376
+ const initialDagEdgeTopPCoverage = readStoredDagEdgeTopPCoverage();
377
+ if (dagEdgeTopPCoverageInput) dagEdgeTopPCoverageInput.value = String(initialDagEdgeTopPCoverage);
378
+ const initialDagLinearArcGap = readStoredDagLinearArcAdjacentGap();
379
+ if (dagLinearArcIntervalInput) dagLinearArcIntervalInput.value = String(initialDagLinearArcGap);
380
 
381
  // DAG 回放节奏:步长 / 总时长 / 模式下拉 — 自 localStorage 恢复后再同步展示哪块输入
382
  const initialDagPlaybackStepMs = readStoredDagPlaybackStepMs();
 
412
  applyDagHideInactiveEdges(hide);
413
  });
414
 
415
+ function readStoredDagHideExcludedTokens(): boolean {
416
+ try {
417
+ return localStorage.getItem(GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY) === '1';
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+ const initialDagHideExcludedTokens = readStoredDagHideExcludedTokens();
423
+ if (dagHideExcludedTokensInput) dagHideExcludedTokensInput.checked = initialDagHideExcludedTokens;
424
+ dagHideExcludedTokensInput?.addEventListener('change', () => {
425
+ const hide = dagHideExcludedTokensInput.checked;
426
+ try {
427
+ localStorage.setItem(GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY, hide ? '1' : '0');
428
+ } catch {
429
+ /* ignore */
430
+ }
431
+ dagHandle.setHideExcludedTokens(hide);
432
+ });
433
+
434
  modelVariantSelect?.addEventListener('change', () => {
435
  try {
436
  localStorage.setItem(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, currentModelVariant());
 
757
  if (!h) return;
758
  replayRunnerStepsIntoDag(h);
759
  },
760
+ layoutMode: initialDagLayoutMode,
761
  measureWidthPx: initialDagMeasureWidth,
762
+ dagCompactness: initialDagCompactness,
763
+ linearArcAdjacentGapPx: initialDagLinearArcGap,
764
+ hideExcludedTokens: initialDagHideExcludedTokens,
765
+ edgeTopPCoverage: initialDagEdgeTopPCoverage,
766
  onFullscreenError: (message) => showToast(message, 'error'),
767
  });
768
 
769
+ dagLayoutModeSelect?.addEventListener('change', () => {
770
+ const mode = currentDagLayoutMode();
771
+ try {
772
+ localStorage.setItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY, mode);
773
+ } catch {
774
+ /* ignore */
775
+ }
776
+ applyDagLayoutModeUi();
777
+ dagHandle.setLayoutMode(mode);
778
+ });
779
+
780
  /**
781
  * DAG 是否处于「不方便」状态:流式生成中或 DAG 播放中(含末 token dwell)。
782
  * 这些状态下改测量宽度只更新设置、不触发重绘,避免打断正在进行的流程/定时器状态机;
 
807
  dagHandle.fitViewportToContent();
808
  dagHandle.clearNodeSelection();
809
  });
810
+
811
+ dagCompactnessInput?.addEventListener('change', () => {
812
+ const raw = parseFloat(dagCompactnessInput.value);
813
+ const c = Number.isFinite(raw) ? clampDagCompactness(raw) : DAG_COMPACTNESS_DEFAULT;
814
+ dagCompactnessInput.value = String(c);
815
+ try {
816
+ localStorage.setItem(GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY, String(c));
817
+ } catch {
818
+ /* ignore */
819
+ }
820
+ dagHandle.setDagCompactness(c);
821
+ if (isDagBusy()) return;
822
+ const h = runnerHandle;
823
+ dagHandle.reset();
824
+ if (h && h.tokenCount > 0) {
825
+ replayRunnerStepsIntoDag(h);
826
+ }
827
+ dagHandle.fitViewportToContent();
828
+ dagHandle.clearNodeSelection();
829
+ });
830
+
831
+ dagEdgeTopPCoverageInput?.addEventListener('change', () => {
832
+ const raw = parseFloat(dagEdgeTopPCoverageInput.value);
833
+ const c = Number.isFinite(raw)
834
+ ? clampDagEdgeTopPCoverage(raw)
835
+ : DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
836
+ dagEdgeTopPCoverageInput.value = String(c);
837
+ try {
838
+ localStorage.setItem(GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY, String(c));
839
+ } catch {
840
+ /* ignore */
841
+ }
842
+ dagHandle.setEdgeTopPCoverage(c);
843
+ if (isDagBusy()) return;
844
+ const h = runnerHandle;
845
+ dagHandle.reset();
846
+ if (h && h.tokenCount > 0) {
847
+ replayRunnerStepsIntoDag(h);
848
+ }
849
+ dagHandle.fitViewportToContent();
850
+ dagHandle.clearNodeSelection();
851
+ });
852
+
853
+ dagLinearArcIntervalInput?.addEventListener('change', () => {
854
+ const raw = parseInt(dagLinearArcIntervalInput.value, 10);
855
+ const n = Number.isFinite(raw)
856
+ ? clampLinearArcAdjacentGap(raw)
857
+ : LINEAR_ARC_ADJACENT_GAP_DEFAULT;
858
+ dagLinearArcIntervalInput.value = String(n);
859
+ try {
860
+ localStorage.setItem(GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY, String(n));
861
+ } catch {
862
+ /* ignore */
863
+ }
864
+ dagHandle.setLinearArcAdjacentGapPx(n, { skipRefit: isDagBusy() });
865
+ });
866
+
867
  window.addEventListener('pagehide', (ev) => {
868
  if (ev.persisted) return;
869
  dagHandle.detach();
 
1491
  void exportJsonFile(payload, `genattr-${Date.now()}.json`);
1492
  });
1493
 
1494
+ initChatPanelLayout({ storageKey: PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE });
client/src/ts/lang/translations.ts CHANGED
@@ -46,10 +46,18 @@ export const translations: Translations = {
46
  'One regex per line (generated continuation only)': '每行一条正则(仅已生成 continuation)',
47
  'One regex per line (global flag), matched only within the generated suffix; if a token offset lies fully inside a match, its score is treated as 0.':
48
  '每行一条正则,`g`,仅在已生成后缀内匹配;token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
 
 
49
  'When checked, gray DAG edges not adjacent to the hovered or selected node are hidden.':
50
  '勾选后,DAG 中未与当前悬浮/选中节点相邻的灰色边将被隐藏。',
51
  '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.':
52
  'DAG 节点几何所基于的不可见测量层宽度(px)。只有测量层宽度会影响节点折行/位置。修改后:稳态下自动按新宽度重放并 fit;若正在生成或 DAG 播放中,仅更新设置,下次刷新/生成时生效。',
 
 
 
 
 
 
53
  'Delay in milliseconds between steps during DAG playback. Stored locally; the value is read when you press play—changing it mid-playback does not affect the current run.':
54
  'DAG 步进重放时相邻两步之间的间隔(ms)。写入本地存储;每次点击播放时读取当前输入,播放中途改数值不影响本轮。',
55
  'Perform gradient attribution on the target token below.': '对以下target token做梯度归因。',
@@ -67,6 +75,7 @@ export const translations: Translations = {
67
  '每行一条正则,`g`,仅在 context 全文上匹配;token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
68
  'For threshold x∈(0,1]: map normalized scores in [0,x] linearly to display intensities [0,1]; scores above x saturate at maximum intensity. At x=1, equivalent to disabling mapping.':
69
  '阈值 x∈(0,1]:将已归一的分数在 [0,x] 上线性映射到显示强度 [0,1];高于 x 的分数饱和为最高强度。x=1 时与关闭映射等价。',
 
70
  'LLM × Linguistics × Information Theory': '大模型 × 语言学 × 信息论',
71
 
72
  // ========== 通用按钮和操作 ==========
 
46
  'One regex per line (generated continuation only)': '每行一条正则(仅已生成 continuation)',
47
  'One regex per line (global flag), matched only within the generated suffix; if a token offset lies fully inside a match, its score is treated as 0.':
48
  '每行一条正则,`g`,仅在已生成后缀内匹配;token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
49
+ '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.':
50
+ 'Coverage 指每一步在 Top-N 候选池内的累计质量份额(先入池、池内归一后按强度排序再累加)。数值越大保留的 DAG 入边越多;分母仅为该候选池,不是该步 API 返回的全部归因 token。',
51
  'When checked, gray DAG edges not adjacent to the hovered or selected node are hidden.':
52
  '勾选后,DAG 中未与当前悬浮/选中节点相邻的灰色边将被隐藏。',
53
  '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.':
54
  'DAG 节点几何所基于的不可见测量层宽度(px)。只有测量层宽度会影响节点折行/位置。修改后:稳态下自动按新宽度重放并 fit;若正在生成或 DAG 播放中,仅更新设置,下次刷新/生成时生效。',
55
+ 'Token distance': 'Token distance 间距',
56
+ '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.':
57
+ '仅 linear-arc 布局下生效:相邻 token 节点矩形外侧边之间的水平间隙(px)。修改后:稳态下立即重绘并 fit;若正在生成或 DAG 播放中,仅写入存储,下一轮同步时再反映。',
58
+ 'Compactness': 'DAG 紧凑度',
59
+ '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.':
60
+ '相对测量层缩放 DAG 节点框与标签;1 与正文阅读比例一致。修改后:稳态下自动重放并 fit;若正在生成或 DAG 播放中,仅更新设置,下次运行或刷新时生效。',
61
  'Delay in milliseconds between steps during DAG playback. Stored locally; the value is read when you press play—changing it mid-playback does not affect the current run.':
62
  'DAG 步进重放时相邻两步之间的间隔(ms)。写入本地存储;每次点击播放时读取当前输入,播放中途改数值不影响本轮。',
63
  'Perform gradient attribution on the target token below.': '对以下target token做梯度归因。',
 
75
  '每行一条正则,`g`,仅在 context 全文上匹配;token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
76
  'For threshold x∈(0,1]: map normalized scores in [0,x] linearly to display intensities [0,1]; scores above x saturate at maximum intensity. At x=1, equivalent to disabling mapping.':
77
  '阈值 x∈(0,1]:将已归一的分数在 [0,x] 上线性映射到显示强度 [0,1];高于 x 的分数饱和为最高强度。x=1 时与关闭映射等价。',
78
+ 'A ❤️ would mean a lot!': '喜欢就点个❤️吧!',
79
  'LLM × Linguistics × Information Theory': '大模型 × 语言学 × 信息论',
80
 
81
  // ========== 通用按钮和操作 ==========
client/src/ts/start.ts CHANGED
@@ -28,6 +28,7 @@ import { DemoResourceLoader } from './storage/demoResourceLoader';
28
  import {TextInputController, calculateTextStatsForController, type ExtendedInputEvent} from './controllers/textInputController';
29
  import {HighlightController, initHighlightClearListeners} from './controllers/highlightController';
30
  import {LayoutController} from './controllers/layoutController';
 
31
  import {handleServerDemoSave} from './controllers/serverDemoController';
32
  // 公共初始化模块
33
  import {initializeCommonApp} from './appInitializer';
@@ -1033,7 +1034,8 @@ window.onload = () => {
1033
  const layoutController = new LayoutController({
1034
  sidebarState: current.sidebar,
1035
  sideBar: side_bar,
1036
- sidebarBtn: d3.select('#sidebar_btn')
 
1037
  });
1038
  };
1039
 
 
28
  import {TextInputController, calculateTextStatsForController, type ExtendedInputEvent} from './controllers/textInputController';
29
  import {HighlightController, initHighlightClearListeners} from './controllers/highlightController';
30
  import {LayoutController} from './controllers/layoutController';
31
+ import {PANEL_SPLIT_STORAGE_KEY_START} from './utils/panelSplitStorage';
32
  import {handleServerDemoSave} from './controllers/serverDemoController';
33
  // 公共初始化模块
34
  import {initializeCommonApp} from './appInitializer';
 
1034
  const layoutController = new LayoutController({
1035
  sidebarState: current.sidebar,
1036
  sideBar: side_bar,
1037
+ sidebarBtn: d3.select('#sidebar_btn'),
1038
+ panelSplitStorageKey: PANEL_SPLIT_STORAGE_KEY_START,
1039
  });
1040
  };
1041
 
client/src/ts/utils/panelSplitStorage.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** 左栏宽度占「主区宽度 − resizer(8px)」的比例,与 LayoutController / chatPanelLayout 内逻辑一致 */
2
+
3
+ const DEFAULT_RATIO = 0.5;
4
+ const MIN_RATIO = 0.1;
5
+ const MAX_RATIO = 0.9;
6
+
7
+ export const PANEL_SPLIT_STORAGE_KEY_START = 'info_radar_panel_split_start';
8
+ export const PANEL_SPLIT_STORAGE_KEY_CHAT = 'info_radar_panel_split_chat';
9
+ export const PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION = 'info_radar_panel_split_attribution';
10
+ export const PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE = 'info_radar_panel_split_gen_attribute';
11
+
12
+ export function readPanelSplitRatio(storageKey: string): number {
13
+ try {
14
+ const raw = localStorage.getItem(storageKey);
15
+ if (raw === null) {
16
+ return DEFAULT_RATIO;
17
+ }
18
+ const n = Number(raw);
19
+ if (!Number.isFinite(n)) {
20
+ return DEFAULT_RATIO;
21
+ }
22
+ return Math.max(MIN_RATIO, Math.min(MAX_RATIO, n));
23
+ } catch {
24
+ return DEFAULT_RATIO;
25
+ }
26
+ }
27
+
28
+ export function writePanelSplitRatio(storageKey: string, ratio: number): void {
29
+ try {
30
+ const clamped = Math.max(MIN_RATIO, Math.min(MAX_RATIO, ratio));
31
+ localStorage.setItem(storageKey, String(clamped));
32
+ } catch {
33
+ // ignore quota / private mode
34
+ }
35
+ }