DAG增加linear-arc布局模式;增加DAG用户自定义选项;UI改进
Browse files- backend/access_log.py +8 -3
- backend/api/static.py +3 -3
- backend/visit_stats.py +30 -3
- client/src/content/page-meta.json +1 -0
- client/src/css/_semantic-analysis.scss +6 -0
- client/src/css/gen_attribute.scss +33 -20
- client/src/css/home.scss +13 -2
- client/src/gen_attribute.html +48 -3
- client/src/index.html +1 -0
- client/src/scripts/injectPageMetaIntoHtml.js +8 -1
- client/src/ts/attribution.ts +2 -1
- client/src/ts/attribution/genAttributeDagPreprocess.ts +21 -11
- client/src/ts/attribution/genAttributeDagTextMeasure.ts +27 -4
- client/src/ts/attribution/genAttributeDagView.ts +240 -91
- client/src/ts/attribution/genAttributeDagViewLinearArcMode.ts +103 -0
- client/src/ts/attribution/genAttributeDagViewTextFlowMode.ts +70 -0
- client/src/ts/chat.ts +2 -1
- client/src/ts/chat/chatPanelLayout.ts +12 -3
- client/src/ts/controllers/layoutController.ts +15 -3
- client/src/ts/gen_attribute.ts +198 -3
- client/src/ts/lang/translations.ts +9 -0
- client/src/ts/start.ts +3 -1
- client/src/ts/utils/panelSplitStorage.ts +35 -0
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
|
| 65 |
-
"""记录demo
|
| 66 |
-
_log_request("🎯 demo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
*
|
| 72 |
-
|
|
|
|
| 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时和普通文本排版一致
|
| 81 |
-
--gen-attr-dag-
|
| 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 中「仅选中
|
| 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 |
-
//
|
| 274 |
-
.gen-attr-max-tokens-input
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 333 |
.gen-attr-dag-measure-width-row {
|
| 334 |
margin-top: 8px;
|
|
|
|
| 335 |
}
|
| 336 |
|
| 337 |
-
.gen-attr-dag-measure-width-
|
| 338 |
-
|
| 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 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 93 |
text-align: center;
|
| 94 |
font-size: 0.8125rem;
|
|
|
|
| 95 |
line-height: 1.45;
|
| 96 |
color: var(--text-muted, #666);
|
| 97 |
-
opacity: 0.
|
| 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"
|
|
|
|
| 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>
|
| 220 |
-
<option value="step" data-i18n>
|
| 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 |
-
|
| 23 |
-
/** 候选池内累计份额
|
| 24 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 >=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 69 |
-
|
| 70 |
-
|
| 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 后
|
|
|
|
|
|
|
| 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-
|
| 375 |
-
* 未设置时沿用样式表(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
| 468 |
-
*
|
| 469 |
*/
|
| 470 |
-
|
| 471 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 605 |
-
const
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('
|
| 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',
|
| 708 |
-
.attr('refX', 8)
|
| 709 |
.attr('refY', 0)
|
| 710 |
-
.attr('markerWidth',
|
| 711 |
-
.attr('markerHeight',
|
| 712 |
.attr('orient', 'auto');
|
| 713 |
m.append('path')
|
| 714 |
-
.attr('d',
|
| 715 |
.attr('fill', 'none')
|
| 716 |
.attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
|
| 717 |
-
|
| 718 |
-
.attr('
|
| 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('
|
| 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 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 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;
|
| 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 {
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|