Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
反向传播动画改进
Browse files- backend/api/openai_completions.py +40 -5
- backend/api/utils.py +7 -0
- backend/core/completion_generator.py +33 -3
- client/src/assets/demos/causal_flow/CoT | 反向传播归因动画.json +0 -0
- client/src/assets/demos/causal_flow/CoT | 苏州所在省的省会城市里最高的山.json +0 -0
- client/src/assets/demos/causal_flow/order.json +18 -5
- client/src/causal_flow.html +7 -5
- client/src/chat.html +1 -1
- client/src/css/components/_query-history-dropdown.scss +4 -0
- client/src/css/pages/causal_flow.scss +8 -4
- client/src/features/causal_flow/bundledDemos.ts +13 -2
- client/src/features/causal_flow/genAttributeBundledDemoManifest.generated.ts +3 -2
- client/src/features/chat/chatPromptTemplateMode.ts +3 -0
- client/src/package.json +1 -0
- client/src/pages/causal_flow/index.ts +56 -20
- client/src/pages/chat/index.ts +87 -18
- client/src/scripts/genAttributeDemoManifestPlugin.js +65 -9
- client/src/shared/api/completionsClient.ts +16 -3
- client/src/shared/cross/maxNewTokensConfig.ts +100 -0
- client/src/shared/cross/queryHistory.ts +5 -2
- client/src/shared/cross/tokenDisplayUtils.ts +6 -2
- client/src/shared/lang/translations.ts +2 -0
- client/src/shared/prediction_attribution/causal_flow/genAttributeDagEdgeRenderStrength.ts +52 -0
- client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackLog.ts +103 -0
- client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackPacing.ts +145 -0
- client/src/shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation.ts +267 -377
- client/src/shared/prediction_attribution/causal_flow/genAttributeDagTextMeasure.ts +1 -0
- client/src/shared/prediction_attribution/causal_flow/genAttributeDagView.ts +129 -64
- client/src/shared/prediction_attribution/causal_flow/tokenGenAttributionRunner.ts +3 -2
- client/src/shared/vis/ToolTip.ts +2 -2
- client/src/tests/prediction_attribution/genAttributeDagPropagationPlayback.test.ts +271 -0
backend/api/openai_completions.py
CHANGED
|
@@ -10,10 +10,13 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
| 10 |
from backend.models.model_manager import inference_lock, get_instruct_model_display_name
|
| 11 |
from backend.core.prediction_attributor import slot_for_prediction_attr_model
|
| 12 |
from backend.platform.oom import exit_if_oom, is_oom_error
|
|
|
|
| 13 |
from backend.core.completion_generator import (
|
|
|
|
| 14 |
PromptTooLongError,
|
| 15 |
apply_chat_template_for_completion,
|
| 16 |
completion_cancel_requested,
|
|
|
|
| 17 |
generate_completion_text,
|
| 18 |
global_completion_stop_event,
|
| 19 |
inference_shutdown_event,
|
|
@@ -84,6 +87,7 @@ def _completion_inference_after_lock(
|
|
| 84 |
*,
|
| 85 |
stream_delta: Optional[Callable[[str, bool], None]] = None,
|
| 86 |
max_tokens: Optional[int] = None,
|
|
|
|
| 87 |
) -> CompletionRunResult:
|
| 88 |
"""
|
| 89 |
在已持有推理锁的上下文中执行续写(旧版非流式路径的持锁体内逻辑)。
|
|
@@ -92,7 +96,12 @@ def _completion_inference_after_lock(
|
|
| 92 |
from backend.platform.access_log import log_openai_completions_start
|
| 93 |
|
| 94 |
log_openai_completions_start(request_id, lock_wait_time)
|
| 95 |
-
return generate_completion_text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
def _log_completion_finished(
|
|
@@ -136,6 +145,7 @@ def _generate_completion_events(
|
|
| 136 |
request_id: int,
|
| 137 |
*,
|
| 138 |
max_tokens: Optional[int] = None,
|
|
|
|
| 139 |
):
|
| 140 |
global_completion_stop_event.clear()
|
| 141 |
q: queue.Queue = queue.Queue()
|
|
@@ -163,6 +173,7 @@ def _generate_completion_events(
|
|
| 163 |
lock_wait_time,
|
| 164 |
stream_delta=stream_delta,
|
| 165 |
max_tokens=max_tokens,
|
|
|
|
| 166 |
)
|
| 167 |
finally:
|
| 168 |
inference_lock.release()
|
|
@@ -246,8 +257,8 @@ def _generate_completion_events(
|
|
| 246 |
return
|
| 247 |
elif kind == "error":
|
| 248 |
err = item[1]
|
| 249 |
-
if isinstance(err, PromptTooLongError):
|
| 250 |
-
_log_cmpl_issue(request_id,
|
| 251 |
yield send_error_event(str(err), 400)
|
| 252 |
elif isinstance(err, QueueTimeoutError):
|
| 253 |
_log_cmpl_issue(request_id, f"排队超时: {err}")
|
|
@@ -276,9 +287,15 @@ def _completions_sse_response(
|
|
| 276 |
request_id: int,
|
| 277 |
*,
|
| 278 |
max_tokens: Optional[int] = None,
|
|
|
|
| 279 |
):
|
| 280 |
return SSEProgressReporter(
|
| 281 |
-
lambda: _generate_completion_events(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
).create_response()
|
| 283 |
|
| 284 |
|
|
@@ -390,7 +407,25 @@ def completions(completions_request):
|
|
| 390 |
else:
|
| 391 |
max_tokens = max_tokens_raw
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
client_ip = get_client_ip()
|
| 394 |
request_id = _log_request(model, prompt, client_ip)
|
| 395 |
|
| 396 |
-
return _completions_sse_response(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from backend.models.model_manager import inference_lock, get_instruct_model_display_name
|
| 11 |
from backend.core.prediction_attributor import slot_for_prediction_attr_model
|
| 12 |
from backend.platform.oom import exit_if_oom, is_oom_error
|
| 13 |
+
from backend.api.utils import request_has_valid_admin
|
| 14 |
from backend.core.completion_generator import (
|
| 15 |
+
ModelContextLimitUnknownError,
|
| 16 |
PromptTooLongError,
|
| 17 |
apply_chat_template_for_completion,
|
| 18 |
completion_cancel_requested,
|
| 19 |
+
completion_max_token_length,
|
| 20 |
generate_completion_text,
|
| 21 |
global_completion_stop_event,
|
| 22 |
inference_shutdown_event,
|
|
|
|
| 87 |
*,
|
| 88 |
stream_delta: Optional[Callable[[str, bool], None]] = None,
|
| 89 |
max_tokens: Optional[int] = None,
|
| 90 |
+
bypass_site_context_limit: bool = False,
|
| 91 |
) -> CompletionRunResult:
|
| 92 |
"""
|
| 93 |
在已持有推理锁的上下文中执行续写(旧版非流式路径的持锁体内逻辑)。
|
|
|
|
| 96 |
from backend.platform.access_log import log_openai_completions_start
|
| 97 |
|
| 98 |
log_openai_completions_start(request_id, lock_wait_time)
|
| 99 |
+
return generate_completion_text(
|
| 100 |
+
prompt,
|
| 101 |
+
stream_delta=stream_delta,
|
| 102 |
+
max_tokens=max_tokens,
|
| 103 |
+
bypass_site_context_limit=bypass_site_context_limit,
|
| 104 |
+
)
|
| 105 |
|
| 106 |
|
| 107 |
def _log_completion_finished(
|
|
|
|
| 145 |
request_id: int,
|
| 146 |
*,
|
| 147 |
max_tokens: Optional[int] = None,
|
| 148 |
+
bypass_site_context_limit: bool = False,
|
| 149 |
):
|
| 150 |
global_completion_stop_event.clear()
|
| 151 |
q: queue.Queue = queue.Queue()
|
|
|
|
| 173 |
lock_wait_time,
|
| 174 |
stream_delta=stream_delta,
|
| 175 |
max_tokens=max_tokens,
|
| 176 |
+
bypass_site_context_limit=bypass_site_context_limit,
|
| 177 |
)
|
| 178 |
finally:
|
| 179 |
inference_lock.release()
|
|
|
|
| 257 |
return
|
| 258 |
elif kind == "error":
|
| 259 |
err = item[1]
|
| 260 |
+
if isinstance(err, (PromptTooLongError, ModelContextLimitUnknownError)):
|
| 261 |
+
_log_cmpl_issue(request_id, str(err))
|
| 262 |
yield send_error_event(str(err), 400)
|
| 263 |
elif isinstance(err, QueueTimeoutError):
|
| 264 |
_log_cmpl_issue(request_id, f"排队超时: {err}")
|
|
|
|
| 287 |
request_id: int,
|
| 288 |
*,
|
| 289 |
max_tokens: Optional[int] = None,
|
| 290 |
+
bypass_site_context_limit: bool = False,
|
| 291 |
):
|
| 292 |
return SSEProgressReporter(
|
| 293 |
+
lambda: _generate_completion_events(
|
| 294 |
+
prompt,
|
| 295 |
+
request_id,
|
| 296 |
+
max_tokens=max_tokens,
|
| 297 |
+
bypass_site_context_limit=bypass_site_context_limit,
|
| 298 |
+
)
|
| 299 |
).create_response()
|
| 300 |
|
| 301 |
|
|
|
|
| 407 |
else:
|
| 408 |
max_tokens = max_tokens_raw
|
| 409 |
|
| 410 |
+
bypass_site = request_has_valid_admin() and max_tokens is not None
|
| 411 |
+
if (
|
| 412 |
+
not bypass_site
|
| 413 |
+
and max_tokens is not None
|
| 414 |
+
and max_tokens > completion_max_token_length
|
| 415 |
+
):
|
| 416 |
+
return {
|
| 417 |
+
"success": False,
|
| 418 |
+
"message": (
|
| 419 |
+
f"max_tokens 不得超过续写上下文上限 {completion_max_token_length}"
|
| 420 |
+
),
|
| 421 |
+
}, 400
|
| 422 |
+
|
| 423 |
client_ip = get_client_ip()
|
| 424 |
request_id = _log_request(model, prompt, client_ip)
|
| 425 |
|
| 426 |
+
return _completions_sse_response(
|
| 427 |
+
prompt,
|
| 428 |
+
request_id,
|
| 429 |
+
max_tokens=max_tokens,
|
| 430 |
+
bypass_site_context_limit=bypass_site,
|
| 431 |
+
)
|
backend/api/utils.py
CHANGED
|
@@ -68,6 +68,13 @@ def get_admin_token() -> str:
|
|
| 68 |
return os.environ.get('INFORADAR_ADMIN_TOKEN')
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
def validate_admin_token(request_token: str) -> tuple[bool, str]:
|
| 72 |
"""
|
| 73 |
验证管理员token是否有效
|
|
|
|
| 68 |
return os.environ.get('INFORADAR_ADMIN_TOKEN')
|
| 69 |
|
| 70 |
|
| 71 |
+
def request_has_valid_admin() -> bool:
|
| 72 |
+
"""当前 HTTP 请求是否携带有效的 X-Admin-Token。"""
|
| 73 |
+
token = request.headers.get('X-Admin-Token') or ''
|
| 74 |
+
is_valid, _ = validate_admin_token(token)
|
| 75 |
+
return is_valid
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def validate_admin_token(request_token: str) -> tuple[bool, str]:
|
| 79 |
"""
|
| 80 |
验证管理员token是否有效
|
backend/core/completion_generator.py
CHANGED
|
@@ -24,7 +24,22 @@ from .pred_topk_format import pred_topk_pairs_from_probs_1d
|
|
| 24 |
from backend.platform.runtime_config import DEFAULT_TOPK
|
| 25 |
|
| 26 |
# 续写路径:prompt + 续写合计不得超过该 token 数(与语义分析 runtime 无关)。
|
| 27 |
-
completion_max_token_length =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# 特殊 token 亦视为分析/展示内容,故不跳过。
|
| 30 |
_COMPLETION_DECODE_SKIP_SPECIAL = False
|
|
@@ -69,6 +84,10 @@ class PromptTooLongError(ValueError):
|
|
| 69 |
"""prompt 过长或占满上下文导致无法续写(``input_len >= ctx_limit`` 时由 ``core_generate_from_text`` 抛出)。"""
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
def _completion_without_generate(
|
| 73 |
prompt_tokens: int,
|
| 74 |
) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
|
|
@@ -392,6 +411,7 @@ def core_generate_from_text(
|
|
| 392 |
*,
|
| 393 |
stream_delta: Optional[Callable[[str, bool], None]] = None,
|
| 394 |
max_tokens: Optional[int] = None,
|
|
|
|
| 395 |
) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
|
| 396 |
"""
|
| 397 |
对一段已确定的模型输入字符串做自回归续写(默认贪心;函数内 ``_use_low_temp_sampling`` 可临时切到低温采样)。
|
|
@@ -403,19 +423,23 @@ def core_generate_from_text(
|
|
| 403 |
Args:
|
| 404 |
stream_delta: 可选;若提供则额外调用(如 SSE)。本地 verbose 打印由 ``_print_completion_stream_delta`` 单独控制,与是否传入 stream_delta 无关。
|
| 405 |
max_tokens: 可选;正整数,限制本次最多生成多少个新 token(与 ``min(max_tokens, 上限 − prompt)`` 取小)。省略则用尽剩余上下文额度。
|
|
|
|
| 406 |
|
| 407 |
Returns:
|
| 408 |
(续写文本, finish_reason, prompt_tokens, completion_tokens, 续写段 bpe_strings, ttft_s)。
|
| 409 |
ttft_s 为自 ``model.generate`` 起至首次产出续写片段的秒数;仅取消时为 ``None``。
|
| 410 |
"""
|
| 411 |
tokenizer, model, device = ensure_instruct_slot_ready()
|
| 412 |
-
ctx_limit = completion_max_token_length
|
| 413 |
|
| 414 |
model.eval()
|
| 415 |
enc = tokenizer(formatted_text, return_tensors="pt")
|
| 416 |
input_ids = enc["input_ids"].to(device)
|
| 417 |
input_len = input_ids.shape[1]
|
| 418 |
n = int(input_len)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
if n >= ctx_limit:
|
| 420 |
raise PromptTooLongError(
|
| 421 |
"Prompt too long: "
|
|
@@ -549,6 +573,7 @@ def generate_completion_text(
|
|
| 549 |
stream_delta: Optional[Callable[[str, bool], None]] = None,
|
| 550 |
*,
|
| 551 |
max_tokens: Optional[int] = None,
|
|
|
|
| 552 |
) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
|
| 553 |
"""
|
| 554 |
``prompt`` 须为已确定的完整模型输入(不再在服务端套用 chat template)。
|
|
@@ -556,4 +581,9 @@ def generate_completion_text(
|
|
| 556 |
流式可传 stream_delta;中止由 ``completion_cancel_requested()`` 统一判断。
|
| 557 |
``max_tokens`` 为可选的正整数续写上限(与 API 约定一致)。
|
| 558 |
"""
|
| 559 |
-
return core_generate_from_text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from backend.platform.runtime_config import DEFAULT_TOPK
|
| 25 |
|
| 26 |
# 续写路径:prompt + 续写合计不得超过该 token 数(与语义分析 runtime 无关)。
|
| 27 |
+
completion_max_token_length = 300
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _model_context_token_limit(tokenizer, model) -> int:
|
| 31 |
+
"""管理员续写路径:须能解析模型上下文,否则抛错(不回退站点 500)。"""
|
| 32 |
+
pe = getattr(getattr(model, "config", None), "max_position_embeddings", None)
|
| 33 |
+
if isinstance(pe, int) and pe > 0:
|
| 34 |
+
return pe
|
| 35 |
+
ml = getattr(tokenizer, "model_max_length", None)
|
| 36 |
+
if isinstance(ml, int) and 0 < ml < 1_000_000:
|
| 37 |
+
return ml
|
| 38 |
+
raise ModelContextLimitUnknownError(
|
| 39 |
+
"无法从模型 config.max_position_embeddings 或 tokenizer.model_max_length "
|
| 40 |
+
"确定上下文长度;管理员续写已拒绝。"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
|
| 44 |
# 特殊 token 亦视为分析/展示内容,故不跳过。
|
| 45 |
_COMPLETION_DECODE_SKIP_SPECIAL = False
|
|
|
|
| 84 |
"""prompt 过长或占满上下文导致无法续写(``input_len >= ctx_limit`` 时由 ``core_generate_from_text`` 抛出)。"""
|
| 85 |
|
| 86 |
|
| 87 |
+
class ModelContextLimitUnknownError(ValueError):
|
| 88 |
+
"""管理员 bypass 站点上限时无法解析模型上下文长度。"""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
def _completion_without_generate(
|
| 92 |
prompt_tokens: int,
|
| 93 |
) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
|
|
|
|
| 411 |
*,
|
| 412 |
stream_delta: Optional[Callable[[str, bool], None]] = None,
|
| 413 |
max_tokens: Optional[int] = None,
|
| 414 |
+
bypass_site_context_limit: bool = False,
|
| 415 |
) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
|
| 416 |
"""
|
| 417 |
对一段已确定的模型输入字符串做自回归续写(默认贪心;函数内 ``_use_low_temp_sampling`` 可临时切到低温采样)。
|
|
|
|
| 423 |
Args:
|
| 424 |
stream_delta: 可选;若提供则额外调用(如 SSE)。本地 verbose 打印由 ``_print_completion_stream_delta`` 单独控制,与是否传入 stream_delta 无关。
|
| 425 |
max_tokens: 可选;正整数,限制本次最多生成多少个新 token(与 ``min(max_tokens, 上限 − prompt)`` 取小)。省略则用尽剩余上下文额度。
|
| 426 |
+
bypass_site_context_limit: 为 True 时(管理员显式 max_tokens)不按站点上限封顶,``ctx_limit`` 为模型上下文上限;无法解析时抛 ``ModelContextLimitUnknownError``。
|
| 427 |
|
| 428 |
Returns:
|
| 429 |
(续写文本, finish_reason, prompt_tokens, completion_tokens, 续写段 bpe_strings, ttft_s)。
|
| 430 |
ttft_s 为自 ``model.generate`` 起至首次产出续写片段的秒数;仅取消时为 ``None``。
|
| 431 |
"""
|
| 432 |
tokenizer, model, device = ensure_instruct_slot_ready()
|
|
|
|
| 433 |
|
| 434 |
model.eval()
|
| 435 |
enc = tokenizer(formatted_text, return_tensors="pt")
|
| 436 |
input_ids = enc["input_ids"].to(device)
|
| 437 |
input_len = input_ids.shape[1]
|
| 438 |
n = int(input_len)
|
| 439 |
+
if bypass_site_context_limit and max_tokens is not None:
|
| 440 |
+
ctx_limit = _model_context_token_limit(tokenizer, model)
|
| 441 |
+
else:
|
| 442 |
+
ctx_limit = completion_max_token_length
|
| 443 |
if n >= ctx_limit:
|
| 444 |
raise PromptTooLongError(
|
| 445 |
"Prompt too long: "
|
|
|
|
| 573 |
stream_delta: Optional[Callable[[str, bool], None]] = None,
|
| 574 |
*,
|
| 575 |
max_tokens: Optional[int] = None,
|
| 576 |
+
bypass_site_context_limit: bool = False,
|
| 577 |
) -> Tuple[str, str, int, int, List[Dict[str, Any]], Optional[float]]:
|
| 578 |
"""
|
| 579 |
``prompt`` 须为已确定的完整模型输入(不再在服务端套用 chat template)。
|
|
|
|
| 581 |
流式可传 stream_delta;中止由 ``completion_cancel_requested()`` 统一判断。
|
| 582 |
``max_tokens`` 为可选的正整数续写上限(与 API 约定一致)。
|
| 583 |
"""
|
| 584 |
+
return core_generate_from_text(
|
| 585 |
+
prompt,
|
| 586 |
+
stream_delta=stream_delta,
|
| 587 |
+
max_tokens=max_tokens,
|
| 588 |
+
bypass_site_context_limit=bypass_site_context_limit,
|
| 589 |
+
)
|
client/src/assets/demos/causal_flow/CoT | 反向传播归因动画.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
client/src/assets/demos/causal_flow/CoT | 苏州所在省的省会城市里最高的山.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
client/src/assets/demos/causal_flow/order.json
CHANGED
|
@@ -1,23 +1,36 @@
|
|
| 1 |
[
|
| 2 |
{
|
| 3 |
"slug": "Write a sonnet about love",
|
| 4 |
-
"label": "Poem | Write a sonnet about love"
|
|
|
|
| 5 |
},
|
| 6 |
{
|
| 7 |
"slug": "写一首绝句,主题是春天",
|
| 8 |
-
"label": "写诗 | 写一首绝句,主题是春天"
|
|
|
|
| 9 |
},
|
| 10 |
{
|
| 11 |
"slug": "CoT | 苏州所在省的省会",
|
| 12 |
-
"label": "CoT | 苏州所在省的省会"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
},
|
| 14 |
{
|
| 15 |
"slug": "CoT | 多“跳”推理",
|
| 16 |
-
"label": "CoT | 多
|
| 17 |
},
|
| 18 |
{
|
| 19 |
"slug": "过拟合|李白 将进酒",
|
| 20 |
-
"label": "过拟合|李白 将进酒"
|
|
|
|
| 21 |
},
|
| 22 |
{
|
| 23 |
"slug": "CN->EN翻译",
|
|
|
|
| 1 |
[
|
| 2 |
{
|
| 3 |
"slug": "Write a sonnet about love",
|
| 4 |
+
"label": "Poem | Write a sonnet about love",
|
| 5 |
+
"featured": "bold"
|
| 6 |
},
|
| 7 |
{
|
| 8 |
"slug": "写一首绝句,主题是春天",
|
| 9 |
+
"label": "写诗 | 写一首绝句,主题是春天",
|
| 10 |
+
"featured": "bold"
|
| 11 |
},
|
| 12 |
{
|
| 13 |
"slug": "CoT | 苏州所在省的省会",
|
| 14 |
+
"label": "CoT | 苏州所在省的省会",
|
| 15 |
+
"featured": "bold"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"slug": "CoT | 苏州所在省的省会城市里最高的山",
|
| 19 |
+
"label": "CoT | 苏州所在省的省会的最高的山"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"slug": "CoT | 反向传播归因动画",
|
| 23 |
+
"label": "CoT | 思维链的反向传播归因动画",
|
| 24 |
+
"featured": "bold"
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"slug": "CoT | 多“跳”推理",
|
| 28 |
+
"label": "CoT | 多跳推理中“跳”的具象化"
|
| 29 |
},
|
| 30 |
{
|
| 31 |
"slug": "过拟合|李白 将进酒",
|
| 32 |
+
"label": "过拟合|李白 将进酒",
|
| 33 |
+
"featured": "bold"
|
| 34 |
},
|
| 35 |
{
|
| 36 |
"slug": "CN->EN翻译",
|
client/src/causal_flow.html
CHANGED
|
@@ -182,9 +182,9 @@
|
|
| 182 |
</select>
|
| 183 |
</span>
|
| 184 |
<span class="semantic-submode-group">
|
| 185 |
-
<label class="semantic-submode-label" for="gen_attr_max_tokens">Max tokens</label>
|
| 186 |
<input type="number" id="gen_attr_max_tokens" class="gen-attr-max-tokens-input"
|
| 187 |
-
value="
|
| 188 |
</span>
|
| 189 |
</div>
|
| 190 |
<div class="button-group">
|
|
@@ -269,13 +269,15 @@
|
|
| 269 |
</span>
|
| 270 |
</div>
|
| 271 |
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
| 272 |
-
<span class="semantic-submode-group">
|
| 273 |
<label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Attribution top-p coverage</label>
|
| 274 |
<input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
|
| 275 |
value="0.7" min="0.05" max="1" step="0.05"
|
| 276 |
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."
|
| 277 |
data-i18n="title">
|
| 278 |
</span>
|
|
|
|
|
|
|
| 279 |
<span class="semantic-submode-group semantic-submode-group--emphasis">
|
| 280 |
<label class="semantic-submode-label">
|
| 281 |
<input type="checkbox" id="gen_attr_dag_recursive_attribution"
|
|
@@ -284,7 +286,7 @@
|
|
| 284 |
Propagated attribution mode
|
| 285 |
</label>
|
| 286 |
</span>
|
| 287 |
-
<span class="semantic-submode-group" id="gen_attr_dag_recursive_edge_animation_group" hidden>
|
| 288 |
<label class="semantic-submode-label">
|
| 289 |
<input type="checkbox" id="gen_attr_dag_recursive_edge_animation" checked
|
| 290 |
title="When checked, propagated incoming edges are shown batch-by-batch with the configured direction."
|
|
@@ -293,7 +295,7 @@
|
|
| 293 |
</label>
|
| 294 |
</span>
|
| 295 |
<span class="semantic-submode-group" id="gen_attr_dag_recursive_edge_animation_direction_group" hidden>
|
| 296 |
-
<label class="semantic-submode-label" for="gen_attr_dag_recursive_edge_animation_direction">
|
| 297 |
<select id="gen_attr_dag_recursive_edge_animation_direction" class="semantic-submode-select"
|
| 298 |
title="Choose one direction for propagated-edge batch animation."
|
| 299 |
data-i18n="title">
|
|
|
|
| 182 |
</select>
|
| 183 |
</span>
|
| 184 |
<span class="semantic-submode-group">
|
| 185 |
+
<label class="semantic-submode-label" for="gen_attr_max_tokens" data-i18n>Max new tokens</label>
|
| 186 |
<input type="number" id="gen_attr_max_tokens" class="gen-attr-max-tokens-input"
|
| 187 |
+
value="200" min="1" max="300" step="1" required>
|
| 188 |
</span>
|
| 189 |
</div>
|
| 190 |
<div class="button-group">
|
|
|
|
| 269 |
</span>
|
| 270 |
</div>
|
| 271 |
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
| 272 |
+
<span class="semantic-submode-group semantic-submode-group--emphasis">
|
| 273 |
<label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Attribution top-p coverage</label>
|
| 274 |
<input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
|
| 275 |
value="0.7" min="0.05" max="1" step="0.05"
|
| 276 |
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."
|
| 277 |
data-i18n="title">
|
| 278 |
</span>
|
| 279 |
+
</div>
|
| 280 |
+
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
| 281 |
<span class="semantic-submode-group semantic-submode-group--emphasis">
|
| 282 |
<label class="semantic-submode-label">
|
| 283 |
<input type="checkbox" id="gen_attr_dag_recursive_attribution"
|
|
|
|
| 286 |
Propagated attribution mode
|
| 287 |
</label>
|
| 288 |
</span>
|
| 289 |
+
<span class="semantic-submode-group semantic-submode-group--emphasis" id="gen_attr_dag_recursive_edge_animation_group" hidden>
|
| 290 |
<label class="semantic-submode-label">
|
| 291 |
<input type="checkbox" id="gen_attr_dag_recursive_edge_animation" checked
|
| 292 |
title="When checked, propagated incoming edges are shown batch-by-batch with the configured direction."
|
|
|
|
| 295 |
</label>
|
| 296 |
</span>
|
| 297 |
<span class="semantic-submode-group" id="gen_attr_dag_recursive_edge_animation_direction_group" hidden>
|
| 298 |
+
<label class="semantic-submode-label" for="gen_attr_dag_recursive_edge_animation_direction">Direction</label>
|
| 299 |
<select id="gen_attr_dag_recursive_edge_animation_direction" class="semantic-submode-select"
|
| 300 |
title="Choose one direction for propagated-edge batch animation."
|
| 301 |
data-i18n="title">
|
client/src/chat.html
CHANGED
|
@@ -120,7 +120,7 @@
|
|
| 120 |
<span class="semantic-submode-group">
|
| 121 |
<label class="chat-max-new-tokens-label" for="chat_max_new_tokens">
|
| 122 |
<span class="semantic-submode-label" data-i18n>Max new tokens:</span>
|
| 123 |
-
<input type="
|
| 124 |
</label>
|
| 125 |
</span>
|
| 126 |
</div>
|
|
|
|
| 120 |
<span class="semantic-submode-group">
|
| 121 |
<label class="chat-max-new-tokens-label" for="chat_max_new_tokens">
|
| 122 |
<span class="semantic-submode-label" data-i18n>Max new tokens:</span>
|
| 123 |
+
<input type="number" id="chat_max_new_tokens" class="semantic-threshold-input chat-max-new-tokens-input" min="1" max="300" step="1" value="200" required />
|
| 124 |
</label>
|
| 125 |
</span>
|
| 126 |
</div>
|
client/src/css/components/_query-history-dropdown.scss
CHANGED
|
@@ -38,6 +38,10 @@
|
|
| 38 |
overflow: hidden;
|
| 39 |
text-overflow: ellipsis;
|
| 40 |
white-space: nowrap;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
}
|
| 43 |
}
|
|
|
|
| 38 |
overflow: hidden;
|
| 39 |
text-overflow: ellipsis;
|
| 40 |
white-space: nowrap;
|
| 41 |
+
|
| 42 |
+
&--bold {
|
| 43 |
+
font-weight: 500;
|
| 44 |
+
}
|
| 45 |
}
|
| 46 |
}
|
| 47 |
}
|
client/src/css/pages/causal_flow.scss
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
@use '../base/narrow-ios-form-font-tail' as ios-form-font;
|
| 8 |
|
| 9 |
$gen-attr-option-row-gap: 12px;
|
| 10 |
-
$gen-attr-cached-demos-panel-width:
|
| 11 |
|
| 12 |
// 单行右对齐:Cached history 最右;Cached demo 靠其左侧
|
| 13 |
.chat-cached-history-bar.chat-cached-history-bar--dual {
|
|
@@ -143,7 +143,7 @@ $gen-attr-cached-demos-panel-width: 18em;
|
|
| 143 |
box-sizing: border-box;
|
| 144 |
}
|
| 145 |
|
| 146 |
-
// DAG Top‑K:贴在 #results 内侧右下(absolute);固定尺寸 HUD;
|
| 147 |
#results.gen-attr-results-surface.LMF > .tooltip.gen-attr-dag-topk-tooltip {
|
| 148 |
box-sizing: border-box;
|
| 149 |
// 与 #major_tooltip 一致:不参与 opacity 过渡,避免 reposition 后与底层叠影
|
|
@@ -164,8 +164,7 @@ $gen-attr-cached-demos-panel-width: 18em;
|
|
| 164 |
flex: 1 1 auto;
|
| 165 |
min-height: 0;
|
| 166 |
margin-top: 6px;
|
| 167 |
-
overflow
|
| 168 |
-
overflow-y: auto;
|
| 169 |
}
|
| 170 |
}
|
| 171 |
|
|
@@ -224,6 +223,11 @@ $gen-attr-cached-demos-panel-width: 18em;
|
|
| 224 |
stroke-opacity: var(--gen-attr-dag-node-recursive-share, 1);
|
| 225 |
}
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
// 选中/悬停 = 焦点(追因起点,nodeShare=1);置于 recursive-chain 之后以覆盖链上节点的 stroke-opacity
|
| 228 |
&--hover .gen-attr-dag-node-stroke,
|
| 229 |
&--selected .gen-attr-dag-node-stroke {
|
|
|
|
| 7 |
@use '../base/narrow-ios-form-font-tail' as ios-form-font;
|
| 8 |
|
| 9 |
$gen-attr-option-row-gap: 12px;
|
| 10 |
+
$gen-attr-cached-demos-panel-width: 21em;
|
| 11 |
|
| 12 |
// 单行右对齐:Cached history 最右;Cached demo 靠其左侧
|
| 13 |
.chat-cached-history-bar.chat-cached-history-bar--dual {
|
|
|
|
| 143 |
box-sizing: border-box;
|
| 144 |
}
|
| 145 |
|
| 146 |
+
// DAG Top‑K:贴在 #results 内侧右下(absolute);固定尺寸 HUD;超出部分裁剪。
|
| 147 |
#results.gen-attr-results-surface.LMF > .tooltip.gen-attr-dag-topk-tooltip {
|
| 148 |
box-sizing: border-box;
|
| 149 |
// 与 #major_tooltip 一致:不参与 opacity 过渡,避免 reposition 后与底层叠影
|
|
|
|
| 164 |
flex: 1 1 auto;
|
| 165 |
min-height: 0;
|
| 166 |
margin-top: 6px;
|
| 167 |
+
overflow: hidden;
|
|
|
|
| 168 |
}
|
| 169 |
}
|
| 170 |
|
|
|
|
| 223 |
stroke-opacity: var(--gen-attr-dag-node-recursive-share, 1);
|
| 224 |
}
|
| 225 |
|
| 226 |
+
// 反向传播动画:当前滑过节点仅改描边色相(与出边红一致),stroke-opacity 仍由 recursive-share 控制。
|
| 227 |
+
&--recursive-chain.gen-attr-dag-node--backward-slide .gen-attr-dag-node-stroke {
|
| 228 |
+
stroke: var(--dag-highlight-line-color-out);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
// 选中/悬停 = 焦点(追因起点,nodeShare=1);置于 recursive-chain 之后以覆盖链上节点的 stroke-opacity
|
| 232 |
&--hover .gen-attr-dag-node-stroke,
|
| 233 |
&--selected .gen-attr-dag-node-stroke {
|
client/src/features/causal_flow/bundledDemos.ts
CHANGED
|
@@ -24,11 +24,22 @@ function isSafeDemoSlug(s: string): boolean {
|
|
| 24 |
const payloadCache = new Map<string, GenAttrCachedRun>();
|
| 25 |
const payloadInflight = new Map<string, Promise<GenAttrCachedRun | undefined>>();
|
| 26 |
|
| 27 |
-
export type BundledDemoListEntry = { id: string; label: string };
|
| 28 |
|
| 29 |
/** 构建期固定的 bundled demo 列表(与当前 JS 同版本)。 */
|
| 30 |
export function getBundledGenAttributeDemoList(): readonly BundledDemoListEntry[] {
|
| 31 |
-
return GEN_ATTRIBUTE_BUNDLED_DEMOS.map(({ slug, label }) => ({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
/**
|
|
|
|
| 24 |
const payloadCache = new Map<string, GenAttrCachedRun>();
|
| 25 |
const payloadInflight = new Map<string, Promise<GenAttrCachedRun | undefined>>();
|
| 26 |
|
| 27 |
+
export type BundledDemoListEntry = { id: string; label: string; featuredStyle?: string };
|
| 28 |
|
| 29 |
/** 构建期固定的 bundled demo 列表(与当前 JS 同版本)。 */
|
| 30 |
export function getBundledGenAttributeDemoList(): readonly BundledDemoListEntry[] {
|
| 31 |
+
return GEN_ATTRIBUTE_BUNDLED_DEMOS.map(({ slug, label, featured }) => ({
|
| 32 |
+
id: slug,
|
| 33 |
+
label,
|
| 34 |
+
...(featured ? { featuredStyle: featured } : {}),
|
| 35 |
+
}));
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/** `?demo=` / 列表 id 为 slug;UI 展示用 order 中的 label,未知 slug 则回退 slug。 */
|
| 39 |
+
export function getBundledGenAttributeDemoLabel(slug: string): string {
|
| 40 |
+
const s = slug.trim();
|
| 41 |
+
const hit = GEN_ATTRIBUTE_BUNDLED_DEMOS.find((d) => d.slug === s);
|
| 42 |
+
return hit?.label ?? s;
|
| 43 |
}
|
| 44 |
|
| 45 |
/**
|
client/src/features/causal_flow/genAttributeBundledDemoManifest.generated.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
/**
|
| 2 |
* Generated by GenAttributeDemoManifestPlugin — do not edit.
|
| 3 |
*/
|
| 4 |
-
export type
|
| 5 |
-
export
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
* Generated by GenAttributeDemoManifestPlugin — do not edit.
|
| 3 |
*/
|
| 4 |
+
export type GenAttributeBundledDemoFeaturedStyle = 'bold';
|
| 5 |
+
export type GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string; readonly featured?: GenAttributeBundledDemoFeaturedStyle };
|
| 6 |
+
export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = [{"slug":"Write a sonnet about love","label":"Poem | Write a sonnet about love","featured":"bold"},{"slug":"写一首绝句,主题是春天","label":"写诗 | 写一首绝句,主题是春天","featured":"bold"},{"slug":"CoT | 苏州所在省的省会","label":"CoT | 苏州所在省的省会","featured":"bold"},{"slug":"CoT | 苏州所在省的省会城市里最高的山","label":"CoT | 苏州所在省的省会的最高的山"},{"slug":"CoT | 反向传播归因动画","label":"CoT | 思维链的反向传播归因动画","featured":"bold"},{"slug":"CoT | 多“跳”推理","label":"CoT | 多跳推理中“跳”的具象化"},{"slug":"过拟合|李白 将进酒","label":"过拟合|李白 将进酒","featured":"bold"},{"slug":"CN->EN翻译","label":"CN->EN | 翻译"}];
|
client/src/features/chat/chatPromptTemplateMode.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
/** Chat / Generate & Attribute 共用的「Raw prompt mode」开关 storage key */
|
| 2 |
export const LS_SKIP_CHAT_TEMPLATE = 'chat_skip_chat_template';
|
| 3 |
|
|
|
|
|
|
|
|
|
|
| 4 |
/** Enable thinking 开关(Chat / Causal Flow 各页独立 key,仅在 Chat template 模式下生效) */
|
| 5 |
export const CHAT_ENABLE_THINKING_STORAGE_KEY = 'info_radar_chat_enable_thinking';
|
| 6 |
export const GEN_ATTR_ENABLE_THINKING_STORAGE_KEY = 'info_radar_gen_attr_enable_thinking';
|
|
|
|
| 1 |
/** Chat / Generate & Attribute 共用的「Raw prompt mode」开关 storage key */
|
| 2 |
export const LS_SKIP_CHAT_TEMPLATE = 'chat_skip_chat_template';
|
| 3 |
|
| 4 |
+
/** Chat 页 Max new tokens(与 Causal Flow 的 gen_attr 键独立,不共享) */
|
| 5 |
+
export const CHAT_MAX_NEW_TOKENS_STORAGE_KEY = 'info_radar_chat_max_new_tokens';
|
| 6 |
+
|
| 7 |
/** Enable thinking 开关(Chat / Causal Flow 各页独立 key,仅在 Chat template 模式下生效) */
|
| 8 |
export const CHAT_ENABLE_THINKING_STORAGE_KEY = 'info_radar_chat_enable_thinking';
|
| 9 |
export const GEN_ATTR_ENABLE_THINKING_STORAGE_KEY = 'info_radar_gen_attr_enable_thinking';
|
client/src/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
"test:charIndex": "npx tsx tests/utils/charIndexForByteLimit.test.ts",
|
| 9 |
"test:findSplitPoint": "npx tsx tests/utils/findSplitPoint.test.ts",
|
| 10 |
"test:splitChunks": "npx tsx tests/utils/splitTextToChunks.test.ts",
|
|
|
|
| 11 |
"prebuild": "node scripts/updateIntroHTML.js",
|
| 12 |
"prebuild:dev": "node scripts/updateIntroHTML.js",
|
| 13 |
"wp": "npm run build:dev",
|
|
|
|
| 8 |
"test:charIndex": "npx tsx tests/utils/charIndexForByteLimit.test.ts",
|
| 9 |
"test:findSplitPoint": "npx tsx tests/utils/findSplitPoint.test.ts",
|
| 10 |
"test:splitChunks": "npx tsx tests/utils/splitTextToChunks.test.ts",
|
| 11 |
+
"test:dagPropagationPlayback": "npx tsx tests/prediction_attribution/genAttributeDagPropagationPlayback.test.ts",
|
| 12 |
"prebuild": "node scripts/updateIntroHTML.js",
|
| 13 |
"prebuild:dev": "node scripts/updateIntroHTML.js",
|
| 14 |
"wp": "npm run build:dev",
|
client/src/pages/causal_flow/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import '../../css/pages/causal_flow.scss';
|
|
| 4 |
|
| 5 |
import { initThemeManager } from '../../shared/ui/theme';
|
| 6 |
import { initLanguageManager } from '../../shared/ui/language';
|
| 7 |
-
import { initI18n, tr } from '../../shared/lang/i18n-lite';
|
| 8 |
import { AdminManager } from '../../shared/cross/adminManager';
|
| 9 |
import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
|
| 10 |
import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
|
|
@@ -37,10 +37,16 @@ import type { DagRecursiveEdgeReplayPacing } from '../../shared/prediction_attri
|
|
| 37 |
import {
|
| 38 |
createHydratedTokenGenHandle,
|
| 39 |
startTokenGenAttribution,
|
| 40 |
-
TOKEN_GEN_MAX_TOKENS_DEFAULT,
|
| 41 |
type TokenGenAttributionHandle,
|
| 42 |
type TokenGenStep,
|
| 43 |
} from '../../shared/prediction_attribution/causal_flow/tokenGenAttributionRunner';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
import { fetchTokenize } from '../../shared/prediction_attribution/core/predictionAttributeClient';
|
| 45 |
import { completionFinishReasonLabel, type CompletionFinishReason } from '../../shared/cross/generationEndReasonLabel';
|
| 46 |
import {
|
|
@@ -72,6 +78,7 @@ import {
|
|
| 72 |
} from '../../shared/cross/contentUrl';
|
| 73 |
import {
|
| 74 |
fetchBundledGenAttributeDemoBySlug,
|
|
|
|
| 75 |
getBundledGenAttributeDemoList,
|
| 76 |
isGenAttrRunPayloadValidForUi,
|
| 77 |
} from '../../features/causal_flow/bundledDemos';
|
|
@@ -110,7 +117,6 @@ const showToast = createToast('#toast').show;
|
|
| 110 |
|
| 111 |
const GEN_ATTR_MODEL_VARIANT_STORAGE_KEY = 'info_radar_gen_attr_model_variant';
|
| 112 |
const GEN_ATTR_MAX_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_max_tokens';
|
| 113 |
-
const GEN_ATTR_MAX_TOKENS_DEFAULT = TOKEN_GEN_MAX_TOKENS_DEFAULT;
|
| 114 |
const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
|
| 115 |
const GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_layout_mode';
|
| 116 |
const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
|
|
@@ -198,8 +204,9 @@ function readStoredModelVariant(): PredictionAttributeModelVariant {
|
|
| 198 |
}
|
| 199 |
|
| 200 |
function readStoredMaxTokens(): number {
|
| 201 |
-
|
| 202 |
-
|
|
|
|
| 203 |
});
|
| 204 |
}
|
| 205 |
|
|
@@ -532,7 +539,10 @@ function genAttrEffectiveExcludeGeneratedPatternsText(): string {
|
|
| 532 |
return genAttrExcludeGeneratedPatternsTa?.value ?? '';
|
| 533 |
}
|
| 534 |
|
| 535 |
-
if (maxTokensInput)
|
|
|
|
|
|
|
|
|
|
| 536 |
const initialDagLayoutMode = readStoredDagLayoutMode();
|
| 537 |
if (dagLayoutModeSelect) dagLayoutModeSelect.value = initialDagLayoutMode;
|
| 538 |
applyDagLayoutModeUi();
|
|
@@ -748,12 +758,17 @@ modelVariantSelect?.addEventListener('change', () => {
|
|
| 748 |
});
|
| 749 |
|
| 750 |
maxTokensInput?.addEventListener('change', () => {
|
|
|
|
| 751 |
lsSet(
|
| 752 |
GEN_ATTR_MAX_TOKENS_STORAGE_KEY,
|
| 753 |
-
maxTokensInput?.value ?? String(
|
| 754 |
);
|
| 755 |
syncSubmitButtonState();
|
| 756 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 757 |
|
| 758 |
// DAG 回放节奏(与上节「DAG 测量宽度」无关;宽度 listener 在后文)
|
| 759 |
dagPlaybackStepMsInput?.addEventListener('change', () => {
|
|
@@ -1575,13 +1590,22 @@ function currentModelVariant(): PredictionAttributeModelVariant {
|
|
| 1575 |
}
|
| 1576 |
|
| 1577 |
function currentMaxTokens(): number {
|
| 1578 |
-
|
| 1579 |
-
maxTokensInput?.value ?? String(
|
| 1580 |
-
|
| 1581 |
);
|
| 1582 |
-
|
| 1583 |
-
|
| 1584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1585 |
}
|
| 1586 |
|
| 1587 |
function syncIdleModelMetric(): void {
|
|
@@ -1605,7 +1629,7 @@ let lastRunInputSnapshot: string | null = null;
|
|
| 1605 |
function getInputSnapshotForRun(): string {
|
| 1606 |
const runOpts = {
|
| 1607 |
v: currentModelVariant(),
|
| 1608 |
-
max:
|
| 1609 |
tfOn: isGenAttrTeacherForcingUiOn(),
|
| 1610 |
tfText: (teacherForcingTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
|
| 1611 |
saOn: isStopAfterTeacherForcingOn(),
|
|
@@ -1640,7 +1664,10 @@ function isInputReadyForRun(): boolean {
|
|
| 1640 |
const forcing = teacherForcingContinuationForRun();
|
| 1641 |
if (prompt.length === 0 && forcing === undefined) return false;
|
| 1642 |
if (prompt.length > 0 && isGenAttrTeacherForcingUiOn() && forcing === undefined) return false;
|
| 1643 |
-
return
|
|
|
|
|
|
|
|
|
|
| 1644 |
}
|
| 1645 |
|
| 1646 |
function syncSubmitButtonState(): void {
|
|
@@ -1971,12 +1998,13 @@ const genAttrCachedHistoryBtn = document.getElementById('gen_attr_cached_history
|
|
| 1971 |
const genAttrCachedDemosBtn = document.getElementById('gen_attr_cached_demos_btn');
|
| 1972 |
const genAttrCachedDemosValueBtn = document.getElementById('gen_attr_cached_demos_value_btn');
|
| 1973 |
const genAttrCachedDemosValueEl = document.getElementById('gen_attr_cached_demos_value');
|
| 1974 |
-
let genAttrBundledDemoEntries: Array<{ id: string; label: string }> = [];
|
| 1975 |
|
| 1976 |
function syncGenAttrCachedDemosValueDisplay(): void {
|
| 1977 |
const slug = readDemoUrlParam();
|
| 1978 |
-
|
| 1979 |
-
if (
|
|
|
|
| 1980 |
}
|
| 1981 |
|
| 1982 |
genAttrCachedDemosValueBtn?.addEventListener('click', (e) => {
|
|
@@ -2343,8 +2371,16 @@ function syncGenAttrExportDemoBtn(): void {
|
|
| 2343 |
if (!exportDemoBtn) return;
|
| 2344 |
exportDemoBtn.style.display = adminManager.isInAdminMode() ? '' : 'none';
|
| 2345 |
}
|
| 2346 |
-
|
| 2347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2348 |
exportDemoBtn?.addEventListener('click', () => {
|
| 2349 |
void (async () => {
|
| 2350 |
const h = runnerHandle;
|
|
|
|
| 4 |
|
| 5 |
import { initThemeManager } from '../../shared/ui/theme';
|
| 6 |
import { initLanguageManager } from '../../shared/ui/language';
|
| 7 |
+
import { initI18n, tr, trf } from '../../shared/lang/i18n-lite';
|
| 8 |
import { AdminManager } from '../../shared/cross/adminManager';
|
| 9 |
import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
|
| 10 |
import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
|
|
|
|
| 37 |
import {
|
| 38 |
createHydratedTokenGenHandle,
|
| 39 |
startTokenGenAttribution,
|
|
|
|
| 40 |
type TokenGenAttributionHandle,
|
| 41 |
type TokenGenStep,
|
| 42 |
} from '../../shared/prediction_attribution/causal_flow/tokenGenAttributionRunner';
|
| 43 |
+
import {
|
| 44 |
+
DEFAULT_MAX_NEW_TOKENS,
|
| 45 |
+
finalizeMaxNewTokensInput,
|
| 46 |
+
isMaxNewTokensRawValid,
|
| 47 |
+
parseMaxNewTokens,
|
| 48 |
+
syncMaxNewTokensInputSiteMax,
|
| 49 |
+
} from '../../shared/cross/maxNewTokensConfig';
|
| 50 |
import { fetchTokenize } from '../../shared/prediction_attribution/core/predictionAttributeClient';
|
| 51 |
import { completionFinishReasonLabel, type CompletionFinishReason } from '../../shared/cross/generationEndReasonLabel';
|
| 52 |
import {
|
|
|
|
| 78 |
} from '../../shared/cross/contentUrl';
|
| 79 |
import {
|
| 80 |
fetchBundledGenAttributeDemoBySlug,
|
| 81 |
+
getBundledGenAttributeDemoLabel,
|
| 82 |
getBundledGenAttributeDemoList,
|
| 83 |
isGenAttrRunPayloadValidForUi,
|
| 84 |
} from '../../features/causal_flow/bundledDemos';
|
|
|
|
| 117 |
|
| 118 |
const GEN_ATTR_MODEL_VARIANT_STORAGE_KEY = 'info_radar_gen_attr_model_variant';
|
| 119 |
const GEN_ATTR_MAX_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_max_tokens';
|
|
|
|
| 120 |
const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
|
| 121 |
const GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_layout_mode';
|
| 122 |
const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
|
|
|
|
| 204 |
}
|
| 205 |
|
| 206 |
function readStoredMaxTokens(): number {
|
| 207 |
+
const admin = adminManager.isInAdminMode();
|
| 208 |
+
return lsReadNumber(GEN_ATTR_MAX_TOKENS_STORAGE_KEY, DEFAULT_MAX_NEW_TOKENS, {
|
| 209 |
+
validate: (n) => isMaxNewTokensRawValid(String(n), admin),
|
| 210 |
});
|
| 211 |
}
|
| 212 |
|
|
|
|
| 539 |
return genAttrExcludeGeneratedPatternsTa?.value ?? '';
|
| 540 |
}
|
| 541 |
|
| 542 |
+
if (maxTokensInput) {
|
| 543 |
+
maxTokensInput.value = String(readStoredMaxTokens());
|
| 544 |
+
syncMaxNewTokensInputSiteMax(maxTokensInput, adminManager.isInAdminMode());
|
| 545 |
+
}
|
| 546 |
const initialDagLayoutMode = readStoredDagLayoutMode();
|
| 547 |
if (dagLayoutModeSelect) dagLayoutModeSelect.value = initialDagLayoutMode;
|
| 548 |
applyDagLayoutModeUi();
|
|
|
|
| 758 |
});
|
| 759 |
|
| 760 |
maxTokensInput?.addEventListener('change', () => {
|
| 761 |
+
if (!normalizeGenAttrMaxTokensField()) return;
|
| 762 |
lsSet(
|
| 763 |
GEN_ATTR_MAX_TOKENS_STORAGE_KEY,
|
| 764 |
+
maxTokensInput?.value ?? String(DEFAULT_MAX_NEW_TOKENS),
|
| 765 |
);
|
| 766 |
syncSubmitButtonState();
|
| 767 |
});
|
| 768 |
+
maxTokensInput?.addEventListener('input', () => syncSubmitButtonState());
|
| 769 |
+
maxTokensInput?.addEventListener('blur', () => {
|
| 770 |
+
normalizeGenAttrMaxTokensField();
|
| 771 |
+
});
|
| 772 |
|
| 773 |
// DAG 回放节奏(与上节「DAG 测量宽度」无关;宽度 listener 在后文)
|
| 774 |
dagPlaybackStepMsInput?.addEventListener('change', () => {
|
|
|
|
| 1590 |
}
|
| 1591 |
|
| 1592 |
function currentMaxTokens(): number {
|
| 1593 |
+
return parseMaxNewTokens(
|
| 1594 |
+
maxTokensInput?.value ?? String(DEFAULT_MAX_NEW_TOKENS),
|
| 1595 |
+
adminManager.isInAdminMode()
|
| 1596 |
);
|
| 1597 |
+
}
|
| 1598 |
+
|
| 1599 |
+
function normalizeGenAttrMaxTokensField(): boolean {
|
| 1600 |
+
const ok = finalizeMaxNewTokensInput(
|
| 1601 |
+
maxTokensInput,
|
| 1602 |
+
adminManager.isInAdminMode(),
|
| 1603 |
+
(msg) => showAlertDialog(tr('LLM Causal Flow'), msg),
|
| 1604 |
+
tr,
|
| 1605 |
+
trf
|
| 1606 |
+
);
|
| 1607 |
+
syncSubmitButtonState();
|
| 1608 |
+
return ok;
|
| 1609 |
}
|
| 1610 |
|
| 1611 |
function syncIdleModelMetric(): void {
|
|
|
|
| 1629 |
function getInputSnapshotForRun(): string {
|
| 1630 |
const runOpts = {
|
| 1631 |
v: currentModelVariant(),
|
| 1632 |
+
max: maxTokensInput?.value ?? String(DEFAULT_MAX_NEW_TOKENS),
|
| 1633 |
tfOn: isGenAttrTeacherForcingUiOn(),
|
| 1634 |
tfText: (teacherForcingTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
|
| 1635 |
saOn: isStopAfterTeacherForcingOn(),
|
|
|
|
| 1664 |
const forcing = teacherForcingContinuationForRun();
|
| 1665 |
if (prompt.length === 0 && forcing === undefined) return false;
|
| 1666 |
if (prompt.length > 0 && isGenAttrTeacherForcingUiOn() && forcing === undefined) return false;
|
| 1667 |
+
return isMaxNewTokensRawValid(
|
| 1668 |
+
maxTokensInput?.value ?? '',
|
| 1669 |
+
adminManager.isInAdminMode()
|
| 1670 |
+
);
|
| 1671 |
}
|
| 1672 |
|
| 1673 |
function syncSubmitButtonState(): void {
|
|
|
|
| 1998 |
const genAttrCachedDemosBtn = document.getElementById('gen_attr_cached_demos_btn');
|
| 1999 |
const genAttrCachedDemosValueBtn = document.getElementById('gen_attr_cached_demos_value_btn');
|
| 2000 |
const genAttrCachedDemosValueEl = document.getElementById('gen_attr_cached_demos_value');
|
| 2001 |
+
let genAttrBundledDemoEntries: Array<{ id: string; label: string; featuredStyle?: string }> = [];
|
| 2002 |
|
| 2003 |
function syncGenAttrCachedDemosValueDisplay(): void {
|
| 2004 |
const slug = readDemoUrlParam();
|
| 2005 |
+
const display = slug ? getBundledGenAttributeDemoLabel(slug) : '';
|
| 2006 |
+
if (genAttrCachedDemosValueEl) genAttrCachedDemosValueEl.textContent = display;
|
| 2007 |
+
if (genAttrCachedDemosValueBtn) genAttrCachedDemosValueBtn.title = display;
|
| 2008 |
}
|
| 2009 |
|
| 2010 |
genAttrCachedDemosValueBtn?.addEventListener('click', (e) => {
|
|
|
|
| 2371 |
if (!exportDemoBtn) return;
|
| 2372 |
exportDemoBtn.style.display = adminManager.isInAdminMode() ? '' : 'none';
|
| 2373 |
}
|
| 2374 |
+
function syncGenAttrAdminUi(): void {
|
| 2375 |
+
syncGenAttrExportDemoBtn();
|
| 2376 |
+
syncMaxNewTokensInputSiteMax(maxTokensInput, adminManager.isInAdminMode());
|
| 2377 |
+
if (maxTokensInput) {
|
| 2378 |
+
maxTokensInput.value = String(readStoredMaxTokens());
|
| 2379 |
+
}
|
| 2380 |
+
normalizeGenAttrMaxTokensField();
|
| 2381 |
+
}
|
| 2382 |
+
syncGenAttrAdminUi();
|
| 2383 |
+
adminManager.onAdminModeChange(() => syncGenAttrAdminUi());
|
| 2384 |
exportDemoBtn?.addEventListener('click', () => {
|
| 2385 |
void (async () => {
|
| 2386 |
const h = runnerHandle;
|
client/src/pages/chat/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import '../../css/pages/chat.scss';
|
|
| 4 |
|
| 5 |
import { initThemeManager } from '../../shared/ui/theme';
|
| 6 |
import { initLanguageManager } from '../../shared/ui/language';
|
| 7 |
-
import { initI18n, tr } from '../../shared/lang/i18n-lite';
|
| 8 |
import { AdminManager } from '../../shared/cross/adminManager';
|
| 9 |
import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
|
| 10 |
import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
|
|
@@ -53,14 +53,24 @@ import {
|
|
| 53 |
} from '../../shared/cross/contentUrl';
|
| 54 |
import { CHAT_SURPRISAL_COLOR_MAP_MAX } from '../../shared/cross/SurprisalColorConfig';
|
| 55 |
import { updateChatCompletionMetrics } from '../../shared/cross/textMetricsUpdater';
|
| 56 |
-
import { lsReadBool, lsWriteBool } from '../../shared/storage/localStorageHelpers';
|
| 57 |
import {
|
| 58 |
CHAT_ENABLE_THINKING_STORAGE_KEY,
|
|
|
|
| 59 |
LS_SKIP_CHAT_TEMPLATE,
|
| 60 |
} from '../../features/chat/chatPromptTemplateMode';
|
| 61 |
import { createToast } from '../../shared/ui/toast';
|
| 62 |
import { initDensityAttributionSidebar } from '../../shared/prediction_attribution/density_sidebar/densityAttributionSidebar';
|
| 63 |
import { syncDraftCommittedButtonPair } from '../../shared/cross/syncDraftCommittedButtonPair';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
// 与首页一致:默认隐藏 Ask 旁的小菊花,仅在请求进行中再显示
|
| 66 |
d3.selectAll('.loadersmall').style('display', 'none');
|
|
@@ -305,7 +315,8 @@ function fingerprintsEqual(a: ChatCommittedFingerprint, b: ChatCommittedFingerpr
|
|
| 305 |
|
| 306 |
function syncAskButtonState(): void {
|
| 307 |
const fp = getCurrentFingerprint();
|
| 308 |
-
const idleInputsReady =
|
|
|
|
| 309 |
const hasUncommittedDraft =
|
| 310 |
lastCommittedFingerprint === null ||
|
| 311 |
!fingerprintsEqual(lastCommittedFingerprint, fp);
|
|
@@ -380,6 +391,12 @@ void new SettingsMenuManager(
|
|
| 380 |
'common'
|
| 381 |
);
|
| 382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
const flushStreamingPreview = (text: string, streamEnd: boolean): void => {
|
| 384 |
if (
|
| 385 |
!streamEnd &&
|
|
@@ -398,20 +415,64 @@ const getActivePromptValue = (): string => {
|
|
| 398 |
return (chatUserTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
|
| 399 |
};
|
| 400 |
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
}
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
}
|
| 412 |
-
|
| 413 |
}
|
| 414 |
|
|
|
|
|
|
|
| 415 |
const setAskLoading = (loading: boolean): void => {
|
| 416 |
askInFlight = loading;
|
| 417 |
loaderSmall.style('display', loading ? null : 'none');
|
|
@@ -433,9 +494,9 @@ const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
|
|
| 433 |
if (askInFlight || prompt.length === 0) return;
|
| 434 |
const forceRefresh = options?.forceRefresh === true;
|
| 435 |
|
| 436 |
-
let maxTokensOpt: number
|
| 437 |
try {
|
| 438 |
-
maxTokensOpt =
|
| 439 |
} catch (e: unknown) {
|
| 440 |
const msg = e instanceof Error ? e.message : String(e);
|
| 441 |
showAlertDialog(tr('LLM Raw Chat'), translateApiErrorMessage(msg));
|
|
@@ -494,7 +555,7 @@ const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
|
|
| 494 |
{
|
| 495 |
model: completionModel,
|
| 496 |
prompt: modelPrompt,
|
| 497 |
-
|
| 498 |
},
|
| 499 |
{
|
| 500 |
signal: askAbort.signal,
|
|
@@ -578,8 +639,16 @@ if (chatSystemPromptTextarea) {
|
|
| 578 |
syncAskButtonState();
|
| 579 |
});
|
| 580 |
}
|
| 581 |
-
maxNewTokensInput?.addEventListener('input', () =>
|
| 582 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
});
|
| 584 |
|
| 585 |
async function restoreChatFromCachedPrompt(
|
|
|
|
| 4 |
|
| 5 |
import { initThemeManager } from '../../shared/ui/theme';
|
| 6 |
import { initLanguageManager } from '../../shared/ui/language';
|
| 7 |
+
import { initI18n, tr, trf } from '../../shared/lang/i18n-lite';
|
| 8 |
import { AdminManager } from '../../shared/cross/adminManager';
|
| 9 |
import { SettingsMenuManager } from '../../shared/cross/settingsMenuManager';
|
| 10 |
import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
|
|
|
|
| 53 |
} from '../../shared/cross/contentUrl';
|
| 54 |
import { CHAT_SURPRISAL_COLOR_MAP_MAX } from '../../shared/cross/SurprisalColorConfig';
|
| 55 |
import { updateChatCompletionMetrics } from '../../shared/cross/textMetricsUpdater';
|
| 56 |
+
import { lsReadBool, lsReadNumber, lsSet, lsWriteBool } from '../../shared/storage/localStorageHelpers';
|
| 57 |
import {
|
| 58 |
CHAT_ENABLE_THINKING_STORAGE_KEY,
|
| 59 |
+
CHAT_MAX_NEW_TOKENS_STORAGE_KEY,
|
| 60 |
LS_SKIP_CHAT_TEMPLATE,
|
| 61 |
} from '../../features/chat/chatPromptTemplateMode';
|
| 62 |
import { createToast } from '../../shared/ui/toast';
|
| 63 |
import { initDensityAttributionSidebar } from '../../shared/prediction_attribution/density_sidebar/densityAttributionSidebar';
|
| 64 |
import { syncDraftCommittedButtonPair } from '../../shared/cross/syncDraftCommittedButtonPair';
|
| 65 |
+
import {
|
| 66 |
+
DEFAULT_MAX_NEW_TOKENS,
|
| 67 |
+
finalizeMaxNewTokensInput,
|
| 68 |
+
formatMaxNewTokensParseError,
|
| 69 |
+
MaxNewTokensParseError,
|
| 70 |
+
parseMaxNewTokens as parseMaxNewTokensShared,
|
| 71 |
+
isMaxNewTokensRawValid,
|
| 72 |
+
syncMaxNewTokensInputSiteMax,
|
| 73 |
+
} from '../../shared/cross/maxNewTokensConfig';
|
| 74 |
|
| 75 |
// 与首页一致:默认隐藏 Ask 旁的小菊花,仅在请求进行中再显示
|
| 76 |
d3.selectAll('.loadersmall').style('display', 'none');
|
|
|
|
| 315 |
|
| 316 |
function syncAskButtonState(): void {
|
| 317 |
const fp = getCurrentFingerprint();
|
| 318 |
+
const idleInputsReady =
|
| 319 |
+
('raw' in fp ? fp.raw.length > 0 : fp.user.length > 0) && isMaxNewTokensInputValid();
|
| 320 |
const hasUncommittedDraft =
|
| 321 |
lastCommittedFingerprint === null ||
|
| 322 |
!fingerprintsEqual(lastCommittedFingerprint, fp);
|
|
|
|
| 391 |
'common'
|
| 392 |
);
|
| 393 |
|
| 394 |
+
adminManager.onAdminModeChange(() => {
|
| 395 |
+
api.setAdminToken(adminManager.isInAdminMode() ? adminManager.getAdminToken() : null);
|
| 396 |
+
syncChatMaxNewTokensUi();
|
| 397 |
+
normalizeChatMaxNewTokensField();
|
| 398 |
+
});
|
| 399 |
+
|
| 400 |
const flushStreamingPreview = (text: string, streamEnd: boolean): void => {
|
| 401 |
if (
|
| 402 |
!streamEnd &&
|
|
|
|
| 415 |
return (chatUserTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
|
| 416 |
};
|
| 417 |
|
| 418 |
+
function parseMaxNewTokens(raw: string): number {
|
| 419 |
+
try {
|
| 420 |
+
return parseMaxNewTokensShared(raw, adminManager.isInAdminMode());
|
| 421 |
+
} catch (e) {
|
| 422 |
+
if (e instanceof MaxNewTokensParseError) {
|
| 423 |
+
throw new Error(
|
| 424 |
+
formatMaxNewTokensParseError(e.code, tr, trf)
|
| 425 |
+
);
|
| 426 |
+
}
|
| 427 |
+
throw e;
|
| 428 |
}
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
function normalizeChatMaxNewTokensField(): boolean {
|
| 432 |
+
const ok = finalizeMaxNewTokensInput(
|
| 433 |
+
maxNewTokensInput,
|
| 434 |
+
adminManager.isInAdminMode(),
|
| 435 |
+
(msg) => showAlertDialog(tr('LLM Raw Chat'), msg),
|
| 436 |
+
tr,
|
| 437 |
+
trf
|
| 438 |
+
);
|
| 439 |
+
syncAskButtonState();
|
| 440 |
+
return ok;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
function isMaxNewTokensInputValid(): boolean {
|
| 444 |
+
return isMaxNewTokensRawValid(
|
| 445 |
+
maxNewTokensInput?.value ?? '',
|
| 446 |
+
adminManager.isInAdminMode()
|
| 447 |
+
);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
function readChatStoredMaxTokens(): number {
|
| 451 |
+
return lsReadNumber(CHAT_MAX_NEW_TOKENS_STORAGE_KEY, DEFAULT_MAX_NEW_TOKENS, {
|
| 452 |
+
validate: (n) =>
|
| 453 |
+
isMaxNewTokensRawValid(String(n), adminManager.isInAdminMode()),
|
| 454 |
+
});
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
function persistChatMaxNewTokens(): void {
|
| 458 |
+
if (!maxNewTokensInput) return;
|
| 459 |
+
try {
|
| 460 |
+
const n = parseMaxNewTokens(maxNewTokensInput.value);
|
| 461 |
+
lsSet(CHAT_MAX_NEW_TOKENS_STORAGE_KEY, String(n));
|
| 462 |
+
} catch {
|
| 463 |
+
/* 非法值不写 storage */
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
function syncChatMaxNewTokensUi(): void {
|
| 468 |
+
if (maxNewTokensInput) {
|
| 469 |
+
maxNewTokensInput.value = String(readChatStoredMaxTokens());
|
| 470 |
}
|
| 471 |
+
syncMaxNewTokensInputSiteMax(maxNewTokensInput, adminManager.isInAdminMode());
|
| 472 |
}
|
| 473 |
|
| 474 |
+
syncChatMaxNewTokensUi();
|
| 475 |
+
|
| 476 |
const setAskLoading = (loading: boolean): void => {
|
| 477 |
askInFlight = loading;
|
| 478 |
loaderSmall.style('display', loading ? null : 'none');
|
|
|
|
| 494 |
if (askInFlight || prompt.length === 0) return;
|
| 495 |
const forceRefresh = options?.forceRefresh === true;
|
| 496 |
|
| 497 |
+
let maxTokensOpt: number;
|
| 498 |
try {
|
| 499 |
+
maxTokensOpt = parseMaxNewTokens(maxNewTokensInput?.value ?? '');
|
| 500 |
} catch (e: unknown) {
|
| 501 |
const msg = e instanceof Error ? e.message : String(e);
|
| 502 |
showAlertDialog(tr('LLM Raw Chat'), translateApiErrorMessage(msg));
|
|
|
|
| 555 |
{
|
| 556 |
model: completionModel,
|
| 557 |
prompt: modelPrompt,
|
| 558 |
+
max_tokens: maxTokensOpt
|
| 559 |
},
|
| 560 |
{
|
| 561 |
signal: askAbort.signal,
|
|
|
|
| 639 |
syncAskButtonState();
|
| 640 |
});
|
| 641 |
}
|
| 642 |
+
maxNewTokensInput?.addEventListener('input', () => syncAskButtonState());
|
| 643 |
+
maxNewTokensInput?.addEventListener('change', () => {
|
| 644 |
+
if (normalizeChatMaxNewTokensField()) {
|
| 645 |
+
persistChatMaxNewTokens();
|
| 646 |
+
}
|
| 647 |
+
});
|
| 648 |
+
maxNewTokensInput?.addEventListener('blur', () => {
|
| 649 |
+
if (normalizeChatMaxNewTokensField()) {
|
| 650 |
+
persistChatMaxNewTokens();
|
| 651 |
+
}
|
| 652 |
});
|
| 653 |
|
| 654 |
async function restoreChatFromCachedPrompt(
|
client/src/scripts/genAttributeDemoManifestPlugin.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
/**
|
| 2 |
* 构建前扫描 `assets/demos/causal_flow/*.json`,写入 `features/causal_flow/genAttributeBundledDemoManifest.generated.ts`,供 bundle 内联 demo 列表。
|
| 3 |
-
* 顺序与 UI 名:`order.json` 数组;项为 slug 字符串或 `{ slug, label? }`(无 label 则 UI 显示 slug)。
|
| 4 |
-
* 未列入 order 的 demo
|
|
|
|
| 5 |
* order 中的 slug 必须对应目录内已有 demo JSON;重复 slug 亦会在构建时报错。
|
| 6 |
*/
|
| 7 |
const path = require('path');
|
|
@@ -10,6 +11,7 @@ const fs = require('fs');
|
|
| 10 |
const REL_DIR = 'assets/demos/causal_flow';
|
| 11 |
const GENERATED_BASENAME = 'genAttributeBundledDemoManifest.generated.ts';
|
| 12 |
const ORDER_FILENAME = 'order.json';
|
|
|
|
| 13 |
|
| 14 |
function utf16Sort(slugs) {
|
| 15 |
return [...slugs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
@@ -24,7 +26,7 @@ function discoverSlugs(srcDir) {
|
|
| 24 |
.filter((s) => s.length > 0);
|
| 25 |
}
|
| 26 |
|
| 27 |
-
/** @returns {{ slug: string, label: string | null } | null} */
|
| 28 |
function parseOrderEntry(entry, index) {
|
| 29 |
const at = `${ORDER_FILENAME}[${index}]`;
|
| 30 |
if (typeof entry === 'string') {
|
|
@@ -38,10 +40,19 @@ function parseOrderEntry(entry, index) {
|
|
| 38 |
typeof entry.label === 'string' && entry.label.trim().length > 0
|
| 39 |
? entry.label.trim()
|
| 40 |
: null;
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
throw new Error(
|
| 44 |
-
`${at}: expected a slug string or { "slug": "...", "label"?: "..." }`
|
| 45 |
);
|
| 46 |
}
|
| 47 |
|
|
@@ -64,6 +75,40 @@ function resolveLabel(slug, label) {
|
|
| 64 |
return label ?? slug;
|
| 65 |
}
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
function collectDemoEntries(srcDir) {
|
| 68 |
const discovered = new Set(discoverSlugs(srcDir));
|
| 69 |
const order = readOrderEntries(srcDir);
|
|
@@ -75,7 +120,7 @@ function collectDemoEntries(srcDir) {
|
|
| 75 |
}
|
| 76 |
const seen = new Set();
|
| 77 |
const result = [];
|
| 78 |
-
for (const { slug, label } of order) {
|
| 79 |
if (seen.has(slug)) {
|
| 80 |
throw new Error(`${ORDER_FILENAME}: duplicate slug ${JSON.stringify(slug)}`);
|
| 81 |
}
|
|
@@ -85,7 +130,9 @@ function collectDemoEntries(srcDir) {
|
|
| 85 |
);
|
| 86 |
}
|
| 87 |
seen.add(slug);
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
for (const slug of utf16Sort([...discovered].filter((s) => !seen.has(s)))) {
|
| 91 |
result.push({ slug, label: slug });
|
|
@@ -94,12 +141,14 @@ function collectDemoEntries(srcDir) {
|
|
| 94 |
}
|
| 95 |
|
| 96 |
function writeGeneratedModule(srcDir, outPath) {
|
|
|
|
| 97 |
const entries = collectDemoEntries(srcDir);
|
| 98 |
const content =
|
| 99 |
'/**\n' +
|
| 100 |
' * Generated by GenAttributeDemoManifestPlugin — do not edit.\n' +
|
| 101 |
' */\n' +
|
| 102 |
-
'export type
|
|
|
|
| 103 |
`export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = ${JSON.stringify(entries)};\n`;
|
| 104 |
if (fs.existsSync(outPath) && fs.readFileSync(outPath, 'utf8') === content) return;
|
| 105 |
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
@@ -126,4 +175,11 @@ class GenAttributeDemoManifestPlugin {
|
|
| 126 |
}
|
| 127 |
}
|
| 128 |
|
| 129 |
-
module.exports = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
* 构建前扫描 `assets/demos/causal_flow/*.json`,写入 `features/causal_flow/genAttributeBundledDemoManifest.generated.ts`,供 bundle 内联 demo 列表。
|
| 3 |
+
* 顺序与 UI 名:`order.json` 数组;项为 slug 字符串或 `{ slug, label?, featured? }`(无 label 则 UI 显示 slug;`featured: "bold"` 等样式见 VALID_ORDER_FEATURED)。
|
| 4 |
+
* 目录内存在但尚未列入 order 的 demo 会在构建时自动追加到 order.json 末尾(`slug` 与 `label` 均为文件名 stem,便于人工改 label)。
|
| 5 |
+
* 若 order.json 不存在则按 UTF-16 码元序生成完整列表。manifest 生成时若仍有遗漏则按 UTF-16 追加。
|
| 6 |
* order 中的 slug 必须对应目录内已有 demo JSON;重复 slug 亦会在构建时报错。
|
| 7 |
*/
|
| 8 |
const path = require('path');
|
|
|
|
| 11 |
const REL_DIR = 'assets/demos/causal_flow';
|
| 12 |
const GENERATED_BASENAME = 'genAttributeBundledDemoManifest.generated.ts';
|
| 13 |
const ORDER_FILENAME = 'order.json';
|
| 14 |
+
const VALID_ORDER_FEATURED = new Set(['bold']);
|
| 15 |
|
| 16 |
function utf16Sort(slugs) {
|
| 17 |
return [...slugs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
|
|
| 26 |
.filter((s) => s.length > 0);
|
| 27 |
}
|
| 28 |
|
| 29 |
+
/** @returns {{ slug: string, label: string | null, featured?: string } | null} */
|
| 30 |
function parseOrderEntry(entry, index) {
|
| 31 |
const at = `${ORDER_FILENAME}[${index}]`;
|
| 32 |
if (typeof entry === 'string') {
|
|
|
|
| 40 |
typeof entry.label === 'string' && entry.label.trim().length > 0
|
| 41 |
? entry.label.trim()
|
| 42 |
: null;
|
| 43 |
+
let featured;
|
| 44 |
+
if (entry.featured != null) {
|
| 45 |
+
if (typeof entry.featured !== 'string' || !VALID_ORDER_FEATURED.has(entry.featured)) {
|
| 46 |
+
throw new Error(
|
| 47 |
+
`${at}: unknown featured ${JSON.stringify(entry.featured)} (supported: ${[...VALID_ORDER_FEATURED].join(', ')})`,
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
featured = entry.featured;
|
| 51 |
+
}
|
| 52 |
+
return featured ? { slug, label, featured } : { slug, label };
|
| 53 |
}
|
| 54 |
throw new Error(
|
| 55 |
+
`${at}: expected a slug string or { "slug": "...", "label"?: "...", "featured"?: "bold" }`
|
| 56 |
);
|
| 57 |
}
|
| 58 |
|
|
|
|
| 75 |
return label ?? slug;
|
| 76 |
}
|
| 77 |
|
| 78 |
+
/** @param {{ slug: string, label: string | null, featured?: string }[]} entries */
|
| 79 |
+
function serializeOrderFile(entries) {
|
| 80 |
+
const body = entries.map(({ slug, label, featured }) => {
|
| 81 |
+
const row = { slug, label: resolveLabel(slug, label) };
|
| 82 |
+
if (featured) row.featured = featured;
|
| 83 |
+
return row;
|
| 84 |
+
});
|
| 85 |
+
return `${JSON.stringify(body, null, 2)}\n`;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/** 将新发现的 demo 追加到 order.json 末尾;无 order 文件时生成完整列表。 */
|
| 89 |
+
function syncOrderJson(srcDir) {
|
| 90 |
+
const discovered = discoverSlugs(srcDir);
|
| 91 |
+
if (discovered.length === 0) return;
|
| 92 |
+
|
| 93 |
+
const orderPath = path.join(srcDir, ORDER_FILENAME);
|
| 94 |
+
let orderEntries = readOrderEntries(srcDir);
|
| 95 |
+
const seen = new Set((orderEntries ?? []).map((e) => e.slug));
|
| 96 |
+
const missing = utf16Sort(discovered.filter((s) => !seen.has(s)));
|
| 97 |
+
if (missing.length === 0) return;
|
| 98 |
+
|
| 99 |
+
if (orderEntries == null) {
|
| 100 |
+
orderEntries = utf16Sort(discovered).map((slug) => ({ slug, label: slug }));
|
| 101 |
+
} else {
|
| 102 |
+
for (const slug of missing) {
|
| 103 |
+
orderEntries.push({ slug, label: slug });
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const next = serializeOrderFile(orderEntries);
|
| 108 |
+
if (fs.existsSync(orderPath) && fs.readFileSync(orderPath, 'utf8') === next) return;
|
| 109 |
+
fs.writeFileSync(orderPath, next, 'utf8');
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
function collectDemoEntries(srcDir) {
|
| 113 |
const discovered = new Set(discoverSlugs(srcDir));
|
| 114 |
const order = readOrderEntries(srcDir);
|
|
|
|
| 120 |
}
|
| 121 |
const seen = new Set();
|
| 122 |
const result = [];
|
| 123 |
+
for (const { slug, label, featured } of order) {
|
| 124 |
if (seen.has(slug)) {
|
| 125 |
throw new Error(`${ORDER_FILENAME}: duplicate slug ${JSON.stringify(slug)}`);
|
| 126 |
}
|
|
|
|
| 130 |
);
|
| 131 |
}
|
| 132 |
seen.add(slug);
|
| 133 |
+
const row = { slug, label: resolveLabel(slug, label) };
|
| 134 |
+
if (featured) row.featured = featured;
|
| 135 |
+
result.push(row);
|
| 136 |
}
|
| 137 |
for (const slug of utf16Sort([...discovered].filter((s) => !seen.has(s)))) {
|
| 138 |
result.push({ slug, label: slug });
|
|
|
|
| 141 |
}
|
| 142 |
|
| 143 |
function writeGeneratedModule(srcDir, outPath) {
|
| 144 |
+
syncOrderJson(srcDir);
|
| 145 |
const entries = collectDemoEntries(srcDir);
|
| 146 |
const content =
|
| 147 |
'/**\n' +
|
| 148 |
' * Generated by GenAttributeDemoManifestPlugin — do not edit.\n' +
|
| 149 |
' */\n' +
|
| 150 |
+
'export type GenAttributeBundledDemoFeaturedStyle = \'bold\';\n' +
|
| 151 |
+
'export type GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string; readonly featured?: GenAttributeBundledDemoFeaturedStyle };\n' +
|
| 152 |
`export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = ${JSON.stringify(entries)};\n`;
|
| 153 |
if (fs.existsSync(outPath) && fs.readFileSync(outPath, 'utf8') === content) return;
|
| 154 |
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
|
|
| 175 |
}
|
| 176 |
}
|
| 177 |
|
| 178 |
+
module.exports = {
|
| 179 |
+
GenAttributeDemoManifestPlugin,
|
| 180 |
+
syncOrderJson,
|
| 181 |
+
collectDemoEntries,
|
| 182 |
+
discoverSlugs,
|
| 183 |
+
ORDER_FILENAME,
|
| 184 |
+
REL_DIR,
|
| 185 |
+
};
|
client/src/shared/api/completionsClient.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
| 1 |
import URLHandler from '../core/URLHandler';
|
| 2 |
import * as completionResultCache from '../../features/chat/completionResultCache';
|
|
|
|
| 3 |
import type { TokenWithOffset } from './generatedSchemas';
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
/** 与 server.yaml basePath `/api` + `/v1/completions` 一致 */
|
| 6 |
const COMPLETIONS_PATH = '/api/v1/completions';
|
| 7 |
const COMPLETIONS_PROMPT_PATH = '/api/v1/completions/prompt';
|
|
@@ -53,7 +66,7 @@ export function postCompletionsStop(): void {
|
|
| 53 |
const url = URLHandler.basicURL() + COMPLETIONS_STOP_PATH;
|
| 54 |
void fetch(url, {
|
| 55 |
method: 'POST',
|
| 56 |
-
headers:
|
| 57 |
body: '{}'
|
| 58 |
}).catch(() => {
|
| 59 |
/* 忽略:Stop 与 SSE 并行,失败时生成仍可能靠墙钟或其它路径结束 */
|
|
@@ -90,7 +103,7 @@ export async function postCompletionsPrompt(
|
|
| 90 |
}
|
| 91 |
const res = await fetch(url, {
|
| 92 |
method: 'POST',
|
| 93 |
-
headers:
|
| 94 |
body: JSON.stringify(payload),
|
| 95 |
signal
|
| 96 |
});
|
|
@@ -219,7 +232,7 @@ export async function postCompletions(
|
|
| 219 |
function fetchRemote(): void {
|
| 220 |
fetch(URLHandler.basicURL() + COMPLETIONS_PATH, {
|
| 221 |
method: 'POST',
|
| 222 |
-
headers:
|
| 223 |
body: JSON.stringify(body),
|
| 224 |
signal
|
| 225 |
})
|
|
|
|
| 1 |
import URLHandler from '../core/URLHandler';
|
| 2 |
import * as completionResultCache from '../../features/chat/completionResultCache';
|
| 3 |
+
import { AdminManager } from '../cross/adminManager';
|
| 4 |
import type { TokenWithOffset } from './generatedSchemas';
|
| 5 |
|
| 6 |
+
function completionsRequestHeaders(): Record<string, string> {
|
| 7 |
+
const headers: Record<string, string> = {
|
| 8 |
+
'Content-Type': 'application/json; charset=UTF-8',
|
| 9 |
+
};
|
| 10 |
+
const admin = AdminManager.getInstance();
|
| 11 |
+
const token = admin.isInAdminMode() ? admin.getAdminToken() : null;
|
| 12 |
+
if (token) {
|
| 13 |
+
headers['X-Admin-Token'] = token;
|
| 14 |
+
}
|
| 15 |
+
return headers;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
/** 与 server.yaml basePath `/api` + `/v1/completions` 一致 */
|
| 19 |
const COMPLETIONS_PATH = '/api/v1/completions';
|
| 20 |
const COMPLETIONS_PROMPT_PATH = '/api/v1/completions/prompt';
|
|
|
|
| 66 |
const url = URLHandler.basicURL() + COMPLETIONS_STOP_PATH;
|
| 67 |
void fetch(url, {
|
| 68 |
method: 'POST',
|
| 69 |
+
headers: completionsRequestHeaders(),
|
| 70 |
body: '{}'
|
| 71 |
}).catch(() => {
|
| 72 |
/* 忽略:Stop 与 SSE 并行,失败时生成仍可能靠墙钟或其它路径结束 */
|
|
|
|
| 103 |
}
|
| 104 |
const res = await fetch(url, {
|
| 105 |
method: 'POST',
|
| 106 |
+
headers: completionsRequestHeaders(),
|
| 107 |
body: JSON.stringify(payload),
|
| 108 |
signal
|
| 109 |
});
|
|
|
|
| 232 |
function fetchRemote(): void {
|
| 233 |
fetch(URLHandler.basicURL() + COMPLETIONS_PATH, {
|
| 234 |
method: 'POST',
|
| 235 |
+
headers: completionsRequestHeaders(),
|
| 236 |
body: JSON.stringify(body),
|
| 237 |
signal
|
| 238 |
})
|
client/src/shared/cross/maxNewTokensConfig.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** 与 backend/core/completion_generator.py completion_max_token_length 一致。 */
|
| 2 |
+
export const SITE_MAX_NEW_TOKENS = 300;
|
| 3 |
+
|
| 4 |
+
export const DEFAULT_MAX_NEW_TOKENS = 200;
|
| 5 |
+
|
| 6 |
+
export type MaxNewTokensParseErrorCode = 'empty' | 'invalid' | 'exceeds_site';
|
| 7 |
+
|
| 8 |
+
export class MaxNewTokensParseError extends Error {
|
| 9 |
+
readonly code: MaxNewTokensParseErrorCode;
|
| 10 |
+
|
| 11 |
+
constructor(code: MaxNewTokensParseErrorCode) {
|
| 12 |
+
super(code);
|
| 13 |
+
this.code = code;
|
| 14 |
+
this.name = 'MaxNewTokensParseError';
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function parseMaxNewTokens(raw: string, admin: boolean): number {
|
| 19 |
+
const t = raw.trim();
|
| 20 |
+
if (t === '') {
|
| 21 |
+
throw new MaxNewTokensParseError('empty');
|
| 22 |
+
}
|
| 23 |
+
if (!/^\d+$/.test(t)) {
|
| 24 |
+
throw new MaxNewTokensParseError('invalid');
|
| 25 |
+
}
|
| 26 |
+
const n = parseInt(t, 10);
|
| 27 |
+
if (n <= 0) {
|
| 28 |
+
throw new MaxNewTokensParseError('invalid');
|
| 29 |
+
}
|
| 30 |
+
if (!admin && n > SITE_MAX_NEW_TOKENS) {
|
| 31 |
+
throw new MaxNewTokensParseError('exceeds_site');
|
| 32 |
+
}
|
| 33 |
+
return n;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export function isMaxNewTokensRawValid(raw: string, admin: boolean): boolean {
|
| 37 |
+
try {
|
| 38 |
+
parseMaxNewTokens(raw, admin);
|
| 39 |
+
return true;
|
| 40 |
+
} catch {
|
| 41 |
+
return false;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export function ensureMaxNewTokensInputNotEmpty(input: HTMLInputElement | null): void {
|
| 46 |
+
if (input && input.value.trim() === '') {
|
| 47 |
+
input.value = String(DEFAULT_MAX_NEW_TOKENS);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** 非管理员时 HTML max=站点上限;管理员去掉 max 以便填更大数字。 */
|
| 52 |
+
export function syncMaxNewTokensInputSiteMax(
|
| 53 |
+
input: HTMLInputElement | null,
|
| 54 |
+
admin: boolean
|
| 55 |
+
): void {
|
| 56 |
+
if (!input) return;
|
| 57 |
+
if (admin) {
|
| 58 |
+
input.removeAttribute('max');
|
| 59 |
+
} else {
|
| 60 |
+
input.max = String(SITE_MAX_NEW_TOKENS);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
type TrFn = (text: string) => string;
|
| 65 |
+
type TrfFn = (text: string, vars: Record<string, string | number>) => string;
|
| 66 |
+
|
| 67 |
+
export function formatMaxNewTokensParseError(
|
| 68 |
+
code: MaxNewTokensParseErrorCode,
|
| 69 |
+
tr: TrFn,
|
| 70 |
+
trf: TrfFn
|
| 71 |
+
): string {
|
| 72 |
+
if (code === 'exceeds_site') {
|
| 73 |
+
return trf('Max new tokens must not exceed {limit}', { limit: SITE_MAX_NEW_TOKENS });
|
| 74 |
+
}
|
| 75 |
+
return tr('Max new tokens must be a positive integer');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/** blur/change 时校验;非法则 alert、修正输入框,返回是否有效。 */
|
| 79 |
+
export function finalizeMaxNewTokensInput(
|
| 80 |
+
input: HTMLInputElement | null,
|
| 81 |
+
admin: boolean,
|
| 82 |
+
onAlert: (message: string) => void,
|
| 83 |
+
tr: TrFn,
|
| 84 |
+
trf: TrfFn
|
| 85 |
+
): boolean {
|
| 86 |
+
ensureMaxNewTokensInputNotEmpty(input);
|
| 87 |
+
if (!input) return false;
|
| 88 |
+
try {
|
| 89 |
+
parseMaxNewTokens(input.value, admin);
|
| 90 |
+
return true;
|
| 91 |
+
} catch (e) {
|
| 92 |
+
if (!(e instanceof MaxNewTokensParseError)) throw e;
|
| 93 |
+
onAlert(formatMaxNewTokensParseError(e.code, tr, trf));
|
| 94 |
+
input.value =
|
| 95 |
+
e.code === 'exceeds_site'
|
| 96 |
+
? String(SITE_MAX_NEW_TOKENS)
|
| 97 |
+
: String(DEFAULT_MAX_NEW_TOKENS);
|
| 98 |
+
return false;
|
| 99 |
+
}
|
| 100 |
+
}
|
client/src/shared/cross/queryHistory.ts
CHANGED
|
@@ -108,7 +108,7 @@ export interface InitQueryHistoryDropdownOptions {
|
|
| 108 |
* 与 {@link getHistoryItems} 二选一:每项含稳定 id(如续写缓存的 contentKey)与展示 label。
|
| 109 |
* 选中/删除/置顶回调均传递 id。
|
| 110 |
*/
|
| 111 |
-
getHistoryEntries?: () => Array<{ id: string; label: string }>;
|
| 112 |
/**
|
| 113 |
* 每次渲染列表前调用(如打开下拉时从 IndexedDB 刷新内存镜像)。
|
| 114 |
* 失败时仍会继续渲染,避免下拉空白。
|
|
@@ -210,7 +210,10 @@ export function initQueryHistoryDropdown(options: InitQueryHistoryDropdownOption
|
|
| 210 |
const display = row.label;
|
| 211 |
const li = document.createElement('li');
|
| 212 |
const span = document.createElement('span');
|
| 213 |
-
span.className =
|
|
|
|
|
|
|
|
|
|
| 214 |
span.textContent = display;
|
| 215 |
if (!pointerFineHover) span.title = display;
|
| 216 |
let promoteBtn: HTMLButtonElement | null = null;
|
|
|
|
| 108 |
* 与 {@link getHistoryItems} 二选一:每项含稳定 id(如续写缓存的 contentKey)与展示 label。
|
| 109 |
* 选中/删除/置顶回调均传递 id。
|
| 110 |
*/
|
| 111 |
+
getHistoryEntries?: () => Array<{ id: string; label: string; featuredStyle?: string }>;
|
| 112 |
/**
|
| 113 |
* 每次渲染列表前调用(如打开下拉时从 IndexedDB 刷新内存镜像)。
|
| 114 |
* 失败时仍会继续渲染,避免下拉空白。
|
|
|
|
| 210 |
const display = row.label;
|
| 211 |
const li = document.createElement('li');
|
| 212 |
const span = document.createElement('span');
|
| 213 |
+
span.className =
|
| 214 |
+
row.featuredStyle === 'bold'
|
| 215 |
+
? 'history-text history-text--bold'
|
| 216 |
+
: 'history-text';
|
| 217 |
span.textContent = display;
|
| 218 |
if (!pointerFineHover) span.title = display;
|
| 219 |
let promoteBtn: HTMLButtonElement | null = null;
|
client/src/shared/cross/tokenDisplayUtils.ts
CHANGED
|
@@ -50,6 +50,11 @@ export type VisualizeSpecialCharsOptions = {
|
|
| 50 |
* 省略或 false:每个 ASCII 空格都变为 ·(与 Tooltip / 候选词等一致)。
|
| 51 |
*/
|
| 52 |
spaceDotExceptBeforeAsciiLetterOrNumber?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
};
|
| 54 |
|
| 55 |
function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialCharsOptions): string {
|
|
@@ -88,8 +93,7 @@ function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialChars
|
|
| 88 |
} else {
|
| 89 |
const codePoint = char.codePointAt(0);
|
| 90 |
if (codePoint !== undefined) {
|
| 91 |
-
|
| 92 |
-
processed.push(`[${hexCode}]`);
|
| 93 |
} else {
|
| 94 |
processed.push(char);
|
| 95 |
}
|
|
|
|
| 50 |
* 省略或 false:每个 ASCII 空格都变为 ·(与 Tooltip / 候选词等一致)。
|
| 51 |
*/
|
| 52 |
spaceDotExceptBeforeAsciiLetterOrNumber?: boolean;
|
| 53 |
+
/**
|
| 54 |
+
* 为 true(如 DAG 节点 SVG 标签):不可打印码点显示为 `[]` 而非 `[hex]`。
|
| 55 |
+
* Tooltip 等需辨认码点的场景勿开启。
|
| 56 |
+
*/
|
| 57 |
+
omitHexInCodePointLabel?: boolean;
|
| 58 |
};
|
| 59 |
|
| 60 |
function visualizeSpecialCharsImpl(text: string, options?: VisualizeSpecialCharsOptions): string {
|
|
|
|
| 93 |
} else {
|
| 94 |
const codePoint = char.codePointAt(0);
|
| 95 |
if (codePoint !== undefined) {
|
| 96 |
+
processed.push(options?.omitHexInCodePointLabel === true ? '[]' : `[${codePoint.toString(16).toLowerCase().padStart(4, '0')}]`);
|
|
|
|
| 97 |
} else {
|
| 98 |
processed.push(char);
|
| 99 |
}
|
client/src/shared/lang/translations.ts
CHANGED
|
@@ -37,6 +37,8 @@ export const translations: Translations = {
|
|
| 37 |
'LLM Raw Chat - chat with explicit raw prompts': 'LLM Raw Chat 原始对话 - 用精确的 prompt 进行对话',
|
| 38 |
'Context Attribution - attribute a predicted token to its context': 'Context Attribution 上下文归因 - 将预测 token 归因到上下文',
|
| 39 |
'LLM Causal Flow - explore the context-attribution DAG': 'LLM Causal Flow 因果流 - 探索上下文归因的 DAG 关系图',
|
|
|
|
|
|
|
| 40 |
// LLM Causal Flow(gen_attribute)页:placeholder / title 文案
|
| 41 |
'When enabled, each line below is a regex with the global flag, matched only within the initial static prompt prefix (excluding generated continuation). If a token offset lies fully inside a match, its score is treated as 0.':
|
| 42 |
'启用后仅在初始静态 prompt 前缀内按下列正则匹配(不含已生成 continuation);token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
|
|
|
|
| 37 |
'LLM Raw Chat - chat with explicit raw prompts': 'LLM Raw Chat 原始对话 - 用精确的 prompt 进行对话',
|
| 38 |
'Context Attribution - attribute a predicted token to its context': 'Context Attribution 上下文归因 - 将预测 token 归因到上下文',
|
| 39 |
'LLM Causal Flow - explore the context-attribution DAG': 'LLM Causal Flow 因果流 - 探索上下文归因的 DAG 关系图',
|
| 40 |
+
'Max new tokens must not exceed {limit}': 'Max new tokens 不得超过 {limit}',
|
| 41 |
+
'Max new tokens must be a positive integer': 'Max new tokens 须为正整数',
|
| 42 |
// LLM Causal Flow(gen_attribute)页:placeholder / title 文案
|
| 43 |
'When enabled, each line below is a regex with the global flag, matched only within the initial static prompt prefix (excluding generated continuation). If a token offset lies fully inside a match, its score is treated as 0.':
|
| 44 |
'启用后仅在初始静态 prompt 前缀内按下列正则匹配(不含已生成 continuation);token 的 offset 完全落在某次匹配区间内则 score 视为 0。',
|
client/src/shared/prediction_attribution/causal_flow/genAttributeDagEdgeRenderStrength.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { DAG_EDGE_RENDER_OPACITY_FLOOR } from './genAttributeDagEdgeDisplay';
|
| 2 |
+
import { maxHighlightEdgeShare } from './genAttributeDagRecursiveEdgeAnimation';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* 池内 max 归一后的 `stroke-opacity`;最强边刻度为 {@link maxOpacity}(默认 1)。
|
| 6 |
+
* 按实际值计算后,最终不低于 {@link DAG_EDGE_RENDER_OPACITY_FLOOR},防止过淡不可见。
|
| 7 |
+
*/
|
| 8 |
+
export function normalizeEdgeRenderOpacity(share: number, maxShare: number, maxOpacity = 1): number {
|
| 9 |
+
if (!Number.isFinite(share) || share <= 0) return 0;
|
| 10 |
+
const cap = Number.isFinite(maxOpacity) && maxOpacity > 0 ? maxOpacity : 1;
|
| 11 |
+
const scaled =
|
| 12 |
+
!Number.isFinite(maxShare) || maxShare <= 0
|
| 13 |
+
? Math.min(cap, share)
|
| 14 |
+
: Math.min(cap, (share / maxShare) * cap);
|
| 15 |
+
if (scaled <= 0) return 0;
|
| 16 |
+
return Math.max(DAG_EDGE_RENDER_OPACITY_FLOOR, scaled);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* 池内 max 归一后的 render 强度。
|
| 21 |
+
* - 默认:{@link sharesByKey} 全表 max;
|
| 22 |
+
* - {@link maxShareOverride}:蓝入边前沿分母;
|
| 23 |
+
* - {@link onlyKeys}:仅输出这些 key(红入边:集合内 max,忽略 maxShareOverride 外的键)。
|
| 24 |
+
*/
|
| 25 |
+
export function buildMaxNormalizedRenderStrengthByKey(
|
| 26 |
+
sharesByKey: Map<string, number>,
|
| 27 |
+
maxOpacity = 1,
|
| 28 |
+
maxShareOverride?: number,
|
| 29 |
+
onlyKeys?: ReadonlySet<string>,
|
| 30 |
+
): Map<string, number> {
|
| 31 |
+
let maxShare: number;
|
| 32 |
+
if (maxShareOverride != null && Number.isFinite(maxShareOverride) && maxShareOverride > 0) {
|
| 33 |
+
maxShare = maxShareOverride;
|
| 34 |
+
} else if (onlyKeys != null) {
|
| 35 |
+
maxShare = 0;
|
| 36 |
+
for (const key of onlyKeys) {
|
| 37 |
+
const share = sharesByKey.get(key);
|
| 38 |
+
if (share != null && share > maxShare) maxShare = share;
|
| 39 |
+
}
|
| 40 |
+
} else {
|
| 41 |
+
maxShare = maxHighlightEdgeShare(sharesByKey);
|
| 42 |
+
}
|
| 43 |
+
const byKey = new Map<string, number>();
|
| 44 |
+
const keys = onlyKeys ?? sharesByKey.keys();
|
| 45 |
+
for (const key of keys) {
|
| 46 |
+
const share = sharesByKey.get(key);
|
| 47 |
+
if (share != null) {
|
| 48 |
+
byKey.set(key, normalizeEdgeRenderOpacity(share, maxShare, maxOpacity));
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
return byKey;
|
| 52 |
+
}
|
client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackLog.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** 浏览器控制台调试前缀;过滤:`dag-prop`。 */
|
| 2 |
+
const DAG_PROPAGATION_PLAYBACK_LOG = '[dag-prop]';
|
| 3 |
+
|
| 4 |
+
/** playback 日志最小列宽(不足则填充;超出不截断)。 */
|
| 5 |
+
export const DAG_PROP_LOG_W = {
|
| 6 |
+
event: 7,
|
| 7 |
+
frame: 6,
|
| 8 |
+
token: 10,
|
| 9 |
+
weight: 7,
|
| 10 |
+
dwell: 5,
|
| 11 |
+
focus: 10,
|
| 12 |
+
direction: 8,
|
| 13 |
+
int3: 3,
|
| 14 |
+
} as const;
|
| 15 |
+
|
| 16 |
+
/** localStorage:`localStorage.setItem('info_radar.dag_propagation_playback_log', '1')` */
|
| 17 |
+
export const DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY = 'info_radar.dag_propagation_playback_log';
|
| 18 |
+
|
| 19 |
+
export function isDagPropagationPlaybackLogEnabled(): boolean {
|
| 20 |
+
if (typeof globalThis === 'undefined') return false;
|
| 21 |
+
const g = globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean };
|
| 22 |
+
if (g.__DAG_PROPAGATION_PLAYBACK_LOG__ === true) return true;
|
| 23 |
+
try {
|
| 24 |
+
return localStorage.getItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY) === '1';
|
| 25 |
+
} catch {
|
| 26 |
+
return false;
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** 控制台:`infoRadar.dagPropagationPlaybackLog(true)` */
|
| 31 |
+
export function setDagPropagationPlaybackLogEnabled(enabled: boolean): void {
|
| 32 |
+
if (typeof globalThis !== 'undefined') {
|
| 33 |
+
(globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean }).__DAG_PROPAGATION_PLAYBACK_LOG__ =
|
| 34 |
+
enabled;
|
| 35 |
+
}
|
| 36 |
+
try {
|
| 37 |
+
if (enabled) localStorage.setItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY, '1');
|
| 38 |
+
else localStorage.removeItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY);
|
| 39 |
+
} catch {
|
| 40 |
+
/* private mode / disabled storage */
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function logDagPropagationPlaybackLine(line: string): void {
|
| 45 |
+
if (!isDagPropagationPlaybackLogEnabled()) return;
|
| 46 |
+
console.log(`${DAG_PROPAGATION_PLAYBACK_LOG} ${line}`);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (typeof window !== 'undefined') {
|
| 50 |
+
const w = window as Window & { infoRadar?: Record<string, unknown> };
|
| 51 |
+
w.infoRadar = { ...w.infoRadar, dagPropagationPlaybackLog: setDagPropagationPlaybackLogEnabled };
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function dagPropLogFmtToken(label: string | null): string {
|
| 55 |
+
return label ?? '?';
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function dagPropLogFmtWeight(w: number | undefined): string {
|
| 59 |
+
return w != null ? w.toFixed(4) : '-';
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export function dagPropLogPad(value: string, width: number): string {
|
| 63 |
+
return value.length >= width ? value : value.padEnd(width, ' ');
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function dagPropLogPadInt(value: number, width: number): string {
|
| 67 |
+
const s = String(value);
|
| 68 |
+
return s.length >= width ? s : s.padStart(width, ' ');
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function dagPropLogPadWeight(w: number | undefined): string {
|
| 72 |
+
return dagPropLogPad(dagPropLogFmtWeight(w), DAG_PROP_LOG_W.weight);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export type DagPropagationPlaybackLogNodeShare = { id: string; share: number };
|
| 76 |
+
|
| 77 |
+
export function nodesAtNodeShareTotalForPlaybackLog(
|
| 78 |
+
nodeShareById: ReadonlyMap<string, number>,
|
| 79 |
+
total: number,
|
| 80 |
+
options?: {
|
| 81 |
+
excludeFocusId?: string;
|
| 82 |
+
onlyNodeIds?: ReadonlySet<string>;
|
| 83 |
+
},
|
| 84 |
+
): DagPropagationPlaybackLogNodeShare[] {
|
| 85 |
+
const out: DagPropagationPlaybackLogNodeShare[] = [];
|
| 86 |
+
for (const [nodeId, share] of nodeShareById) {
|
| 87 |
+
if (options?.excludeFocusId != null && nodeId === options.excludeFocusId) continue;
|
| 88 |
+
if (options?.onlyNodeIds != null && !options.onlyNodeIds.has(nodeId)) continue;
|
| 89 |
+
if (share === total) out.push({ id: nodeId, share });
|
| 90 |
+
}
|
| 91 |
+
out.sort((a, b) => a.id.localeCompare(b.id));
|
| 92 |
+
return out;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export function dagPropLogFmtNodeShareList(
|
| 96 |
+
entries: readonly DagPropagationPlaybackLogNodeShare[],
|
| 97 |
+
tokenLabelOf: (id: string) => string | null,
|
| 98 |
+
): string {
|
| 99 |
+
if (entries.length === 0) return '-';
|
| 100 |
+
return entries
|
| 101 |
+
.map((e) => `${dagPropLogFmtToken(tokenLabelOf(e.id))}(${dagPropLogPadWeight(e.share)})`)
|
| 102 |
+
.join(', ');
|
| 103 |
+
}
|
client/src/shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackPacing.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** forward prompt / backward 首帧固定停留(ms),不参与 Play speed 权重分配。 */
|
| 2 |
+
export const FORWARD_PROMPT_FRAME_DWELL_MS = 500;
|
| 3 |
+
|
| 4 |
+
/** 链序 running max 前瞻:lookahead = max(MIN, round(RATIO × 传播组数))。 */
|
| 5 |
+
export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO = 0.1;
|
| 6 |
+
export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN = 2;
|
| 7 |
+
|
| 8 |
+
export function propagationRunningMaxLookaheadForGroupCount(groupCount: number): number {
|
| 9 |
+
if (groupCount <= 0) return 0;
|
| 10 |
+
return Math.max(
|
| 11 |
+
DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN,
|
| 12 |
+
Math.round(DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO * groupCount),
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/** 与 UI「DAG replay speed」一致。 */
|
| 17 |
+
export type DagReplayPacingMode = 'total' | 'step';
|
| 18 |
+
|
| 19 |
+
export type DagRecursiveEdgeReplayPacing = {
|
| 20 |
+
mode: DagReplayPacingMode;
|
| 21 |
+
/**
|
| 22 |
+
* `step`:单步名义间隔(ms)。
|
| 23 |
+
* 实际间隔 = `propagationWeight × stepMs`;对权重连续,权重为 0 时恰为 0ms。
|
| 24 |
+
*/
|
| 25 |
+
stepMs: number;
|
| 26 |
+
/**
|
| 27 |
+
* `total`:整段动画名义总时长(s)。
|
| 28 |
+
* 权重步从 `totalS×1000 − {@link FORWARD_PROMPT_FRAME_DWELL_MS}` 按占比分配;固定帧另计。
|
| 29 |
+
*/
|
| 30 |
+
totalS: number;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* **当前帧**展示完成后的停留时长(ms),再切到下一批(不含 forward prompt / backward 首帧等固定帧)。
|
| 35 |
+
*
|
| 36 |
+
* **与权重的关系**:停留时间对 `propagationWeight` 连续;权重为 0 时恰为 0(`step` 下为 0ms,不设最小间隔)。
|
| 37 |
+
*
|
| 38 |
+
* - `step`:`propagationWeight × stepMs`
|
| 39 |
+
* - `total`:`(propagationWeight / weightTotal) × (totalS×1000 − FORWARD_PROMPT_FRAME_DWELL_MS)`;
|
| 40 |
+
* 假定 `weightTotal > 0`。
|
| 41 |
+
*/
|
| 42 |
+
export function batchPlaybackDelayMs(
|
| 43 |
+
batch: { propagationWeight: number },
|
| 44 |
+
plan: { weightTotal: number },
|
| 45 |
+
pacing: DagRecursiveEdgeReplayPacing,
|
| 46 |
+
): number {
|
| 47 |
+
const w = batch.propagationWeight;
|
| 48 |
+
if (pacing.mode === 'step') {
|
| 49 |
+
return Math.round(w * pacing.stepMs);
|
| 50 |
+
}
|
| 51 |
+
const totalWeight = plan.weightTotal;
|
| 52 |
+
const weightedBudgetMs = Math.max(0, pacing.totalS * 1000 - FORWARD_PROMPT_FRAME_DWELL_MS);
|
| 53 |
+
return Math.round((w / totalWeight) * weightedBudgetMs);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export type PropagationWeightGroup = { tgtIds: Iterable<string> };
|
| 57 |
+
|
| 58 |
+
export type PropagationGroupPrep = {
|
| 59 |
+
propagationWeight: number;
|
| 60 |
+
runningMaxNorm: number;
|
| 61 |
+
shareNorm?: number;
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
function summarizePropagationGroup(
|
| 65 |
+
group: PropagationWeightGroup,
|
| 66 |
+
nodeShareById: ReadonlyMap<string, number>,
|
| 67 |
+
focusId: string,
|
| 68 |
+
): { hasFocus: boolean; nonFocusGroupShare: number } {
|
| 69 |
+
let hasFocus = false;
|
| 70 |
+
let nonFocusGroupShare = 0;
|
| 71 |
+
for (const tgtId of group.tgtIds) {
|
| 72 |
+
if (tgtId === focusId) {
|
| 73 |
+
hasFocus = true;
|
| 74 |
+
continue;
|
| 75 |
+
}
|
| 76 |
+
const share = nodeShareById.get(tgtId) ?? 0;
|
| 77 |
+
if (share > nonFocusGroupShare) nonFocusGroupShare = share;
|
| 78 |
+
}
|
| 79 |
+
return { hasFocus, nonFocusGroupShare };
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function maxShareNormInRunningMaxLookaheadWindow(
|
| 83 |
+
shareNormPacing: readonly number[],
|
| 84 |
+
startIndex: number,
|
| 85 |
+
lookahead: number,
|
| 86 |
+
): number {
|
| 87 |
+
let windowMax = 0;
|
| 88 |
+
const end = Math.min(shareNormPacing.length - 1, startIndex + lookahead);
|
| 89 |
+
for (let j = startIndex; j <= end; j++) {
|
| 90 |
+
windowMax = Math.max(windowMax, shareNormPacing[j] ?? 0);
|
| 91 |
+
}
|
| 92 |
+
return windowMax;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* 文序准备:非焦点 `weightMax` → share_norm pacing → running max(含 lookahead)→ `propagationWeight`。
|
| 97 |
+
* 含焦点的组无 `shareNorm`(pacing 仍用非焦点 share,通常为 0)。
|
| 98 |
+
*/
|
| 99 |
+
export function computePropagationGroupPacings(
|
| 100 |
+
groups: readonly PropagationWeightGroup[],
|
| 101 |
+
nodeShareById: ReadonlyMap<string, number>,
|
| 102 |
+
focusId: string,
|
| 103 |
+
): {
|
| 104 |
+
groupPreps: PropagationGroupPrep[];
|
| 105 |
+
weightMax: number;
|
| 106 |
+
weightTotal: number;
|
| 107 |
+
runningMaxLookahead: number;
|
| 108 |
+
} {
|
| 109 |
+
const groupSummaries = groups.map((group) =>
|
| 110 |
+
summarizePropagationGroup(group, nodeShareById, focusId),
|
| 111 |
+
);
|
| 112 |
+
|
| 113 |
+
let weightMax = 0;
|
| 114 |
+
for (const { nonFocusGroupShare } of groupSummaries) {
|
| 115 |
+
if (nonFocusGroupShare > weightMax) weightMax = nonFocusGroupShare;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
const invWeightMax = weightMax > 0 ? 1 / weightMax : 0;
|
| 119 |
+
const shareNormPacing = groupSummaries.map(
|
| 120 |
+
({ nonFocusGroupShare }) => nonFocusGroupShare * invWeightMax,
|
| 121 |
+
);
|
| 122 |
+
const runningMaxLookahead = propagationRunningMaxLookaheadForGroupCount(groups.length);
|
| 123 |
+
|
| 124 |
+
const groupPreps: PropagationGroupPrep[] = [];
|
| 125 |
+
let runningMaxNorm = 0;
|
| 126 |
+
let weightTotal = 0;
|
| 127 |
+
|
| 128 |
+
for (let i = 0; i < groups.length; i++) {
|
| 129 |
+
const { hasFocus } = groupSummaries[i]!;
|
| 130 |
+
const shareNorm = shareNormPacing[i]!;
|
| 131 |
+
runningMaxNorm = Math.max(
|
| 132 |
+
runningMaxNorm,
|
| 133 |
+
maxShareNormInRunningMaxLookaheadWindow(shareNormPacing, i, runningMaxLookahead),
|
| 134 |
+
);
|
| 135 |
+
const propagationWeight = runningMaxNorm > 0 ? shareNorm / runningMaxNorm : 0;
|
| 136 |
+
weightTotal += propagationWeight;
|
| 137 |
+
groupPreps.push({
|
| 138 |
+
propagationWeight,
|
| 139 |
+
runningMaxNorm,
|
| 140 |
+
...(hasFocus ? {} : { shareNorm: shareNorm }),
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return { groupPreps, weightMax, weightTotal, runningMaxLookahead };
|
| 145 |
+
}
|
client/src/shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation.ts
CHANGED
|
@@ -1,27 +1,44 @@
|
|
| 1 |
import { DAG_MIN_ATTRIBUTION_SHARE } from './genAttributeDagEdgeDisplay';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export type DagRecursiveEdgeAnimationDirection = 'backward' | 'forward';
|
| 4 |
|
| 5 |
/** forward 专有第 0 帧:仅 prompt(稳态描边/归一),无传播链边。 */
|
| 6 |
export const FORWARD_PROMPT_BATCH_INDEX = -1;
|
| 7 |
|
| 8 |
-
/** forward prompt 第 0 帧固定停留(ms),不参与 Play speed 权重分配。 */
|
| 9 |
-
const FORWARD_PROMPT_FRAME_DWELL_MS = 500;
|
| 10 |
-
|
| 11 |
-
/** 链序 running max 前瞻:lookahead = max(MIN, round(RATIO × 传播层数))。 */
|
| 12 |
-
//决定了动画前期的播放速度,值越小,前面部分播放速度越慢
|
| 13 |
-
export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO = 0.1;
|
| 14 |
-
export const DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN = 2;
|
| 15 |
-
|
| 16 |
-
/** 与 {@link computePropagationLayerPacings} 一致:按层数算向后看的层数。 */
|
| 17 |
-
export function propagationRunningMaxLookaheadForLayerCount(layerCount: number): number {
|
| 18 |
-
if (layerCount <= 0) return 0;
|
| 19 |
-
return Math.max(
|
| 20 |
-
DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN,
|
| 21 |
-
Math.round(DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO * layerCount),
|
| 22 |
-
);
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
/** forward {@link FORWARD_PROMPT_BATCH_INDEX} 帧:仅展示 prompt,外观与稳态一致。 */
|
| 26 |
export function isForwardPromptOnlyBatchIndex(
|
| 27 |
direction: DagRecursiveEdgeAnimationDirection,
|
|
@@ -45,7 +62,7 @@ export type DagFocusAttributionComputeOptions = {
|
|
| 45 |
};
|
| 46 |
|
| 47 |
export type DagFocusAttributionGraphContext = {
|
| 48 |
-
nodesSortedByStepDesc: readonly { id: string }[];
|
| 49 |
incomingLinksByTarget: ReadonlyMap<string, readonly unknown[]>;
|
| 50 |
};
|
| 51 |
|
|
@@ -63,124 +80,6 @@ type ComputeSteadyStateStayShareByIdFn = (
|
|
| 63 |
/** 无 {@link CreateDagRecursiveEdgeAnimationControllerOptions.getReplayPacing} 时的兜底 step 间隔(ms)。 */
|
| 64 |
const DAG_RECURSIVE_EDGE_BATCH_STEP_MS_FALLBACK = 500;
|
| 65 |
|
| 66 |
-
/** 浏览器控制台调试前缀;过滤:`dag-prop`。 */
|
| 67 |
-
const DAG_PROPAGATION_PLAYBACK_LOG = '[dag-prop]';
|
| 68 |
-
|
| 69 |
-
/** playback 日志最小列宽(不足则填充;超出不截断)。 */
|
| 70 |
-
const DAG_PROP_LOG_W = {
|
| 71 |
-
event: 7,
|
| 72 |
-
frame: 6,
|
| 73 |
-
token: 10,
|
| 74 |
-
weight: 7,
|
| 75 |
-
dwell: 5,
|
| 76 |
-
focus: 10,
|
| 77 |
-
direction: 8,
|
| 78 |
-
int3: 3,
|
| 79 |
-
} as const;
|
| 80 |
-
|
| 81 |
-
/** localStorage:`localStorage.setItem('info_radar.dag_propagation_playback_log', '1')` */
|
| 82 |
-
export const DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY = 'info_radar.dag_propagation_playback_log';
|
| 83 |
-
|
| 84 |
-
export function isDagPropagationPlaybackLogEnabled(): boolean {
|
| 85 |
-
if (typeof globalThis === 'undefined') return false;
|
| 86 |
-
const g = globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean };
|
| 87 |
-
if (g.__DAG_PROPAGATION_PLAYBACK_LOG__ === true) return true;
|
| 88 |
-
try {
|
| 89 |
-
return localStorage.getItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY) === '1';
|
| 90 |
-
} catch {
|
| 91 |
-
return false;
|
| 92 |
-
}
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
/** 控制台:`infoRadar.dagPropagationPlaybackLog(true)` */
|
| 96 |
-
export function setDagPropagationPlaybackLogEnabled(enabled: boolean): void {
|
| 97 |
-
if (typeof globalThis !== 'undefined') {
|
| 98 |
-
(globalThis as typeof globalThis & { __DAG_PROPAGATION_PLAYBACK_LOG__?: boolean }).__DAG_PROPAGATION_PLAYBACK_LOG__ =
|
| 99 |
-
enabled;
|
| 100 |
-
}
|
| 101 |
-
try {
|
| 102 |
-
if (enabled) localStorage.setItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY, '1');
|
| 103 |
-
else localStorage.removeItem(DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY);
|
| 104 |
-
} catch {
|
| 105 |
-
/* private mode / disabled storage */
|
| 106 |
-
}
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
function playbackLogLine(line: string): void {
|
| 110 |
-
if (!isDagPropagationPlaybackLogEnabled()) return;
|
| 111 |
-
console.log(`${DAG_PROPAGATION_PLAYBACK_LOG} ${line}`);
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
if (typeof window !== 'undefined') {
|
| 115 |
-
const w = window as Window & { infoRadar?: Record<string, unknown> };
|
| 116 |
-
w.infoRadar = { ...w.infoRadar, dagPropagationPlaybackLog: setDagPropagationPlaybackLogEnabled };
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
function playbackFmtToken(label: string | null): string {
|
| 120 |
-
return label ?? '?';
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
function playbackFmtWeight(w: number | undefined): string {
|
| 124 |
-
return w != null ? w.toFixed(4) : '-';
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
function playbackPad(value: string, width: number): string {
|
| 128 |
-
return value.length >= width ? value : value.padEnd(width, ' ');
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
function playbackPadInt(value: number, width: number): string {
|
| 132 |
-
const s = String(value);
|
| 133 |
-
return s.length >= width ? s : s.padStart(width, ' ');
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
function playbackPadWeight(w: number | undefined): string {
|
| 137 |
-
return playbackPad(playbackFmtWeight(w), DAG_PROP_LOG_W.weight);
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
function playbackFmtNodeShareList(
|
| 141 |
-
entries: readonly NodeShareEntry[],
|
| 142 |
-
tokenLabelOf: (id: string) => string | null,
|
| 143 |
-
): string {
|
| 144 |
-
if (entries.length === 0) return '-';
|
| 145 |
-
return entries
|
| 146 |
-
.map((e) => `${playbackFmtToken(tokenLabelOf(e.id))}(${playbackFmtWeight(e.share)})`)
|
| 147 |
-
.join(', ');
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
/** 与 UI「DAG replay speed」一致。 */
|
| 151 |
-
export type DagReplayPacingMode = 'total' | 'step';
|
| 152 |
-
|
| 153 |
-
export type DagRecursiveEdgeReplayPacing = {
|
| 154 |
-
mode: DagReplayPacingMode;
|
| 155 |
-
/** `step`:单步名义间隔(ms),实际间隔 = `propagationWeight × stepMs`。 */
|
| 156 |
-
stepMs: number;
|
| 157 |
-
/** `total`:整段动画名义总时长(s),各步按权重占比分配。 */
|
| 158 |
-
totalS: number;
|
| 159 |
-
};
|
| 160 |
-
|
| 161 |
-
/**
|
| 162 |
-
* **当前帧**展示完成后的停留时长(ms),再切到下一批。
|
| 163 |
-
* - `step`:`propagationWeight × stepMs`
|
| 164 |
-
* - `total`:`(propagationWeight / weightTotal) × totalS`(权重全 0 时均分 `totalS`)
|
| 165 |
-
*/
|
| 166 |
-
export function batchPlaybackDelayMs(
|
| 167 |
-
batch: DagRecursiveIncomingEdgeBatch,
|
| 168 |
-
plan: Pick<DagPropagationPlaybackPlan, 'batches' | 'weightTotal'>,
|
| 169 |
-
pacing: DagRecursiveEdgeReplayPacing,
|
| 170 |
-
): number {
|
| 171 |
-
const w = batch.propagationWeight;
|
| 172 |
-
if (pacing.mode === 'step') {
|
| 173 |
-
return Math.round(w * pacing.stepMs);
|
| 174 |
-
}
|
| 175 |
-
const totalWeight = plan.weightTotal;
|
| 176 |
-
const totalMs = pacing.totalS * 1000;
|
| 177 |
-
if (totalWeight <= 0) {
|
| 178 |
-
const intervalCount = Math.max(0, plan.batches.length - 1);
|
| 179 |
-
return intervalCount > 0 ? Math.round(totalMs / intervalCount) : 0;
|
| 180 |
-
}
|
| 181 |
-
return Math.round((w / totalWeight) * totalMs);
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
/**
|
| 185 |
* 仅用于「Propagated attribution mode」焦点入边的分批显示状态。
|
| 186 |
* 两方向均按 `start(tgt)` 分批;backward 从高 tgt 向低 tgt 播放(贴合向上追溯),forward 反向。
|
|
@@ -192,38 +91,45 @@ export function batchPlaybackDelayMs(
|
|
| 192 |
* **forward**
|
| 193 |
* - 第 0 帧 {@link FORWARD_PROMPT_BATCH_INDEX}:无传播链边,仅 prompt 节点按稳态 stay 描边/归一;固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms。
|
| 194 |
* - 其后从最远 batch 递减;share 始终用全量焦点快照,动画只改「可见边集合」与归一分母(前沿内 max share)。
|
|
|
|
| 195 |
* - 同一帧内,已可见边的相对强弱 = share 相对强弱;绝对 opacity 可因分母随新批次变大而变暗。
|
| 196 |
* - 末帧 `batchIndex === 0` 时前沿 = 全链、分母 = 全链 max、可见性全开,与无动画稳定态数值一致(收敛)。
|
| 197 |
*
|
| 198 |
* **backward**
|
| 199 |
-
* - 首帧 `batchIndex === 0`(焦点侧):固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms,与 forward prompt 首帧
|
| 200 |
-
* -
|
| 201 |
-
* -
|
|
|
|
|
|
|
| 202 |
*
|
| 203 |
* **播放计划(见 {@link DagPropagationPlaybackPlan})**
|
| 204 |
-
* - 一批 = 同一生成 offset 的入边
|
| 205 |
-
* - 播放间隔权重(准备阶段一遍):按**文字顺序**对非焦点 `
|
| 206 |
-
* - `backwardFrontierByBatchIndex` / `forwardFrontierByBatchIndex`:各
|
| 207 |
* - forward / backward 共用同一 plan;不用 backward 部分快照的 nodeShare 定权重。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
*/
|
| 209 |
-
/** 传播链动画的一批:同一生成 offset 的入边
|
| 210 |
export type DagRecursiveIncomingEdgeBatch = {
|
| 211 |
-
/** 与 {@link buildPropagationPlaybackPlan} 分批键一致。 */
|
| 212 |
-
|
| 213 |
-
/** 本
|
| 214 |
tgtId: string;
|
| 215 |
-
/** 本
|
| 216 |
edgeKeys: string[];
|
| 217 |
/**
|
| 218 |
* 文字顺序局部归一化权重:share_norm ÷ runningMax(含向后 lookahead 窗口内的非焦点 share_norm)。
|
| 219 |
*/
|
| 220 |
propagationWeight: number;
|
| 221 |
/**
|
| 222 |
-
* 非焦点 `
|
| 223 |
-
*
|
| 224 |
*/
|
| 225 |
shareNorm?: number;
|
| 226 |
-
/** 准备阶段:截至本
|
| 227 |
runningMaxNorm: number;
|
| 228 |
};
|
| 229 |
|
|
@@ -231,11 +137,11 @@ export type DagRecursiveIncomingEdgeBatch = {
|
|
| 231 |
export type DagPropagationPlaybackPlan = {
|
| 232 |
focusId: string;
|
| 233 |
batches: DagRecursiveIncomingEdgeBatch[];
|
| 234 |
-
/** 全链非焦点
|
| 235 |
weightMax: number;
|
| 236 |
/** Σ `batches[].propagationWeight`;total 模式分母。 */
|
| 237 |
weightTotal: number;
|
| 238 |
-
/** 本计划 running max 前瞻
|
| 239 |
runningMaxLookahead: number;
|
| 240 |
/** backward:`batchIndex = i` 时可见边 = `batches[0..i]` 并集。 */
|
| 241 |
backwardFrontierByBatchIndex: ReadonlyArray<ReadonlySet<string>>;
|
|
@@ -258,6 +164,22 @@ export function tgtIdFromEdgeKey(edgeKey: string): string | null {
|
|
| 258 |
|
| 259 |
const EMPTY_EDGE_KEY_SET: ReadonlySet<string> = new Set();
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
/** 当前 batchIndex、方向下已启用的传播链入边(计划内预计算)。 */
|
| 262 |
function frontierEdgeKeysAtBatch(
|
| 263 |
plan: DagPropagationPlaybackPlan,
|
|
@@ -272,7 +194,7 @@ function frontierEdgeKeysAtBatch(
|
|
| 272 |
return table[batchIndex] ?? EMPTY_EDGE_KEY_SET;
|
| 273 |
}
|
| 274 |
|
| 275 |
-
function maxShareInEdgeKeySet(
|
| 276 |
incomingEdgeShareByKey: Map<string, number>,
|
| 277 |
edgeKeys: ReadonlySet<string>,
|
| 278 |
): number {
|
|
@@ -330,7 +252,7 @@ export function isRecursiveEdgeAnimationFrontierPartial(
|
|
| 330 |
if (lastBatch <= 0) return false;
|
| 331 |
return animation.batchIndex < lastBatch;
|
| 332 |
}
|
| 333 |
-
// forward:
|
| 334 |
return animation.batchIndex !== 0;
|
| 335 |
}
|
| 336 |
|
|
@@ -345,27 +267,6 @@ function isBackwardRecursiveEdgeAnimationInProgress(
|
|
| 345 |
);
|
| 346 |
}
|
| 347 |
|
| 348 |
-
type NodeShareEntry = { id: string; share: number };
|
| 349 |
-
|
| 350 |
-
function nodesAtNodeShareTotal(
|
| 351 |
-
nodeShareById: ReadonlyMap<string, number>,
|
| 352 |
-
total: number,
|
| 353 |
-
options?: {
|
| 354 |
-
excludeFocusId?: string;
|
| 355 |
-
/** 若设,仅保留该集合内的节点。 */
|
| 356 |
-
onlyNodeIds?: ReadonlySet<string>;
|
| 357 |
-
},
|
| 358 |
-
): NodeShareEntry[] {
|
| 359 |
-
const out: NodeShareEntry[] = [];
|
| 360 |
-
for (const [nodeId, share] of nodeShareById) {
|
| 361 |
-
if (options?.excludeFocusId != null && nodeId === options.excludeFocusId) continue;
|
| 362 |
-
if (options?.onlyNodeIds != null && !options.onlyNodeIds.has(nodeId)) continue;
|
| 363 |
-
if (share === total) out.push({ id: nodeId, share });
|
| 364 |
-
}
|
| 365 |
-
out.sort((a, b) => a.id.localeCompare(b.id));
|
| 366 |
-
return out;
|
| 367 |
-
}
|
| 368 |
-
|
| 369 |
function tgtIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set<string> {
|
| 370 |
const ids = new Set<string>();
|
| 371 |
for (const edgeKey of batch.edgeKeys) {
|
|
@@ -375,106 +276,14 @@ function tgtIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set<string> {
|
|
| 375 |
return ids;
|
| 376 |
}
|
| 377 |
|
| 378 |
-
type PropagationWeightLayer = { tgtIds: Iterable<string> };
|
| 379 |
-
|
| 380 |
-
/** 文序单层:pacing 权重 + 日志字段(与 {@link computePropagationLayerPacings} 一致)。 */
|
| 381 |
-
type PropagationLayerPrep = {
|
| 382 |
-
propagationWeight: number;
|
| 383 |
-
runningMaxNorm: number;
|
| 384 |
-
shareNorm?: number;
|
| 385 |
-
};
|
| 386 |
-
|
| 387 |
-
function summarizePropagationLayer(
|
| 388 |
-
layer: PropagationWeightLayer,
|
| 389 |
-
nodeShareById: ReadonlyMap<string, number>,
|
| 390 |
-
focusId: string,
|
| 391 |
-
): { hasFocus: boolean; nonFocusLayerShare: number } {
|
| 392 |
-
let hasFocus = false;
|
| 393 |
-
let nonFocusLayerShare = 0;
|
| 394 |
-
for (const tgtId of layer.tgtIds) {
|
| 395 |
-
if (tgtId === focusId) {
|
| 396 |
-
hasFocus = true;
|
| 397 |
-
continue;
|
| 398 |
-
}
|
| 399 |
-
const share = nodeShareById.get(tgtId) ?? 0;
|
| 400 |
-
if (share > nonFocusLayerShare) nonFocusLayerShare = share;
|
| 401 |
-
}
|
| 402 |
-
return { hasFocus, nonFocusLayerShare };
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
function maxShareNormInRunningMaxLookaheadWindow(
|
| 406 |
-
shareNormPacing: readonly number[],
|
| 407 |
-
startIndex: number,
|
| 408 |
-
lookahead: number,
|
| 409 |
-
): number {
|
| 410 |
-
let windowMax = 0;
|
| 411 |
-
const end = Math.min(shareNormPacing.length - 1, startIndex + lookahead);
|
| 412 |
-
for (let j = startIndex; j <= end; j++) {
|
| 413 |
-
windowMax = Math.max(windowMax, shareNormPacing[j] ?? 0);
|
| 414 |
-
}
|
| 415 |
-
return windowMax;
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
/**
|
| 419 |
-
* 文序准备:非焦点 `weightMax` → share_norm pacing → running max(含 lookahead)→ `propagationWeight`。
|
| 420 |
-
* 含焦点的层无 `shareNorm`(pacing 仍用非焦点 share,通常为 0)。
|
| 421 |
-
*/
|
| 422 |
-
function computePropagationLayerPacings(
|
| 423 |
-
layers: readonly PropagationWeightLayer[],
|
| 424 |
-
nodeShareById: ReadonlyMap<string, number>,
|
| 425 |
-
focusId: string,
|
| 426 |
-
): {
|
| 427 |
-
layerPreps: PropagationLayerPrep[];
|
| 428 |
-
weightMax: number;
|
| 429 |
-
weightTotal: number;
|
| 430 |
-
runningMaxLookahead: number;
|
| 431 |
-
} {
|
| 432 |
-
const layerSummaries = layers.map((layer) =>
|
| 433 |
-
summarizePropagationLayer(layer, nodeShareById, focusId),
|
| 434 |
-
);
|
| 435 |
-
|
| 436 |
-
let weightMax = 0;
|
| 437 |
-
for (const { nonFocusLayerShare } of layerSummaries) {
|
| 438 |
-
if (nonFocusLayerShare > weightMax) weightMax = nonFocusLayerShare;
|
| 439 |
-
}
|
| 440 |
-
|
| 441 |
-
const invWeightMax = weightMax > 0 ? 1 / weightMax : 0;
|
| 442 |
-
const shareNormPacing = layerSummaries.map(
|
| 443 |
-
({ nonFocusLayerShare }) => nonFocusLayerShare * invWeightMax,
|
| 444 |
-
);
|
| 445 |
-
const runningMaxLookahead = propagationRunningMaxLookaheadForLayerCount(layers.length);
|
| 446 |
-
|
| 447 |
-
const layerPreps: PropagationLayerPrep[] = [];
|
| 448 |
-
let runningMaxNorm = 0;
|
| 449 |
-
let weightTotal = 0;
|
| 450 |
-
|
| 451 |
-
for (let i = 0; i < layers.length; i++) {
|
| 452 |
-
const { hasFocus } = layerSummaries[i]!;
|
| 453 |
-
const shareNorm = shareNormPacing[i]!;
|
| 454 |
-
runningMaxNorm = Math.max(
|
| 455 |
-
runningMaxNorm,
|
| 456 |
-
maxShareNormInRunningMaxLookaheadWindow(shareNormPacing, i, runningMaxLookahead),
|
| 457 |
-
);
|
| 458 |
-
const propagationWeight = runningMaxNorm > 0 ? shareNorm / runningMaxNorm : 0;
|
| 459 |
-
weightTotal += propagationWeight;
|
| 460 |
-
layerPreps.push({
|
| 461 |
-
propagationWeight,
|
| 462 |
-
runningMaxNorm,
|
| 463 |
-
...(hasFocus ? {} : { shareNorm: shareNorm }),
|
| 464 |
-
});
|
| 465 |
-
}
|
| 466 |
-
|
| 467 |
-
return { layerPreps, weightMax, weightTotal, runningMaxLookahead };
|
| 468 |
-
}
|
| 469 |
-
|
| 470 |
function batchesInTextOrder(
|
| 471 |
batches: readonly DagRecursiveIncomingEdgeBatch[],
|
| 472 |
): DagRecursiveIncomingEdgeBatch[] {
|
| 473 |
-
return [...batches].sort((a, b) => a.
|
| 474 |
}
|
| 475 |
|
| 476 |
-
/**
|
| 477 |
-
function
|
| 478 |
tgtIds: Iterable<string>,
|
| 479 |
nodeShareById: ReadonlyMap<string, number>,
|
| 480 |
): string {
|
|
@@ -490,17 +299,17 @@ function primaryTgtIdForLayer(
|
|
| 490 |
return bestId;
|
| 491 |
}
|
| 492 |
|
| 493 |
-
function
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
prep:
|
| 497 |
nodeShareById: ReadonlyMap<string, number>,
|
| 498 |
): DagRecursiveIncomingEdgeBatch {
|
| 499 |
-
|
| 500 |
return {
|
| 501 |
-
|
| 502 |
-
tgtId:
|
| 503 |
-
edgeKeys:
|
| 504 |
propagationWeight: prep.propagationWeight,
|
| 505 |
runningMaxNorm: prep.runningMaxNorm,
|
| 506 |
...(prep.shareNorm != null ? { shareNorm: prep.shareNorm } : {}),
|
|
@@ -511,19 +320,17 @@ function buildFrontierEdgeKeysByBatchIndex(
|
|
| 511 |
batches: readonly DagRecursiveIncomingEdgeBatch[],
|
| 512 |
): Pick<DagPropagationPlaybackPlan, 'backwardFrontierByBatchIndex' | 'forwardFrontierByBatchIndex'> {
|
| 513 |
const n = batches.length;
|
| 514 |
-
const backward: Set<string>[] =
|
| 515 |
-
const forward: Set<string>[] =
|
| 516 |
|
| 517 |
for (let i = 0; i < n; i++) {
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
}
|
| 521 |
for (const key of batches[i]!.edgeKeys) backward[i]!.add(key);
|
| 522 |
}
|
| 523 |
for (let i = n - 1; i >= 0; i--) {
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
}
|
| 527 |
for (const key of batches[i]!.edgeKeys) forward[i]!.add(key);
|
| 528 |
}
|
| 529 |
|
|
@@ -532,7 +339,7 @@ function buildFrontierEdgeKeysByBatchIndex(
|
|
| 532 |
|
| 533 |
/**
|
| 534 |
* 传播链播放计划:入边按 `start(tgt)` 分批(offset 降序),并预计算双向前沿。
|
| 535 |
-
* backward 从 index 0 递增,forward 从末批递减。
|
| 536 |
*/
|
| 537 |
export function buildPropagationPlaybackPlan(
|
| 538 |
incomingEdgeShareByKey: Map<string, number>,
|
|
@@ -547,25 +354,26 @@ export function buildPropagationPlaybackPlan(
|
|
| 547 |
const tgtId = tgtIdFromEdgeKey(edgeKey);
|
| 548 |
if (tgtId == null) continue;
|
| 549 |
const offset = offsetOf(tgtId);
|
| 550 |
-
let
|
| 551 |
-
if (
|
| 552 |
-
|
| 553 |
-
byOffset.set(offset,
|
| 554 |
}
|
| 555 |
-
|
| 556 |
-
|
| 557 |
}
|
| 558 |
|
| 559 |
const sortedOffsetsAsc = [...byOffset.keys()].sort((a, b) => a - b);
|
| 560 |
const sortedOffsetsDesc = [...sortedOffsetsAsc].reverse();
|
| 561 |
-
const {
|
| 562 |
-
sortedOffsetsAsc.map((
|
| 563 |
nodeShareById,
|
| 564 |
focusId,
|
| 565 |
);
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
|
|
|
| 569 |
});
|
| 570 |
|
| 571 |
return {
|
|
@@ -579,8 +387,8 @@ export function buildPropagationPlaybackPlan(
|
|
| 579 |
}
|
| 580 |
|
| 581 |
/**
|
| 582 |
-
*
|
| 583 |
-
* forward
|
| 584 |
*/
|
| 585 |
function resolveFocusAttributionAtFrontier(
|
| 586 |
focusId: string,
|
|
@@ -617,7 +425,38 @@ function resolveFocusAttributionAtFrontier(
|
|
| 617 |
return partial ?? fullState;
|
| 618 |
}
|
| 619 |
|
| 620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
function resolveEffectiveStayShareByIdForStroke(
|
| 622 |
focusState: DagFocusAttributionState,
|
| 623 |
focusId: string,
|
|
@@ -629,6 +468,7 @@ function resolveEffectiveStayShareByIdForStroke(
|
|
| 629 |
if (!isBackwardRecursiveEdgeAnimationInProgress(animation, focusId)) {
|
| 630 |
return computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
|
| 631 |
}
|
|
|
|
| 632 |
const atFrontier = resolveFocusAttributionAtFrontier(
|
| 633 |
focusId,
|
| 634 |
focusState,
|
|
@@ -636,19 +476,35 @@ function resolveEffectiveStayShareByIdForStroke(
|
|
| 636 |
computeFocusState,
|
| 637 |
ctx,
|
| 638 |
);
|
| 639 |
-
|
| 640 |
atFrontier.nodeShareById,
|
| 641 |
atFrontier.incomingEdgeShareByKey,
|
| 642 |
focusId,
|
| 643 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
}
|
| 645 |
|
| 646 |
export type RecursiveEdgeAnimationRenderOverlay = {
|
| 647 |
animationFrontierPartial: boolean;
|
| 648 |
anim: DagEdgeBatchAnimationState | null;
|
| 649 |
frontierEdgeKeys: ReadonlySet<string> | null;
|
|
|
|
| 650 |
linkFocusState: DagFocusAttributionState | null;
|
| 651 |
-
displayFocusState: DagFocusAttributionState | null;
|
| 652 |
nodeStrokeShareById: Map<string, number> | null;
|
| 653 |
/** backward 部分帧:稳定态 stay 池 max,供描边归一分母;否则 undefined 用当前池 max。 */
|
| 654 |
nodeStrokeMaxForRender?: number;
|
|
@@ -656,12 +512,29 @@ export type RecursiveEdgeAnimationRenderOverlay = {
|
|
| 656 |
incomingMaxForRender: number;
|
| 657 |
/** forward {@link FORWARD_PROMPT_BATCH_INDEX}:仅 prompt 稳态描边,无链边、无 slide 高亮。 */
|
| 658 |
forwardPromptOnlyFrame: boolean;
|
| 659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
edgeVisibility(edgeKey: string, inPropagationChain: boolean): number;
|
| 661 |
};
|
| 662 |
|
| 663 |
const INACTIVE_EDGE_VISIBILITY = (_edgeKey: string, _inPropagationChain: boolean): number => 1;
|
| 664 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
|
| 666 |
effectiveFocusId: string | null;
|
| 667 |
focusState: DagFocusAttributionState | null;
|
|
@@ -703,12 +576,13 @@ export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
|
|
| 703 |
anim: null,
|
| 704 |
frontierEdgeKeys: null,
|
| 705 |
linkFocusState: focusState,
|
| 706 |
-
displayFocusState: focusState,
|
| 707 |
nodeStrokeShareById,
|
| 708 |
incomingShareForRender: focusState?.incomingEdgeShareByKey ?? emptyIncoming,
|
| 709 |
incomingMaxForRender: maxHighlightEdgeShare(focusState?.incomingEdgeShareByKey ?? emptyIncoming),
|
| 710 |
forwardPromptOnlyFrame: false,
|
| 711 |
-
|
|
|
|
|
|
|
| 712 |
edgeVisibility: INACTIVE_EDGE_VISIBILITY,
|
| 713 |
};
|
| 714 |
}
|
|
@@ -721,15 +595,6 @@ export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
|
|
| 721 |
animationFrontierPartial && anim != null
|
| 722 |
? frontierEdgeKeysAtBatch(anim.plan, anim.direction, anim.batchIndex)
|
| 723 |
: null;
|
| 724 |
-
const displayFocusState = resolveFocusAttributionAtFrontier(
|
| 725 |
-
userAnimationFocusId,
|
| 726 |
-
focusState,
|
| 727 |
-
anim,
|
| 728 |
-
computeFocusState,
|
| 729 |
-
ctx,
|
| 730 |
-
);
|
| 731 |
-
const linkFocusState =
|
| 732 |
-
animationFrontierPartial && anim?.direction === 'backward' ? displayFocusState : focusState;
|
| 733 |
const nodeStrokeShareById = resolveEffectiveStayShareByIdForStroke(
|
| 734 |
focusState,
|
| 735 |
focusId,
|
|
@@ -742,52 +607,92 @@ export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
|
|
| 742 |
animationFrontierPartial && anim?.direction === 'backward'
|
| 743 |
? maxHighlightEdgeShare(computeSteadyStateStayShareById(focusState.nodeShareById, focusId))
|
| 744 |
: undefined;
|
| 745 |
-
const incomingShareForRender =
|
| 746 |
-
animationFrontierPartial && anim?.direction === 'backward' && displayFocusState != null
|
| 747 |
-
? displayFocusState.incomingEdgeShareByKey
|
| 748 |
-
: focusState.incomingEdgeShareByKey;
|
| 749 |
const incomingMaxForRender =
|
| 750 |
animationFrontierPartial && frontierEdgeKeys != null
|
| 751 |
-
? maxShareInEdgeKeySet(
|
| 752 |
: maxHighlightEdgeShare(incomingShareForRender);
|
| 753 |
const forwardPromptOnlyFrame =
|
| 754 |
anim != null && isForwardPromptOnlyBatchIndex(anim.direction, anim.batchIndex);
|
| 755 |
-
const
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
const edgeVisibility = (edgeKey: string, inPropagationChain: boolean): number => {
|
| 760 |
-
if (
|
| 761 |
-
!animationFrontierPartial ||
|
| 762 |
-
anim?.direction !== 'forward' ||
|
| 763 |
-
!inPropagationChain
|
| 764 |
-
) {
|
| 765 |
return 1;
|
| 766 |
}
|
| 767 |
return frontierEdgeKeys?.has(edgeKey) ? 1 : 0;
|
| 768 |
};
|
| 769 |
-
|
| 770 |
return {
|
| 771 |
animationFrontierPartial,
|
| 772 |
anim,
|
| 773 |
frontierEdgeKeys,
|
| 774 |
-
linkFocusState,
|
| 775 |
-
displayFocusState,
|
| 776 |
nodeStrokeShareById,
|
| 777 |
nodeStrokeMaxForRender,
|
| 778 |
incomingShareForRender,
|
| 779 |
incomingMaxForRender,
|
| 780 |
forwardPromptOnlyFrame,
|
| 781 |
-
|
|
|
|
|
|
|
| 782 |
edgeVisibility,
|
| 783 |
};
|
| 784 |
}
|
| 785 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
export type DagRecursiveEdgeAnimationController = {
|
| 787 |
onUserSelect(focusId: string, ctx: DagFocusAttributionGraphContext): void;
|
| 788 |
onClear(): void;
|
| 789 |
setEnabled(enabled: boolean): void;
|
| 790 |
setDirection(direction: DagRecursiveEdgeAnimationDirection): void;
|
|
|
|
| 791 |
getUserAnimationFocusId(): string | null;
|
| 792 |
isEnabled(): boolean;
|
| 793 |
resolveRenderOverlay(args: {
|
|
@@ -837,8 +742,8 @@ export function createDagRecursiveEdgeAnimationController(
|
|
| 837 |
const s = animation;
|
| 838 |
const batch = s.plan.batches[s.batchIndex];
|
| 839 |
const lastBatch = s.plan.batches.length - 1;
|
| 840 |
-
|
| 841 |
-
`${
|
| 842 |
);
|
| 843 |
}
|
| 844 |
version++;
|
|
@@ -885,34 +790,15 @@ export function createDagRecursiveEdgeAnimationController(
|
|
| 885 |
direction,
|
| 886 |
batchIndex: initialBatchIndex,
|
| 887 |
};
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
const nodeShareById = focusState.nodeShareById;
|
| 897 |
-
const batchTgtIds = new Set<string>();
|
| 898 |
-
for (const b of plan.batches) {
|
| 899 |
-
for (const tgtId of tgtIdsInBatch(b)) batchTgtIds.add(tgtId);
|
| 900 |
-
}
|
| 901 |
-
const refNodes = nodesAtNodeShareTotal(nodeShareById, plan.weightMax, {
|
| 902 |
-
excludeFocusId: focusId,
|
| 903 |
-
onlyNodeIds: batchTgtIds,
|
| 904 |
});
|
| 905 |
-
playbackLogLine(
|
| 906 |
-
`${playbackPad('pacing', DAG_PROP_LOG_W.event)} | weightMax=${playbackPadWeight(plan.weightMax)} | weightTotal=${playbackPadWeight(plan.weightTotal)} | lookahead=${playbackPadInt(plan.runningMaxLookahead, DAG_PROP_LOG_W.int3)} | nodes=${playbackFmtNodeShareList(refNodes, options.tokenLabelOf)}`,
|
| 907 |
-
);
|
| 908 |
-
const planTextOrder = batchesInTextOrder(plan.batches);
|
| 909 |
-
for (let chainStep = 0; chainStep < planTextOrder.length; chainStep++) {
|
| 910 |
-
const b = planTextOrder[chainStep]!;
|
| 911 |
-
const token = playbackFmtToken(options.tokenLabelOf(b.tgtId));
|
| 912 |
-
playbackLogLine(
|
| 913 |
-
`${playbackPad(`plan[${chainStep}]`, DAG_PROP_LOG_W.event)} | token=${playbackPad(token, DAG_PROP_LOG_W.token)} | share_norm=${playbackPadWeight(b.shareNorm)} | running_max=${playbackPadWeight(b.runningMaxNorm)} | weight=${playbackPadWeight(b.propagationWeight)}`,
|
| 914 |
-
);
|
| 915 |
-
}
|
| 916 |
scheduleAnimationStep(focusId);
|
| 917 |
}
|
| 918 |
|
|
@@ -937,6 +823,7 @@ export function createDagRecursiveEdgeAnimationController(
|
|
| 937 |
return delayMsForCurrentBatch(state);
|
| 938 |
}
|
| 939 |
|
|
|
|
| 940 |
function hasNextBatch(state: DagEdgeBatchAnimationState, lastBatch: number): boolean {
|
| 941 |
if (state.direction === 'backward') {
|
| 942 |
return state.batchIndex < lastBatch;
|
|
@@ -966,10 +853,10 @@ export function createDagRecursiveEdgeAnimationController(
|
|
| 966 |
const dwellMs = dwellMsAfterCurrentFrame(state);
|
| 967 |
const token = promptFrame
|
| 968 |
? 'prompt'
|
| 969 |
-
:
|
| 970 |
-
const weight = promptFrame ? 'fixed' :
|
| 971 |
-
|
| 972 |
-
`${
|
| 973 |
);
|
| 974 |
}
|
| 975 |
|
|
@@ -986,27 +873,27 @@ export function createDagRecursiveEdgeAnimationController(
|
|
| 986 |
return;
|
| 987 |
}
|
| 988 |
|
| 989 |
-
const
|
| 990 |
|
| 991 |
const showFrameAndScheduleNext = (): void => {
|
| 992 |
-
if (version !==
|
| 993 |
-
const
|
| 994 |
-
if (!
|
| 995 |
|
| 996 |
options.onTick();
|
| 997 |
-
logPropagationFrame(
|
| 998 |
|
| 999 |
-
const dwellMs = dwellMsAfterCurrentFrame(
|
| 1000 |
timer = setTimeout(() => {
|
| 1001 |
-
if (version !==
|
| 1002 |
-
const
|
| 1003 |
-
if (!
|
| 1004 |
|
| 1005 |
-
if (!hasNextBatch(
|
| 1006 |
timer = null;
|
| 1007 |
return;
|
| 1008 |
}
|
| 1009 |
-
advanceBatchIndex(
|
| 1010 |
showFrameAndScheduleNext();
|
| 1011 |
}, dwellMs);
|
| 1012 |
};
|
|
@@ -1031,6 +918,9 @@ export function createDagRecursiveEdgeAnimationController(
|
|
| 1031 |
direction = next;
|
| 1032 |
stopAnimation();
|
| 1033 |
},
|
|
|
|
|
|
|
|
|
|
| 1034 |
getUserAnimationFocusId(): string | null {
|
| 1035 |
return userAnimationFocusId;
|
| 1036 |
},
|
|
|
|
| 1 |
import { DAG_MIN_ATTRIBUTION_SHARE } from './genAttributeDagEdgeDisplay';
|
| 2 |
+
import {
|
| 3 |
+
DAG_PROP_LOG_W,
|
| 4 |
+
dagPropLogFmtNodeShareList,
|
| 5 |
+
dagPropLogFmtToken,
|
| 6 |
+
dagPropLogFmtWeight,
|
| 7 |
+
dagPropLogPad,
|
| 8 |
+
dagPropLogPadInt,
|
| 9 |
+
dagPropLogPadWeight,
|
| 10 |
+
logDagPropagationPlaybackLine,
|
| 11 |
+
nodesAtNodeShareTotalForPlaybackLog,
|
| 12 |
+
} from './genAttributeDagPropagationPlaybackLog';
|
| 13 |
+
import {
|
| 14 |
+
batchPlaybackDelayMs,
|
| 15 |
+
computePropagationGroupPacings,
|
| 16 |
+
FORWARD_PROMPT_FRAME_DWELL_MS,
|
| 17 |
+
type DagRecursiveEdgeReplayPacing,
|
| 18 |
+
type DagReplayPacingMode,
|
| 19 |
+
type PropagationGroupPrep,
|
| 20 |
+
} from './genAttributeDagPropagationPlaybackPacing';
|
| 21 |
+
|
| 22 |
+
export type { DagRecursiveEdgeReplayPacing, DagReplayPacingMode } from './genAttributeDagPropagationPlaybackPacing';
|
| 23 |
+
export {
|
| 24 |
+
batchPlaybackDelayMs,
|
| 25 |
+
computePropagationGroupPacings,
|
| 26 |
+
DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN,
|
| 27 |
+
DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO,
|
| 28 |
+
FORWARD_PROMPT_FRAME_DWELL_MS,
|
| 29 |
+
propagationRunningMaxLookaheadForGroupCount,
|
| 30 |
+
} from './genAttributeDagPropagationPlaybackPacing';
|
| 31 |
+
export {
|
| 32 |
+
DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY,
|
| 33 |
+
isDagPropagationPlaybackLogEnabled,
|
| 34 |
+
setDagPropagationPlaybackLogEnabled,
|
| 35 |
+
} from './genAttributeDagPropagationPlaybackLog';
|
| 36 |
|
| 37 |
export type DagRecursiveEdgeAnimationDirection = 'backward' | 'forward';
|
| 38 |
|
| 39 |
/** forward 专有第 0 帧:仅 prompt(稳态描边/归一),无传播链边。 */
|
| 40 |
export const FORWARD_PROMPT_BATCH_INDEX = -1;
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
/** forward {@link FORWARD_PROMPT_BATCH_INDEX} 帧:仅展示 prompt,外观与稳态一致。 */
|
| 43 |
export function isForwardPromptOnlyBatchIndex(
|
| 44 |
direction: DagRecursiveEdgeAnimationDirection,
|
|
|
|
| 62 |
};
|
| 63 |
|
| 64 |
export type DagFocusAttributionGraphContext = {
|
| 65 |
+
nodesSortedByStepDesc: readonly { id: string; step: number }[];
|
| 66 |
incomingLinksByTarget: ReadonlyMap<string, readonly unknown[]>;
|
| 67 |
};
|
| 68 |
|
|
|
|
| 80 |
/** 无 {@link CreateDagRecursiveEdgeAnimationControllerOptions.getReplayPacing} 时的兜底 step 间隔(ms)。 */
|
| 81 |
const DAG_RECURSIVE_EDGE_BATCH_STEP_MS_FALLBACK = 500;
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
/**
|
| 84 |
* 仅用于「Propagated attribution mode」焦点入边的分批显示状态。
|
| 85 |
* 两方向均按 `start(tgt)` 分批;backward 从高 tgt 向低 tgt 播放(贴合向上追溯),forward 反向。
|
|
|
|
| 91 |
* **forward**
|
| 92 |
* - 第 0 帧 {@link FORWARD_PROMPT_BATCH_INDEX}:无传播链边,仅 prompt 节点按稳态 stay 描边/归一;固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms。
|
| 93 |
* - 其后从最远 batch 递减;share 始终用全量焦点快照,动画只改「可见边集合」与归一分母(前沿内 max share)。
|
| 94 |
+
* - 部分帧内焦点不提前高亮/描边(render 延后至末帧 `batchIndex === 0` 稳态),与反向首帧才亮焦点对称。
|
| 95 |
* - 同一帧内,已可见边的相对强弱 = share 相对强弱;绝对 opacity 可因分母随新批次变大而变暗。
|
| 96 |
* - 末帧 `batchIndex === 0` 时前沿 = 全链、分母 = 全链 max、可见性全开,与无动画稳定态数值一致(收敛)。
|
| 97 |
*
|
| 98 |
* **backward**
|
| 99 |
+
* - 首帧 `batchIndex === 0`(焦点侧):固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms,焦点红色 slide;与 forward prompt 首帧对称,不参与权重分配。
|
| 100 |
+
* - 蓝线从焦点侧逐批显现:稳态 share + {@link backwardFrontierByBatchIndex} 门控(`batches[0..i]` 递增)。
|
| 101 |
+
* - 未滑过:live stay;已滑过 batch:稳态 stay;非播放链生成 token 不描边;prompt(`step === -1`)若在候选集中则用 live stay(可不在传播链上)。
|
| 102 |
+
* - 描边分母为稳态 `max(stay)`。
|
| 103 |
+
* - 当前帧 slide 节点(`--backward-slide`)的入边:红色,强度在本批指向 slide 的入边集合内 max 归一 × 焦点 MI。
|
| 104 |
*
|
| 105 |
* **播放计划(见 {@link DagPropagationPlaybackPlan})**
|
| 106 |
+
* - 一批 = 同一生成 offset 的入边组;`groupOffset` + `tgtId` 标���该组代表 token。
|
| 107 |
+
* - 播放间隔权重(准备阶段一遍):按**文字顺序**对非焦点 `groupShare/weightMax` 做 running max 归一化;向后看组数 = max({@link DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN}, round(比例×组数));与播放方向无关。组内含焦点则无 `shareNorm`。
|
| 108 |
+
* - `backwardFrontierByBatchIndex` / `forwardFrontierByBatchIndex`:各方向蓝线可见边并集,render 热路径 O(1)。
|
| 109 |
* - forward / backward 共用同一 plan;不用 backward 部分快照的 nodeShare 定权重。
|
| 110 |
+
*
|
| 111 |
+
* **播放停留(见 {@link batchPlaybackDelayMs})**
|
| 112 |
+
* - 对 `propagationWeight` 连续;权重为 0 时停留恰为 0(`step` 下 0ms,不设最小间隔)。
|
| 113 |
+
* - `total` 模式:UI `totalS` 中预留 {@link FORWARD_PROMPT_FRAME_DWELL_MS} 给 forward prompt / backward 首帧,其余按权重分配。
|
| 114 |
*/
|
| 115 |
+
/** 传播链动画的一批:同一生成 offset 的入边组 + 播放元数据。 */
|
| 116 |
export type DagRecursiveIncomingEdgeBatch = {
|
| 117 |
+
/** 与 {@link buildPropagationPlaybackPlan} 分批键一致(`start(tgt)`)。 */
|
| 118 |
+
groupOffset: number;
|
| 119 |
+
/** 本组代表 token(forward 高亮)。 */
|
| 120 |
tgtId: string;
|
| 121 |
+
/** 本组传播链入边(`src->tgt`)。 */
|
| 122 |
edgeKeys: string[];
|
| 123 |
/**
|
| 124 |
* 文字顺序局部归一化权重:share_norm ÷ runningMax(含向后 lookahead 窗口内的非焦点 share_norm)。
|
| 125 |
*/
|
| 126 |
propagationWeight: number;
|
| 127 |
/**
|
| 128 |
+
* 非焦点 `groupShare / weightMax`(playback 日志 share_norm)。
|
| 129 |
+
* 组内含焦点时为 undefined。
|
| 130 |
*/
|
| 131 |
shareNorm?: number;
|
| 132 |
+
/** 准备阶段:截至本组(含 lookahead 窗口)的链序 running max。 */
|
| 133 |
runningMaxNorm: number;
|
| 134 |
};
|
| 135 |
|
|
|
|
| 137 |
export type DagPropagationPlaybackPlan = {
|
| 138 |
focusId: string;
|
| 139 |
batches: DagRecursiveIncomingEdgeBatch[];
|
| 140 |
+
/** 全链非焦点组 Total share 上限(日志 / 对照;量纲同 Total share)。 */
|
| 141 |
weightMax: number;
|
| 142 |
/** Σ `batches[].propagationWeight`;total 模式分母。 */
|
| 143 |
weightTotal: number;
|
| 144 |
+
/** 本计划 running max 前瞻组数(max(MIN, round(比例×组数)))。 */
|
| 145 |
runningMaxLookahead: number;
|
| 146 |
/** backward:`batchIndex = i` 时可见边 = `batches[0..i]` 并集。 */
|
| 147 |
backwardFrontierByBatchIndex: ReadonlyArray<ReadonlySet<string>>;
|
|
|
|
| 164 |
|
| 165 |
const EMPTY_EDGE_KEY_SET: ReadonlySet<string> = new Set();
|
| 166 |
|
| 167 |
+
/** backward 当前批:指向 slide 节点的入边(与全图按 tgt 筛等价,仅扫本批 edgeKeys)。 */
|
| 168 |
+
export function backwardSlideIncomingEdgeKeysForBatch(
|
| 169 |
+
plan: DagPropagationPlaybackPlan,
|
| 170 |
+
batchIndex: number,
|
| 171 |
+
focusId: string,
|
| 172 |
+
): ReadonlySet<string> {
|
| 173 |
+
const batch = plan.batches[batchIndex];
|
| 174 |
+
if (batch == null) return EMPTY_EDGE_KEY_SET;
|
| 175 |
+
const slideTgtId = batchIndex === 0 ? focusId : batch.tgtId;
|
| 176 |
+
const keys = new Set<string>();
|
| 177 |
+
for (const key of batch.edgeKeys) {
|
| 178 |
+
if (tgtIdFromEdgeKey(key) === slideTgtId) keys.add(key);
|
| 179 |
+
}
|
| 180 |
+
return keys;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
/** 当前 batchIndex、方向下已启用的传播链入边(计划内预计算)。 */
|
| 184 |
function frontierEdgeKeysAtBatch(
|
| 185 |
plan: DagPropagationPlaybackPlan,
|
|
|
|
| 194 |
return table[batchIndex] ?? EMPTY_EDGE_KEY_SET;
|
| 195 |
}
|
| 196 |
|
| 197 |
+
export function maxShareInEdgeKeySet(
|
| 198 |
incomingEdgeShareByKey: Map<string, number>,
|
| 199 |
edgeKeys: ReadonlySet<string>,
|
| 200 |
): number {
|
|
|
|
| 252 |
if (lastBatch <= 0) return false;
|
| 253 |
return animation.batchIndex < lastBatch;
|
| 254 |
}
|
| 255 |
+
// forward:batchIndex===0 为稳态终帧;其余含 prompt(-1) 均属「动画未结束」(边/焦点行为由专帧 flag 区分)
|
| 256 |
return animation.batchIndex !== 0;
|
| 257 |
}
|
| 258 |
|
|
|
|
| 267 |
);
|
| 268 |
}
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
function tgtIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set<string> {
|
| 271 |
const ids = new Set<string>();
|
| 272 |
for (const edgeKey of batch.edgeKeys) {
|
|
|
|
| 276 |
return ids;
|
| 277 |
}
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
function batchesInTextOrder(
|
| 280 |
batches: readonly DagRecursiveIncomingEdgeBatch[],
|
| 281 |
): DagRecursiveIncomingEdgeBatch[] {
|
| 282 |
+
return [...batches].sort((a, b) => a.groupOffset - b.groupOffset);
|
| 283 |
}
|
| 284 |
|
| 285 |
+
/** 组内代表 tgt;并列时取 id 字典序最小。 */
|
| 286 |
+
function primaryTgtIdForGroup(
|
| 287 |
tgtIds: Iterable<string>,
|
| 288 |
nodeShareById: ReadonlyMap<string, number>,
|
| 289 |
): string {
|
|
|
|
| 299 |
return bestId;
|
| 300 |
}
|
| 301 |
|
| 302 |
+
function incomingEdgeBatchFromGroup(
|
| 303 |
+
groupOffset: number,
|
| 304 |
+
group: { edgeKeys: string[]; tgtIds: Set<string> },
|
| 305 |
+
prep: PropagationGroupPrep,
|
| 306 |
nodeShareById: ReadonlyMap<string, number>,
|
| 307 |
): DagRecursiveIncomingEdgeBatch {
|
| 308 |
+
group.edgeKeys.sort();
|
| 309 |
return {
|
| 310 |
+
groupOffset,
|
| 311 |
+
tgtId: primaryTgtIdForGroup(group.tgtIds, nodeShareById),
|
| 312 |
+
edgeKeys: group.edgeKeys,
|
| 313 |
propagationWeight: prep.propagationWeight,
|
| 314 |
runningMaxNorm: prep.runningMaxNorm,
|
| 315 |
...(prep.shareNorm != null ? { shareNorm: prep.shareNorm } : {}),
|
|
|
|
| 320 |
batches: readonly DagRecursiveIncomingEdgeBatch[],
|
| 321 |
): Pick<DagPropagationPlaybackPlan, 'backwardFrontierByBatchIndex' | 'forwardFrontierByBatchIndex'> {
|
| 322 |
const n = batches.length;
|
| 323 |
+
const backward: Set<string>[] = [];
|
| 324 |
+
const forward: Set<string>[] = [];
|
| 325 |
|
| 326 |
for (let i = 0; i < n; i++) {
|
| 327 |
+
const prev = backward[i - 1];
|
| 328 |
+
backward.push(new Set(prev));
|
|
|
|
| 329 |
for (const key of batches[i]!.edgeKeys) backward[i]!.add(key);
|
| 330 |
}
|
| 331 |
for (let i = n - 1; i >= 0; i--) {
|
| 332 |
+
const next = forward[i + 1];
|
| 333 |
+
forward[i] = new Set(next);
|
|
|
|
| 334 |
for (const key of batches[i]!.edgeKeys) forward[i]!.add(key);
|
| 335 |
}
|
| 336 |
|
|
|
|
| 339 |
|
| 340 |
/**
|
| 341 |
* 传播链播放计划:入边按 `start(tgt)` 分批(offset 降序),并预计算双向前沿。
|
| 342 |
+
* backward 从 index 0 递增(蓝线递增),forward 从末批递减(蓝线递增)。
|
| 343 |
*/
|
| 344 |
export function buildPropagationPlaybackPlan(
|
| 345 |
incomingEdgeShareByKey: Map<string, number>,
|
|
|
|
| 354 |
const tgtId = tgtIdFromEdgeKey(edgeKey);
|
| 355 |
if (tgtId == null) continue;
|
| 356 |
const offset = offsetOf(tgtId);
|
| 357 |
+
let group = byOffset.get(offset);
|
| 358 |
+
if (group == null) {
|
| 359 |
+
group = { edgeKeys: [], tgtIds: new Set() };
|
| 360 |
+
byOffset.set(offset, group);
|
| 361 |
}
|
| 362 |
+
group.edgeKeys.push(edgeKey);
|
| 363 |
+
group.tgtIds.add(tgtId);
|
| 364 |
}
|
| 365 |
|
| 366 |
const sortedOffsetsAsc = [...byOffset.keys()].sort((a, b) => a - b);
|
| 367 |
const sortedOffsetsDesc = [...sortedOffsetsAsc].reverse();
|
| 368 |
+
const { groupPreps, weightMax, weightTotal, runningMaxLookahead } = computePropagationGroupPacings(
|
| 369 |
+
sortedOffsetsAsc.map((groupOffset) => byOffset.get(groupOffset)!),
|
| 370 |
nodeShareById,
|
| 371 |
focusId,
|
| 372 |
);
|
| 373 |
+
// groupPreps 按文序 ASC;batches 按播放序 DESC(远→近),故播放序下标 j 对应文序下标 n-1-j
|
| 374 |
+
const batches: DagRecursiveIncomingEdgeBatch[] = sortedOffsetsDesc.map((groupOffset, j) => {
|
| 375 |
+
const prep = groupPreps[sortedOffsetsAsc.length - 1 - j]!;
|
| 376 |
+
return incomingEdgeBatchFromGroup(groupOffset, byOffset.get(groupOffset)!, prep, nodeShareById);
|
| 377 |
});
|
| 378 |
|
| 379 |
return {
|
|
|
|
| 387 |
}
|
| 388 |
|
| 389 |
/**
|
| 390 |
+
* backward 节点 stay 用:沿 {@link backwardFrontierByBatchIndex}(与蓝线同前沿)重算部分快照。
|
| 391 |
+
* 蓝边不经过此函数(与 forward 一样用全量 share + 前沿门控)。
|
| 392 |
*/
|
| 393 |
function resolveFocusAttributionAtFrontier(
|
| 394 |
focusId: string,
|
|
|
|
| 425 |
return partial ?? fullState;
|
| 426 |
}
|
| 427 |
|
| 428 |
+
function passedBatchTgtIdsBeforeIndex(
|
| 429 |
+
batches: readonly DagRecursiveIncomingEdgeBatch[],
|
| 430 |
+
batchIndex: number,
|
| 431 |
+
): Set<string> {
|
| 432 |
+
const ids = new Set<string>();
|
| 433 |
+
for (let i = 0; i < batchIndex; i++) {
|
| 434 |
+
for (const id of tgtIdsInBatch(batches[i]!)) ids.add(id);
|
| 435 |
+
}
|
| 436 |
+
return ids;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/** 播放计划传播链上的全部 tgt(含同 offset 非代表 token)。 */
|
| 440 |
+
function playbackChainNodeIds(batches: readonly DagRecursiveIncomingEdgeBatch[]): Set<string> {
|
| 441 |
+
const onChain = new Set<string>();
|
| 442 |
+
for (const batch of batches) {
|
| 443 |
+
for (const id of tgtIdsInBatch(batch)) onChain.add(id);
|
| 444 |
+
}
|
| 445 |
+
return onChain;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
function promptNodeIdsFromCtx(ctx: DagFocusAttributionGraphContext): Set<string> {
|
| 449 |
+
const ids = new Set<string>();
|
| 450 |
+
for (const n of ctx.nodesSortedByStepDesc) {
|
| 451 |
+
if (n.step === -1) ids.add(n.id);
|
| 452 |
+
}
|
| 453 |
+
return ids;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
/**
|
| 457 |
+
* 传播模式描边:backward 动画进行中,未滑过 batch 用 live stay;
|
| 458 |
+
* 已滑过 batch 用稳态 stay;候选中的 prompt 用 live(可不在链上);其余链外生成 token 不描边。
|
| 459 |
+
*/
|
| 460 |
function resolveEffectiveStayShareByIdForStroke(
|
| 461 |
focusState: DagFocusAttributionState,
|
| 462 |
focusId: string,
|
|
|
|
| 468 |
if (!isBackwardRecursiveEdgeAnimationInProgress(animation, focusId)) {
|
| 469 |
return computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
|
| 470 |
}
|
| 471 |
+
// ① 与蓝线同前沿的部分快照 → live stay 池
|
| 472 |
const atFrontier = resolveFocusAttributionAtFrontier(
|
| 473 |
focusId,
|
| 474 |
focusState,
|
|
|
|
| 476 |
computeFocusState,
|
| 477 |
ctx,
|
| 478 |
);
|
| 479 |
+
const liveById = computeLivePartialStayShareById(
|
| 480 |
atFrontier.nodeShareById,
|
| 481 |
atFrontier.incomingEdgeShareByKey,
|
| 482 |
focusId,
|
| 483 |
);
|
| 484 |
+
// ② 全链稳态 stay 池(已滑过 batch 用)
|
| 485 |
+
const steadyById = computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
|
| 486 |
+
const batches = animation!.plan.batches;
|
| 487 |
+
const batchIndex = animation!.batchIndex;
|
| 488 |
+
const passedTgtIds = passedBatchTgtIdsBeforeIndex(batches, batchIndex);
|
| 489 |
+
const onChain = playbackChainNodeIds(batches);
|
| 490 |
+
const promptIds = promptNodeIdsFromCtx(ctx);
|
| 491 |
+
// ③ 合并候选:未滑过→live,已滑过→steady;仅链上或 prompt
|
| 492 |
+
const strokeCandidates = new Set([...liveById.keys(), ...passedTgtIds]);
|
| 493 |
+
const byNodeId = new Map<string, number>();
|
| 494 |
+
for (const nodeId of strokeCandidates) {
|
| 495 |
+
if (!onChain.has(nodeId) && !promptIds.has(nodeId)) continue;
|
| 496 |
+
const stay = passedTgtIds.has(nodeId) ? steadyById.get(nodeId) : liveById.get(nodeId);
|
| 497 |
+
if (stay != null && stay >= DAG_MIN_ATTRIBUTION_SHARE) byNodeId.set(nodeId, stay);
|
| 498 |
+
}
|
| 499 |
+
return byNodeId;
|
| 500 |
}
|
| 501 |
|
| 502 |
export type RecursiveEdgeAnimationRenderOverlay = {
|
| 503 |
animationFrontierPartial: boolean;
|
| 504 |
anim: DagEdgeBatchAnimationState | null;
|
| 505 |
frontierEdgeKeys: ReadonlySet<string> | null;
|
| 506 |
+
/** 与 focusState 同引用;入边 share 恒为全量,可见性由 frontier / edgeVisibility 裁切。 */
|
| 507 |
linkFocusState: DagFocusAttributionState | null;
|
|
|
|
| 508 |
nodeStrokeShareById: Map<string, number> | null;
|
| 509 |
/** backward 部分帧:稳定态 stay 池 max,供描边归一分母;否则 undefined 用当前池 max。 */
|
| 510 |
nodeStrokeMaxForRender?: number;
|
|
|
|
| 512 |
incomingMaxForRender: number;
|
| 513 |
/** forward {@link FORWARD_PROMPT_BATCH_INDEX}:仅 prompt 稳态描边,无链边、无 slide 高亮。 */
|
| 514 |
forwardPromptOnlyFrame: boolean;
|
| 515 |
+
/** 当前帧 slide token:forward 为 batch 代表;backward 首帧为焦点。不含 forward prompt 专帧。 */
|
| 516 |
+
propagationSlideTgtId: string | null;
|
| 517 |
+
/** 正向传播部分帧:焦点不提前全亮(末帧 partial 结束即恢复)。 */
|
| 518 |
+
deferFocusHighlightDuringAnim: boolean;
|
| 519 |
+
/** 传播部分帧:不为焦点挂 `--selected` 描边(正向全程;反向仅 slide=焦点时)。 */
|
| 520 |
+
suppressFocusSelectedStroke: boolean;
|
| 521 |
edgeVisibility(edgeKey: string, inPropagationChain: boolean): number;
|
| 522 |
};
|
| 523 |
|
| 524 |
const INACTIVE_EDGE_VISIBILITY = (_edgeKey: string, _inPropagationChain: boolean): number => 1;
|
| 525 |
|
| 526 |
+
/** backward 首帧 slide = 焦点;forward prompt 专帧和无动画 = null;其余 = 当前批代表 token。 */
|
| 527 |
+
function resolvePropagationSlideTgtId(
|
| 528 |
+
anim: DagEdgeBatchAnimationState | null,
|
| 529 |
+
animationFrontierPartial: boolean,
|
| 530 |
+
forwardPromptOnlyFrame: boolean,
|
| 531 |
+
focusId: string,
|
| 532 |
+
): string | null {
|
| 533 |
+
if (anim == null || !animationFrontierPartial || forwardPromptOnlyFrame) return null;
|
| 534 |
+
if (anim.direction === 'backward' && anim.batchIndex === 0) return focusId;
|
| 535 |
+
return anim.plan.batches[anim.batchIndex]?.tgtId ?? null;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
|
| 539 |
effectiveFocusId: string | null;
|
| 540 |
focusState: DagFocusAttributionState | null;
|
|
|
|
| 576 |
anim: null,
|
| 577 |
frontierEdgeKeys: null,
|
| 578 |
linkFocusState: focusState,
|
|
|
|
| 579 |
nodeStrokeShareById,
|
| 580 |
incomingShareForRender: focusState?.incomingEdgeShareByKey ?? emptyIncoming,
|
| 581 |
incomingMaxForRender: maxHighlightEdgeShare(focusState?.incomingEdgeShareByKey ?? emptyIncoming),
|
| 582 |
forwardPromptOnlyFrame: false,
|
| 583 |
+
propagationSlideTgtId: null,
|
| 584 |
+
deferFocusHighlightDuringAnim: false,
|
| 585 |
+
suppressFocusSelectedStroke: false,
|
| 586 |
edgeVisibility: INACTIVE_EDGE_VISIBILITY,
|
| 587 |
};
|
| 588 |
}
|
|
|
|
| 595 |
animationFrontierPartial && anim != null
|
| 596 |
? frontierEdgeKeysAtBatch(anim.plan, anim.direction, anim.batchIndex)
|
| 597 |
: null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
const nodeStrokeShareById = resolveEffectiveStayShareByIdForStroke(
|
| 599 |
focusState,
|
| 600 |
focusId,
|
|
|
|
| 607 |
animationFrontierPartial && anim?.direction === 'backward'
|
| 608 |
? maxHighlightEdgeShare(computeSteadyStateStayShareById(focusState.nodeShareById, focusId))
|
| 609 |
: undefined;
|
| 610 |
+
const incomingShareForRender = focusState.incomingEdgeShareByKey;
|
|
|
|
|
|
|
|
|
|
| 611 |
const incomingMaxForRender =
|
| 612 |
animationFrontierPartial && frontierEdgeKeys != null
|
| 613 |
+
? maxShareInEdgeKeySet(incomingShareForRender, frontierEdgeKeys)
|
| 614 |
: maxHighlightEdgeShare(incomingShareForRender);
|
| 615 |
const forwardPromptOnlyFrame =
|
| 616 |
anim != null && isForwardPromptOnlyBatchIndex(anim.direction, anim.batchIndex);
|
| 617 |
+
const forwardPartial = animationFrontierPartial && anim?.direction === 'forward';
|
| 618 |
+
const propagationSlideTgtId = resolvePropagationSlideTgtId(
|
| 619 |
+
anim,
|
| 620 |
+
animationFrontierPartial,
|
| 621 |
+
forwardPromptOnlyFrame,
|
| 622 |
+
focusId,
|
| 623 |
+
);
|
| 624 |
+
const deferFocusHighlightDuringAnim = forwardPartial;
|
| 625 |
+
const suppressFocusSelectedStroke =
|
| 626 |
+
animationFrontierPartial &&
|
| 627 |
+
focusId != null &&
|
| 628 |
+
(forwardPartial || propagationSlideTgtId === focusId);
|
| 629 |
const edgeVisibility = (edgeKey: string, inPropagationChain: boolean): number => {
|
| 630 |
+
if (!animationFrontierPartial || !inPropagationChain) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
return 1;
|
| 632 |
}
|
| 633 |
return frontierEdgeKeys?.has(edgeKey) ? 1 : 0;
|
| 634 |
};
|
|
|
|
| 635 |
return {
|
| 636 |
animationFrontierPartial,
|
| 637 |
anim,
|
| 638 |
frontierEdgeKeys,
|
| 639 |
+
linkFocusState: focusState,
|
|
|
|
| 640 |
nodeStrokeShareById,
|
| 641 |
nodeStrokeMaxForRender,
|
| 642 |
incomingShareForRender,
|
| 643 |
incomingMaxForRender,
|
| 644 |
forwardPromptOnlyFrame,
|
| 645 |
+
propagationSlideTgtId,
|
| 646 |
+
deferFocusHighlightDuringAnim,
|
| 647 |
+
suppressFocusSelectedStroke,
|
| 648 |
edgeVisibility,
|
| 649 |
};
|
| 650 |
}
|
| 651 |
|
| 652 |
+
function logPropagationPlaybackPlanOnStart(args: {
|
| 653 |
+
plan: DagPropagationPlaybackPlan;
|
| 654 |
+
focusId: string;
|
| 655 |
+
direction: DagRecursiveEdgeAnimationDirection;
|
| 656 |
+
initialBatchIndex: number;
|
| 657 |
+
pacing: DagRecursiveEdgeReplayPacing;
|
| 658 |
+
nodeShareById: ReadonlyMap<string, number>;
|
| 659 |
+
tokenLabelOf: (id: string) => string | null;
|
| 660 |
+
}): void {
|
| 661 |
+
const { plan, focusId, direction, initialBatchIndex, pacing, nodeShareById, tokenLabelOf } = args;
|
| 662 |
+
const pacingLine =
|
| 663 |
+
pacing.mode === 'step'
|
| 664 |
+
? `pacing=step stepMs=${pacing.stepMs}`
|
| 665 |
+
: `pacing=total totalS=${pacing.totalS}`;
|
| 666 |
+
logDagPropagationPlaybackLine(
|
| 667 |
+
`${dagPropLogPad('start', DAG_PROP_LOG_W.event)} | focus=${dagPropLogPad(dagPropLogFmtToken(tokenLabelOf(focusId)), DAG_PROP_LOG_W.focus)} | direction=${dagPropLogPad(direction, DAG_PROP_LOG_W.direction)} | batches=${dagPropLogPadInt(plan.batches.length, DAG_PROP_LOG_W.int3)} | initial=${dagPropLogPadInt(initialBatchIndex, DAG_PROP_LOG_W.int3)} | ${pacingLine}`,
|
| 668 |
+
);
|
| 669 |
+
const batchTgtIds = new Set<string>();
|
| 670 |
+
for (const b of plan.batches) {
|
| 671 |
+
for (const tgtId of tgtIdsInBatch(b)) batchTgtIds.add(tgtId);
|
| 672 |
+
}
|
| 673 |
+
const refNodes = nodesAtNodeShareTotalForPlaybackLog(nodeShareById, plan.weightMax, {
|
| 674 |
+
excludeFocusId: focusId,
|
| 675 |
+
onlyNodeIds: batchTgtIds,
|
| 676 |
+
});
|
| 677 |
+
logDagPropagationPlaybackLine(
|
| 678 |
+
`${dagPropLogPad('pacing', DAG_PROP_LOG_W.event)} | weightMax=${dagPropLogPadWeight(plan.weightMax)} | weightTotal=${dagPropLogPadWeight(plan.weightTotal)} | lookahead=${dagPropLogPadInt(plan.runningMaxLookahead, DAG_PROP_LOG_W.int3)} | nodes=${dagPropLogFmtNodeShareList(refNodes, tokenLabelOf)}`,
|
| 679 |
+
);
|
| 680 |
+
const planTextOrder = batchesInTextOrder(plan.batches);
|
| 681 |
+
for (let chainStep = 0; chainStep < planTextOrder.length; chainStep++) {
|
| 682 |
+
const b = planTextOrder[chainStep]!;
|
| 683 |
+
const token = dagPropLogFmtToken(tokenLabelOf(b.tgtId));
|
| 684 |
+
logDagPropagationPlaybackLine(
|
| 685 |
+
`${dagPropLogPad(`plan[${chainStep}]`, DAG_PROP_LOG_W.event)} | token=${dagPropLogPad(token, DAG_PROP_LOG_W.token)} | share_norm=${dagPropLogPadWeight(b.shareNorm)} | running_max=${dagPropLogPadWeight(b.runningMaxNorm)} | weight=${dagPropLogPadWeight(b.propagationWeight)}`,
|
| 686 |
+
);
|
| 687 |
+
}
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
export type DagRecursiveEdgeAnimationController = {
|
| 691 |
onUserSelect(focusId: string, ctx: DagFocusAttributionGraphContext): void;
|
| 692 |
onClear(): void;
|
| 693 |
setEnabled(enabled: boolean): void;
|
| 694 |
setDirection(direction: DagRecursiveEdgeAnimationDirection): void;
|
| 695 |
+
getDirection(): DagRecursiveEdgeAnimationDirection;
|
| 696 |
getUserAnimationFocusId(): string | null;
|
| 697 |
isEnabled(): boolean;
|
| 698 |
resolveRenderOverlay(args: {
|
|
|
|
| 742 |
const s = animation;
|
| 743 |
const batch = s.plan.batches[s.batchIndex];
|
| 744 |
const lastBatch = s.plan.batches.length - 1;
|
| 745 |
+
logDagPropagationPlaybackLine(
|
| 746 |
+
`${dagPropLogPad('stop', DAG_PROP_LOG_W.event)} | focus=${dagPropLogPad(dagPropLogFmtToken(options.tokenLabelOf(s.plan.focusId)), DAG_PROP_LOG_W.focus)} | frame=${dagPropLogPad(`${s.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${dagPropLogPad(dagPropLogFmtToken(batch != null ? options.tokenLabelOf(batch.tgtId) : null), DAG_PROP_LOG_W.token)}`,
|
| 747 |
);
|
| 748 |
}
|
| 749 |
version++;
|
|
|
|
| 790 |
direction,
|
| 791 |
batchIndex: initialBatchIndex,
|
| 792 |
};
|
| 793 |
+
logPropagationPlaybackPlanOnStart({
|
| 794 |
+
plan,
|
| 795 |
+
focusId,
|
| 796 |
+
direction,
|
| 797 |
+
initialBatchIndex,
|
| 798 |
+
pacing: getReplayPacing(),
|
| 799 |
+
nodeShareById: focusState.nodeShareById,
|
| 800 |
+
tokenLabelOf: options.tokenLabelOf,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 802 |
scheduleAnimationStep(focusId);
|
| 803 |
}
|
| 804 |
|
|
|
|
| 823 |
return delayMsForCurrentBatch(state);
|
| 824 |
}
|
| 825 |
|
| 826 |
+
/** batchIndex 时间线:backward `0 → last`;forward `-1(prompt) → last → … → 0(稳态)`。 */
|
| 827 |
function hasNextBatch(state: DagEdgeBatchAnimationState, lastBatch: number): boolean {
|
| 828 |
if (state.direction === 'backward') {
|
| 829 |
return state.batchIndex < lastBatch;
|
|
|
|
| 853 |
const dwellMs = dwellMsAfterCurrentFrame(state);
|
| 854 |
const token = promptFrame
|
| 855 |
? 'prompt'
|
| 856 |
+
: dagPropLogFmtToken(batch?.tgtId != null ? options.tokenLabelOf(batch.tgtId) : null);
|
| 857 |
+
const weight = promptFrame ? 'fixed' : dagPropLogFmtWeight(batch?.propagationWeight);
|
| 858 |
+
logDagPropagationPlaybackLine(
|
| 859 |
+
`${dagPropLogPad('frame', DAG_PROP_LOG_W.event)} ${dagPropLogPad(`${state.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${dagPropLogPad(token, DAG_PROP_LOG_W.token)} | weight=${dagPropLogPad(weight, DAG_PROP_LOG_W.weight)} | dwellMs=${dagPropLogPadInt(dwellMs, DAG_PROP_LOG_W.dwell)}`,
|
| 860 |
);
|
| 861 |
}
|
| 862 |
|
|
|
|
| 873 |
return;
|
| 874 |
}
|
| 875 |
|
| 876 |
+
const capturedVersion = ++version;
|
| 877 |
|
| 878 |
const showFrameAndScheduleNext = (): void => {
|
| 879 |
+
if (version !== capturedVersion) return;
|
| 880 |
+
const liveState = animation;
|
| 881 |
+
if (!liveState || liveState.plan.focusId !== focusId) return;
|
| 882 |
|
| 883 |
options.onTick();
|
| 884 |
+
logPropagationFrame(liveState);
|
| 885 |
|
| 886 |
+
const dwellMs = dwellMsAfterCurrentFrame(liveState);
|
| 887 |
timer = setTimeout(() => {
|
| 888 |
+
if (version !== capturedVersion) return;
|
| 889 |
+
const stateAfterDwell = animation;
|
| 890 |
+
if (!stateAfterDwell || stateAfterDwell.plan.focusId !== focusId) return;
|
| 891 |
|
| 892 |
+
if (!hasNextBatch(stateAfterDwell, lastBatch)) {
|
| 893 |
timer = null;
|
| 894 |
return;
|
| 895 |
}
|
| 896 |
+
advanceBatchIndex(stateAfterDwell);
|
| 897 |
showFrameAndScheduleNext();
|
| 898 |
}, dwellMs);
|
| 899 |
};
|
|
|
|
| 918 |
direction = next;
|
| 919 |
stopAnimation();
|
| 920 |
},
|
| 921 |
+
getDirection(): DagRecursiveEdgeAnimationDirection {
|
| 922 |
+
return direction;
|
| 923 |
+
},
|
| 924 |
getUserAnimationFocusId(): string | null {
|
| 925 |
return userAnimationFocusId;
|
| 926 |
},
|
client/src/shared/prediction_attribution/causal_flow/genAttributeDagTextMeasure.ts
CHANGED
|
@@ -42,6 +42,7 @@ 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);
|
|
|
|
| 42 |
const APPROX_CHAR_WIDTH_PX = 10;
|
| 43 |
const displayLabel = visualizeSpecialChars(raw, {
|
| 44 |
spaceDotExceptBeforeAsciiLetterOrNumber: true,
|
| 45 |
+
omitHexInCodePointLabel: true,
|
| 46 |
});
|
| 47 |
const displayLen = Array.from(displayLabel).length;
|
| 48 |
return Math.max(displayLen * APPROX_CHAR_WIDTH_PX, 1);
|
client/src/shared/prediction_attribution/causal_flow/genAttributeDagView.ts
CHANGED
|
@@ -12,11 +12,15 @@ import {
|
|
| 12 |
} from './genAttributeDagPreprocess';
|
| 13 |
import {
|
| 14 |
DAG_EDGE_MIN_NORMALIZED_SCORE,
|
| 15 |
-
DAG_EDGE_RENDER_OPACITY_FLOOR,
|
| 16 |
DAG_MIN_ATTRIBUTION_SHARE,
|
| 17 |
DAG_NODE_STROKE_OPACITY_BASE,
|
| 18 |
} from './genAttributeDagEdgeDisplay';
|
| 19 |
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
createDagRecursiveEdgeAnimationController,
|
| 21 |
type DagRecursiveEdgeReplayPacing,
|
| 22 |
maxHighlightEdgeShare,
|
|
@@ -214,7 +218,7 @@ type DagNodeAttrs = {
|
|
| 214 |
* 下台阶等处用 {@link dagStepDownEffectiveCiRatio}(dagTargetProb)(高置信 p>p₁ 为 0;与「关闭 CI 视觉」无关);
|
| 215 |
*/
|
| 216 |
dagTargetProb?: number;
|
| 217 |
-
/** {@link visualizeSpecialChars}(DAG:
|
| 218 |
displayLabel: string;
|
| 219 |
/** 悬停 / 选中焦点时 Top‑K tooltip;仅生成节点(`step >= 0`) */
|
| 220 |
gltrTooltipToken?: FrontendToken;
|
|
@@ -271,21 +275,6 @@ function nodeTargetMiRatio(node: DagNode): number {
|
|
| 271 |
return computeMutualInformationRatio(node.dagTargetProb);
|
| 272 |
}
|
| 273 |
|
| 274 |
-
/**
|
| 275 |
-
* 池内 max 归一后的 `stroke-opacity`;最强边刻度为 {@link maxOpacity}(默认 1)。
|
| 276 |
-
* 按实际值计算后,最终不低于 {@link DAG_EDGE_RENDER_OPACITY_FLOOR},防止过淡不可见。
|
| 277 |
-
*/
|
| 278 |
-
function normalizeEdgeRenderOpacity(share: number, maxShare: number, maxOpacity = 1): number {
|
| 279 |
-
if (!Number.isFinite(share) || share <= 0) return 0;
|
| 280 |
-
const cap = Number.isFinite(maxOpacity) && maxOpacity > 0 ? maxOpacity : 1;
|
| 281 |
-
const scaled =
|
| 282 |
-
!Number.isFinite(maxShare) || maxShare <= 0
|
| 283 |
-
? Math.min(cap, share)
|
| 284 |
-
: Math.min(cap, (share / maxShare) * cap);
|
| 285 |
-
if (scaled <= 0) return 0;
|
| 286 |
-
return Math.max(DAG_EDGE_RENDER_OPACITY_FLOOR, scaled);
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
/**
|
| 290 |
* 候选归因节点描边透明度:池内 `stay / max(stay)` 线性映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`,
|
| 291 |
* 避免弱节点描边过淡、在 UI 里看不出来(见 {@link DAG_NODE_STROKE_OPACITY_BASE})。
|
|
@@ -352,23 +341,6 @@ function computeSteadyStateStayShareById(
|
|
| 352 |
return byNodeId;
|
| 353 |
}
|
| 354 |
|
| 355 |
-
/** 池内 max 归一后的 render 强度;{@link maxOpacity} 为链内最强边刻度(蓝入边见 {@link refreshNodeLinkHighlight},默认 1)。 */
|
| 356 |
-
function buildMaxNormalizedRenderStrengthByKey(
|
| 357 |
-
sharesByKey: Map<string, number>,
|
| 358 |
-
maxOpacity = 1,
|
| 359 |
-
maxShareOverride?: number,
|
| 360 |
-
): Map<string, number> {
|
| 361 |
-
const maxShare =
|
| 362 |
-
maxShareOverride != null && Number.isFinite(maxShareOverride) && maxShareOverride > 0
|
| 363 |
-
? maxShareOverride
|
| 364 |
-
: maxHighlightEdgeShare(sharesByKey);
|
| 365 |
-
const byKey = new Map<string, number>();
|
| 366 |
-
for (const [key, share] of sharesByKey) {
|
| 367 |
-
byKey.set(key, normalizeEdgeRenderOpacity(share, maxShare, maxOpacity));
|
| 368 |
-
}
|
| 369 |
-
return byKey;
|
| 370 |
-
}
|
| 371 |
-
|
| 372 |
/** 递归链候选节点描边强度:stay 池内 max 归一后映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`。 */
|
| 373 |
function buildNodeStrokeRenderStrengthById(
|
| 374 |
stayByNodeId: Map<string, number>,
|
|
@@ -722,9 +694,10 @@ function buildLinkTitleText(snapshot: DagLinkTitleSnapshot): string {
|
|
| 722 |
`Link strength: ${formatTooltipLinkStrength(snapshot.linkStrength)}`,
|
| 723 |
];
|
| 724 |
|
|
|
|
| 725 |
return [
|
| 726 |
-
`From:\n${snapshot.src.
|
| 727 |
-
`To:\n${snapshot.tgt.
|
| 728 |
metrics.join('\n'),
|
| 729 |
].join('\n\n');
|
| 730 |
}
|
|
@@ -792,6 +765,7 @@ function resolveDagLinkHighlightDisplay(
|
|
| 792 |
grayRenderByKey: Map<string, number>,
|
| 793 |
incomingHighlightRenderByKey: Map<string, number>,
|
| 794 |
downstreamHighlightRenderByKey: Map<string, number>,
|
|
|
|
| 795 |
): DagLinkHighlightDisplay {
|
| 796 |
const directStrength = directAttributionStrength(d);
|
| 797 |
const grayRender = grayRenderByKey.get(edgeKey) ?? directStrength;
|
|
@@ -808,9 +782,14 @@ function resolveDagLinkHighlightDisplay(
|
|
| 808 |
|
| 809 |
const incomingShare = focusState.incomingEdgeShareByKey.get(edgeKey);
|
| 810 |
if (incomingShare != null) {
|
|
|
|
| 811 |
return {
|
| 812 |
-
stroke:
|
| 813 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
linkStrength: incomingShare,
|
| 815 |
recursiveAttributionShare: recursiveAttributionEnabled ? incomingShare : undefined,
|
| 816 |
};
|
|
@@ -1181,6 +1160,11 @@ export function initGenAttributeDagView(
|
|
| 1181 |
let hoveredId: string | null = null;
|
| 1182 |
/** 最近一次 {@link refreshNodeLinkHighlight} 计算出的归因状态(基于 {@link effectiveFocusId});tooltip 用于展示归因份额 */
|
| 1183 |
let currentFocusState: FocusAttributionState | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1184 |
|
| 1185 |
const focusAttributionCtx = () => ({
|
| 1186 |
nodesSortedByStepDesc,
|
|
@@ -1217,10 +1201,35 @@ export function initGenAttributeDagView(
|
|
| 1217 |
return selectedId ?? hoveredId;
|
| 1218 |
}
|
| 1219 |
|
| 1220 |
-
/** tooltip
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1221 |
function tooltipFocusId(): string | null {
|
| 1222 |
-
if (
|
| 1223 |
-
return
|
|
|
|
|
|
|
|
|
|
| 1224 |
}
|
| 1225 |
return effectiveFocusId();
|
| 1226 |
}
|
|
@@ -1282,6 +1291,15 @@ export function initGenAttributeDagView(
|
|
| 1282 |
svg.attr('width', w).attr('height', h);
|
| 1283 |
}
|
| 1284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1285 |
function paint(): void {
|
| 1286 |
syncNodeStrokeRects(nodeSel, displayScale);
|
| 1287 |
if (layoutMode === 'linear-arc' || layoutMode === 'linear-arc-step-down') {
|
|
@@ -1294,10 +1312,7 @@ export function initGenAttributeDagView(
|
|
| 1294 |
nodes: layoutNodes,
|
| 1295 |
adjacentGapPx: linearArcAdjacentGapPx,
|
| 1296 |
variant: layoutMode === 'linear-arc-step-down' ? 'step-down' : 'flat',
|
| 1297 |
-
getLinkNodes:
|
| 1298 |
-
src: endpointNode(d.source, graph),
|
| 1299 |
-
tgt: endpointNode(d.target, graph),
|
| 1300 |
-
}),
|
| 1301 |
});
|
| 1302 |
} else if (layoutMode === 'spiral') {
|
| 1303 |
const layoutNodes = hideExcludedTokens
|
|
@@ -1308,20 +1323,14 @@ export function initGenAttributeDagView(
|
|
| 1308 |
nodeSel,
|
| 1309 |
nodes: layoutNodes,
|
| 1310 |
linkEndInsetPx,
|
| 1311 |
-
getLinkNodes:
|
| 1312 |
-
src: endpointNode(d.source, graph),
|
| 1313 |
-
tgt: endpointNode(d.target, graph),
|
| 1314 |
-
}),
|
| 1315 |
});
|
| 1316 |
} else {
|
| 1317 |
paintTextFlowLayout({
|
| 1318 |
linkSel,
|
| 1319 |
nodeSel,
|
| 1320 |
linkEndInsetPx,
|
| 1321 |
-
getLinkNodes:
|
| 1322 |
-
src: endpointNode(d.source, graph),
|
| 1323 |
-
tgt: endpointNode(d.target, graph),
|
| 1324 |
-
}),
|
| 1325 |
});
|
| 1326 |
}
|
| 1327 |
syncNodeHitTransforms();
|
|
@@ -1374,6 +1383,11 @@ export function initGenAttributeDagView(
|
|
| 1374 |
recursiveAttributionEnabled,
|
| 1375 |
ctx: focusAttributionCtx(),
|
| 1376 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1377 |
const linkFocusState = animOverlay.linkFocusState ?? focusState;
|
| 1378 |
const focusNodeIds = focusState?.activeNodeIds ?? null;
|
| 1379 |
const nodeStrokeShareById = animOverlay.nodeStrokeShareById;
|
|
@@ -1408,8 +1422,42 @@ export function initGenAttributeDagView(
|
|
| 1408 |
: buildMaxNormalizedRenderStrengthByKey(focusState.downstreamEdgeStrengthByKey);
|
| 1409 |
grayRenderCache ??= buildGrayRenderStrengthByEdgeKey(graph, incomingLinksByTarget);
|
| 1410 |
const grayRenderByKey = grayRenderCache;
|
| 1411 |
-
const
|
| 1412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1413 |
const nodeOnChainForRender = (d: DagNode): boolean => {
|
| 1414 |
if (!forwardPromptOnlyFrame) return nodeStrokeShareById?.has(d.id) ?? false;
|
| 1415 |
return d.step === -1 && (nodeStrokeShareById?.has(d.id) ?? false);
|
|
@@ -1420,15 +1468,15 @@ export function initGenAttributeDagView(
|
|
| 1420 |
: null;
|
| 1421 |
nodeSel
|
| 1422 |
.classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
|
| 1423 |
-
.classed('gen-attr-dag-node--selected',
|
| 1424 |
.style('display', nodeDisplay)
|
| 1425 |
.attr('opacity', (d) => {
|
| 1426 |
const nodeFullyHighlighted = recursiveAttributionEnabled
|
| 1427 |
? forwardPromptOnlyFrame
|
| 1428 |
? nodeOnChainForRender(d)
|
| 1429 |
-
: d.id === focusId ||
|
| 1430 |
(nodeStrokeShareById?.has(d.id) ?? false) ||
|
| 1431 |
-
(
|
| 1432 |
: (focusNodeIds?.has(d.id) ?? false);
|
| 1433 |
if (nodeFullyHighlighted) return 1;
|
| 1434 |
if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
|
|
@@ -1439,15 +1487,19 @@ export function initGenAttributeDagView(
|
|
| 1439 |
if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
|
| 1440 |
return 1;
|
| 1441 |
})
|
| 1442 |
-
.classed(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1443 |
.style(CSS_VAR_DAG_NODE_RECURSIVE_SHARE, (d) => {
|
| 1444 |
-
if (!nodeOnChainForRender(d)) return null;
|
| 1445 |
const renderStrength = nodeStrokeRenderById?.get(d.id);
|
| 1446 |
return renderStrength != null ? String(renderStrength) : null;
|
| 1447 |
});
|
| 1448 |
nodeHitSel
|
| 1449 |
.classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
|
| 1450 |
-
.classed('gen-attr-dag-node--selected',
|
| 1451 |
.style('display', nodeDisplay);
|
| 1452 |
// 每条边:颜色/强度(见 resolveDagLinkHighlightDisplay)、`<title>` 一并刷新(含 linkGFront 高亮边)。
|
| 1453 |
rootG.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function(d) {
|
|
@@ -1463,6 +1515,7 @@ export function initGenAttributeDagView(
|
|
| 1463 |
grayRenderByKey,
|
| 1464 |
incomingHighlightRenderByKey,
|
| 1465 |
downstreamHighlightRenderByKey,
|
|
|
|
| 1466 |
);
|
| 1467 |
const finalRenderStrength =
|
| 1468 |
renderStrength *
|
|
@@ -1536,16 +1589,24 @@ export function initGenAttributeDagView(
|
|
| 1536 |
pred_topk: [],
|
| 1537 |
};
|
| 1538 |
|
| 1539 |
-
//
|
| 1540 |
const rowsBeforeInfo: ToolTipUpdateAugment['rowsBeforeInfo'] = [];
|
| 1541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1542 |
const selectedStep = (graph.getNodeAttributes(selectedId) as DagNode).step;
|
| 1543 |
// 归因范围:选中 token 之前的所有 token(prompt 节点 step=-1,生成节点 step < selectedStep)
|
| 1544 |
const inAttributionRange =
|
| 1545 |
selectedStep >= 0 &&
|
| 1546 |
(attrs.step === -1 || (attrs.step >= 0 && attrs.step < selectedStep));
|
| 1547 |
if (inAttributionRange) {
|
| 1548 |
-
const share = currentFocusState.nodeShareById.get(
|
| 1549 |
if (recursiveAttributionEnabled) {
|
| 1550 |
const stay = share * (1 - nodePropagationMiRatio(attrs));
|
| 1551 |
rowsBeforeInfo.push(
|
|
@@ -1728,6 +1789,7 @@ export function initGenAttributeDagView(
|
|
| 1728 |
}
|
| 1729 |
const displayLabel = visualizeSpecialChars(attr.raw, {
|
| 1730 |
spaceDotExceptBeforeAsciiLetterOrNumber: true,
|
|
|
|
| 1731 |
});
|
| 1732 |
const srcNode: DagNode = {
|
| 1733 |
id: srcId,
|
|
@@ -1784,6 +1846,7 @@ export function initGenAttributeDagView(
|
|
| 1784 |
const g = textMeasure.appendGeneratedToken(token, [targetStart, targetEnd]);
|
| 1785 |
const displayLabel = visualizeSpecialChars(token, {
|
| 1786 |
spaceDotExceptBeforeAsciiLetterOrNumber: true,
|
|
|
|
| 1787 |
});
|
| 1788 |
const ciVisualScale = dagGeneratedNodeCiVisualScale(response.target_prob);
|
| 1789 |
const gltrTooltipToken = frontendTokenFromGenAttrStep(step);
|
|
@@ -2075,6 +2138,7 @@ export function initGenAttributeDagView(
|
|
| 2075 |
if (recursiveAttributionEnabled === enabled) return;
|
| 2076 |
recursiveAttributionEnabled = enabled;
|
| 2077 |
if (!enabled) recursiveEdgeAnimation.stopAnimation();
|
|
|
|
| 2078 |
refreshNodeLinkHighlight();
|
| 2079 |
}
|
| 2080 |
|
|
@@ -2089,6 +2153,7 @@ export function initGenAttributeDagView(
|
|
| 2089 |
|
| 2090 |
function setRecursiveEdgeBatchAnimationDirection(direction: DagRecursiveEdgeAnimationDirection): void {
|
| 2091 |
recursiveEdgeAnimation.setDirection(direction);
|
|
|
|
| 2092 |
refreshNodeLinkHighlight();
|
| 2093 |
}
|
| 2094 |
|
|
|
|
| 12 |
} from './genAttributeDagPreprocess';
|
| 13 |
import {
|
| 14 |
DAG_EDGE_MIN_NORMALIZED_SCORE,
|
|
|
|
| 15 |
DAG_MIN_ATTRIBUTION_SHARE,
|
| 16 |
DAG_NODE_STROKE_OPACITY_BASE,
|
| 17 |
} from './genAttributeDagEdgeDisplay';
|
| 18 |
import {
|
| 19 |
+
buildMaxNormalizedRenderStrengthByKey,
|
| 20 |
+
normalizeEdgeRenderOpacity,
|
| 21 |
+
} from './genAttributeDagEdgeRenderStrength';
|
| 22 |
+
import {
|
| 23 |
+
backwardSlideIncomingEdgeKeysForBatch,
|
| 24 |
createDagRecursiveEdgeAnimationController,
|
| 25 |
type DagRecursiveEdgeReplayPacing,
|
| 26 |
maxHighlightEdgeShare,
|
|
|
|
| 218 |
* 下台阶等处用 {@link dagStepDownEffectiveCiRatio}(dagTargetProb)(高置信 p>p₁ 为 0;与「关闭 CI 视觉」无关);
|
| 219 |
*/
|
| 220 |
dagTargetProb?: number;
|
| 221 |
+
/** {@link visualizeSpecialChars}(DAG 节点:词界空格 + 不可打印为 `[]`),建点后不变;边 tooltip 用完整 `[hex]` */
|
| 222 |
displayLabel: string;
|
| 223 |
/** 悬停 / 选中焦点时 Top‑K tooltip;仅生成节点(`step >= 0`) */
|
| 224 |
gltrTooltipToken?: FrontendToken;
|
|
|
|
| 275 |
return computeMutualInformationRatio(node.dagTargetProb);
|
| 276 |
}
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
/**
|
| 279 |
* 候选归因节点描边透明度:池内 `stay / max(stay)` 线性映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`,
|
| 280 |
* 避免弱节点描边过淡、在 UI 里看不出来(见 {@link DAG_NODE_STROKE_OPACITY_BASE})。
|
|
|
|
| 341 |
return byNodeId;
|
| 342 |
}
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
/** 递归链候选节点描边强度:stay 池内 max 归一后映射到 `[{@link DAG_NODE_STROKE_OPACITY_BASE}, 1]`。 */
|
| 345 |
function buildNodeStrokeRenderStrengthById(
|
| 346 |
stayByNodeId: Map<string, number>,
|
|
|
|
| 694 |
`Link strength: ${formatTooltipLinkStrength(snapshot.linkStrength)}`,
|
| 695 |
];
|
| 696 |
|
| 697 |
+
const dagTooltipLabelOpts = { spaceDotExceptBeforeAsciiLetterOrNumber: true as const };
|
| 698 |
return [
|
| 699 |
+
`From:\n${visualizeSpecialChars(snapshot.src.label, dagTooltipLabelOpts)}\nOffset: ${formatNodeOffsetRange(snapshot.src.id)}`,
|
| 700 |
+
`To:\n${visualizeSpecialChars(snapshot.tgt.label, dagTooltipLabelOpts)}\nOffset: ${formatNodeOffsetRange(snapshot.tgt.id)}`,
|
| 701 |
metrics.join('\n'),
|
| 702 |
].join('\n\n');
|
| 703 |
}
|
|
|
|
| 765 |
grayRenderByKey: Map<string, number>,
|
| 766 |
incomingHighlightRenderByKey: Map<string, number>,
|
| 767 |
downstreamHighlightRenderByKey: Map<string, number>,
|
| 768 |
+
backwardSlideIncomingRenderByKey: Map<string, number> | null,
|
| 769 |
): DagLinkHighlightDisplay {
|
| 770 |
const directStrength = directAttributionStrength(d);
|
| 771 |
const grayRender = grayRenderByKey.get(edgeKey) ?? directStrength;
|
|
|
|
| 782 |
|
| 783 |
const incomingShare = focusState.incomingEdgeShareByKey.get(edgeKey);
|
| 784 |
if (incomingShare != null) {
|
| 785 |
+
const backwardSlideRender = backwardSlideIncomingRenderByKey?.get(edgeKey);
|
| 786 |
return {
|
| 787 |
+
stroke:
|
| 788 |
+
backwardSlideRender != null
|
| 789 |
+
? `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_OUT})`
|
| 790 |
+
: `var(${CSS_VAR_DAG_HIGHLIGHT_LINE_IN})`,
|
| 791 |
+
renderStrength:
|
| 792 |
+
backwardSlideRender ?? incomingHighlightRenderByKey.get(edgeKey)!,
|
| 793 |
linkStrength: incomingShare,
|
| 794 |
recursiveAttributionShare: recursiveAttributionEnabled ? incomingShare : undefined,
|
| 795 |
};
|
|
|
|
| 1160 |
let hoveredId: string | null = null;
|
| 1161 |
/** 最近一次 {@link refreshNodeLinkHighlight} 计算出的归因状态(基于 {@link effectiveFocusId});tooltip 用于展示归因份额 */
|
| 1162 |
let currentFocusState: FocusAttributionState | null = null;
|
| 1163 |
+
/** 传播链动画进行中 tooltip 锚点;播放结束后为 null,恢复 target / hover。 */
|
| 1164 |
+
let propagationPlaybackTooltip: {
|
| 1165 |
+
nodeId: string;
|
| 1166 |
+
direction: DagRecursiveEdgeAnimationDirection;
|
| 1167 |
+
} | null = null;
|
| 1168 |
|
| 1169 |
const focusAttributionCtx = () => ({
|
| 1170 |
nodesSortedByStepDesc,
|
|
|
|
| 1201 |
return selectedId ?? hoveredId;
|
| 1202 |
}
|
| 1203 |
|
| 1204 |
+
/** 传播链动画当前帧应对应 tooltip 的节点;非播放中返回 null。 */
|
| 1205 |
+
function resolvePropagationPlaybackTooltipNodeId(
|
| 1206 |
+
animOverlay: ReturnType<typeof recursiveEdgeAnimation.resolveRenderOverlay>,
|
| 1207 |
+
focusId: string | null,
|
| 1208 |
+
): string | null {
|
| 1209 |
+
if (focusId == null || !animOverlay.animationFrontierPartial || animOverlay.anim == null) {
|
| 1210 |
+
return null;
|
| 1211 |
+
}
|
| 1212 |
+
const { anim, forwardPromptOnlyFrame, propagationSlideTgtId, nodeStrokeShareById } = animOverlay;
|
| 1213 |
+
if (forwardPromptOnlyFrame) {
|
| 1214 |
+
if (nodeStrokeShareById != null) {
|
| 1215 |
+
for (const id of nodeStrokeShareById.keys()) {
|
| 1216 |
+
if (graph.hasNode(id) && (graph.getNodeAttributes(id) as DagNode).step === -1) {
|
| 1217 |
+
return id;
|
| 1218 |
+
}
|
| 1219 |
+
}
|
| 1220 |
+
}
|
| 1221 |
+
return nodes.find((n) => n.step === -1)?.id ?? null;
|
| 1222 |
+
}
|
| 1223 |
+
return propagationSlideTgtId ?? anim.plan.batches[anim.batchIndex]?.tgtId ?? null;
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
/** tooltip 锚点:传播播放(忽略 hover)> hover > 焦点 */
|
| 1227 |
function tooltipFocusId(): string | null {
|
| 1228 |
+
if (propagationPlaybackTooltip != null && graph.hasNode(propagationPlaybackTooltip.nodeId)) {
|
| 1229 |
+
return propagationPlaybackTooltip.nodeId;
|
| 1230 |
+
}
|
| 1231 |
+
if (hoveredId != null && graph.hasNode(hoveredId)) {
|
| 1232 |
+
return hoveredId;
|
| 1233 |
}
|
| 1234 |
return effectiveFocusId();
|
| 1235 |
}
|
|
|
|
| 1291 |
svg.attr('width', w).attr('height', h);
|
| 1292 |
}
|
| 1293 |
|
| 1294 |
+
/** 传播归因 + backward:仅 UI 路径反向,不改边数据与归因 key。 */
|
| 1295 |
+
function linkEndpointsForPaint(d: DagLink): { src: DagNode; tgt: DagNode } {
|
| 1296 |
+
const src = endpointNode(d.source, graph);
|
| 1297 |
+
const tgt = endpointNode(d.target, graph);
|
| 1298 |
+
const flipArrows =
|
| 1299 |
+
recursiveAttributionEnabled && recursiveEdgeAnimation.getDirection() === 'backward';
|
| 1300 |
+
return flipArrows ? { src: tgt, tgt: src } : { src, tgt };
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
function paint(): void {
|
| 1304 |
syncNodeStrokeRects(nodeSel, displayScale);
|
| 1305 |
if (layoutMode === 'linear-arc' || layoutMode === 'linear-arc-step-down') {
|
|
|
|
| 1312 |
nodes: layoutNodes,
|
| 1313 |
adjacentGapPx: linearArcAdjacentGapPx,
|
| 1314 |
variant: layoutMode === 'linear-arc-step-down' ? 'step-down' : 'flat',
|
| 1315 |
+
getLinkNodes: linkEndpointsForPaint,
|
|
|
|
|
|
|
|
|
|
| 1316 |
});
|
| 1317 |
} else if (layoutMode === 'spiral') {
|
| 1318 |
const layoutNodes = hideExcludedTokens
|
|
|
|
| 1323 |
nodeSel,
|
| 1324 |
nodes: layoutNodes,
|
| 1325 |
linkEndInsetPx,
|
| 1326 |
+
getLinkNodes: linkEndpointsForPaint,
|
|
|
|
|
|
|
|
|
|
| 1327 |
});
|
| 1328 |
} else {
|
| 1329 |
paintTextFlowLayout({
|
| 1330 |
linkSel,
|
| 1331 |
nodeSel,
|
| 1332 |
linkEndInsetPx,
|
| 1333 |
+
getLinkNodes: linkEndpointsForPaint,
|
|
|
|
|
|
|
|
|
|
| 1334 |
});
|
| 1335 |
}
|
| 1336 |
syncNodeHitTransforms();
|
|
|
|
| 1383 |
recursiveAttributionEnabled,
|
| 1384 |
ctx: focusAttributionCtx(),
|
| 1385 |
});
|
| 1386 |
+
const playbackNodeId = resolvePropagationPlaybackTooltipNodeId(animOverlay, focusId);
|
| 1387 |
+
propagationPlaybackTooltip =
|
| 1388 |
+
playbackNodeId != null && animOverlay.anim != null
|
| 1389 |
+
? { nodeId: playbackNodeId, direction: animOverlay.anim.direction }
|
| 1390 |
+
: null;
|
| 1391 |
const linkFocusState = animOverlay.linkFocusState ?? focusState;
|
| 1392 |
const focusNodeIds = focusState?.activeNodeIds ?? null;
|
| 1393 |
const nodeStrokeShareById = animOverlay.nodeStrokeShareById;
|
|
|
|
| 1422 |
: buildMaxNormalizedRenderStrengthByKey(focusState.downstreamEdgeStrengthByKey);
|
| 1423 |
grayRenderCache ??= buildGrayRenderStrengthByEdgeKey(graph, incomingLinksByTarget);
|
| 1424 |
const grayRenderByKey = grayRenderCache;
|
| 1425 |
+
const {
|
| 1426 |
+
propagationSlideTgtId,
|
| 1427 |
+
forwardPromptOnlyFrame,
|
| 1428 |
+
deferFocusHighlightDuringAnim,
|
| 1429 |
+
suppressFocusSelectedStroke,
|
| 1430 |
+
incomingShareForRender,
|
| 1431 |
+
anim,
|
| 1432 |
+
animationFrontierPartial,
|
| 1433 |
+
} = animOverlay;
|
| 1434 |
+
let backwardSlideIncomingRenderByKey: Map<string, number> | null = null;
|
| 1435 |
+
if (
|
| 1436 |
+
animationFrontierPartial &&
|
| 1437 |
+
anim?.direction === 'backward' &&
|
| 1438 |
+
!forwardPromptOnlyFrame &&
|
| 1439 |
+
focusId != null
|
| 1440 |
+
) {
|
| 1441 |
+
const slideKeys = backwardSlideIncomingEdgeKeysForBatch(
|
| 1442 |
+
anim.plan,
|
| 1443 |
+
anim.batchIndex,
|
| 1444 |
+
focusId,
|
| 1445 |
+
);
|
| 1446 |
+
if (slideKeys.size > 0) {
|
| 1447 |
+
backwardSlideIncomingRenderByKey = buildMaxNormalizedRenderStrengthByKey(
|
| 1448 |
+
incomingShareForRender,
|
| 1449 |
+
focusTargetMiRatio,
|
| 1450 |
+
undefined,
|
| 1451 |
+
slideKeys,
|
| 1452 |
+
);
|
| 1453 |
+
}
|
| 1454 |
+
}
|
| 1455 |
+
const isPropagationSlide = (d: DagNode): boolean =>
|
| 1456 |
+
propagationSlideTgtId != null && d.id === propagationSlideTgtId;
|
| 1457 |
+
const isBackwardSlide = (d: DagNode): boolean =>
|
| 1458 |
+
animOverlay.anim?.direction === 'backward' && isPropagationSlide(d);
|
| 1459 |
+
const showFocusSelectedStroke = (d: DagNode): boolean =>
|
| 1460 |
+
selectedId === d.id && !(suppressFocusSelectedStroke && d.id === focusId);
|
| 1461 |
const nodeOnChainForRender = (d: DagNode): boolean => {
|
| 1462 |
if (!forwardPromptOnlyFrame) return nodeStrokeShareById?.has(d.id) ?? false;
|
| 1463 |
return d.step === -1 && (nodeStrokeShareById?.has(d.id) ?? false);
|
|
|
|
| 1468 |
: null;
|
| 1469 |
nodeSel
|
| 1470 |
.classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
|
| 1471 |
+
.classed('gen-attr-dag-node--selected', showFocusSelectedStroke)
|
| 1472 |
.style('display', nodeDisplay)
|
| 1473 |
.attr('opacity', (d) => {
|
| 1474 |
const nodeFullyHighlighted = recursiveAttributionEnabled
|
| 1475 |
? forwardPromptOnlyFrame
|
| 1476 |
? nodeOnChainForRender(d)
|
| 1477 |
+
: (!deferFocusHighlightDuringAnim && d.id === focusId) ||
|
| 1478 |
(nodeStrokeShareById?.has(d.id) ?? false) ||
|
| 1479 |
+
isPropagationSlide(d)
|
| 1480 |
: (focusNodeIds?.has(d.id) ?? false);
|
| 1481 |
if (nodeFullyHighlighted) return 1;
|
| 1482 |
if (isOffsetSpanFullyExcluded(d.start, d.end, dagExcludeIntervals)) {
|
|
|
|
| 1487 |
if (focusId || isPromptLeaf) return DAG_NODE_WEAKEN_OPACITY;
|
| 1488 |
return 1;
|
| 1489 |
})
|
| 1490 |
+
.classed(
|
| 1491 |
+
'gen-attr-dag-node--recursive-chain',
|
| 1492 |
+
(d) => nodeOnChainForRender(d) || isBackwardSlide(d),
|
| 1493 |
+
)
|
| 1494 |
+
.classed('gen-attr-dag-node--backward-slide', isBackwardSlide)
|
| 1495 |
.style(CSS_VAR_DAG_NODE_RECURSIVE_SHARE, (d) => {
|
| 1496 |
+
if (!nodeOnChainForRender(d) && !isBackwardSlide(d)) return null;
|
| 1497 |
const renderStrength = nodeStrokeRenderById?.get(d.id);
|
| 1498 |
return renderStrength != null ? String(renderStrength) : null;
|
| 1499 |
});
|
| 1500 |
nodeHitSel
|
| 1501 |
.classed('gen-attr-dag-node--hover', (d) => hoveredId === d.id)
|
| 1502 |
+
.classed('gen-attr-dag-node--selected', showFocusSelectedStroke)
|
| 1503 |
.style('display', nodeDisplay);
|
| 1504 |
// 每条边:颜色/强度(见 resolveDagLinkHighlightDisplay)、`<title>` 一并刷新(含 linkGFront 高亮边)。
|
| 1505 |
rootG.selectAll<SVGGElement, DagLink>('g.gen-attr-dag-link').each(function(d) {
|
|
|
|
| 1515 |
grayRenderByKey,
|
| 1516 |
incomingHighlightRenderByKey,
|
| 1517 |
downstreamHighlightRenderByKey,
|
| 1518 |
+
backwardSlideIncomingRenderByKey,
|
| 1519 |
);
|
| 1520 |
const finalRenderStrength =
|
| 1521 |
renderStrength *
|
|
|
|
| 1589 |
pred_topk: [],
|
| 1590 |
};
|
| 1591 |
|
| 1592 |
+
// 反向播放:当前 token 展示稳态归因份额(同 hover);正向播放 / 非播放:仅 hover 时展示
|
| 1593 |
const rowsBeforeInfo: ToolTipUpdateAugment['rowsBeforeInfo'] = [];
|
| 1594 |
+
const shareSourceId =
|
| 1595 |
+
propagationPlaybackTooltip?.direction === 'backward' ? focusIdNext : hoveredId;
|
| 1596 |
+
if (
|
| 1597 |
+
selectedId &&
|
| 1598 |
+
shareSourceId &&
|
| 1599 |
+
currentFocusState &&
|
| 1600 |
+
shareSourceId !== selectedId &&
|
| 1601 |
+
graph.hasNode(selectedId)
|
| 1602 |
+
) {
|
| 1603 |
const selectedStep = (graph.getNodeAttributes(selectedId) as DagNode).step;
|
| 1604 |
// 归因范围:选中 token 之前的所有 token(prompt 节点 step=-1,生成节点 step < selectedStep)
|
| 1605 |
const inAttributionRange =
|
| 1606 |
selectedStep >= 0 &&
|
| 1607 |
(attrs.step === -1 || (attrs.step >= 0 && attrs.step < selectedStep));
|
| 1608 |
if (inAttributionRange) {
|
| 1609 |
+
const share = currentFocusState.nodeShareById.get(shareSourceId) ?? 0;
|
| 1610 |
if (recursiveAttributionEnabled) {
|
| 1611 |
const stay = share * (1 - nodePropagationMiRatio(attrs));
|
| 1612 |
rowsBeforeInfo.push(
|
|
|
|
| 1789 |
}
|
| 1790 |
const displayLabel = visualizeSpecialChars(attr.raw, {
|
| 1791 |
spaceDotExceptBeforeAsciiLetterOrNumber: true,
|
| 1792 |
+
omitHexInCodePointLabel: true,
|
| 1793 |
});
|
| 1794 |
const srcNode: DagNode = {
|
| 1795 |
id: srcId,
|
|
|
|
| 1846 |
const g = textMeasure.appendGeneratedToken(token, [targetStart, targetEnd]);
|
| 1847 |
const displayLabel = visualizeSpecialChars(token, {
|
| 1848 |
spaceDotExceptBeforeAsciiLetterOrNumber: true,
|
| 1849 |
+
omitHexInCodePointLabel: true,
|
| 1850 |
});
|
| 1851 |
const ciVisualScale = dagGeneratedNodeCiVisualScale(response.target_prob);
|
| 1852 |
const gltrTooltipToken = frontendTokenFromGenAttrStep(step);
|
|
|
|
| 2138 |
if (recursiveAttributionEnabled === enabled) return;
|
| 2139 |
recursiveAttributionEnabled = enabled;
|
| 2140 |
if (!enabled) recursiveEdgeAnimation.stopAnimation();
|
| 2141 |
+
paint();
|
| 2142 |
refreshNodeLinkHighlight();
|
| 2143 |
}
|
| 2144 |
|
|
|
|
| 2153 |
|
| 2154 |
function setRecursiveEdgeBatchAnimationDirection(direction: DagRecursiveEdgeAnimationDirection): void {
|
| 2155 |
recursiveEdgeAnimation.setDirection(direction);
|
| 2156 |
+
paint();
|
| 2157 |
refreshNodeLinkHighlight();
|
| 2158 |
}
|
| 2159 |
|
client/src/shared/prediction_attribution/causal_flow/tokenGenAttributionRunner.ts
CHANGED
|
@@ -6,9 +6,10 @@ import type { AttributionApiResponse, PredictionAttributeModelVariant } from '..
|
|
| 6 |
import type { PromptTokenSpan } from './genAttributeDagPreprocess';
|
| 7 |
import type { CompletionFinishReason } from '../../cross/generationEndReasonLabel';
|
| 8 |
import { fetchPredictionAttribute, fetchTokenize } from '../core/predictionAttributeClient';
|
|
|
|
| 9 |
|
| 10 |
-
/**
|
| 11 |
-
export const TOKEN_GEN_MAX_TOKENS_DEFAULT =
|
| 12 |
|
| 13 |
function splitCodePointPrefix(text: string, prefixLength: number): { prefix: string; rest: string } | null {
|
| 14 |
if (prefixLength < 0) return null;
|
|
|
|
| 6 |
import type { PromptTokenSpan } from './genAttributeDagPreprocess';
|
| 7 |
import type { CompletionFinishReason } from '../../cross/generationEndReasonLabel';
|
| 8 |
import { fetchPredictionAttribute, fetchTokenize } from '../core/predictionAttributeClient';
|
| 9 |
+
import { DEFAULT_MAX_NEW_TOKENS } from '../../cross/maxNewTokensConfig';
|
| 10 |
|
| 11 |
+
/** @deprecated 使用 {@link DEFAULT_MAX_NEW_TOKENS} */
|
| 12 |
+
export const TOKEN_GEN_MAX_TOKENS_DEFAULT = DEFAULT_MAX_NEW_TOKENS;
|
| 13 |
|
| 14 |
function splitCodePointPrefix(text: string, prefixLength: number): { prefix: string; rest: string } | null {
|
| 15 |
if (prefixLength < 0) return null;
|
client/src/shared/vis/ToolTip.ts
CHANGED
|
@@ -21,13 +21,13 @@ export type ToolTipOptions = {
|
|
| 21 |
surprisalRowLabel?: string;
|
| 22 |
/**
|
| 23 |
* `parent-bottom-right`:`position:absolute`,贴在定位包含块(`offsetParent`)右下角(DAG Top‑K 作为 `#results` 直接子节点时即为 results 内侧右下)。
|
| 24 |
-
* 该 HUD 模式不依赖锚点几何;面板应由页面 CSS(如 `.gen-attr-dag-topk-tooltip`)约束宽高
|
| 25 |
* 默认 `anchor`:沿用原有相对 token rect 的定位。
|
| 26 |
*/
|
| 27 |
placement?: 'anchor' | 'parent-bottom-right';
|
| 28 |
/**
|
| 29 |
* false:面板不参与命中测试(`pointer-events: none`),避免盖住底层 SVG 时在节点上反复 `mouseleave`/闪动;
|
| 30 |
-
* 同时不注册点击/触摸收起
|
| 31 |
*/
|
| 32 |
pointerInteractive?: boolean;
|
| 33 |
};
|
|
|
|
| 21 |
surprisalRowLabel?: string;
|
| 22 |
/**
|
| 23 |
* `parent-bottom-right`:`position:absolute`,贴在定位包含块(`offsetParent`)右下角(DAG Top‑K 作为 `#results` 直接子节点时即为 results 内侧右下)。
|
| 24 |
+
* 该 HUD 模式不依赖锚点几何;面板应由页面 CSS(如 `.gen-attr-dag-topk-tooltip`)约束宽高,超出部分裁剪。
|
| 25 |
* 默认 `anchor`:沿用原有相对 token rect 的定位。
|
| 26 |
*/
|
| 27 |
placement?: 'anchor' | 'parent-bottom-right';
|
| 28 |
/**
|
| 29 |
* false:面板不参与命中测试(`pointer-events: none`),避免盖住底层 SVG 时在节点上反复 `mouseleave`/闪动;
|
| 30 |
+
* 同时不注册点击/触摸收起。
|
| 31 |
*/
|
| 32 |
pointerInteractive?: boolean;
|
| 33 |
};
|
client/src/tests/prediction_attribution/genAttributeDagPropagationPlayback.test.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 传播链播放计划 / 节奏单元测试
|
| 3 |
+
* 运行: cd client/src && npm run test:dagPropagationPlayback
|
| 4 |
+
*/
|
| 5 |
+
import {
|
| 6 |
+
batchPlaybackDelayMs,
|
| 7 |
+
computePropagationGroupPacings,
|
| 8 |
+
FORWARD_PROMPT_FRAME_DWELL_MS,
|
| 9 |
+
propagationRunningMaxLookaheadForGroupCount,
|
| 10 |
+
} from '../../shared/prediction_attribution/causal_flow/genAttributeDagPropagationPlaybackPacing';
|
| 11 |
+
import { buildMaxNormalizedRenderStrengthByKey } from '../../shared/prediction_attribution/causal_flow/genAttributeDagEdgeRenderStrength';
|
| 12 |
+
import {
|
| 13 |
+
backwardSlideIncomingEdgeKeysForBatch,
|
| 14 |
+
buildPropagationPlaybackPlan,
|
| 15 |
+
maxShareInEdgeKeySet,
|
| 16 |
+
tgtIdFromEdgeKey,
|
| 17 |
+
} from '../../shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation';
|
| 18 |
+
|
| 19 |
+
/** 与重构前独立的 `buildMaxNormalizedRenderStrengthForEdgeKeySet` 同公式,用于回归对照。 */
|
| 20 |
+
function legacySubsetMaxNormalizedRender(
|
| 21 |
+
sharesByKey: Map<string, number>,
|
| 22 |
+
edgeKeys: ReadonlySet<string>,
|
| 23 |
+
maxOpacity = 1,
|
| 24 |
+
): Map<string, number> {
|
| 25 |
+
let maxShare = 0;
|
| 26 |
+
for (const key of edgeKeys) {
|
| 27 |
+
const share = sharesByKey.get(key);
|
| 28 |
+
if (share != null && share > maxShare) maxShare = share;
|
| 29 |
+
}
|
| 30 |
+
const byKey = new Map<string, number>();
|
| 31 |
+
for (const key of edgeKeys) {
|
| 32 |
+
const share = sharesByKey.get(key);
|
| 33 |
+
if (share != null) {
|
| 34 |
+
byKey.set(
|
| 35 |
+
key,
|
| 36 |
+
buildMaxNormalizedRenderStrengthByKey(
|
| 37 |
+
new Map([[key, share]]),
|
| 38 |
+
maxOpacity,
|
| 39 |
+
maxShare,
|
| 40 |
+
).get(key)!,
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
return byKey;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
let passed = 0;
|
| 48 |
+
let failed = 0;
|
| 49 |
+
|
| 50 |
+
function assert(desc: string, cond: boolean) {
|
| 51 |
+
if (cond) {
|
| 52 |
+
console.log(` ✓ ${desc}`);
|
| 53 |
+
passed++;
|
| 54 |
+
} else {
|
| 55 |
+
console.error(` ✗ ${desc}`);
|
| 56 |
+
failed++;
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function assertEq<T>(desc: string, actual: T, expected: T) {
|
| 61 |
+
assert(desc, actual === expected);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function assertClose(desc: string, actual: number, expected: number, eps = 1e-9) {
|
| 65 |
+
assert(desc, Math.abs(actual - expected) <= eps);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// ── lookahead ───────────────────────────────────────────────────────────────
|
| 69 |
+
console.log('1. propagationRunningMaxLookaheadForGroupCount');
|
| 70 |
+
assertEq('0 组 → 0', propagationRunningMaxLookaheadForGroupCount(0), 0);
|
| 71 |
+
assertEq('1 组 → MIN(2)', propagationRunningMaxLookaheadForGroupCount(1), 2);
|
| 72 |
+
assertEq('10 组 → max(2, round(1))', propagationRunningMaxLookaheadForGroupCount(10), 2);
|
| 73 |
+
assertEq('30 组 → 3', propagationRunningMaxLookaheadForGroupCount(30), 3);
|
| 74 |
+
|
| 75 |
+
// ── computePropagationGroupPacings ──────────────────────────────────────────
|
| 76 |
+
console.log('2. computePropagationGroupPacings');
|
| 77 |
+
{
|
| 78 |
+
const focusId = 'f';
|
| 79 |
+
const nodeShare = new Map([
|
| 80 |
+
['f', 1],
|
| 81 |
+
['a', 0.4],
|
| 82 |
+
['b', 0.2],
|
| 83 |
+
['c', 0.1],
|
| 84 |
+
]);
|
| 85 |
+
const groups = [
|
| 86 |
+
{ tgtIds: ['a'] },
|
| 87 |
+
{ tgtIds: ['b'] },
|
| 88 |
+
{ tgtIds: ['c'] },
|
| 89 |
+
];
|
| 90 |
+
const { groupPreps, weightMax, weightTotal, runningMaxLookahead } = computePropagationGroupPacings(
|
| 91 |
+
groups,
|
| 92 |
+
nodeShare,
|
| 93 |
+
focusId,
|
| 94 |
+
);
|
| 95 |
+
assertClose('weightMax = 非焦点组内 max', weightMax, 0.4);
|
| 96 |
+
assertEq('3 组 lookahead', runningMaxLookahead, 2);
|
| 97 |
+
assert('每组有 propagationWeight', groupPreps.length === 3);
|
| 98 |
+
assert('weightTotal > 0', weightTotal > 0);
|
| 99 |
+
assertClose('首组 shareNorm = 1', groupPreps[0]!.shareNorm ?? -1, 1);
|
| 100 |
+
assert('running max 非降', groupPreps[1]!.runningMaxNorm >= groupPreps[0]!.runningMaxNorm);
|
| 101 |
+
assert(
|
| 102 |
+
'propagationWeight ∈ [0,1]',
|
| 103 |
+
groupPreps.every((p) => p.propagationWeight >= 0 && p.propagationWeight <= 1),
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
{
|
| 108 |
+
const { groupPreps, weightTotal } = computePropagationGroupPacings(
|
| 109 |
+
[{ tgtIds: ['f', 'x'] }, { tgtIds: ['y'] }],
|
| 110 |
+
new Map([
|
| 111 |
+
['f', 1],
|
| 112 |
+
['x', 0.5],
|
| 113 |
+
['y', 0.25],
|
| 114 |
+
]),
|
| 115 |
+
'f',
|
| 116 |
+
);
|
| 117 |
+
assert('含焦点组无 shareNorm', groupPreps[0]!.shareNorm === undefined);
|
| 118 |
+
assert('weightTotal 可累加', weightTotal >= 0);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// ── batchPlaybackDelayMs ────────────────────────────────────────────────────
|
| 122 |
+
console.log('3. batchPlaybackDelayMs');
|
| 123 |
+
const batch = { propagationWeight: 0.25 };
|
| 124 |
+
const plan = { weightTotal: 1 };
|
| 125 |
+
|
| 126 |
+
assertEq(
|
| 127 |
+
'step:0 权重 → 0ms',
|
| 128 |
+
batchPlaybackDelayMs({ propagationWeight: 0 }, plan, { mode: 'step', stepMs: 500, totalS: 7 }),
|
| 129 |
+
0,
|
| 130 |
+
);
|
| 131 |
+
assertEq(
|
| 132 |
+
'step:权重连续',
|
| 133 |
+
batchPlaybackDelayMs(batch, plan, { mode: 'step', stepMs: 400, totalS: 7 }),
|
| 134 |
+
100,
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
const totalPacing = { mode: 'total' as const, stepMs: 500, totalS: 7 };
|
| 138 |
+
const weightedBudgetMs = 7 * 1000 - FORWARD_PROMPT_FRAME_DWELL_MS;
|
| 139 |
+
assertEq(
|
| 140 |
+
'total:按权重占比,预算已扣固定帧',
|
| 141 |
+
batchPlaybackDelayMs(batch, plan, totalPacing),
|
| 142 |
+
Math.round(0.25 * weightedBudgetMs),
|
| 143 |
+
);
|
| 144 |
+
|
| 145 |
+
assertEq(
|
| 146 |
+
'total:权重 0 → 0ms',
|
| 147 |
+
batchPlaybackDelayMs({ propagationWeight: 0 }, plan, totalPacing),
|
| 148 |
+
0,
|
| 149 |
+
);
|
| 150 |
+
|
| 151 |
+
// ── buildPropagationPlaybackPlan ────────────────────────────────────────────
|
| 152 |
+
console.log('4. buildPropagationPlaybackPlan');
|
| 153 |
+
{
|
| 154 |
+
const incoming = new Map<string, number>([
|
| 155 |
+
['p->a', 0.3],
|
| 156 |
+
['a->b', 0.2],
|
| 157 |
+
['b->f', 0.5],
|
| 158 |
+
]);
|
| 159 |
+
const offsetOf = (id: string) => ({ p: 0, a: 1, b: 2, f: 3 })[id] ?? 0;
|
| 160 |
+
const nodeShare = new Map([
|
| 161 |
+
['f', 1],
|
| 162 |
+
['b', 0.4],
|
| 163 |
+
['a', 0.3],
|
| 164 |
+
['p', 0.2],
|
| 165 |
+
]);
|
| 166 |
+
const plan = buildPropagationPlaybackPlan(incoming, offsetOf, nodeShare, 'f');
|
| 167 |
+
assert('非空计划', plan != null);
|
| 168 |
+
if (plan != null) {
|
| 169 |
+
assertEq('批次数 = offset 组数', plan.batches.length, 3);
|
| 170 |
+
assert('播放序 offset 降序', plan.batches[0]!.groupOffset > plan.batches[1]!.groupOffset);
|
| 171 |
+
assertEq(
|
| 172 |
+
'backward batch0 = 焦点侧单组(b->f)',
|
| 173 |
+
plan.backwardFrontierByBatchIndex[0]?.has('b->f') ?? false,
|
| 174 |
+
true,
|
| 175 |
+
);
|
| 176 |
+
assert(
|
| 177 |
+
'forward batch0 前沿 = 全链',
|
| 178 |
+
plan.forwardFrontierByBatchIndex[0]?.size === 3,
|
| 179 |
+
);
|
| 180 |
+
const last = plan.batches.length - 1;
|
| 181 |
+
assertEq(
|
| 182 |
+
'backward 末批前沿 = 全链',
|
| 183 |
+
plan.backwardFrontierByBatchIndex[last]?.size ?? 0,
|
| 184 |
+
3,
|
| 185 |
+
);
|
| 186 |
+
for (const b of plan.batches) {
|
| 187 |
+
for (const key of b.edgeKeys) {
|
| 188 |
+
assertEq('edgeKey 可解析 tgt', tgtIdFromEdgeKey(key) != null, true);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
const textOrder = [...plan.batches].sort((a, b) => a.groupOffset - b.groupOffset);
|
| 192 |
+
assertClose('文序首组 offset 最小', textOrder[0]!.groupOffset, 1);
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
assertEq('空入边 → null', buildPropagationPlaybackPlan(new Map(), () => 0, new Map(), 'f'), null);
|
| 197 |
+
|
| 198 |
+
// ── buildMaxNormalizedRenderStrengthByKey(重构前后等价 + 蓝/红分母)────────
|
| 199 |
+
console.log('5. buildMaxNormalizedRenderStrengthByKey');
|
| 200 |
+
{
|
| 201 |
+
const shares = new Map<string, number>([
|
| 202 |
+
['p->a', 0.3],
|
| 203 |
+
['a->b', 0.2],
|
| 204 |
+
['b->f', 0.5],
|
| 205 |
+
['x->y', 0.9],
|
| 206 |
+
]);
|
| 207 |
+
const slideKeys = new Set(['a->b', 'p->a']);
|
| 208 |
+
const merged = buildMaxNormalizedRenderStrengthByKey(shares, 0.75, undefined, slideKeys);
|
| 209 |
+
const legacy = legacySubsetMaxNormalizedRender(shares, slideKeys, 0.75);
|
| 210 |
+
assert('onlyKeys 与重构前子集归一一致', merged.size === legacy.size);
|
| 211 |
+
for (const key of slideKeys) {
|
| 212 |
+
assertClose(`onlyKeys[${key}]`, merged.get(key) ?? -1, legacy.get(key) ?? -2);
|
| 213 |
+
}
|
| 214 |
+
assert('onlyKeys 不输出集合外键', !merged.has('b->f'));
|
| 215 |
+
|
| 216 |
+
const frontierMax = 0.5;
|
| 217 |
+
const blue = buildMaxNormalizedRenderStrengthByKey(shares, 0.8, frontierMax);
|
| 218 |
+
const red = buildMaxNormalizedRenderStrengthByKey(shares, 0.8, undefined, new Set(['a->b']));
|
| 219 |
+
assertClose('蓝入边用前沿 max', blue.get('a->b') ?? 0, 0.8 * (0.2 / 0.5));
|
| 220 |
+
assertClose('红入边用集合内 max', red.get('a->b') ?? 0, 0.8);
|
| 221 |
+
assert('红边强于同键蓝边(分母更小)', (red.get('a->b') ?? 0) > (blue.get('a->b') ?? 0));
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// ── backwardSlideIncomingEdgeKeysForBatch + 播放计划前沿 ───────────────────
|
| 225 |
+
console.log('6. backwardSlideIncomingEdgeKeysForBatch');
|
| 226 |
+
{
|
| 227 |
+
const incoming = new Map<string, number>([
|
| 228 |
+
['p->a', 0.3],
|
| 229 |
+
['a->b', 0.2],
|
| 230 |
+
['b->f', 0.5],
|
| 231 |
+
]);
|
| 232 |
+
const offsetOf = (id: string) => ({ p: 0, a: 1, b: 2, f: 3 })[id] ?? 0;
|
| 233 |
+
const nodeShare = new Map([
|
| 234 |
+
['f', 1],
|
| 235 |
+
['b', 0.4],
|
| 236 |
+
['a', 0.3],
|
| 237 |
+
['p', 0.2],
|
| 238 |
+
]);
|
| 239 |
+
const plan = buildPropagationPlaybackPlan(incoming, offsetOf, nodeShare, 'f');
|
| 240 |
+
assert('计划非空', plan != null);
|
| 241 |
+
if (plan != null) {
|
| 242 |
+
const batch0Keys = backwardSlideIncomingEdgeKeysForBatch(plan, 0, 'f');
|
| 243 |
+
assertEq('batch0 仅焦点入边', batch0Keys.size, 1);
|
| 244 |
+
assert('batch0 含 b->f', batch0Keys.has('b->f'));
|
| 245 |
+
|
| 246 |
+
const batch1 = plan.batches[1]!;
|
| 247 |
+
const batch1Keys = backwardSlideIncomingEdgeKeysForBatch(plan, 1, 'f');
|
| 248 |
+
assert('batch1 含指向 slide(b) 的 a->b', batch1Keys.has('a->b'));
|
| 249 |
+
for (const key of batch1Keys) {
|
| 250 |
+
assertEq('batch1 键的 tgt = slide', tgtIdFromEdgeKey(key), batch1.tgtId);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const frontier = plan.backwardFrontierByBatchIndex[1]!;
|
| 254 |
+
const frontierMax = maxShareInEdgeKeySet(incoming, frontier);
|
| 255 |
+
const mi = 0.6;
|
| 256 |
+
const redMap = buildMaxNormalizedRenderStrengthByKey(incoming, mi, undefined, batch1Keys);
|
| 257 |
+
const blueMap = buildMaxNormalizedRenderStrengthByKey(incoming, mi, frontierMax);
|
| 258 |
+
for (const key of batch1Keys) {
|
| 259 |
+
assert('红图仅含 slide 入边', redMap.has(key));
|
| 260 |
+
assertClose(`红[${key}] 集合内 max`, redMap.get(key) ?? 0, mi);
|
| 261 |
+
assert(
|
| 262 |
+
`红[${key}] ≥ 蓝(前沿 max 归一)`,
|
| 263 |
+
(redMap.get(key) ?? 0) >= (blueMap.get(key) ?? 0) - 1e-9,
|
| 264 |
+
);
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// ── summary ─────────────────────────────────────────────────────────────────
|
| 270 |
+
console.log(`\n${passed} passed, ${failed} failed`);
|
| 271 |
+
if (failed > 0) process.exit(1);
|