dqy08 commited on
Commit
28147a1
·
1 Parent(s): 17037b0

dag支持thinking模式;动画速度改进;其它小改进

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +2 -2
  2. backend/api/analyze.py +4 -4
  3. backend/api/client_activity.py +1 -1
  4. backend/api/model_switch.py +3 -3
  5. backend/api/openai_completions.py +14 -3
  6. backend/core/completion_generator.py +6 -5
  7. backend/core/language_checker.py +5 -5
  8. backend/core/prediction_attributor.py +6 -6
  9. backend/core/semantic_analyzer.py +5 -5
  10. backend/models/model_manager.py +58 -69
  11. backend/platform/access_log.py +6 -2
  12. backend/platform/app_context.py +19 -7
  13. backend/platform/visit_stats.py +2 -1
  14. client/src/assets/demos/causal_flow/CoT | 多“跳”推理.json +0 -0
  15. client/src/assets/demos/causal_flow/CoT | 苏州所在省的省会.json +8 -5
  16. client/src/assets/demos/causal_flow/order.json +26 -0
  17. client/src/assets/images/{dag-cot.png → dag-cot.mov} +2 -2
  18. client/src/causal_flow.html +14 -7
  19. client/src/chat.html +13 -5
  20. client/src/css/base/_narrow-ios-form-font-tail.scss +14 -0
  21. client/src/css/base/_responsive.scss +32 -12
  22. client/src/css/components/_chat-input-panel.scss +39 -0
  23. client/src/css/components/_semantic-analysis.scss +0 -8
  24. client/src/css/components/dialog.scss +17 -0
  25. client/src/css/pages/analysis.scss +4 -0
  26. client/src/css/pages/attribution.scss +4 -1
  27. client/src/css/pages/causal_flow.scss +7 -46
  28. client/src/css/pages/chat.scss +4 -0
  29. client/src/css/pages/compare.scss +4 -0
  30. client/src/features/analysis/infoDensityRenderManager.ts +6 -5
  31. client/src/features/causal_flow/bundledDemos.ts +2 -2
  32. client/src/features/causal_flow/genAttributeBundledDemoManifest.generated.ts +2 -1
  33. client/src/features/chat/chatPromptTemplateMode.ts +4 -19
  34. client/src/pages/analysis/index.ts +10 -9
  35. client/src/pages/attribution/index.ts +3 -12
  36. client/src/pages/causal_flow/index.ts +243 -334
  37. client/src/pages/chat/index.ts +23 -5
  38. client/src/pages/home/index.ts +8 -5
  39. client/src/scripts/genAttributeDemoManifestPlugin.js +85 -10
  40. client/src/scripts/injectPageMetaIntoHtml.js +2 -2
  41. client/src/shared/api/completionsClient.ts +10 -2
  42. client/src/shared/controllers/serverDemoController.ts +4 -3
  43. client/src/shared/core/responsive.ts +5 -3
  44. client/src/shared/cross/adminManager.ts +8 -6
  45. client/src/shared/cross/digitsMergeManager.ts +4 -2
  46. client/src/shared/cross/panelSplitStorage.ts +11 -17
  47. client/src/shared/cross/queryHistory.ts +4 -3
  48. client/src/shared/cross/semanticResultCache.ts +12 -12
  49. client/src/shared/cross/semanticThresholdManager.ts +3 -2
  50. client/src/shared/cross/settingsMenuManager.ts +4 -1
Dockerfile CHANGED
@@ -63,5 +63,5 @@ EXPOSE 7860
63
  # 在CPU basic 上使用0.6b模型能达到及格的速度
64
  # 在CPU upgrade 上使用1.7b模型能达到及格的速度
65
  # 在本地M5 16G芯片上使用4b模型能达到及格的速度(瓶颈是内存大小);M5 16G内存仅能同时支持一种分析模型(信息密度分析或语义分析)
66
- CMD ["python", "run.py", "--no_auto_load", "--port", "7860", "--model", "qwen3-1.7b", "--semantic_model", "qwen3-1.7b-instruct"]
67
- # CMD ["python", "run.py", "--no_auto_load", "--port", "7860", "--model", "qwen3-0.6b", "--semantic_model", "qwen3-0.6b-instruct"]
 
63
  # 在CPU basic 上使用0.6b模型能达到及格的速度
64
  # 在CPU upgrade 上使用1.7b模型能达到及格的速度
65
  # 在本地M5 16G芯片上使用4b模型能达到及格的速度(瓶颈是内存大小);M5 16G内存仅能同时支持一种分析模型(信息密度分析或语义分析)
66
+ CMD ["python", "run.py", "--no_auto_load", "--port", "7860", "--base_model", "qwen3-1.7b", "--instruct_model", "qwen3-1.7b-instruct"]
67
+ # CMD ["python", "run.py", "--no_auto_load", "--port", "7860", "--base_model", "qwen3-0.6b", "--instruct_model", "qwen3-0.6b-instruct"]
backend/api/analyze.py CHANGED
@@ -6,7 +6,7 @@ import queue
6
  import threading
7
  from typing import Optional
8
  from backend.platform.schemas import create_empty_analysis_result
9
- from backend.models.model_manager import project_registry, DEFAULT_MODEL, inference_lock
10
  from model_paths import resolve_hf_path
11
  from backend.platform.oom import exit_if_oom
12
  from backend.api.sse_utils import (
@@ -86,7 +86,7 @@ def _validate_and_prepare_request(analyze_request):
86
  # 获取默认模型(使用模块级上下文以获取持久化的当前活动模型)
87
  from backend.platform.app_context import get_app_context
88
  context = get_app_context(prefer_module_context=True)
89
- default_model = context.model_name if context.model_name else DEFAULT_MODEL
90
 
91
  # 处理 default、None 或空字符串,使用默认模型
92
  if not model or model == 'default' or model == '':
@@ -119,7 +119,7 @@ def _load_project_with_error_handling(model):
119
  p = project_registry.get(model)
120
  if p is None:
121
  from backend.platform.app_context import get_app_context
122
- from backend.models.model_manager import ensure_main_slot_ready
123
 
124
  context = get_app_context(prefer_module_context=True)
125
  if context.model_loading:
@@ -129,7 +129,7 @@ def _load_project_with_error_handling(model):
129
  # 懒加载模式 (--no_auto_load):首次请求仅初始化主槽位(权重 + QwenLM 项目)
130
  if getattr(context.args, 'no_auto_load', False):
131
  try:
132
- ensure_main_slot_ready()
133
  p = project_registry.get(model)
134
  except Exception as e: # noqa: BLE001
135
  import traceback
 
6
  import threading
7
  from typing import Optional
8
  from backend.platform.schemas import create_empty_analysis_result
9
+ from backend.models.model_manager import project_registry, DEFAULT_BASE_MODEL, inference_lock
10
  from model_paths import resolve_hf_path
11
  from backend.platform.oom import exit_if_oom
12
  from backend.api.sse_utils import (
 
86
  # 获取默认模型(使用模块级上下文以获取持久化的当前活动模型)
87
  from backend.platform.app_context import get_app_context
88
  context = get_app_context(prefer_module_context=True)
89
+ default_model = context.base_model_id if context.base_model_id else DEFAULT_BASE_MODEL
90
 
91
  # 处理 default、None 或空字符串,使用默认模型
92
  if not model or model == 'default' or model == '':
 
119
  p = project_registry.get(model)
120
  if p is None:
121
  from backend.platform.app_context import get_app_context
122
+ from backend.models.model_manager import ensure_base_slot_ready
123
 
124
  context = get_app_context(prefer_module_context=True)
125
  if context.model_loading:
 
129
  # 懒加载模式 (--no_auto_load):首次请求仅初始化主槽位(权重 + QwenLM 项目)
130
  if getattr(context.args, 'no_auto_load', False):
131
  try:
132
+ ensure_base_slot_ready()
133
  p = project_registry.get(model)
134
  except Exception as e: # noqa: BLE001
135
  import traceback
backend/api/client_activity.py CHANGED
@@ -47,6 +47,6 @@ def client_activity_report(activity_body=None):
47
  if _sparse_page_activity_log_cum(cum):
48
  log_request(
49
  "📄 页面活跃",
50
- f"path={log_path!r} total_sec={cum} delta_sec={dlt}",
51
  )
52
  return {"ok": True}
 
47
  if _sparse_page_activity_log_cum(cum):
48
  log_request(
49
  "📄 页面活跃",
50
+ f"path(sampled)={log_path!r} total_sec={cum} delta_sec={dlt}",
51
  )
52
  return {"ok": True}
backend/api/model_switch.py CHANGED
@@ -49,7 +49,7 @@ def get_current_model():
49
 
50
  return {
51
  'success': True,
52
- 'model': context.model_name,
53
  'loading': context.model_loading,
54
  'device_type': device_type,
55
  'use_int8': os.environ.get('FORCE_INT8') == '1',
@@ -115,7 +115,7 @@ def switch_model(switch_request):
115
 
116
  # 使用模块级上下文以确保状态修改持久化(不会被后续请求重置)
117
  context = get_app_context(prefer_module_context=True)
118
- current_model = context.model_name
119
 
120
  # 保存当前环境变量配置(用于回滚)
121
  old_force_int8 = os.environ.get('FORCE_INT8')
@@ -223,7 +223,7 @@ def switch_model(switch_request):
223
  return (
224
  {
225
  'success': False,
226
- 'message': '在线模型切换已禁用,请通过命令行 --model / --semantic_model 指定后重启服务',
227
  },
228
  501,
229
  )
 
49
 
50
  return {
51
  'success': True,
52
+ 'model': context.base_model_id,
53
  'loading': context.model_loading,
54
  'device_type': device_type,
55
  'use_int8': os.environ.get('FORCE_INT8') == '1',
 
115
 
116
  # 使用模块级上下文以确保状态修改持久化(不会被后续请求重置)
117
  context = get_app_context(prefer_module_context=True)
118
+ current_model = context.base_model_id
119
 
120
  # 保存当前环境变量配置(用于回滚)
121
  old_force_int8 = os.environ.get('FORCE_INT8')
 
223
  return (
224
  {
225
  'success': False,
226
+ 'message': '在线模型切换已禁用,请通过命令行 --base_model / --instruct_model 指定后重启服务',
227
  },
228
  501,
229
  )
backend/api/openai_completions.py CHANGED
@@ -7,7 +7,7 @@ import time
7
  import traceback
8
  from typing import Any, Callable, Dict, List, Optional, Tuple
9
 
10
- from backend.models.model_manager import inference_lock, get_semantic_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 (
@@ -54,7 +54,7 @@ def _build_response(
54
  "id": "cmpl-stub-info-radar",
55
  "object": "text_completion",
56
  "created": int(time.time()),
57
- "model": get_semantic_model_display_name(),
58
  "choices": [
59
  {
60
  "text": completion_text,
@@ -322,6 +322,14 @@ def completions_prompt(completions_prompt_request):
322
  return {"success": False, "message": "system 必须为字符串"}, 400
323
  system_opt = system_raw
324
 
 
 
 
 
 
 
 
 
325
  client_ip = get_client_ip()
326
  from backend.platform.access_log import log_openai_completions_prompt_request
327
 
@@ -329,6 +337,7 @@ def completions_prompt(completions_prompt_request):
329
  model,
330
  user_prompt=prompt,
331
  system=system_opt,
 
332
  client_ip=client_ip,
333
  )
334
 
@@ -338,7 +347,9 @@ def completions_prompt(completions_prompt_request):
338
  return {"success": False, "message": str(e)}, 400
339
 
340
  try:
341
- prompt_used = apply_chat_template_for_completion(prompt, system_opt, slot=slot)
 
 
342
  except PromptTooLongError as e:
343
  return {"success": False, "message": str(e)}, 400
344
 
 
7
  import traceback
8
  from typing import Any, Callable, Dict, List, Optional, Tuple
9
 
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 (
 
54
  "id": "cmpl-stub-info-radar",
55
  "object": "text_completion",
56
  "created": int(time.time()),
57
+ "model": get_instruct_model_display_name(),
58
  "choices": [
59
  {
60
  "text": completion_text,
 
322
  return {"success": False, "message": "system 必须为字符串"}, 400
323
  system_opt = system_raw
324
 
325
+ enable_thinking_raw = completions_prompt_request.get("enable_thinking")
326
+ if enable_thinking_raw is None:
327
+ enable_thinking = False
328
+ elif not isinstance(enable_thinking_raw, bool):
329
+ return {"success": False, "message": "enable_thinking 必须为布尔值"}, 400
330
+ else:
331
+ enable_thinking = enable_thinking_raw
332
+
333
  client_ip = get_client_ip()
334
  from backend.platform.access_log import log_openai_completions_prompt_request
335
 
 
337
  model,
338
  user_prompt=prompt,
339
  system=system_opt,
340
+ enable_thinking=enable_thinking,
341
  client_ip=client_ip,
342
  )
343
 
 
347
  return {"success": False, "message": str(e)}, 400
348
 
349
  try:
350
+ prompt_used = apply_chat_template_for_completion(
351
+ prompt, system_opt, slot=slot, enable_thinking=enable_thinking
352
+ )
353
  except PromptTooLongError as e:
354
  return {"success": False, "message": str(e)}, 400
355
 
backend/core/completion_generator.py CHANGED
@@ -19,7 +19,7 @@ from transformers import StoppingCriteria, StoppingCriteriaList, TextStreamer
19
  from backend.platform.format import round_to_sig_figs
20
  from backend.platform.app_context import get_verbose
21
  from backend.models.device import DeviceManager
22
- from backend.models.model_manager import ModelSlot, ensure_semantic_slot_ready, ensure_slot_weights_loaded
23
  from .pred_topk_format import pred_topk_pairs_from_probs_1d
24
  from backend.platform.runtime_config import DEFAULT_TOPK
25
 
@@ -408,7 +408,7 @@ def core_generate_from_text(
408
  (续写文本, finish_reason, prompt_tokens, completion_tokens, 续写段 bpe_strings, ttft_s)。
409
  ttft_s 为自 ``model.generate`` 起至首次产出续写片段的秒数;仅取消时为 ``None``。
410
  """
411
- tokenizer, model, device = ensure_semantic_slot_ready()
412
  ctx_limit = completion_max_token_length
413
 
414
  model.eval()
@@ -518,14 +518,15 @@ def apply_chat_template_for_completion(
518
  user_content: str,
519
  system: Optional[str] = None,
520
  *,
521
- slot: ModelSlot = ModelSlot.SEMANTIC,
 
522
  ) -> str:
523
  """
524
  将单条 user 文本套用到 tokenizer chat template,返回实际送入 core_generate_from_text 的字符串。
525
 
526
  调用方未传入 ``system``(即 ``None``)时仅拼装单条 user 消息;传入字符串时(含 ``\"\"``、仅空白)
527
  原样作为 chat template 的 system 段,不做裁剪或改写。长度与上下文上限由 ``core_generate_from_text``
528
- 在生成前校验。slot 控制使用哪个槽位的 tokenizer(base 传 ModelSlot.MAIN)。
529
  """
530
  tokenizer, _, _ = ensure_slot_weights_loaded(slot)
531
  if system is None:
@@ -539,7 +540,7 @@ def apply_chat_template_for_completion(
539
  messages,
540
  tokenize=False,
541
  add_generation_prompt=True,
542
- enable_thinking=False,
543
  )
544
 
545
 
 
19
  from backend.platform.format import round_to_sig_figs
20
  from backend.platform.app_context import get_verbose
21
  from backend.models.device import DeviceManager
22
+ from backend.models.model_manager import ModelSlot, ensure_instruct_slot_ready, ensure_slot_weights_loaded
23
  from .pred_topk_format import pred_topk_pairs_from_probs_1d
24
  from backend.platform.runtime_config import DEFAULT_TOPK
25
 
 
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()
 
518
  user_content: str,
519
  system: Optional[str] = None,
520
  *,
521
+ slot: ModelSlot = ModelSlot.INSTRUCT,
522
+ enable_thinking: bool = False,
523
  ) -> str:
524
  """
525
  将单条 user 文本套用到 tokenizer chat template,返回实际送入 core_generate_from_text 的字符串。
526
 
527
  调用方未传入 ``system``(即 ``None``)时仅拼装单条 user 消息;传入字符串时(含 ``\"\"``、仅空白)
528
  原样作为 chat template 的 system 段,不做裁剪或改写。长度与上下文上限由 ``core_generate_from_text``
529
+ 在生成前校验。slot 控制使用哪个槽位的 tokenizer(base 传 ModelSlot.BASE)。
530
  """
531
  tokenizer, _, _ = ensure_slot_weights_loaded(slot)
532
  if system is None:
 
540
  messages,
541
  tokenize=False,
542
  add_generation_prompt=True,
543
+ enable_thinking=enable_thinking,
544
  )
545
 
546
 
backend/core/language_checker.py CHANGED
@@ -8,7 +8,7 @@ from backend.models.class_register import register_model, REGISTERED_MODELS
8
  from backend.models.device import DeviceManager
9
  from backend.models.model_manager import ensure_model_loaded
10
  from backend.platform.runtime_config import load_runtime_config, DEFAULT_TOPK
11
- from model_paths import DEFAULT_MODEL, MODEL_PATHS, SEMANTIC_MODEL_PATHS, resolve_hf_path
12
 
13
  # 按 id(model) 缓存「仅含 BOS/等价起始符一步 forward」得到的末位词表 logits(全词表,不随分析文本变)
14
  _bos_first_position_logits_cache: Dict[int, torch.Tensor] = {}
@@ -109,7 +109,7 @@ class QwenLM(AbstractLanguageChecker):
109
  """
110
  def __init__(self, model_path=None, model_name=None):
111
  super(QwenLM, self).__init__()
112
- model_name = model_name or getattr(self.__class__, '_registered_model_name', DEFAULT_MODEL)
113
  if model_path is not None and str(model_path).strip():
114
  resolved = str(model_path).strip()
115
  else:
@@ -392,13 +392,13 @@ class QwenLM(AbstractLanguageChecker):
392
 
393
 
394
  # ============================================================
395
- # 自动注册:根据 MODEL_PATHS 与 SEMANTIC_MODEL_PATHS 自动注册所有模型
396
  # ============================================================
397
  # 只需要在 model_paths.py 中添加模型路径,即可自动注册
398
  # 无需手动创建子类,实现 DRY 原则
399
  def _auto_register_models():
400
- """自动注册 MODEL_PATHS 与 SEMANTIC_MODEL_PATHS 中的所有模型"""
401
- for model_name in (*MODEL_PATHS.keys(), *SEMANTIC_MODEL_PATHS.keys()):
402
  if model_name not in REGISTERED_MODELS:
403
  # 动态创建模型类并注册
404
  # 使用闭包捕获当前 model_name
 
8
  from backend.models.device import DeviceManager
9
  from backend.models.model_manager import ensure_model_loaded
10
  from backend.platform.runtime_config import load_runtime_config, DEFAULT_TOPK
11
+ from model_paths import DEFAULT_BASE_MODEL, INSTRUCT_MODEL_PATHS, MODEL_PATHS, resolve_hf_path
12
 
13
  # 按 id(model) 缓存「仅含 BOS/等价起始符一步 forward」得到的末位词表 logits(全词表,不随分析文本变)
14
  _bos_first_position_logits_cache: Dict[int, torch.Tensor] = {}
 
109
  """
110
  def __init__(self, model_path=None, model_name=None):
111
  super(QwenLM, self).__init__()
112
+ model_name = model_name or getattr(self.__class__, '_registered_model_name', DEFAULT_BASE_MODEL)
113
  if model_path is not None and str(model_path).strip():
114
  resolved = str(model_path).strip()
115
  else:
 
392
 
393
 
394
  # ============================================================
395
+ # 自动注册:根据 MODEL_PATHS 与 INSTRUCT_MODEL_PATHS 自动注册所有模型
396
  # ============================================================
397
  # 只需要在 model_paths.py 中添加模型路径,即可自动注册
398
  # 无需手动创建子类,实现 DRY 原则
399
  def _auto_register_models():
400
+ """自动注册 MODEL_PATHS 与 INSTRUCT_MODEL_PATHS 中的所有模型"""
401
+ for model_name in (*MODEL_PATHS.keys(), *INSTRUCT_MODEL_PATHS.keys()):
402
  if model_name not in REGISTERED_MODELS:
403
  # 动态创建模型类并注册
404
  # 使用闭包捕获当前 model_name
backend/core/prediction_attributor.py CHANGED
@@ -2,7 +2,7 @@
2
  预测归因:对任意上下文的下一个 token 预测,计算指定候选 token 的 logit
3
  对输入各 token embedding 的梯度,以梯度 L2 范数作为归因分。
4
 
5
- 由请求参数 `model` 选择权重槽位:base 为主槽位(--model),instruct 为语义槽位(--semantic_model)。
6
  """
7
 
8
  import math
@@ -15,8 +15,8 @@ from backend.models.device import DeviceManager
15
  from backend.models.model_manager import (
16
  ModelSlot,
17
  ensure_slot_weights_loaded,
18
- get_main_model_display_name,
19
- get_semantic_model_display_name,
20
  )
21
  from .next_token_topk import decode_topk_ids_to_strings_and_rounded_probs, DEFAULT_NEXT_TOKEN_TOPK
22
 
@@ -41,9 +41,9 @@ PREDICTION_ATTR_MODEL_INSTRUCT = "instruct"
41
 
42
  def slot_for_prediction_attr_model(model: str) -> ModelSlot:
43
  if model == PREDICTION_ATTR_MODEL_BASE:
44
- return ModelSlot.MAIN
45
  if model == PREDICTION_ATTR_MODEL_INSTRUCT:
46
- return ModelSlot.SEMANTIC
47
  raise ValueError(
48
  f"Unsupported model {model!r}; only {PREDICTION_ATTR_MODEL_BASE!r} and "
49
  f"{PREDICTION_ATTR_MODEL_INSTRUCT!r} are supported."
@@ -80,7 +80,7 @@ def analyze_prediction_attribution(
80
  slot = slot_for_prediction_attr_model(model)
81
  tokenizer, hf_model, device = ensure_slot_weights_loaded(slot)
82
  model_display = (
83
- get_main_model_display_name() if slot == ModelSlot.MAIN else get_semantic_model_display_name()
84
  )
85
 
86
  if target_prediction is not None and target_token_id is not None:
 
2
  预测归因:对任意上下文的下一个 token 预测,计算指定候选 token 的 logit
3
  对输入各 token embedding 的梯度,以梯度 L2 范数作为归因分。
4
 
5
+ 由请求参数 `model` 选择权重槽位:base 为主槽位(--base_model),instruct 为 instruct 槽位(--instruct_model)。
6
  """
7
 
8
  import math
 
15
  from backend.models.model_manager import (
16
  ModelSlot,
17
  ensure_slot_weights_loaded,
18
+ get_base_model_display_name,
19
+ get_instruct_model_display_name,
20
  )
21
  from .next_token_topk import decode_topk_ids_to_strings_and_rounded_probs, DEFAULT_NEXT_TOKEN_TOPK
22
 
 
41
 
42
  def slot_for_prediction_attr_model(model: str) -> ModelSlot:
43
  if model == PREDICTION_ATTR_MODEL_BASE:
44
+ return ModelSlot.BASE
45
  if model == PREDICTION_ATTR_MODEL_INSTRUCT:
46
+ return ModelSlot.INSTRUCT
47
  raise ValueError(
48
  f"Unsupported model {model!r}; only {PREDICTION_ATTR_MODEL_BASE!r} and "
49
  f"{PREDICTION_ATTR_MODEL_INSTRUCT!r} are supported."
 
80
  slot = slot_for_prediction_attr_model(model)
81
  tokenizer, hf_model, device = ensure_slot_weights_loaded(slot)
82
  model_display = (
83
+ get_base_model_display_name() if slot == ModelSlot.BASE else get_instruct_model_display_name()
84
  )
85
 
86
  if target_prediction is not None and target_token_id is not None:
backend/core/semantic_analyzer.py CHANGED
@@ -8,7 +8,7 @@ Semantic analysis:基于 instruct 模型提取原文 token 与 query 的相关
8
 
9
  count/fill_blank 按概率加权(Σ pᵢ·zᵢ)。
10
 
11
- 模型由 --semantic_model 参数指定,默认 qwen3-0.6b-instruct
12
  """
13
 
14
  import gc
@@ -19,7 +19,7 @@ import torch
19
 
20
  from backend.platform.format import round_to_sig_figs
21
  from backend.models.device import DeviceManager
22
- from backend.models.model_manager import ensure_semantic_slot_ready, get_semantic_model_display_name
23
  from .next_token_topk import decode_topk_ids_to_strings_and_rounded_probs, DEFAULT_NEXT_TOKEN_TOPK
24
  from backend.platform.runtime_config import get_semantic_max_token_length
25
 
@@ -178,7 +178,7 @@ def _analyze_logits_gradient(
178
 
179
  if full_match_degree_only:
180
  return {
181
- "model": get_semantic_model_display_name(),
182
  "token_attention": [],
183
  "full_match_degree": full_match_degree,
184
  }
@@ -235,7 +235,7 @@ def _analyze_logits_gradient(
235
  print(f"⚠️ token_attention 中有 {nan_count} 个 score 为 NaN/Inf,已替换为 0。")
236
 
237
  out = {
238
- "model": get_semantic_model_display_name(),
239
  "token_attention": token_attention,
240
  "full_match_degree": full_match_degree,
241
  }
@@ -270,7 +270,7 @@ def analyze_semantic(
270
  Returns:
271
  {"model", "token_attention", "full_match_degree"};debug_info=True 时包含 debug_info 对象
272
  """
273
- tokenizer, model, device = ensure_semantic_slot_ready()
274
  return _analyze_logits_gradient(
275
  query, text, tokenizer, model, device,
276
  submode_override=submode_override,
 
8
 
9
  count/fill_blank 按概率加权(Σ pᵢ·zᵢ)。
10
 
11
+ 模型由 --instruct_model 参数指定,默认 qwen3-0.6b-instruct
12
  """
13
 
14
  import gc
 
19
 
20
  from backend.platform.format import round_to_sig_figs
21
  from backend.models.device import DeviceManager
22
+ from backend.models.model_manager import ensure_instruct_slot_ready, get_instruct_model_display_name
23
  from .next_token_topk import decode_topk_ids_to_strings_and_rounded_probs, DEFAULT_NEXT_TOKEN_TOPK
24
  from backend.platform.runtime_config import get_semantic_max_token_length
25
 
 
178
 
179
  if full_match_degree_only:
180
  return {
181
+ "model": get_instruct_model_display_name(),
182
  "token_attention": [],
183
  "full_match_degree": full_match_degree,
184
  }
 
235
  print(f"⚠️ token_attention 中有 {nan_count} 个 score 为 NaN/Inf,已替换为 0。")
236
 
237
  out = {
238
+ "model": get_instruct_model_display_name(),
239
  "token_attention": token_attention,
240
  "full_match_degree": full_match_degree,
241
  }
 
270
  Returns:
271
  {"model", "token_attention", "full_match_degree"};debug_info=True 时包含 debug_info 对象
272
  """
273
+ tokenizer, model, device = ensure_instruct_slot_ready()
274
  return _analyze_logits_gradient(
275
  query, text, tokenizer, model, device,
276
  submode_override=submode_override,
backend/models/model_manager.py CHANGED
@@ -1,4 +1,10 @@
1
- """模型管理模块槽位与语义槽位对称配置,权重缓存共用。"""
 
 
 
 
 
 
2
  from enum import Enum
3
  import threading
4
 
@@ -7,55 +13,52 @@ from backend.models.project_registry import ModelRegistry
7
  from backend.models.device import DeviceManager
8
  from backend.models.model_loader import attn_implementation_for_device, load_causal_lm, load_tokenizer
9
 
10
- from model_paths import DEFAULT_MODEL, DEFAULT_SEMANTIC_MODEL, resolve_hf_path
11
 
12
  project_registry = ModelRegistry(REGISTERED_MODELS)
13
  _init_lock = threading.Lock()
14
 
15
- # 统一推理锁:信息密度分析与 Semantic 分析共用,确保模型推理串行执行
16
  inference_lock = threading.Lock()
17
 
18
- # 按 HuggingFace 路径去重的已加载模型缓存(主分析 / 语义 / 续写共用)
19
  _hf_load_lock = threading.Lock()
20
  _hf_loaded: dict[str, tuple] = {}
21
 
22
 
23
  class ModelSlot(str, Enum):
24
- """与 CLI --model / --semantic_model 对应的两个对等槽位。"""
25
 
26
- MAIN = "main"
27
- SEMANTIC = "semantic"
28
 
29
 
30
- # 启动预载与「全部权重」枚举时使用的槽位顺序(对等、无主次)
31
- CONFIGURED_SLOTS: tuple[ModelSlot, ...] = (ModelSlot.MAIN, ModelSlot.SEMANTIC)
32
 
33
 
34
  def _resolved_hf_path_for_slot(slot: ModelSlot) -> str:
35
  """由应用上下文解析槽位对应的 HuggingFace 路径(或本地路径字符串)。"""
36
- if slot == ModelSlot.MAIN:
37
- try:
38
- from backend.platform.app_context import get_app_context
39
-
40
- context = get_app_context(prefer_module_context=True)
41
- model_name = context.model_name or DEFAULT_MODEL
42
- except RuntimeError:
43
- model_name = DEFAULT_MODEL
44
- return resolve_hf_path(model_name)
45
- if slot == ModelSlot.SEMANTIC:
46
- try:
47
- from backend.platform.app_context import get_args
48
-
49
- raw = getattr(get_args(), "semantic_model", DEFAULT_SEMANTIC_MODEL)
50
- except RuntimeError:
51
- raw = DEFAULT_SEMANTIC_MODEL
52
- return resolve_hf_path(raw)
53
  raise ValueError(f"unknown ModelSlot: {slot!r}")
54
 
55
 
56
  def ensure_slot_weights_loaded(slot: ModelSlot):
57
  """
58
- 加载指定槽位权重(若未缓存);主 / 语义完全相同的入口
59
  返回 (tokenizer, model, device)。
60
  """
61
  return ensure_model_loaded(_resolved_hf_path_for_slot(slot))
@@ -99,24 +102,23 @@ def ensure_project_loaded(project_name: str):
99
  try:
100
  return project_registry.ensure_loaded(project_name)
101
  except KeyError:
102
- # Re-raise to allow caller to format message uniformly.
103
  raise
104
  except Exception as exc: # noqa: BLE001 - propagate detailed message
105
  raise RuntimeError(f"模型 '{project_name}' 加载失败: {exc}") from exc
106
 
107
 
108
- def _register_main_qwenlm_if_needed():
109
  """
110
- 信息密度路径:在 MAIN 槽位权重已就绪后,注册 project_registry 中的 QwenLM 实例。
111
- 语义槽位无对应 registry 包装,故仅此槽位需要
112
  """
113
  from backend.platform.app_context import get_app_context
114
 
115
  context = get_app_context(prefer_module_context=True)
116
- selected_name = context.model_name
117
 
118
  if not selected_name:
119
- raise ValueError("未指定模型名称")
120
 
121
  if selected_name in project_registry:
122
  _ensure_default_project_ready(selected_name)
@@ -135,7 +137,7 @@ def _register_main_qwenlm_if_needed():
135
  def preload_all_slots():
136
  """
137
  启动预载(非 --no_auto_load):对 CONFIGURED_SLOTS 各解析 HF 路径,去重后加载全部权重,
138
- 再注册槽位 QwenLM 项目。两槽位在「先加载权重」层面完全对等。
139
  """
140
  from backend.platform.app_context import get_app_context
141
 
@@ -146,45 +148,43 @@ def preload_all_slots():
146
  with _init_lock:
147
  for path in paths:
148
  ensure_model_loaded(path)
149
- _register_main_qwenlm_if_needed()
150
 
151
 
152
  def ensure_slot_ready(slot: ModelSlot):
153
  """
154
- 槽位业务就绪(对称 API):保证该槽位后续推理所需状态已备好。
155
 
156
  - 两槽位均先保证 HF 权重已加载,返回 (tokenizer, model, device)。
157
- - MAIN 另需将 QwenLM 挂入 project_registry(信息密度管线);SEMANTIC 无 registry 步骤。
158
-
159
- 懒加载时:信息密度调 ensure_main_slot_ready();语义/续写调 ensure_semantic_slot_ready()。
160
  """
161
  from backend.platform.app_context import get_app_context
162
 
163
  get_app_context(prefer_module_context=True)
164
 
165
- if slot == ModelSlot.MAIN:
166
  with _init_lock:
167
- out = ensure_slot_weights_loaded(ModelSlot.MAIN)
168
- _register_main_qwenlm_if_needed()
169
  return out
170
- if slot == ModelSlot.SEMANTIC:
171
- return ensure_slot_weights_loaded(ModelSlot.SEMANTIC)
172
  raise ValueError(f"unknown ModelSlot: {slot!r}")
173
 
174
 
175
- def ensure_main_slot_ready():
176
- """懒加载首次信息密度:ensure_slot_ready(ModelSlot.MAIN)。"""
177
- return ensure_slot_ready(ModelSlot.MAIN)
178
 
179
 
180
- def ensure_semantic_slot_ready():
181
- """懒加载首次语义类请求:同 ensure_slot_ready(ModelSlot.SEMANTIC)。"""
182
- return ensure_slot_ready(ModelSlot.SEMANTIC)
183
 
184
 
185
  def get_current_model_max_token_length() -> int:
186
  """
187
- 查询当前生效模型的 max_token_length 参数。
188
  优先从已加载的模型实例获取,未加载时取 default_model.default_cpu_machine 配置。
189
  """
190
  from backend.platform.app_context import get_app_context
@@ -192,7 +192,7 @@ def get_current_model_max_token_length() -> int:
192
 
193
  try:
194
  context = get_app_context(prefer_module_context=True)
195
- model_name = context.model_name or DEFAULT_MODEL
196
  except RuntimeError:
197
  model_name = "default_model"
198
 
@@ -212,22 +212,11 @@ def _ensure_default_project_ready(selected_name: str):
212
  project_registry.ensure_loaded(selected_name)
213
 
214
 
215
- # 旧名保留(与槽位就绪 API 等价)
216
- ensure_semantic_loaded = ensure_semantic_slot_ready
217
- ensure_main_project_ready = ensure_main_slot_ready
218
-
219
- def get_semantic_model_display_name() -> str:
220
- """返回 semantic 槽位 HuggingFace 路径(用于结果中的 model 字段)"""
221
- return _resolved_hf_path_for_slot(ModelSlot.SEMANTIC)
222
-
223
-
224
- def ensure_main_model_loaded():
225
- """
226
- 仅需主模型前向、且不必经过 project_registry 时(如 attribution):MAIN 槽位权重。
227
- """
228
- return ensure_slot_weights_loaded(ModelSlot.MAIN)
229
 
230
 
231
- def get_main_model_display_name() -> str:
232
- """返回槽位 HuggingFace 路径(用于结果中的 model 字段)"""
233
- return _resolved_hf_path_for_slot(ModelSlot.MAIN)
 
1
+ """模型管理:base / instruct 双槽位,HF 权重缓存共用。
2
+
3
+ 加载约定(由简到繁):
4
+ - ``ensure_slot_weights_loaded(slot)``:仅保证该槽位 HF 权重在 ``_hf_loaded`` 中(归因、tokenize)。
5
+ - ``ensure_slot_ready(slot)``:槽位可推理;base 另挂 ``project_registry`` / QwenLM(信息密度)。
6
+ - 业务入口:信息密度 ``ensure_base_slot_ready()``;语义 / 续写 ``ensure_instruct_slot_ready()``。
7
+ """
8
  from enum import Enum
9
  import threading
10
 
 
13
  from backend.models.device import DeviceManager
14
  from backend.models.model_loader import attn_implementation_for_device, load_causal_lm, load_tokenizer
15
 
16
+ from model_paths import DEFAULT_BASE_MODEL, DEFAULT_INSTRUCT_MODEL, resolve_hf_path
17
 
18
  project_registry = ModelRegistry(REGISTERED_MODELS)
19
  _init_lock = threading.Lock()
20
 
21
+ # 统一推理锁:信息密度分析与 instruct 路径共用,确保模型推理串行执行
22
  inference_lock = threading.Lock()
23
 
24
+ # 按 HuggingFace 路径去重的已加载模型缓存(两槽位共用)
25
  _hf_load_lock = threading.Lock()
26
  _hf_loaded: dict[str, tuple] = {}
27
 
28
 
29
  class ModelSlot(str, Enum):
30
+ """与 CLI --base_model / --instruct_model 对应的两个对等槽位。"""
31
 
32
+ BASE = "base"
33
+ INSTRUCT = "instruct"
34
 
35
 
36
+ CONFIGURED_SLOTS: tuple[ModelSlot, ...] = (ModelSlot.BASE, ModelSlot.INSTRUCT)
 
37
 
38
 
39
  def _resolved_hf_path_for_slot(slot: ModelSlot) -> str:
40
  """由应用上下文解析槽位对应的 HuggingFace 路径(或本地路径字符串)。"""
41
+ from backend.platform.app_context import get_app_context
42
+
43
+ try:
44
+ context = get_app_context(prefer_module_context=True)
45
+ except RuntimeError:
46
+ if slot == ModelSlot.BASE:
47
+ return resolve_hf_path(DEFAULT_BASE_MODEL)
48
+ if slot == ModelSlot.INSTRUCT:
49
+ return resolve_hf_path(DEFAULT_INSTRUCT_MODEL)
50
+ raise ValueError(f"unknown ModelSlot: {slot!r}") from None
51
+
52
+ if slot == ModelSlot.BASE:
53
+ return resolve_hf_path(context.base_model_id or DEFAULT_BASE_MODEL)
54
+ if slot == ModelSlot.INSTRUCT:
55
+ return resolve_hf_path(context.instruct_model_id or DEFAULT_INSTRUCT_MODEL)
 
 
56
  raise ValueError(f"unknown ModelSlot: {slot!r}")
57
 
58
 
59
  def ensure_slot_weights_loaded(slot: ModelSlot):
60
  """
61
+ 加载指定槽位权重(若未缓存)。
62
  返回 (tokenizer, model, device)。
63
  """
64
  return ensure_model_loaded(_resolved_hf_path_for_slot(slot))
 
102
  try:
103
  return project_registry.ensure_loaded(project_name)
104
  except KeyError:
 
105
  raise
106
  except Exception as exc: # noqa: BLE001 - propagate detailed message
107
  raise RuntimeError(f"模型 '{project_name}' 加载失败: {exc}") from exc
108
 
109
 
110
+ def _register_base_qwenlm_if_needed():
111
  """
112
+ 信息密度路径:在 base 槽位权重已就绪后,注册 project_registry 中的 QwenLM 实例。
113
+ instruct 槽位无对应 registry 包装。
114
  """
115
  from backend.platform.app_context import get_app_context
116
 
117
  context = get_app_context(prefer_module_context=True)
118
+ selected_name = context.base_model_id
119
 
120
  if not selected_name:
121
+ raise ValueError("未指定 base 模型 id")
122
 
123
  if selected_name in project_registry:
124
  _ensure_default_project_ready(selected_name)
 
137
  def preload_all_slots():
138
  """
139
  启动预载(非 --no_auto_load):对 CONFIGURED_SLOTS 各解析 HF 路径,去重后加载全部权重,
140
+ 再注册 base 槽位 QwenLM 项目。
141
  """
142
  from backend.platform.app_context import get_app_context
143
 
 
148
  with _init_lock:
149
  for path in paths:
150
  ensure_model_loaded(path)
151
+ _register_base_qwenlm_if_needed()
152
 
153
 
154
  def ensure_slot_ready(slot: ModelSlot):
155
  """
156
+ 槽位业务就绪:保证该槽位后续推理所需状态已备好。
157
 
158
  - 两槽位均先保证 HF 权重已加载,返回 (tokenizer, model, device)。
159
+ - base 另需将 QwenLM 挂入 project_registry(信息密度);instruct 无 registry 步骤。
 
 
160
  """
161
  from backend.platform.app_context import get_app_context
162
 
163
  get_app_context(prefer_module_context=True)
164
 
165
+ if slot == ModelSlot.BASE:
166
  with _init_lock:
167
+ out = ensure_slot_weights_loaded(ModelSlot.BASE)
168
+ _register_base_qwenlm_if_needed()
169
  return out
170
+ if slot == ModelSlot.INSTRUCT:
171
+ return ensure_slot_weights_loaded(ModelSlot.INSTRUCT)
172
  raise ValueError(f"unknown ModelSlot: {slot!r}")
173
 
174
 
175
+ def ensure_base_slot_ready():
176
+ """信息密度等业务``ensure_slot_ready(ModelSlot.BASE)``。"""
177
+ return ensure_slot_ready(ModelSlot.BASE)
178
 
179
 
180
+ def ensure_instruct_slot_ready():
181
+ """语义分析 / 续写:``ensure_slot_ready(ModelSlot.INSTRUCT)``。"""
182
+ return ensure_slot_ready(ModelSlot.INSTRUCT)
183
 
184
 
185
  def get_current_model_max_token_length() -> int:
186
  """
187
+ 查询当前生效 base 模型的 max_token_length 参数。
188
  优先从已加载的模型实例获取,未加载时取 default_model.default_cpu_machine 配置。
189
  """
190
  from backend.platform.app_context import get_app_context
 
192
 
193
  try:
194
  context = get_app_context(prefer_module_context=True)
195
+ model_name = context.base_model_id or DEFAULT_BASE_MODEL
196
  except RuntimeError:
197
  model_name = "default_model"
198
 
 
212
  project_registry.ensure_loaded(selected_name)
213
 
214
 
215
+ def get_instruct_model_display_name() -> str:
216
+ """返回 instruct 槽位 HuggingFace 路径(用于结果中的 model 字段)。"""
217
+ return _resolved_hf_path_for_slot(ModelSlot.INSTRUCT)
 
 
 
 
 
 
 
 
 
 
 
218
 
219
 
220
+ def get_base_model_display_name() -> str:
221
+ """返回 base 槽位 HuggingFace 路径(用于结果中的 model 字段)"""
222
+ return _resolved_hf_path_for_slot(ModelSlot.BASE)
backend/platform/access_log.py CHANGED
@@ -270,6 +270,7 @@ def log_openai_completions_prompt_request(
270
  model: str,
271
  user_prompt: str,
272
  system: Optional[str] = None,
 
273
  client_ip: str = None,
274
  ) -> None:
275
  """记录 POST /v1/completions/prompt(仅拼装 chat template,不分配 req_id)。"""
@@ -277,8 +278,11 @@ def log_openai_completions_prompt_request(
277
 
278
  up = _log_str_preview(user_prompt, preview)
279
  if system is None:
280
- details = f"model='{model}', user_prompt='{up}'"
281
  else:
282
- details = f"model='{model}', system='{_log_str_preview(system, preview)}', user_prompt='{up}'"
 
 
 
283
  log_request("📥 openai completions/prompt 请求", details, client_ip)
284
 
 
270
  model: str,
271
  user_prompt: str,
272
  system: Optional[str] = None,
273
+ enable_thinking: bool = False,
274
  client_ip: str = None,
275
  ) -> None:
276
  """记录 POST /v1/completions/prompt(仅拼装 chat template,不分配 req_id)。"""
 
278
 
279
  up = _log_str_preview(user_prompt, preview)
280
  if system is None:
281
+ details = f"model='{model}', enable_thinking={enable_thinking}, user_prompt='{up}'"
282
  else:
283
+ details = (
284
+ f"model='{model}', enable_thinking={enable_thinking}, "
285
+ f"system='{_log_str_preview(system, preview)}', user_prompt='{up}'"
286
+ )
287
  log_request("📥 openai completions/prompt 请求", details, client_ip)
288
 
backend/platform/app_context.py CHANGED
@@ -8,6 +8,8 @@ from pathlib import Path
8
  from typing import Optional
9
  from argparse import Namespace
10
 
 
 
11
 
12
  class AppContext:
13
  """
@@ -54,21 +56,31 @@ class AppContext:
54
  self.args = args
55
  self.data_dir = data_dir
56
  self._model_loading = True # 初始时处于加载状态
57
- self._current_model_name = getattr(args, 'model', None)
 
58
 
59
  @property
60
- def model_name(self) -> str:
61
- """当前模型名称"""
62
- return self._current_model_name
 
 
 
 
 
63
 
64
  @property
65
  def model_loading(self) -> bool:
66
  """模型是否正在加载"""
67
  return self._model_loading
68
 
69
- def set_current_model(self, model_name: str):
70
- """设置当前模型名称"""
71
- self._current_model_name = model_name
 
 
 
 
72
 
73
  def set_model_loading(self, loading: bool):
74
  """设置模型加载状态"""
 
8
  from typing import Optional
9
  from argparse import Namespace
10
 
11
+ from model_paths import DEFAULT_BASE_MODEL, DEFAULT_INSTRUCT_MODEL
12
+
13
 
14
  class AppContext:
15
  """
 
56
  self.args = args
57
  self.data_dir = data_dir
58
  self._model_loading = True # 初始时处于加载状态
59
+ self._base_model_id = getattr(args, "base_model", None) or DEFAULT_BASE_MODEL
60
+ self._instruct_model_id = getattr(args, "instruct_model", None) or DEFAULT_INSTRUCT_MODEL
61
 
62
  @property
63
+ def base_model_id(self) -> str:
64
+ """当前 base 槽位 CLI 模型 id(信息密度主模型)。"""
65
+ return self._base_model_id
66
+
67
+ @property
68
+ def instruct_model_id(self) -> str:
69
+ """当前 instruct 槽位 CLI 模型 id(语义 / 续写)。"""
70
+ return self._instruct_model_id
71
 
72
  @property
73
  def model_loading(self) -> bool:
74
  """模型是否正在加载"""
75
  return self._model_loading
76
 
77
+ def set_base_model_id(self, model_id: str):
78
+ """设置 base 槽位 CLI 模型 id(如在线切换)。"""
79
+ self._base_model_id = model_id
80
+
81
+ def set_current_model(self, model_id: str):
82
+ """兼容旧名:同 set_base_model_id。"""
83
+ self.set_base_model_id(model_id)
84
 
85
  def set_model_loading(self, loading: bool):
86
  """设置模型加载状态"""
backend/platform/visit_stats.py CHANGED
@@ -41,7 +41,8 @@ _STATS_API_ORDER = (
41
  _STATS_OS_ORDER = ("ios", "android", "windows", "macos", "linux", "unknown")
42
  _STATS_GEN_ATTR_OPT_ORDER = (
43
  "layout_linear_arc", "layout_step_down", "layout_spiral",
44
- "propagated", "downstream", "token_tooltip",
 
45
  )
46
 
47
  # RLock:_persist_tick 在已持锁时调用 _sample_locked_counters,同线程需可重入。
 
41
  _STATS_OS_ORDER = ("ios", "android", "windows", "macos", "linux", "unknown")
42
  _STATS_GEN_ATTR_OPT_ORDER = (
43
  "layout_linear_arc", "layout_step_down", "layout_spiral",
44
+ "propagated", "propagated_anim_backward",
45
+ "downstream", "token_tooltip",
46
  )
47
 
48
  # RLock:_persist_tick 在已持锁时调用 _sample_locked_counters,同线程需可重入。
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 CHANGED
@@ -29624,12 +29624,13 @@
29624
  "maxTokens": 200,
29625
  "system": "You are a helpful assistant.",
29626
  "user": "苏州所在省的省会是哪个?先一步步推理再回答。",
29627
- "useSystem": true
 
29628
  },
29629
  "demoUiOptions": {
29630
  "layoutMode": "text-flow",
29631
  "measureWidthPx": 500,
29632
- "dagCompactness": 0.8,
29633
  "linearArcAdjacentGapPx": 0,
29634
  "hideExcludedTokens": false,
29635
  "edgeTopPCoverage": 0.4,
@@ -29638,14 +29639,16 @@
29638
  "hideInactiveEdges": true,
29639
  "showDownstreamInfluence": false,
29640
  "recursiveAttributionEnabled": true,
 
 
29641
  "showTokenInfoOnSelected": true,
29642
- "replayPacingMode": "step",
29643
- "playbackTotalS": 7,
29644
  "playbackStepMs": 200,
29645
  "excludePromptPatternsEnabled": true,
29646
  "excludePromptPatternsText": "先一步步推理再回答。\n#comment# use '#comment#' to comment lines; support regex\n<\\|im_start\\|>system\\n\n<\\|im_start\\|>user\\n\n<\\|im_start\\|>assistant\\n\n<\\|im_start\\|>assistant\\n\\n\n<\\|im_end\\|>\\n\n<think>\\n\\n\n</think>\\n\\n\n<\\|im_start\\|>system\\n[\\s\\S]*?<\\|im_end\\|>#comment# all system prompt",
29647
  "excludeGeneratedPatternsEnabled": true,
29648
- "excludeGeneratedPatternsText": "<think>\\n\n</think>\\n\\n",
29649
  "selectedNodeId": "237_239"
29650
  }
29651
  }
 
29624
  "maxTokens": 200,
29625
  "system": "You are a helpful assistant.",
29626
  "user": "苏州所在省的省会是哪个?先一步步推理再回答。",
29627
+ "useSystem": true,
29628
+ "enableThinking": false
29629
  },
29630
  "demoUiOptions": {
29631
  "layoutMode": "text-flow",
29632
  "measureWidthPx": 500,
29633
+ "dagCompactness": 0.9,
29634
  "linearArcAdjacentGapPx": 0,
29635
  "hideExcludedTokens": false,
29636
  "edgeTopPCoverage": 0.4,
 
29639
  "hideInactiveEdges": true,
29640
  "showDownstreamInfluence": false,
29641
  "recursiveAttributionEnabled": true,
29642
+ "recursiveEdgeBatchAnimationEnabled": true,
29643
+ "recursiveEdgeBatchAnimationDirection": "forward",
29644
  "showTokenInfoOnSelected": true,
29645
+ "replayPacingMode": "total",
29646
+ "playbackTotalS": 6,
29647
  "playbackStepMs": 200,
29648
  "excludePromptPatternsEnabled": true,
29649
  "excludePromptPatternsText": "先一步步推理再回答。\n#comment# use '#comment#' to comment lines; support regex\n<\\|im_start\\|>system\\n\n<\\|im_start\\|>user\\n\n<\\|im_start\\|>assistant\\n\n<\\|im_start\\|>assistant\\n\\n\n<\\|im_end\\|>\\n\n<think>\\n\\n\n</think>\\n\\n\n<\\|im_start\\|>system\\n[\\s\\S]*?<\\|im_end\\|>#comment# all system prompt",
29650
  "excludeGeneratedPatternsEnabled": true,
29651
+ "excludeGeneratedPatternsText": "[\\s\\S]*?推理[\\s\\S]*?:\\n\\n\n<think>\\n\n</think>\\n\\n",
29652
  "selectedNodeId": "237_239"
29653
  }
29654
  }
client/src/assets/demos/causal_flow/order.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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翻译",
24
+ "label": "CN->EN | 翻译"
25
+ }
26
+ ]
client/src/assets/images/{dag-cot.png → dag-cot.mov} RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:28f1add37e2c5f5a8b0d0151d0de1f5007d25676ea5f892f0692e36d6ddac7c6
3
- size 69056
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:47a48bf00f0d2cbd8347915deba2a3f9e0e843e7a57e5b0d69e75ac8d0a984e7
3
+ size 65679
client/src/causal_flow.html CHANGED
@@ -119,6 +119,14 @@
119
  </div>
120
  </div>
121
  </div>
 
 
 
 
 
 
 
 
122
  </div>
123
 
124
  <div class="attribution-exclude-prompt-patterns-row">
@@ -187,7 +195,6 @@
187
  <div class="loadersmall"></div>
188
  <span id="gen_attr_complete_reason" class="generation-end-reason"></span>
189
  </div>
190
- <span id="gen_attr_analyze_progress" class="analyze-progress"></span>
191
  </div>
192
  </div>
193
  <div id="gen_attr_text_metrics" class="text-metrics text-metrics-chat">
@@ -200,7 +207,7 @@
200
 
201
  <div class="gen-attr-reset-ui-options-row">
202
  <button type="button" id="gen_attr_reset_ui_options_btn" class="text-action-btn"
203
- title="Restore DAG options, replay speed, exclusions, etc. to defaults and clear saved preferences for those controls."
204
  data-i18n="text,title">
205
  Reset UI options
206
  </button>
@@ -290,8 +297,8 @@
290
  <select id="gen_attr_dag_recursive_edge_animation_direction" class="semantic-submode-select"
291
  title="Choose one direction for propagated-edge batch animation."
292
  data-i18n="title">
293
- <option value="backward">backward</option>
294
  <option value="forward">forward</option>
 
295
  </select>
296
  </span>
297
  <span class="semantic-submode-group" id="gen_attr_dag_show_downstream_influence_group">
@@ -379,10 +386,10 @@
379
  </div>
380
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
381
  <span class="semantic-submode-group gen-attr-dag-replay-speed-row semantic-submode-group--emphasis">
382
- <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
383
  <select id="gen_attr_dag_replay_mode"
384
  class="semantic-submode-select gen-attr-dag-replay-mode-select"
385
- title="Total duration vs. fixed delay per step. Waits shrink if a step is slow; long idle realigns the beat."
386
  data-i18n="title">
387
  <option value="total" data-i18n>total time</option>
388
  <option value="step" data-i18n>step time</option>
@@ -390,14 +397,14 @@
390
  <span id="gen_attr_dag_replay_total_wrap" class="gen-attr-dag-replay-value-wrap">
391
  <input type="number" id="gen_attr_dag_playback_total_s" class="gen-attr-dag-measure-width-input"
392
  value="7" min="1" max="3600" step="1"
393
- title="Nominal delay = total seconds ÷ (steps 1). Saved locally; applied when you press play."
394
  data-i18n="title">
395
  <span class="semantic-submode-label">s</span>
396
  </span>
397
  <span id="gen_attr_dag_replay_step_wrap" class="gen-attr-dag-replay-value-wrap" hidden>
398
  <input type="number" id="gen_attr_dag_playback_step_ms" class="gen-attr-dag-measure-width-input"
399
  value="200" min="0" max="10000" step="10"
400
- title="Nominal ms between steps. Saved locally; applied when you press play (not mid-run)."
401
  data-i18n="title">
402
  <span class="semantic-submode-label">ms</span>
403
  </span>
 
119
  </div>
120
  </div>
121
  </div>
122
+ <div class="semantic-submode-row chat-enable-thinking-row">
123
+ <span class="semantic-submode-group">
124
+ <label class="semantic-submode-label" for="gen_attr_enable_thinking">
125
+ <input type="checkbox" id="gen_attr_enable_thinking" />
126
+ <span data-i18n>Enable thinking</span>
127
+ </label>
128
+ </span>
129
+ </div>
130
  </div>
131
 
132
  <div class="attribution-exclude-prompt-patterns-row">
 
195
  <div class="loadersmall"></div>
196
  <span id="gen_attr_complete_reason" class="generation-end-reason"></span>
197
  </div>
 
198
  </div>
199
  </div>
200
  <div id="gen_attr_text_metrics" class="text-metrics text-metrics-chat">
 
207
 
208
  <div class="gen-attr-reset-ui-options-row">
209
  <button type="button" id="gen_attr_reset_ui_options_btn" class="text-action-btn"
210
+ title="Restore DAG options, play speed, exclusions, etc. to defaults and clear saved preferences for those controls."
211
  data-i18n="text,title">
212
  Reset UI options
213
  </button>
 
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">
 
300
  <option value="forward">forward</option>
301
+ <option value="backward">backward</option>
302
  </select>
303
  </span>
304
  <span class="semantic-submode-group" id="gen_attr_dag_show_downstream_influence_group">
 
386
  </div>
387
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
388
  <span class="semantic-submode-group gen-attr-dag-replay-speed-row semantic-submode-group--emphasis">
389
+ <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>Play speed</label>
390
  <select id="gen_attr_dag_replay_mode"
391
  class="semantic-submode-select gen-attr-dag-replay-mode-select"
392
+ title="Total duration or per-step delay. DAG Play uses equal steps or a fixed step interval; focus-chain animation scales each step by attribution weight."
393
  data-i18n="title">
394
  <option value="total" data-i18n>total time</option>
395
  <option value="step" data-i18n>step time</option>
 
397
  <span id="gen_attr_dag_replay_total_wrap" class="gen-attr-dag-replay-value-wrap">
398
  <input type="number" id="gen_attr_dag_playback_total_s" class="gen-attr-dag-measure-width-input"
399
  value="7" min="1" max="3600" step="1"
400
+ title="Total seconds. DAG Play divides evenly across steps; focus-chain animation splits by layer weight. Saved locally; applied when you press Play or select a focus node."
401
  data-i18n="title">
402
  <span class="semantic-submode-label">s</span>
403
  </span>
404
  <span id="gen_attr_dag_replay_step_wrap" class="gen-attr-dag-replay-value-wrap" hidden>
405
  <input type="number" id="gen_attr_dag_playback_step_ms" class="gen-attr-dag-measure-width-input"
406
  value="200" min="0" max="10000" step="10"
407
+ title="Milliseconds per step. DAG Play uses this fixed interval; focus-chain animation multiplies by layer weight. Saved locally; applied when you press Play or select a focus node."
408
  data-i18n="title">
409
  <span class="semantic-submode-label">ms</span>
410
  </span>
client/src/chat.html CHANGED
@@ -39,7 +39,7 @@
39
  <section class="input-section">
40
  <div class="semantic-submode-row chat-raw-prompt-mode-row">
41
  <span class="semantic-submode-group">
42
- <label for="chat_skip_chat_template">
43
  <input type="checkbox" id="chat_skip_chat_template" />
44
  <span data-i18n>Raw prompt mode</span>
45
  </label>
@@ -47,7 +47,7 @@
47
  </div>
48
  <div id="raw_input_panel" class="chat-prompt-panel">
49
  <div class="input-header">
50
- <span><span class="demo" data-i18n>Raw prompt</span></span>
51
  <div class="text-action-buttons-top">
52
  <div class="textarea-counter" id="text_count_display">
53
  <span id="text_count_value">0</span> <span data-i18n>chars</span>
@@ -67,9 +67,9 @@
67
  <div id="chat_input_panel" hidden>
68
  <div class="chat-prompt-panel" id="chat_system_prompt_panel">
69
  <div class="input-header">
70
- <label class="chat-use-system-label">
71
  <input type="checkbox" id="chat_use_system_prompt" checked />
72
- <span class="demo" data-i18n>System</span>
73
  </label>
74
  <div class="text-action-buttons-top">
75
  <div class="textarea-counter" id="chat_system_text_count_display">
@@ -89,7 +89,7 @@
89
  </div>
90
  <div class="chat-prompt-panel">
91
  <div class="input-header">
92
- <span><span class="demo" data-i18n>User</span></span>
93
  <div class="text-action-buttons-top">
94
  <div class="textarea-counter" id="chat_user_text_count_display">
95
  <span id="chat_user_text_count_value">0</span> <span data-i18n>chars</span>
@@ -106,6 +106,14 @@
106
  </div>
107
  </div>
108
  </div>
 
 
 
 
 
 
 
 
109
  </div>
110
  <div class="textarea-wrapper chat-prompt-actions-row">
111
  <div class="semantic-submode-row chat-completion-options-row">
 
39
  <section class="input-section">
40
  <div class="semantic-submode-row chat-raw-prompt-mode-row">
41
  <span class="semantic-submode-group">
42
+ <label class="semantic-submode-label" for="chat_skip_chat_template">
43
  <input type="checkbox" id="chat_skip_chat_template" />
44
  <span data-i18n>Raw prompt mode</span>
45
  </label>
 
47
  </div>
48
  <div id="raw_input_panel" class="chat-prompt-panel">
49
  <div class="input-header">
50
+ <span class="semantic-submode-label" data-i18n>Raw prompt</span>
51
  <div class="text-action-buttons-top">
52
  <div class="textarea-counter" id="text_count_display">
53
  <span id="text_count_value">0</span> <span data-i18n>chars</span>
 
67
  <div id="chat_input_panel" hidden>
68
  <div class="chat-prompt-panel" id="chat_system_prompt_panel">
69
  <div class="input-header">
70
+ <label class="chat-use-system-label semantic-submode-label" for="chat_use_system_prompt">
71
  <input type="checkbox" id="chat_use_system_prompt" checked />
72
+ <span data-i18n>System</span>
73
  </label>
74
  <div class="text-action-buttons-top">
75
  <div class="textarea-counter" id="chat_system_text_count_display">
 
89
  </div>
90
  <div class="chat-prompt-panel">
91
  <div class="input-header">
92
+ <span class="semantic-submode-label" data-i18n>User</span>
93
  <div class="text-action-buttons-top">
94
  <div class="textarea-counter" id="chat_user_text_count_display">
95
  <span id="chat_user_text_count_value">0</span> <span data-i18n>chars</span>
 
106
  </div>
107
  </div>
108
  </div>
109
+ <div class="semantic-submode-row chat-enable-thinking-row">
110
+ <span class="semantic-submode-group">
111
+ <label class="semantic-submode-label" for="chat_enable_thinking">
112
+ <input type="checkbox" id="chat_enable_thinking" />
113
+ <span data-i18n>Enable thinking</span>
114
+ </label>
115
+ </span>
116
+ </div>
117
  </div>
118
  <div class="textarea-wrapper chat-prompt-actions-row">
119
  <div class="semantic-submode-row chat-completion-options-row">
client/src/css/base/_narrow-ios-form-font-tail.scss ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 子页 bundle 末尾:@use '../base/narrow-ios-form-font-tail' as ios-form-font; @include ios-form-font.apply;
2
+ // 设计说明见 responsive.narrow-ios-form-font
3
+ @use 'breakpoints' as *;
4
+ @use 'responsive';
5
+
6
+ @mixin apply {
7
+ @media (max-width: $breakpoint-mobile) {
8
+ @include responsive.narrow-ios-form-font('html');
9
+ }
10
+
11
+ @media (min-width: $breakpoint-tablet) {
12
+ @include responsive.narrow-ios-form-font('html[data-force-narrow]:has(.main_frame)');
13
+ }
14
+ }
client/src/css/base/_responsive.scss CHANGED
@@ -24,6 +24,38 @@
24
  box-sizing: border-box;
25
  }
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  // 窄屏布局(视口 ≤767px 与设置「强制窄屏」共用)
28
  @mixin narrow-app-layout($root) {
29
  #{$root} {
@@ -164,18 +196,6 @@
164
 
165
  #{$root} .textarea-wrapper textarea {
166
  min-height: 80px;
167
- font-size: 16px; // 设置为至少 16px,防止 iOS 自动缩放
168
- }
169
-
170
- #{$root} input[type="text"],
171
- #{$root} input[type="url"],
172
- #{$root} input[type="search"],
173
- #{$root} input[type="email"],
174
- #{$root} input[type="number"],
175
- #{$root} input[type="tel"],
176
- #{$root} input[type="password"],
177
- #{$root} select {
178
- font-size: 16px; // 所有文本类输入控件在移动端统一使用 16px
179
  }
180
  }
181
 
 
24
  box-sizing: border-box;
25
  }
26
 
27
+ // --- 窄屏表单字号(防 iOS 聚焦自动放大)---
28
+ //
29
+ // 背景:iOS Safari 在可编辑控件 computed font-size < 16px 时,聚焦常会整页 zoom。
30
+ //
31
+ // 约定:
32
+ // - 窄屏(≤$breakpoint-mobile)与「强制窄屏」下,input/textarea/select 统一 16px(不用 viewport 禁缩放)。
33
+ // - 桌面端仍可用 9pt 等紧凑字号;弹窗表单见 dialog.scss(全宽 16px,与窄屏无关)。
34
+ // - 页面 SCSS 勿对表单写 font-size < 16px;布局类名、padding 不受限。小字号请用于 label/说明,勿用于可编辑控件。
35
+ //
36
+ // 实现注意:
37
+ // - 本 mixin 须在子页 SCSS 最后生效:各 pages/*.scss 末尾 @include narrow-ios-form-font-tail.apply
38
+ // (若只写在 narrow-app-layout 里,会被同文件后加载的 .foo-input { font-size: 9pt } 盖住)。
39
+ // - 选择器含 textarea[class] 等,用于压过带 class 的页面规则;不要改回仅 .textarea-wrapper textarea。
40
+ // - 新增子页(@use app-pages)时,记得在文件末尾加上述 tail include。
41
+ //
42
+ @mixin narrow-ios-form-font($root) {
43
+ #{$root} textarea,
44
+ #{$root} textarea[class],
45
+ #{$root} select,
46
+ #{$root} select[class],
47
+ #{$root} input[type="text"],
48
+ #{$root} input[type="url"],
49
+ #{$root} input[type="search"],
50
+ #{$root} input[type="email"],
51
+ #{$root} input[type="number"],
52
+ #{$root} input[type="tel"],
53
+ #{$root} input[type="password"],
54
+ #{$root} input[class] {
55
+ font-size: 16px;
56
+ }
57
+ }
58
+
59
  // 窄屏布局(视口 ≤767px 与设置「强制窄屏」共用)
60
  @mixin narrow-app-layout($root) {
61
  #{$root} {
 
196
 
197
  #{$root} .textarea-wrapper textarea {
198
  min-height: 80px;
 
 
 
 
 
 
 
 
 
 
 
 
199
  }
200
  }
201
 
client/src/css/components/_chat-input-panel.scss CHANGED
@@ -6,6 +6,45 @@
6
  margin-bottom: 0;
7
  }
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  .input-section .textarea-wrapper.chat-prompt-actions-row .chat-completion-options-row {
10
  margin-bottom: 8px;
11
  width: 100%;
 
6
  margin-bottom: 0;
7
  }
8
 
9
+ // Prompt 区选项标签:panel 标题、勾选行同系(9pt + 勾选主次色)
10
+ .input-section {
11
+ span.semantic-submode-label {
12
+ color: var(--text-muted);
13
+ }
14
+
15
+ .semantic-submode-row:not(.attribution-exclude-prompt-patterns-header):not(.attribution-exclude-generated-patterns-header) {
16
+ label.semantic-submode-label {
17
+ color: var(--text-primary);
18
+ }
19
+
20
+ label.semantic-submode-label:has(> input[type='checkbox']:not(:checked)) {
21
+ color: var(--text-muted);
22
+ }
23
+ }
24
+
25
+ .chat-prompt-panel > .input-header > .semantic-submode-label {
26
+ font-size: 9pt;
27
+ }
28
+
29
+ // System 在 input-header 内(非 semantic-submode-row)
30
+ .chat-prompt-panel > .input-header label.chat-use-system-label.semantic-submode-label {
31
+ font-size: 9pt;
32
+
33
+ input[type='checkbox'] {
34
+ margin: 0;
35
+ flex-shrink: 0;
36
+ }
37
+
38
+ &:has(> input[type='checkbox']:not(:checked)) {
39
+ color: var(--text-muted);
40
+ }
41
+
42
+ &:has(> input[type='checkbox']:checked) {
43
+ color: var(--text-primary);
44
+ }
45
+ }
46
+ }
47
+
48
  .input-section .textarea-wrapper.chat-prompt-actions-row .chat-completion-options-row {
49
  margin-bottom: 8px;
50
  width: 100%;
client/src/css/components/_semantic-analysis.scss CHANGED
@@ -1,7 +1,6 @@
1
  // 语义分析相关样式(section、查询输入、历史下拉、loader)
2
 
3
  @use "loaders";
4
- @use "breakpoints" as *;
5
  @use "query-history-dropdown" as qh;
6
  @use "lmf-readout" as lmf;
7
 
@@ -263,10 +262,3 @@
263
  color: var(--text-muted, #999);
264
  }
265
  }
266
-
267
- // 窄屏:与 _responsive 中 input[type=text] 的 16px 一致;上方三层嵌套选择器优先级高于 input[type=text],故在此用同权重规则 + 源顺序覆盖
268
- @media (max-width: $breakpoint-mobile) {
269
- .semantic-analysis-section .semantic-analysis-controls .semantic-search-input {
270
- font-size: 16px;
271
- }
272
- }
 
1
  // 语义分析相关样式(section、查询输入、历史下拉、loader)
2
 
3
  @use "loaders";
 
4
  @use "query-history-dropdown" as qh;
5
  @use "lmf-readout" as lmf;
6
 
 
262
  color: var(--text-muted, #999);
263
  }
264
  }
 
 
 
 
 
 
 
client/src/css/components/dialog.scss CHANGED
@@ -149,6 +149,22 @@
149
  // 对话框表单元素样式
150
  .dialog-form-container {
151
  margin-bottom: 15px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
  .dialog-label {
@@ -171,6 +187,7 @@
171
  }
172
  }
173
 
 
174
  .dialog-input {
175
  width: 100%;
176
  padding: 8px;
 
149
  // 对话框表单元素样式
150
  .dialog-form-container {
151
  margin-bottom: 15px;
152
+
153
+ // 长内容弹窗:占满 .dialog-content,正文在 .dialog-scroll-region 内滚动
154
+ &--fill {
155
+ display: flex;
156
+ flex-direction: column;
157
+ flex: 1;
158
+ min-height: 0;
159
+ margin-bottom: 0;
160
+ }
161
+ }
162
+
163
+ .dialog-scroll-region {
164
+ flex: 1;
165
+ min-height: 0;
166
+ overflow-y: auto;
167
+ -webkit-overflow-scrolling: touch;
168
  }
169
 
170
  .dialog-label {
 
187
  }
188
  }
189
 
190
+ // 弹窗表单任意屏宽 16px(iOS 防放大);子页左栏窄屏见 responsive.narrow-ios-form-font
191
  .dialog-input {
192
  width: 100%;
193
  padding: 8px;
client/src/css/pages/analysis.scss CHANGED
@@ -1,5 +1,6 @@
1
  // Analysis 页专有样式(intro / 统计图 / 分析进度)
2
  @use 'app-pages';
 
3
 
4
  // 介绍区域样式
5
  .intro-section {
@@ -191,3 +192,6 @@
191
  box-sizing: border-box; // 确保宽度计算正确
192
  }
193
  }
 
 
 
 
1
  // Analysis 页专有样式(intro / 统计图 / 分析进度)
2
  @use 'app-pages';
3
+ @use '../base/narrow-ios-form-font-tail' as ios-form-font;
4
 
5
  // 介绍区域样式
6
  .intro-section {
 
192
  box-sizing: border-box; // 确保宽度计算正确
193
  }
194
  }
195
+
196
+ // 保持在本文件末尾;说明见 responsive.narrow-ios-form-font
197
+ @include ios-form-font.apply;
client/src/css/pages/attribution.scss CHANGED
@@ -3,6 +3,7 @@
3
  @use "attribution-inspector";
4
  @use "chat-input-panel";
5
  @use "breakpoints" as *;
 
6
 
7
  // 桌面端:覆盖全局 min-height:100%,使 #results 按内容收缩(debug 面板紧跟其后)
8
  @media (min-width: $breakpoint-tablet) {
@@ -116,7 +117,6 @@
116
  .attribution-exclude-prompt-patterns-input {
117
  width: 100%;
118
  box-sizing: border-box;
119
- font-size: 9pt;
120
  padding: 6px 8px;
121
  border: 1px solid var(--input-border);
122
  border-radius: 4px;
@@ -128,3 +128,6 @@
128
  cursor: not-allowed;
129
  }
130
  }
 
 
 
 
3
  @use "attribution-inspector";
4
  @use "chat-input-panel";
5
  @use "breakpoints" as *;
6
+ @use '../base/narrow-ios-form-font-tail' as ios-form-font;
7
 
8
  // 桌面端:覆盖全局 min-height:100%,使 #results 按内容收缩(debug 面板紧跟其后)
9
  @media (min-width: $breakpoint-tablet) {
 
117
  .attribution-exclude-prompt-patterns-input {
118
  width: 100%;
119
  box-sizing: border-box;
 
120
  padding: 6px 8px;
121
  border: 1px solid var(--input-border);
122
  border-radius: 4px;
 
128
  cursor: not-allowed;
129
  }
130
  }
131
+
132
+ // 保持在本文件末尾;说明见 responsive.narrow-ios-form-font
133
+ @include ios-form-font.apply;
client/src/css/pages/causal_flow.scss CHANGED
@@ -4,6 +4,7 @@
4
  @use "chat-input-panel";
5
  @use "generation-status";
6
  @use "lmf-readout" as lmf;
 
7
 
8
  $gen-attr-option-row-gap: 12px;
9
  $gen-attr-cached-demos-panel-width: 18em;
@@ -345,7 +346,7 @@ $gen-attr-cached-demos-panel-width: 18em;
345
  #results.gen-attr-results-surface.LMF.css-pseudo-fullscreen-target {
346
  width: 100vw;
347
  height: 100vh;
348
- padding: 20px;
349
  background: var(--text-area-bg);
350
  color: var(--text-color);
351
  overflow: auto;
@@ -421,50 +422,8 @@ $gen-attr-cached-demos-panel-width: 18em;
421
  max-height: 250px;
422
  }
423
 
424
- body.gen-attribute-page .input-section {
425
- span.semantic-submode-label {
426
- color: var(--text-muted);
427
- }
428
-
429
- .semantic-submode-row:not(.attribution-exclude-prompt-patterns-header):not(.attribution-exclude-generated-patterns-header) {
430
- label.semantic-submode-label {
431
- color: var(--text-primary);
432
- }
433
-
434
- label.semantic-submode-label:has(> input[type='checkbox']:not(:checked)) {
435
- color: var(--text-muted);
436
- }
437
- }
438
-
439
- // Start 上方的 prompt 区标题与同系字号(非 .semantic-submode-row 后代时补齐 9pt)
440
- > .semantic-submode-row.chat-raw-prompt-mode-row label.semantic-submode-label {
441
- display: inline-flex;
442
- align-items: center;
443
- gap: 6px;
444
- cursor: pointer;
445
- user-select: none;
446
- }
447
-
448
- .chat-prompt-panel > .input-header > .semantic-submode-label {
449
- font-size: 9pt;
450
- }
451
-
452
- // System:在 input-header 内(非 semantic-submode-row),勾选主次色与勾选行一致
453
- #gen_attr_system_prompt_panel.chat-prompt-panel > .input-header label.chat-use-system-label.semantic-submode-label {
454
- font-size: 9pt;
455
-
456
- &:has(> input[type='checkbox']:not(:checked)) {
457
- color: var(--text-muted);
458
- }
459
-
460
- &:has(> input[type='checkbox']:checked) {
461
- color: var(--text-primary);
462
- }
463
- }
464
-
465
- .textarea-wrapper.chat-prompt-actions-row > .chat-completion-options-row.semantic-submode-row {
466
- gap: $gen-attr-option-row-gap;
467
- }
468
  }
469
 
470
  // 与 Attribution 页 Exclude prompt patterns 同形;generated 仅本页有 UI(持久化键见 attributionExclude*PatternsStorage)
@@ -583,7 +542,6 @@ body.gen-attribute-page .input-section {
583
  .attribution-exclude-prompt-patterns-input {
584
  width: 100%;
585
  box-sizing: border-box;
586
- font-size: 9pt;
587
  padding: 6px 8px;
588
  border: 1px solid var(--input-border);
589
  border-radius: 4px;
@@ -600,3 +558,6 @@ body.gen-attribute-page .input-section {
600
  body.css-pseudo-fullscreen-body-lock {
601
  overflow: hidden;
602
  }
 
 
 
 
4
  @use "chat-input-panel";
5
  @use "generation-status";
6
  @use "lmf-readout" as lmf;
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: 18em;
 
346
  #results.gen-attr-results-surface.LMF.css-pseudo-fullscreen-target {
347
  width: 100vw;
348
  height: 100vh;
349
+ padding: 6px;
350
  background: var(--text-area-bg);
351
  color: var(--text-color);
352
  overflow: auto;
 
422
  max-height: 250px;
423
  }
424
 
425
+ body.gen-attribute-page .input-section .textarea-wrapper.chat-prompt-actions-row > .chat-completion-options-row.semantic-submode-row {
426
+ gap: $gen-attr-option-row-gap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  }
428
 
429
  // 与 Attribution 页 Exclude prompt patterns 同形;generated 仅本页有 UI(持久化键见 attributionExclude*PatternsStorage)
 
542
  .attribution-exclude-prompt-patterns-input {
543
  width: 100%;
544
  box-sizing: border-box;
 
545
  padding: 6px 8px;
546
  border: 1px solid var(--input-border);
547
  border-radius: 4px;
 
558
  body.css-pseudo-fullscreen-body-lock {
559
  overflow: hidden;
560
  }
561
+
562
+ // 保持在本文件末尾;说明见 responsive.narrow-ios-form-font
563
+ @include ios-form-font.apply;
client/src/css/pages/chat.scss CHANGED
@@ -1,5 +1,6 @@
1
  // Chat 页:共用左栏见 _chat-input-panel.scss;此处仅右栏续写栈与 Chat 专有控件
2
  @use 'app-pages';
 
3
 
4
  @use 'chat-input-panel';
5
  @use 'breakpoints' as *;
@@ -102,3 +103,6 @@
102
  padding-right: calc(12px + var(--minimap-width));
103
  }
104
  }
 
 
 
 
1
  // Chat 页:共用左栏见 _chat-input-panel.scss;此处仅右栏续写栈与 Chat 专有控件
2
  @use 'app-pages';
3
+ @use '../base/narrow-ios-form-font-tail' as ios-form-font;
4
 
5
  @use 'chat-input-panel';
6
  @use 'breakpoints' as *;
 
103
  padding-right: calc(12px + var(--minimap-width));
104
  }
105
  }
106
+
107
+ // 保持在本文件末尾;说明见 responsive.narrow-ios-form-font
108
+ @include ios-form-font.apply;
client/src/css/pages/compare.scss CHANGED
@@ -1,6 +1,7 @@
1
  // 对比页面样式 - app-pages 提供 chrome / demo / tooltip 等共用样式
2
  @use 'app-pages';
3
  @use "lmf-readout" as lmf;
 
4
 
5
  // 主框架样式
6
  .main_frame {
@@ -401,3 +402,6 @@
401
  }
402
  }
403
 
 
 
 
 
1
  // 对比页面样式 - app-pages 提供 chrome / demo / tooltip 等共用样式
2
  @use 'app-pages';
3
  @use "lmf-readout" as lmf;
4
+ @use '../base/narrow-ios-form-font-tail' as ios-form-font;
5
 
6
  // 主框架样式
7
  .main_frame {
 
402
  }
403
  }
404
 
405
+ // 保持在本文件末尾;说明见 responsive.narrow-ios-form-font
406
+ @include ios-form-font.apply;
407
+
client/src/features/analysis/infoDensityRenderManager.ts CHANGED
@@ -1,11 +1,12 @@
1
- /** 信息密度底色渲染开关:为 true 时关闭信息密度/classic 底色(语义叠加层不受影响) */
2
- const KEY = 'info_radar_disable_info_density_render';
 
 
3
 
4
  export function getInfoDensityRenderDisabled(): boolean {
5
- const v = localStorage.getItem(KEY);
6
- return v === 'true';
7
  }
8
 
9
  export function setInfoDensityRenderDisabled(disabled: boolean): void {
10
- localStorage.setItem(KEY, disabled ? 'true' : 'false');
11
  }
 
1
+ import { lsReadBool, lsWriteBool } from '../../shared/storage/localStorageHelpers';
2
+
3
+ /** 信息密度底色渲染开关 key:为 true 时关闭信息密度/classic 底色(语义叠加层不受影响) */
4
+ export const INFO_DENSITY_RENDER_DISABLED_KEY = 'info_radar_disable_info_density_render';
5
 
6
  export function getInfoDensityRenderDisabled(): boolean {
7
+ return lsReadBool(INFO_DENSITY_RENDER_DISABLED_KEY, false);
 
8
  }
9
 
10
  export function setInfoDensityRenderDisabled(disabled: boolean): void {
11
+ lsWriteBool(INFO_DENSITY_RENDER_DISABLED_KEY, disabled);
12
  }
client/src/features/causal_flow/bundledDemos.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
  parseGenAttrCachedRunPayload,
8
  type GenAttrCachedRun,
9
  } from '../../shared/storage/genAttributeRunCache';
10
- import { GEN_ATTRIBUTE_BUNDLED_DEMO_SLUGS } from './genAttributeBundledDemoManifest.generated';
11
 
12
  const BASE = 'assets/demos/causal_flow/';
13
 
@@ -28,7 +28,7 @@ export type BundledDemoListEntry = { id: string; label: string };
28
 
29
  /** 构建期固定的 bundled demo 列表(与当前 JS 同版本)。 */
30
  export function getBundledGenAttributeDemoList(): readonly BundledDemoListEntry[] {
31
- return GEN_ATTRIBUTE_BUNDLED_DEMO_SLUGS.map((slug) => ({ id: slug, label: slug }));
32
  }
33
 
34
  /**
 
7
  parseGenAttrCachedRunPayload,
8
  type GenAttrCachedRun,
9
  } from '../../shared/storage/genAttributeRunCache';
10
+ import { GEN_ATTRIBUTE_BUNDLED_DEMOS } from './genAttributeBundledDemoManifest.generated';
11
 
12
  const BASE = 'assets/demos/causal_flow/';
13
 
 
28
 
29
  /** 构建期固定的 bundled demo 列表(与当前 JS 同版本)。 */
30
  export function getBundledGenAttributeDemoList(): readonly BundledDemoListEntry[] {
31
+ return GEN_ATTRIBUTE_BUNDLED_DEMOS.map(({ slug, label }) => ({ id: slug, label }));
32
  }
33
 
34
  /**
client/src/features/causal_flow/genAttributeBundledDemoManifest.generated.ts CHANGED
@@ -1,4 +1,5 @@
1
  /**
2
  * Generated by GenAttributeDemoManifestPlugin — do not edit.
3
  */
4
- export const GEN_ATTRIBUTE_BUNDLED_DEMO_SLUGS: readonly string[] = ["CN->EN翻译","CoT | 苏州所在省的省会","Write a sonnet about love","写一首绝句,主题是春天","过拟合|李白 将进酒"];
 
 
1
  /**
2
  * Generated by GenAttributeDemoManifestPlugin — do not edit.
3
  */
4
+ export type GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string };
5
+ export const GEN_ATTRIBUTE_BUNDLED_DEMOS: readonly GenAttributeBundledDemoManifestEntry[] = [{"slug":"Write a sonnet about love","label":"Poem | Write a sonnet about love"},{"slug":"写一首绝句,主题是春天","label":"写诗 | 写一首绝句,主题是春天"},{"slug":"CoT | 苏州所在省的省会","label":"CoT | 苏州所在省的省会"},{"slug":"CoT | 多“跳”推理","label":"CoT | 多“跳”推理"},{"slug":"过拟合|李白 将进酒","label":"过拟合|李白 将进酒"},{"slug":"CN->EN翻译","label":"CN->EN | 翻译"}];
client/src/features/chat/chatPromptTemplateMode.ts CHANGED
@@ -1,21 +1,6 @@
1
- /**
2
- * 与 Chat 页共用的「Raw prompt mode」开关(localStorage)。
3
- * 在 Generate & Attribute 与 Chat 之间切换时保持一致。
4
- */
5
  export const LS_SKIP_CHAT_TEMPLATE = 'chat_skip_chat_template';
6
 
7
- export function readSkipChatTemplateFromStorage(): boolean {
8
- try {
9
- return localStorage.getItem(LS_SKIP_CHAT_TEMPLATE) === 'true';
10
- } catch {
11
- return false;
12
- }
13
- }
14
-
15
- export function writeSkipChatTemplateToStorage(value: boolean): void {
16
- try {
17
- localStorage.setItem(LS_SKIP_CHAT_TEMPLATE, value ? 'true' : 'false');
18
- } catch {
19
- /* ignore quota / private mode */
20
- }
21
- }
 
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';
 
 
 
 
 
 
 
 
 
 
 
 
client/src/pages/analysis/index.ts CHANGED
@@ -52,6 +52,7 @@ import { saveHistory, initQueryHistoryDropdown } from '../../shared/cross/queryH
52
  import { removeByQuery as removeSemanticCacheByQuery } from '../../shared/cross/semanticResultCache';
53
  import { playAnalysisCompleteSound } from '../../shared/cross/soundNotification';
54
  import { getSemanticMatchThreshold, setSemanticMatchThreshold } from '../../shared/cross/semanticThresholdManager';
 
55
  import { SEMANTIC_MATCH_THRESHOLD } from '../../shared/core/constants';
56
  import { SemanticSearchController } from '../../shared/controllers/semanticSearchController';
57
  import { initDensityAttributionSidebar } from '../../shared/prediction_attribution/density_sidebar/densityAttributionSidebar';
@@ -137,7 +138,7 @@ window.onload = () => {
137
  loadHomeContent('home-intro-content');
138
 
139
  // minimap启用状态(优先使用localStorage,否则根据设备类型判断:移动端默认为false,桌面端默认为true)
140
- const storedMinimap = localStorage.getItem('minimap_enabled');
141
  let enableMinimap: boolean = storedMinimap !== null
142
  ? storedMinimap === '1'
143
  : !isMobileDevice();
@@ -257,9 +258,9 @@ window.onload = () => {
257
  const validSubmodes = ['count', 'fill_blank', 'hybrid'];
258
  const validColorSources = ['raw_score_normed', 'signal_probability', 'pw_score'];
259
  const query = URLHandler.parameters['semantic_query'] ?? '';
260
- const submode = localStorage.getItem(SEMANTIC_KEYS.submode) ?? 'hybrid';
261
- const chunked = localStorage.getItem(SEMANTIC_KEYS.chunked) !== '0';
262
- const colorSource = localStorage.getItem(SEMANTIC_KEYS.colorSource) ?? 'pw_score';
263
  const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
264
  if (queryEl) queryEl.value = typeof query === 'string' ? query : '';
265
  const submodeEl = document.getElementById('semantic_submode_select') as HTMLSelectElement | null;
@@ -276,9 +277,9 @@ window.onload = () => {
276
  const chunkedEl = document.getElementById('semantic_chunked_mode') as HTMLInputElement | null;
277
  const colorEl = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null;
278
  const thresholdEl = document.getElementById('semantic_threshold_input') as HTMLInputElement | null;
279
- localStorage.setItem(SEMANTIC_KEYS.submode, submodeEl?.value ?? 'hybrid');
280
- if (chunkedEl) localStorage.setItem(SEMANTIC_KEYS.chunked, chunkedEl.checked ? '1' : '0');
281
- if (colorEl) localStorage.setItem(SEMANTIC_KEYS.colorSource, colorEl.value);
282
  if (thresholdEl) {
283
  const v = parseFloat(thresholdEl.value);
284
  if (Number.isFinite(v)) {
@@ -315,7 +316,7 @@ window.onload = () => {
315
  lmf.updateOptions({
316
  enableMinimap: enableMinimap
317
  }, false);
318
- localStorage.setItem('minimap_enabled', enableMinimap ? '1' : '0');
319
  },
320
  onSemanticAnalysisToggle: (_enabled: boolean) => {
321
  // 打开/关闭时都清除 query,并将 submode/chunked/color/阈值 重置为默认值并写回 localStorage
@@ -709,7 +710,7 @@ window.onload = () => {
709
  return;
710
  }
711
 
712
- const lastPath = localStorage.getItem(LAST_SAVE_PATH_KEY);
713
  const { options: folderOptions, defaultPath } = buildFolderOptions(folders, lastPath);
714
  const defaultName = getDefaultDemoName(null, prefillText);
715
 
 
52
  import { removeByQuery as removeSemanticCacheByQuery } from '../../shared/cross/semanticResultCache';
53
  import { playAnalysisCompleteSound } from '../../shared/cross/soundNotification';
54
  import { getSemanticMatchThreshold, setSemanticMatchThreshold } from '../../shared/cross/semanticThresholdManager';
55
+ import { lsGet, lsSet, lsWriteBool } from '../../shared/storage/localStorageHelpers';
56
  import { SEMANTIC_MATCH_THRESHOLD } from '../../shared/core/constants';
57
  import { SemanticSearchController } from '../../shared/controllers/semanticSearchController';
58
  import { initDensityAttributionSidebar } from '../../shared/prediction_attribution/density_sidebar/densityAttributionSidebar';
 
138
  loadHomeContent('home-intro-content');
139
 
140
  // minimap启用状态(优先使用localStorage,否则根据设备类型判断:移动端默认为false,桌面端默认为true)
141
+ const storedMinimap = lsGet('minimap_enabled');
142
  let enableMinimap: boolean = storedMinimap !== null
143
  ? storedMinimap === '1'
144
  : !isMobileDevice();
 
258
  const validSubmodes = ['count', 'fill_blank', 'hybrid'];
259
  const validColorSources = ['raw_score_normed', 'signal_probability', 'pw_score'];
260
  const query = URLHandler.parameters['semantic_query'] ?? '';
261
+ const submode = lsGet(SEMANTIC_KEYS.submode) ?? 'hybrid';
262
+ const chunked = lsGet(SEMANTIC_KEYS.chunked) !== '0';
263
+ const colorSource = lsGet(SEMANTIC_KEYS.colorSource) ?? 'pw_score';
264
  const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
265
  if (queryEl) queryEl.value = typeof query === 'string' ? query : '';
266
  const submodeEl = document.getElementById('semantic_submode_select') as HTMLSelectElement | null;
 
277
  const chunkedEl = document.getElementById('semantic_chunked_mode') as HTMLInputElement | null;
278
  const colorEl = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null;
279
  const thresholdEl = document.getElementById('semantic_threshold_input') as HTMLInputElement | null;
280
+ lsSet(SEMANTIC_KEYS.submode, submodeEl?.value ?? 'hybrid');
281
+ if (chunkedEl) lsWriteBool(SEMANTIC_KEYS.chunked, chunkedEl.checked, '1');
282
+ if (colorEl) lsSet(SEMANTIC_KEYS.colorSource, colorEl.value);
283
  if (thresholdEl) {
284
  const v = parseFloat(thresholdEl.value);
285
  if (Number.isFinite(v)) {
 
316
  lmf.updateOptions({
317
  enableMinimap: enableMinimap
318
  }, false);
319
+ lsWriteBool('minimap_enabled', enableMinimap, '1');
320
  },
321
  onSemanticAnalysisToggle: (_enabled: boolean) => {
322
  // 打开/关闭时都清除 query,并将 submode/chunked/color/阈值 重置为默认值并写回 localStorage
 
710
  return;
711
  }
712
 
713
+ const lastPath = lsGet(LAST_SAVE_PATH_KEY);
714
  const { options: folderOptions, defaultPath } = buildFolderOptions(folders, lastPath);
715
  const defaultName = getDefaultDemoName(null, prefillText);
716
 
client/src/pages/attribution/index.ts CHANGED
@@ -40,6 +40,7 @@ import { loadPredictionAttributeWithCache } from '../../shared/prediction_attrib
40
  import { readStoredEffectiveExcludePromptPatternsText } from '../../shared/prediction_attribution/core/attributionExcludePromptPatternsStorage';
41
  import { bindExcludePromptPatternsUi } from '../../shared/prediction_attribution/core/excludePromptPatternsUi';
42
  import { syncDraftCommittedButtonPair } from '../../shared/cross/syncDraftCommittedButtonPair';
 
43
 
44
  d3.selectAll('.loadersmall').style('display', 'none');
45
 
@@ -52,13 +53,7 @@ const TARGET_HISTORY_KEY = 'info_radar_attribution_target_history';
52
  const ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY = 'info_radar_attribution_model_variant';
53
 
54
  function readStoredAttributionPageModelVariant(): PredictionAttributeModelVariant {
55
- try {
56
- const v = localStorage.getItem(ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY);
57
- if (v === 'base' || v === 'instruct') return v;
58
- } catch {
59
- // ignore
60
- }
61
- return 'instruct';
62
  }
63
 
64
  const apiPrefix = URLHandler.parameters['api'] || '';
@@ -101,11 +96,7 @@ function currentAttributionModelVariant(): PredictionAttributeModelVariant {
101
  }
102
 
103
  modelVariantSelect?.addEventListener('change', () => {
104
- try {
105
- localStorage.setItem(ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY, currentAttributionModelVariant());
106
- } catch {
107
- // ignore
108
- }
109
  });
110
 
111
  // --- TextInputController ---
 
40
  import { readStoredEffectiveExcludePromptPatternsText } from '../../shared/prediction_attribution/core/attributionExcludePromptPatternsStorage';
41
  import { bindExcludePromptPatternsUi } from '../../shared/prediction_attribution/core/excludePromptPatternsUi';
42
  import { syncDraftCommittedButtonPair } from '../../shared/cross/syncDraftCommittedButtonPair';
43
+ import { lsReadEnum, lsWriteString } from '../../shared/storage/localStorageHelpers';
44
 
45
  d3.selectAll('.loadersmall').style('display', 'none');
46
 
 
53
  const ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY = 'info_radar_attribution_model_variant';
54
 
55
  function readStoredAttributionPageModelVariant(): PredictionAttributeModelVariant {
56
+ return lsReadEnum(ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY, ['base', 'instruct'] as const, 'instruct');
 
 
 
 
 
 
57
  }
58
 
59
  const apiPrefix = URLHandler.parameters['api'] || '';
 
96
  }
97
 
98
  modelVariantSelect?.addEventListener('change', () => {
99
+ lsWriteString(ATTRIBUTION_MODEL_VARIANT_STORAGE_KEY, currentAttributionModelVariant());
 
 
 
 
100
  });
101
 
102
  // --- TextInputController ---
client/src/pages/causal_flow/index.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
  DAG_COMPACTNESS_DEFAULT,
34
  LINEAR_ARC_ADJACENT_GAP_DEFAULT,
35
  } from '../../shared/prediction_attribution/causal_flow/genAttributeDagView';
 
36
  import {
37
  createHydratedTokenGenHandle,
38
  startTokenGenAttribution,
@@ -85,11 +86,21 @@ import {
85
  saveHistory,
86
  } from '../../shared/cross/queryHistory';
87
  import {
88
- readSkipChatTemplateFromStorage,
89
- writeSkipChatTemplateToStorage,
90
  } from '../../features/chat/chatPromptTemplateMode';
91
  import { postCompletionsPrompt, postCompletionsStop } from '../../shared/api/completionsClient';
92
  import { updateApiUsageDisplay, updateModel, validateMetricsElements } from '../../shared/cross/textMetricsUpdater';
 
 
 
 
 
 
 
 
 
 
93
 
94
  d3.selectAll('.loadersmall').style('display', 'none');
95
 
@@ -162,7 +173,7 @@ const DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS: GenAttrDemoUiOptions = {
162
  showDownstreamInfluence: false,
163
  recursiveAttributionEnabled: false,
164
  recursiveEdgeBatchAnimationEnabled: true,
165
- recursiveEdgeBatchAnimationDirection: 'backward',
166
  showTokenInfoOnSelected: false,
167
  replayPacingMode: 'total',
168
  playbackTotalS: GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT,
@@ -183,24 +194,13 @@ function createFlowId(): string {
183
  }
184
 
185
  function readStoredModelVariant(): PredictionAttributeModelVariant {
186
- try {
187
- const v = localStorage.getItem(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY);
188
- if (v === 'base' || v === 'instruct') return v;
189
- } catch {
190
- // ignore
191
- }
192
- return 'instruct';
193
  }
194
 
195
  function readStoredMaxTokens(): number {
196
- try {
197
- const v = localStorage.getItem(GEN_ATTR_MAX_TOKENS_STORAGE_KEY);
198
- const n = v !== null ? parseInt(v, 10) : NaN;
199
- if (Number.isFinite(n) && n >= 1 && n <= 500) return n;
200
- } catch {
201
- // ignore
202
- }
203
- return GEN_ATTR_MAX_TOKENS_DEFAULT;
204
  }
205
 
206
  function clampDagMeasureWidth(n: number): number {
@@ -211,47 +211,32 @@ function clampDagMeasureWidth(n: number): number {
211
  }
212
 
213
  function readStoredDagMeasureWidth(): number {
214
- try {
215
- const v = localStorage.getItem(GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY);
216
- const n = v !== null ? parseInt(v, 10) : NaN;
217
- if (Number.isFinite(n)) return clampDagMeasureWidth(n);
218
- } catch {
219
- // ignore
220
- }
221
- return GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT;
222
  }
223
 
224
  function readStoredDagCompactness(): number {
225
- try {
226
- const v = localStorage.getItem(GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY);
227
- const n = v !== null ? parseFloat(v) : NaN;
228
- if (Number.isFinite(n)) return clampDagCompactness(n);
229
- } catch {
230
- // ignore
231
- }
232
- return DAG_COMPACTNESS_DEFAULT;
233
  }
234
 
235
  function readStoredDagEdgeTopPCoverage(): number {
236
- try {
237
- const v = localStorage.getItem(GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY);
238
- const n = v !== null ? parseFloat(v) : NaN;
239
- if (Number.isFinite(n)) return clampDagEdgeTopPCoverage(n);
240
- } catch {
241
- // ignore
242
- }
243
- return DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
244
  }
245
 
246
  function readStoredDagLinearArcAdjacentGap(): number {
247
- try {
248
- const v = localStorage.getItem(GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY);
249
- const n = v !== null ? parseInt(v, 10) : NaN;
250
- if (Number.isFinite(n)) return clampLinearArcAdjacentGap(n);
251
- } catch {
252
- // ignore
253
- }
254
- return LINEAR_ARC_ADJACENT_GAP_DEFAULT;
255
  }
256
 
257
  function clampDagPlaybackStepMs(n: number): number {
@@ -262,14 +247,11 @@ function clampDagPlaybackStepMs(n: number): number {
262
  }
263
 
264
  function readStoredDagPlaybackStepMs(): number {
265
- try {
266
- const v = localStorage.getItem(GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY);
267
- const n = v !== null ? parseInt(v, 10) : NaN;
268
- if (Number.isFinite(n)) return clampDagPlaybackStepMs(n);
269
- } catch {
270
- // ignore
271
- }
272
- return GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT;
273
  }
274
 
275
  function clampDagPlaybackTotalS(n: number): number {
@@ -280,41 +262,27 @@ function clampDagPlaybackTotalS(n: number): number {
280
  }
281
 
282
  function readStoredDagPlaybackTotalS(): number {
283
- try {
284
- const v = localStorage.getItem(GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY);
285
- const n = v !== null ? parseInt(v, 10) : NaN;
286
- if (Number.isFinite(n)) return clampDagPlaybackTotalS(n);
287
- } catch {
288
- // ignore
289
- }
290
- return GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT;
291
  }
292
 
293
  function readStoredDagReplayPacingMode(): DagReplayPacingMode {
294
- try {
295
- const v = localStorage.getItem(GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY);
296
- if (v === 'total' || v === 'step') return v;
297
- } catch {
298
- // ignore
299
- }
300
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.replayPacingMode;
301
  }
302
 
303
  function readStoredDagLayoutMode(): DagLayoutMode {
304
- try {
305
- const v = localStorage.getItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY);
306
- if (
307
- v === 'text-flow' ||
308
- v === 'linear-arc' ||
309
- v === 'linear-arc-step-down' ||
310
- v === 'spiral'
311
- ) {
312
- return v;
313
- }
314
- } catch {
315
- // ignore
316
- }
317
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.layoutMode;
318
  }
319
 
320
  const apiPrefix = URLHandler.parameters['api'] || '';
@@ -366,10 +334,12 @@ const genAttrTeacherForcingBlock = document.getElementById('gen_attr_teacher_for
366
  const genAttrStopAfterTeacherForcing = document.getElementById(
367
  'gen_attr_stop_after_teacher_forcing'
368
  ) as HTMLInputElement | null;
 
 
 
369
 
370
  const submitBtn = d3.select('#gen_attr_submit_btn');
371
  const loaderSmall = d3.select('.loadersmall');
372
- const analyzeProgressEl = d3.select('#gen_attr_analyze_progress');
373
  const metricUsage = d3.select('#gen_attr_metric_usage');
374
  const metricModel = d3.select('#gen_attr_metric_model');
375
  const genAttrResultsEl = d3.select('#results.gen-attr-results-surface');
@@ -410,6 +380,23 @@ function currentDagReplayPacingMode(): DagReplayPacingMode {
410
  return dagReplayModeSelect?.value === 'step' ? 'step' : 'total';
411
  }
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  /** 切换下拉时更新 `hidden`;样式见 `.gen-attr-dag-replay-value-wrap:not([hidden])`。 */
414
  function applyDagReplaySpeedUi(): void {
415
  const mode = currentDagReplayPacingMode();
@@ -504,28 +491,30 @@ function syncGenAttrExcludePatternTextareasDisabled(): void {
504
  }
505
 
506
  function hydrateGenAttrExcludePatternsFromGenAttrStorage(): void {
507
- try {
508
- const savedPrompt = localStorage.getItem(GEN_ATTR_EXCLUDE_PROMPT_PATTERNS_STORAGE_KEY);
509
- if (genAttrExcludePromptPatternsTa) {
510
- genAttrExcludePromptPatternsTa.value =
511
- savedPrompt !== null ? savedPrompt : DEFAULT_EXCLUDE_PROMPT_PATTERNS_TEXT;
512
- }
513
- const promptEnRaw = localStorage.getItem(GEN_ATTR_EXCLUDE_PROMPT_PATTERNS_ENABLED_STORAGE_KEY);
514
- if (genAttrExcludePromptPatternsEnable) {
515
- genAttrExcludePromptPatternsEnable.checked = promptEnRaw === null ? true : promptEnRaw === '1';
516
- }
 
 
517
 
518
- const savedGen = localStorage.getItem(GEN_ATTR_EXCLUDE_GENERATED_PATTERNS_STORAGE_KEY);
519
- if (genAttrExcludeGeneratedPatternsTa) {
520
- genAttrExcludeGeneratedPatternsTa.value =
521
- savedGen !== null ? savedGen : DEFAULT_EXCLUDE_GENERATED_PATTERNS_TEXT;
522
- }
523
- const genEnRaw = localStorage.getItem(GEN_ATTR_EXCLUDE_GENERATED_PATTERNS_ENABLED_STORAGE_KEY);
524
- if (genAttrExcludeGeneratedPatternsEnable) {
525
- genAttrExcludeGeneratedPatternsEnable.checked = genEnRaw === null ? true : genEnRaw === '1';
526
- }
527
- } catch {
528
- /* ignore */
529
  }
530
  syncGenAttrExcludePatternTextareasDisabled();
531
  }
@@ -543,7 +532,6 @@ function genAttrEffectiveExcludeGeneratedPatternsText(): string {
543
  return genAttrExcludeGeneratedPatternsTa?.value ?? '';
544
  }
545
 
546
- if (modelVariantSelect) modelVariantSelect.value = readStoredModelVariant();
547
  if (maxTokensInput) maxTokensInput.value = String(readStoredMaxTokens());
548
  const initialDagLayoutMode = readStoredDagLayoutMode();
549
  if (dagLayoutModeSelect) dagLayoutModeSelect.value = initialDagLayoutMode;
@@ -568,37 +556,27 @@ applyDagReplaySpeedUi();
568
 
569
  const genAttrResultsNode = genAttrResultsEl.node() as HTMLElement | null;
570
  function readStoredDagNodeCiVisualScale(): boolean {
571
- try {
572
- const v = localStorage.getItem(GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY);
573
- if (v !== null) return v === '1';
574
- } catch {
575
- // ignore
576
- }
577
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.nodeCiVisualScaleEnabled;
578
  }
579
  const initialDagNodeCiVisualScale = readStoredDagNodeCiVisualScale();
580
  if (dagNodeCiVisualScaleInput) dagNodeCiVisualScaleInput.checked = initialDagNodeCiVisualScale;
581
  setDagNodeCiVisualScaleEnabled(initialDagNodeCiVisualScale);
582
  dagNodeCiVisualScaleInput?.addEventListener('change', () => {
583
  const enabled = dagNodeCiVisualScaleInput.checked;
584
- try {
585
- localStorage.setItem(GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY, enabled ? '1' : '0');
586
- } catch {
587
- /* ignore */
588
- }
589
  setDagNodeCiVisualScaleEnabled(enabled);
590
  tryResetAndReplayDag();
591
  });
592
 
593
  function readStoredDagDecayAttributionToHighSurprisalTarget(): boolean {
594
- try {
595
- const v = localStorage.getItem(GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY);
596
- if (v !== null) return v === '1';
597
- const legacy = localStorage.getItem(GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY_LEGACY);
598
- if (legacy !== null) return legacy === '1';
599
- } catch {
600
- // ignore
601
- }
602
  return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.decayAttributionToHighSurprisalTargetEnabled;
603
  }
604
  const initialDagDecayAttributionHighSurprisal = readStoredDagDecayAttributionToHighSurprisalTarget();
@@ -608,13 +586,9 @@ if (dagDecayAttributionHighSurprisalInput) {
608
  setDagDecayAttributionToHighSurprisalTargetEnabled(initialDagDecayAttributionHighSurprisal);
609
  dagDecayAttributionHighSurprisalInput?.addEventListener('change', () => {
610
  const enabled = dagDecayAttributionHighSurprisalInput.checked;
611
- try {
612
- localStorage.setItem(GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY, enabled ? '1' : '0');
613
- } catch {
614
- /* ignore */
615
- }
616
  setDagDecayAttributionToHighSurprisalTargetEnabled(enabled);
617
- tryResetAndReplayDag();
618
  });
619
 
620
  function applyDagHideInactiveEdges(hide: boolean): void {
@@ -622,35 +596,27 @@ function applyDagHideInactiveEdges(hide: boolean): void {
622
  genAttrResultsNode.classList.toggle('gen-attr-dag-hide-inactive-edges', hide);
623
  }
624
  function readStoredDagHideInactiveEdges(): boolean {
625
- try {
626
- const v = localStorage.getItem(GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY);
627
- if (v !== null) return v === '1';
628
- } catch {
629
- // ignore
630
- }
631
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.hideInactiveEdges;
632
  }
633
  const initialDagHideInactiveEdges = readStoredDagHideInactiveEdges();
634
  if (dagHideInactiveEdgesInput) dagHideInactiveEdgesInput.checked = initialDagHideInactiveEdges;
635
  applyDagHideInactiveEdges(initialDagHideInactiveEdges);
636
  dagHideInactiveEdgesInput?.addEventListener('change', () => {
637
  const hide = dagHideInactiveEdgesInput.checked;
638
- try {
639
- localStorage.setItem(GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY, hide ? '1' : '0');
640
- } catch {
641
- /* ignore */
642
- }
643
  applyDagHideInactiveEdges(hide);
644
  });
645
 
646
  function readStoredDagShowDownstreamInfluence(): boolean {
647
- try {
648
- const v = localStorage.getItem(GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY);
649
- if (v !== null) return v === '1';
650
- } catch {
651
- // ignore
652
- }
653
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.showDownstreamInfluence;
654
  }
655
  const initialDagShowDownstreamInfluence = readStoredDagShowDownstreamInfluence();
656
  if (dagShowDownstreamInfluenceInput) {
@@ -658,11 +624,7 @@ if (dagShowDownstreamInfluenceInput) {
658
  }
659
  dagShowDownstreamInfluenceInput?.addEventListener('change', () => {
660
  const show = dagShowDownstreamInfluenceInput.checked;
661
- try {
662
- localStorage.setItem(GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY, show ? '1' : '0');
663
- } catch {
664
- /* ignore */
665
- }
666
  dagHandle.setShowDownstreamInfluence(show);
667
  });
668
 
@@ -682,35 +644,29 @@ function applyDagRecursiveAttributionSubmodeUi(): void {
682
  }
683
 
684
  function readStoredDagRecursiveAttribution(): boolean {
685
- try {
686
- const v = localStorage.getItem(GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY);
687
- if (v !== null) return v === '1';
688
- } catch {
689
- // ignore
690
- }
691
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.recursiveAttributionEnabled;
692
  }
693
  const initialDagRecursiveAttribution = readStoredDagRecursiveAttribution();
694
  if (dagRecursiveAttributionInput) dagRecursiveAttributionInput.checked = initialDagRecursiveAttribution;
695
 
696
  function readStoredDagRecursiveEdgeAnimationEnabled(): boolean {
697
- try {
698
- const v = localStorage.getItem(GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_STORAGE_KEY);
699
- if (v !== null) return v === '1';
700
- } catch {
701
- // ignore
702
- }
703
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.recursiveEdgeBatchAnimationEnabled;
704
  }
705
 
706
  function readStoredDagRecursiveEdgeAnimationDirection(): DagRecursiveEdgeAnimationDirection {
707
- try {
708
- const v = localStorage.getItem(GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_DIRECTION_STORAGE_KEY);
709
- if (v === 'backward' || v === 'forward') return v;
710
- } catch {
711
- // ignore
712
- }
713
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.recursiveEdgeBatchAnimationDirection;
714
  }
715
 
716
  const initialDagRecursiveEdgeAnimationEnabled = readStoredDagRecursiveEdgeAnimationEnabled();
@@ -723,74 +679,50 @@ if (dagRecursiveEdgeAnimationDirectionSelect) {
723
  applyDagRecursiveAttributionSubmodeUi();
724
  dagRecursiveAttributionInput?.addEventListener('change', () => {
725
  const enabled = dagRecursiveAttributionInput.checked;
726
- try {
727
- localStorage.setItem(GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY, enabled ? '1' : '0');
728
- } catch {
729
- /* ignore */
730
- }
731
  applyDagRecursiveAttributionSubmodeUi();
732
  dagHandle.setRecursiveAttributionEnabled(enabled);
733
  });
734
  dagRecursiveEdgeAnimationInput?.addEventListener('change', () => {
735
  const enabled = dagRecursiveEdgeAnimationInput.checked;
736
- try {
737
- localStorage.setItem(GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_STORAGE_KEY, enabled ? '1' : '0');
738
- } catch {
739
- /* ignore */
740
- }
741
  applyDagRecursiveAttributionSubmodeUi();
742
  dagHandle.setRecursiveEdgeBatchAnimationEnabled(enabled);
743
  });
744
  dagRecursiveEdgeAnimationDirectionSelect?.addEventListener('change', () => {
745
  const direction = currentDagRecursiveEdgeAnimationDirection();
746
  dagRecursiveEdgeAnimationDirectionSelect.value = direction;
747
- try {
748
- localStorage.setItem(GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_DIRECTION_STORAGE_KEY, direction);
749
- } catch {
750
- /* ignore */
751
- }
752
  dagHandle.setRecursiveEdgeBatchAnimationDirection(direction);
753
  });
754
 
755
  function readStoredDagHideExcludedTokens(): boolean {
756
- try {
757
- const v = localStorage.getItem(GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY);
758
- if (v !== null) return v === '1';
759
- } catch {
760
- // ignore
761
- }
762
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.hideExcludedTokens;
763
  }
764
  const initialDagHideExcludedTokens = readStoredDagHideExcludedTokens();
765
  if (dagHideExcludedTokensInput) dagHideExcludedTokensInput.checked = initialDagHideExcludedTokens;
766
  function readStoredDagShowTopkOnSelected(): boolean {
767
- try {
768
- const v = localStorage.getItem(GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY);
769
- if (v !== null) return v === '1';
770
- } catch {
771
- // ignore
772
- }
773
- return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.showTokenInfoOnSelected;
774
  }
775
  const initialDagShowTopkOnSelected = readStoredDagShowTopkOnSelected();
776
  if (dagShowTopkOnSelectedInput) dagShowTopkOnSelectedInput.checked = initialDagShowTopkOnSelected;
777
  dagHideExcludedTokensInput?.addEventListener('change', () => {
778
  const hide = dagHideExcludedTokensInput.checked;
779
- try {
780
- localStorage.setItem(GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY, hide ? '1' : '0');
781
- } catch {
782
- /* ignore */
783
- }
784
  dagHandle.setHideExcludedTokens(hide);
785
  });
786
 
787
  dagShowTopkOnSelectedInput?.addEventListener('change', () => {
788
  const show = dagShowTopkOnSelectedInput.checked;
789
- try {
790
- localStorage.setItem(GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY, show ? '1' : '0');
791
- } catch {
792
- /* ignore */
793
- }
794
  dagHandle.setShowTokenInfoOnSelected(show);
795
  });
796
 
@@ -802,31 +734,24 @@ setPageOptsGetter(() => {
802
  layout_spiral: mode === 'spiral',
803
  propagated: dagRecursiveAttributionInput?.checked ?? false,
804
  propagated_anim: dagRecursiveEdgeAnimationInput?.checked ?? true,
805
- propagated_anim_forward: currentDagRecursiveEdgeAnimationDirection() === 'forward',
806
  downstream: dagShowDownstreamInfluenceInput?.checked ?? false,
807
  token_tooltip: dagShowTopkOnSelectedInput?.checked ?? false,
808
  };
809
  });
810
 
811
  modelVariantSelect?.addEventListener('change', () => {
812
- try {
813
- localStorage.setItem(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, currentModelVariant());
814
- } catch {
815
- /* ignore */
816
- }
817
  syncIdleModelMetric();
818
  syncSubmitButtonState();
819
  });
820
 
821
  maxTokensInput?.addEventListener('change', () => {
822
- try {
823
- localStorage.setItem(
824
- GEN_ATTR_MAX_TOKENS_STORAGE_KEY,
825
- maxTokensInput?.value ?? String(GEN_ATTR_MAX_TOKENS_DEFAULT)
826
- );
827
- } catch {
828
- /* ignore */
829
- }
830
  syncSubmitButtonState();
831
  });
832
 
@@ -837,20 +762,12 @@ dagPlaybackStepMsInput?.addEventListener('change', () => {
837
  ? clampDagPlaybackStepMs(raw)
838
  : GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT;
839
  dagPlaybackStepMsInput.value = String(ms);
840
- try {
841
- localStorage.setItem(GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY, String(ms));
842
- } catch {
843
- /* ignore */
844
- }
845
  });
846
 
847
  dagReplayModeSelect?.addEventListener('change', () => {
848
  const mode = currentDagReplayPacingMode();
849
- try {
850
- localStorage.setItem(GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY, mode);
851
- } catch {
852
- /* ignore */
853
- }
854
  applyDagReplaySpeedUi();
855
  });
856
 
@@ -860,11 +777,7 @@ dagPlaybackTotalSInput?.addEventListener('change', () => {
860
  ? clampDagPlaybackTotalS(raw)
861
  : GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT;
862
  dagPlaybackTotalSInput.value = String(s);
863
- try {
864
- localStorage.setItem(GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY, String(s));
865
- } catch {
866
- /* ignore */
867
- }
868
  });
869
 
870
  function isSkipChatTemplate(): boolean {
@@ -875,6 +788,10 @@ function isGenAttrUseSystemPrompt(): boolean {
875
  return genAttrUseSystemPromptInput?.checked ?? true;
876
  }
877
 
 
 
 
 
878
  function syncGenAttrSystemPromptSuppressedUi(): void {
879
  const on = isGenAttrUseSystemPrompt();
880
  genAttrSystemPromptPanel?.classList.toggle('chat-system-prompt-suppressed', !on);
@@ -896,6 +813,20 @@ function syncPromptPanelVisibility(): void {
896
  if (chatInputPanel) chatInputPanel.hidden = skip;
897
  }
898
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
  function getActivePromptValue(): string {
900
  if (isSkipChatTemplate()) {
901
  return (rawTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
@@ -947,6 +878,7 @@ function buildGenAttrRunDraftForCache(): GenAttrRunDraft {
947
  system: systemPromptTextarea?.value ?? '',
948
  user: userPromptTextarea?.value ?? '',
949
  useSystem: isGenAttrUseSystemPrompt(),
 
950
  ...tfDraftFields,
951
  };
952
  }
@@ -1093,25 +1025,13 @@ function scheduleDagLastTokenDwell(action: () => void, dwellMs: number = DAG_LAS
1093
  * `fullStepCount` 即生成 token 步数;prompt 帧 → step0 占一段,step0 → step1 占一段,依此类推。
1094
  */
1095
  function resolveDagPlaybackStepDelayMsOnPlay(fullStepCount: number): number {
1096
- if (currentDagReplayPacingMode() === 'step') {
1097
- const raw = parseInt(dagPlaybackStepMsInput?.value ?? '', 10);
1098
- const ms = Number.isFinite(raw)
1099
- ? clampDagPlaybackStepMs(raw)
1100
- : readStoredDagPlaybackStepMs();
1101
- if (dagPlaybackStepMsInput) dagPlaybackStepMsInput.value = String(ms);
1102
- return ms;
1103
- }
1104
-
1105
- const rawS = parseInt(dagPlaybackTotalSInput?.value ?? '', 10);
1106
- const totalS = Number.isFinite(rawS)
1107
- ? clampDagPlaybackTotalS(rawS)
1108
- : readStoredDagPlaybackTotalS();
1109
- if (dagPlaybackTotalSInput) dagPlaybackTotalSInput.value = String(totalS);
1110
 
1111
  // prompt 帧作为等权第一段,共 fullStepCount 段(比原来的 fullStepCount-1 多一段)
1112
  const transitionCount = Math.max(0, fullStepCount);
1113
  if (transitionCount <= 0) return 0;
1114
- return Math.round((totalS * 1000) / transitionCount);
1115
  }
1116
 
1117
  function stopDagPlayback(): void {
@@ -1232,6 +1152,7 @@ const dagHandle = initGenAttributeDagView(d3.select('#results'), {
1232
  recursiveAttributionEnabled: initialDagRecursiveAttribution,
1233
  recursiveEdgeBatchAnimationEnabled: initialDagRecursiveEdgeAnimationEnabled,
1234
  recursiveEdgeBatchAnimationDirection: initialDagRecursiveEdgeAnimationDirection,
 
1235
  edgeTopPCoverage: initialDagEdgeTopPCoverage,
1236
  onFullscreenError: (message) => showToast(message, 'error'),
1237
  getEffectiveExcludePromptPatternsText: genAttrEffectiveExcludePromptPatternsText,
@@ -1240,11 +1161,7 @@ const dagHandle = initGenAttributeDagView(d3.select('#results'), {
1240
 
1241
  dagLayoutModeSelect?.addEventListener('change', () => {
1242
  const mode = currentDagLayoutMode();
1243
- try {
1244
- localStorage.setItem(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY, mode);
1245
- } catch {
1246
- /* ignore */
1247
- }
1248
  applyDagLayoutModeUi();
1249
  dagHandle.setLayoutMode(mode);
1250
  });
@@ -1259,19 +1176,23 @@ function isDagBusy(): boolean {
1259
  }
1260
 
1261
  /**
1262
- * 非忙状态下 reset + replay + fit,供各设置项切换后复用。忙时为 no-op。
1263
  * 默认保留 DAG 选中节点;整页重置 UI 等场景传 `preserveNodeSelection: false`。
 
1264
  */
1265
- function tryResetAndReplayDag(opts?: { preserveNodeSelection?: boolean }): void {
1266
  if (isDagBusy()) return;
 
1267
  const preserveSelection = opts?.preserveNodeSelection !== false;
1268
  const preservedSelectedId = preserveSelection ? dagHandle.getSelectedNodeId() : null;
1269
  const h = runnerHandle;
1270
- dagHandle.reset();
1271
  if (h && h.tokenCount > 0) {
1272
  replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
1273
  }
1274
- dagHandle.fitViewportToContent();
 
 
1275
  if (preservedSelectedId != null) {
1276
  dagHandle.setSelectedNodeId(preservedSelectedId);
1277
  } else {
@@ -1285,11 +1206,7 @@ dagMeasureWidthInput?.addEventListener('change', () => {
1285
  ? clampDagMeasureWidth(raw)
1286
  : GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT;
1287
  dagMeasureWidthInput.value = String(w);
1288
- try {
1289
- localStorage.setItem(GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY, String(w));
1290
- } catch {
1291
- /* ignore */
1292
- }
1293
  dagHandle.setMeasureWidthPx(w);
1294
  tryResetAndReplayDag();
1295
  });
@@ -1298,11 +1215,7 @@ dagCompactnessInput?.addEventListener('change', () => {
1298
  const raw = parseFloat(dagCompactnessInput.value);
1299
  const c = Number.isFinite(raw) ? clampDagCompactness(raw) : DAG_COMPACTNESS_DEFAULT;
1300
  dagCompactnessInput.value = String(c);
1301
- try {
1302
- localStorage.setItem(GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY, String(c));
1303
- } catch {
1304
- /* ignore */
1305
- }
1306
  dagHandle.setDagCompactness(c);
1307
  tryResetAndReplayDag();
1308
  });
@@ -1313,13 +1226,9 @@ dagEdgeTopPCoverageInput?.addEventListener('change', () => {
1313
  ? clampDagEdgeTopPCoverage(raw)
1314
  : DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
1315
  dagEdgeTopPCoverageInput.value = String(c);
1316
- try {
1317
- localStorage.setItem(GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY, String(c));
1318
- } catch {
1319
- /* ignore */
1320
- }
1321
  dagHandle.setEdgeTopPCoverage(c);
1322
- tryResetAndReplayDag();
1323
  });
1324
 
1325
  dagLinearArcIntervalInput?.addEventListener('change', () => {
@@ -1328,11 +1237,7 @@ dagLinearArcIntervalInput?.addEventListener('change', () => {
1328
  ? clampLinearArcAdjacentGap(raw)
1329
  : LINEAR_ARC_ADJACENT_GAP_DEFAULT;
1330
  dagLinearArcIntervalInput.value = String(n);
1331
- try {
1332
- localStorage.setItem(GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY, String(n));
1333
- } catch {
1334
- /* ignore */
1335
- }
1336
  dagHandle.setLinearArcAdjacentGapPx(n, { skipRefit: isDagBusy() });
1337
  });
1338
 
@@ -1354,14 +1259,11 @@ function readGenAttrDemoUiOptionsFromControls(): GenAttrDemoUiOptions {
1354
  const edgeTopPCoverage = Number.isFinite(rawTop)
1355
  ? clampDagEdgeTopPCoverage(rawTop)
1356
  : DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
1357
- const rawTotal = parseInt(dagPlaybackTotalSInput?.value ?? '', 10);
1358
- const playbackTotalS = Number.isFinite(rawTotal)
1359
- ? clampDagPlaybackTotalS(rawTotal)
1360
- : GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT;
1361
- const rawStep = parseInt(dagPlaybackStepMsInput?.value ?? '', 10);
1362
- const playbackStepMs = Number.isFinite(rawStep)
1363
- ? clampDagPlaybackStepMs(rawStep)
1364
- : GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT;
1365
  return {
1366
  layoutMode: currentDagLayoutMode(),
1367
  measureWidthPx,
@@ -1378,7 +1280,7 @@ function readGenAttrDemoUiOptionsFromControls(): GenAttrDemoUiOptions {
1378
  recursiveEdgeBatchAnimationEnabled: dagRecursiveEdgeAnimationInput?.checked ?? true,
1379
  recursiveEdgeBatchAnimationDirection: currentDagRecursiveEdgeAnimationDirection(),
1380
  showTokenInfoOnSelected: dagShowTopkOnSelectedInput?.checked ?? false,
1381
- replayPacingMode: currentDagReplayPacingMode(),
1382
  playbackTotalS,
1383
  playbackStepMs,
1384
  excludePromptPatternsEnabled: genAttrExcludePromptPatternsEnable?.checked ?? true,
@@ -1600,12 +1502,8 @@ const GEN_ATTR_DEMO_UI_LOCAL_STORAGE_KEYS: readonly string[] = [
1600
  ];
1601
 
1602
  function removeGenAttrDemoUiOptionsFromLocalStorage(): void {
1603
- try {
1604
- for (const k of GEN_ATTR_DEMO_UI_LOCAL_STORAGE_KEYS) {
1605
- localStorage.removeItem(k);
1606
- }
1607
- } catch {
1608
- /* ignore */
1609
  }
1610
  }
1611
 
@@ -1646,17 +1544,13 @@ function bindExcludePatternControls(
1646
  enabledKey: string,
1647
  ): void {
1648
  enableEl?.addEventListener('change', () => {
1649
- try {
1650
- if (textEl) localStorage.setItem(textKey, textEl.value);
1651
- localStorage.setItem(enabledKey, enableEl.checked ? '1' : '0');
1652
- } catch { /* ignore */ }
1653
  syncGenAttrExcludePatternTextareasDisabled();
1654
  onExcludePatternsEffectiveChange();
1655
  });
1656
  textEl?.addEventListener('blur', () => {
1657
- try {
1658
- localStorage.setItem(textKey, textEl.value);
1659
- } catch { /* ignore */ }
1660
  onExcludePatternsEffectiveChange();
1661
  });
1662
  }
@@ -1675,6 +1569,7 @@ bindExcludePatternControls(
1675
  );
1676
 
1677
  function currentModelVariant(): PredictionAttributeModelVariant {
 
1678
  const v = modelVariantSelect?.value;
1679
  return v === 'base' || v === 'instruct' ? v : 'instruct';
1680
  }
@@ -1727,6 +1622,7 @@ function getInputSnapshotForRun(): string {
1727
  useSys: isGenAttrUseSystemPrompt(),
1728
  sys: (systemTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
1729
  user: (userTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
 
1730
  ...runOpts,
1731
  });
1732
  }
@@ -1735,9 +1631,6 @@ function setGenLoading(loading: boolean): void {
1735
  inFlight = loading;
1736
  loaderSmall.style('display', loading ? null : 'none');
1737
  genAttrResultsEl.classed('gen-attr-in-flight', loading);
1738
- if (!loading) {
1739
- analyzeProgressEl.text('').style('display', 'none');
1740
- }
1741
  syncSubmitButtonState();
1742
  }
1743
 
@@ -1790,15 +1683,24 @@ function bindInputsForSync(): void {
1790
  }
1791
 
1792
  if (skipChatTemplateInput) {
1793
- skipChatTemplateInput.checked = readSkipChatTemplateFromStorage();
1794
  skipChatTemplateInput.addEventListener('change', () => {
1795
- writeSkipChatTemplateToStorage(skipChatTemplateInput.checked);
1796
  syncPromptPanelVisibility();
1797
  syncGenAttrSystemPromptSuppressedUi();
 
 
 
 
 
 
 
 
1798
  syncSubmitButtonState();
1799
  });
1800
  }
1801
  syncPromptPanelVisibility();
 
1802
  syncGenAttrSystemPromptSuppressedUi();
1803
  genAttrUseSystemPromptInput?.addEventListener('change', () => {
1804
  syncGenAttrSystemPromptSuppressedUi();
@@ -1914,28 +1816,39 @@ async function applyGenAttrCachedRun(
1914
  }
1915
  if (skipChatTemplateInput) {
1916
  skipChatTemplateInput.checked = false;
1917
- writeSkipChatTemplateToStorage(false);
1918
  syncPromptPanelVisibility();
1919
  syncGenAttrSystemPromptSuppressedUi();
 
1920
  }
1921
  systemTextField.property('value', draft.system ?? '');
1922
  systemPromptTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1923
  userTextField.property('value', draft.user ?? '');
1924
  userPromptTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
 
 
 
 
 
 
 
1925
  } else {
1926
  if (skipChatTemplateInput) {
1927
  skipChatTemplateInput.checked = true;
1928
- writeSkipChatTemplateToStorage(true);
1929
  syncPromptPanelVisibility();
 
1930
  }
1931
  rawTextField.property('value', rec.initialContext);
1932
  rawTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1933
  }
1934
 
1935
  // 恢复 model / maxTokens(必须在 getInputSnapshotForRun() 之前,使快照与实际一致)
1936
- if (draft?.model && modelVariantSelect) {
1937
  modelVariantSelect.value = draft.model;
 
1938
  }
 
1939
  if (draft?.maxTokens != null && maxTokensInput) {
1940
  maxTokensInput.value = String(draft.maxTokens);
1941
  }
@@ -2107,11 +2020,7 @@ initQueryHistoryDropdown({
2107
  refreshGenAttrBundledDemoEntriesList();
2108
  syncGenAttrCachedDemosValueDisplay();
2109
 
2110
- // --- 进度与指标 ---
2111
- function showProgress(current: number, total: number): void {
2112
- analyzeProgressEl.text(`${current} / ${total}`).style('display', null);
2113
- }
2114
-
2115
  /** 首步 `token_attribution.length` ≈ 初始 prompt 子词数(与 Chat 展示同形,无需后端 usage) */
2116
  function initialPromptTokensFromFirstStep(step: TokenGenStep): number | undefined {
2117
  const n = step.response.token_attribution?.length;
@@ -2213,13 +2122,16 @@ async function resolveInitialContext(signal: AbortSignal): Promise<string> {
2213
  const user = (userTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
2214
  const useSystem = isGenAttrUseSystemPrompt();
2215
  const systemRaw = (systemTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
2216
- const promptReq: { model: string; prompt: string; system?: string } = {
2217
  model: currentModelVariant(),
2218
  prompt: user,
2219
  };
2220
  if (useSystem) {
2221
  promptReq.system = systemRaw;
2222
  }
 
 
 
2223
  const assembled = await postCompletionsPrompt(promptReq, { signal });
2224
  return assembled.prompt_used;
2225
  }
@@ -2277,7 +2189,6 @@ async function runGeneration(): Promise<void> {
2277
  const tokenizeModel = currentModelVariant();
2278
  const runDraft = buildGenAttrRunDraftForCache();
2279
  const prompt = getActivePromptValue();
2280
- analyzeProgressEl.text('Assembling prompt…').style('display', null);
2281
  initialContext = await resolveInitialContext(signal);
2282
  lastRunInitialContext = initialContext;
2283
  lastRunInputSnapshot = getInputSnapshotForRun();
@@ -2301,7 +2212,6 @@ async function runGeneration(): Promise<void> {
2301
  let initialPromptTokens: number | undefined;
2302
  currentRunPromptSpans = [];
2303
  setGenAttrUsageMetric(undefined, 0);
2304
- showProgress(0, maxTokens);
2305
 
2306
  dagHandle.reset();
2307
  void fetchTokenize(apiBaseForRequests, initialContext, tokenizeModel).then((spans) => {
@@ -2332,7 +2242,6 @@ async function runGeneration(): Promise<void> {
2332
  const excludeCtx = excludeIntervalContextFromSteps(h.getAllSteps());
2333
  pushDagFromPreprocess(step, stepIndex, true, excludeCtx);
2334
  dagPlaybackNextIndex = stepIndex + 1;
2335
- showProgress(stepIndex + 1, maxTokens);
2336
  setGenAttrUsageMetric(initialPromptTokens, stepIndex + 1);
2337
  showAttributionForStepIndex(stepIndex);
2338
  },
@@ -2402,7 +2311,7 @@ function refreshDagForThemeChange(): void {
2402
  stopDagPlayback();
2403
  const h = runnerHandle;
2404
  if (!h || h.tokenCount === 0) return;
2405
- tryResetAndReplayDag();
2406
  }
2407
 
2408
  const themeManager = initThemeManager(
 
33
  DAG_COMPACTNESS_DEFAULT,
34
  LINEAR_ARC_ADJACENT_GAP_DEFAULT,
35
  } from '../../shared/prediction_attribution/causal_flow/genAttributeDagView';
36
+ import type { DagRecursiveEdgeReplayPacing } from '../../shared/prediction_attribution/causal_flow/genAttributeDagRecursiveEdgeAnimation';
37
  import {
38
  createHydratedTokenGenHandle,
39
  startTokenGenAttribution,
 
86
  saveHistory,
87
  } from '../../shared/cross/queryHistory';
88
  import {
89
+ GEN_ATTR_ENABLE_THINKING_STORAGE_KEY,
90
+ LS_SKIP_CHAT_TEMPLATE,
91
  } from '../../features/chat/chatPromptTemplateMode';
92
  import { postCompletionsPrompt, postCompletionsStop } from '../../shared/api/completionsClient';
93
  import { updateApiUsageDisplay, updateModel, validateMetricsElements } from '../../shared/cross/textMetricsUpdater';
94
+ import {
95
+ lsGet,
96
+ lsReadBool,
97
+ lsReadEnum,
98
+ lsReadNumber,
99
+ lsRemove,
100
+ lsSet,
101
+ lsWriteBool,
102
+ lsWriteString,
103
+ } from '../../shared/storage/localStorageHelpers';
104
 
105
  d3.selectAll('.loadersmall').style('display', 'none');
106
 
 
173
  showDownstreamInfluence: false,
174
  recursiveAttributionEnabled: false,
175
  recursiveEdgeBatchAnimationEnabled: true,
176
+ recursiveEdgeBatchAnimationDirection: 'forward',
177
  showTokenInfoOnSelected: false,
178
  replayPacingMode: 'total',
179
  playbackTotalS: GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT,
 
194
  }
195
 
196
  function readStoredModelVariant(): PredictionAttributeModelVariant {
197
+ return lsReadEnum(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, ['base', 'instruct'] as const, 'instruct');
 
 
 
 
 
 
198
  }
199
 
200
  function readStoredMaxTokens(): number {
201
+ return lsReadNumber(GEN_ATTR_MAX_TOKENS_STORAGE_KEY, GEN_ATTR_MAX_TOKENS_DEFAULT, {
202
+ validate: (n) => n >= 1 && n <= 500,
203
+ });
 
 
 
 
 
204
  }
205
 
206
  function clampDagMeasureWidth(n: number): number {
 
211
  }
212
 
213
  function readStoredDagMeasureWidth(): number {
214
+ return lsReadNumber(GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY, GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT, {
215
+ clamp: clampDagMeasureWidth,
216
+ });
 
 
 
 
 
217
  }
218
 
219
  function readStoredDagCompactness(): number {
220
+ return lsReadNumber(GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY, DAG_COMPACTNESS_DEFAULT, {
221
+ parse: 'float',
222
+ clamp: clampDagCompactness,
223
+ });
 
 
 
 
224
  }
225
 
226
  function readStoredDagEdgeTopPCoverage(): number {
227
+ return lsReadNumber(
228
+ GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY,
229
+ DAG_EDGE_TOP_P_COVERAGE_DEFAULT,
230
+ { parse: 'float', clamp: clampDagEdgeTopPCoverage },
231
+ );
 
 
 
232
  }
233
 
234
  function readStoredDagLinearArcAdjacentGap(): number {
235
+ return lsReadNumber(
236
+ GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY,
237
+ LINEAR_ARC_ADJACENT_GAP_DEFAULT,
238
+ { clamp: clampLinearArcAdjacentGap },
239
+ );
 
 
 
240
  }
241
 
242
  function clampDagPlaybackStepMs(n: number): number {
 
247
  }
248
 
249
  function readStoredDagPlaybackStepMs(): number {
250
+ return lsReadNumber(
251
+ GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY,
252
+ GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT,
253
+ { clamp: clampDagPlaybackStepMs },
254
+ );
 
 
 
255
  }
256
 
257
  function clampDagPlaybackTotalS(n: number): number {
 
262
  }
263
 
264
  function readStoredDagPlaybackTotalS(): number {
265
+ return lsReadNumber(
266
+ GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY,
267
+ GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT,
268
+ { clamp: clampDagPlaybackTotalS },
269
+ );
 
 
 
270
  }
271
 
272
  function readStoredDagReplayPacingMode(): DagReplayPacingMode {
273
+ return lsReadEnum(
274
+ GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY,
275
+ ['total', 'step'] as const,
276
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.replayPacingMode,
277
+ );
 
 
278
  }
279
 
280
  function readStoredDagLayoutMode(): DagLayoutMode {
281
+ return lsReadEnum(
282
+ GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY,
283
+ ['text-flow', 'linear-arc', 'linear-arc-step-down', 'spiral'] as const,
284
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.layoutMode,
285
+ );
 
 
 
 
 
 
 
 
 
286
  }
287
 
288
  const apiPrefix = URLHandler.parameters['api'] || '';
 
334
  const genAttrStopAfterTeacherForcing = document.getElementById(
335
  'gen_attr_stop_after_teacher_forcing'
336
  ) as HTMLInputElement | null;
337
+ const genAttrEnableThinkingInput = document.getElementById(
338
+ 'gen_attr_enable_thinking'
339
+ ) as HTMLInputElement | null;
340
 
341
  const submitBtn = d3.select('#gen_attr_submit_btn');
342
  const loaderSmall = d3.select('.loadersmall');
 
343
  const metricUsage = d3.select('#gen_attr_metric_usage');
344
  const metricModel = d3.select('#gen_attr_metric_model');
345
  const genAttrResultsEl = d3.select('#results.gen-attr-results-surface');
 
380
  return dagReplayModeSelect?.value === 'step' ? 'step' : 'total';
381
  }
382
 
383
+ /** DAG replay speed 控件 → 规范化节奏;生成回放、传播链动画、demo 导出共用。 */
384
+ function readDagReplayPacingFromControls(options?: { writeBack?: boolean }): DagRecursiveEdgeReplayPacing {
385
+ const rawStep = parseInt(dagPlaybackStepMsInput?.value ?? '', 10);
386
+ const stepMs = Number.isFinite(rawStep)
387
+ ? clampDagPlaybackStepMs(rawStep)
388
+ : readStoredDagPlaybackStepMs();
389
+ const rawS = parseInt(dagPlaybackTotalSInput?.value ?? '', 10);
390
+ const totalS = Number.isFinite(rawS)
391
+ ? clampDagPlaybackTotalS(rawS)
392
+ : readStoredDagPlaybackTotalS();
393
+ if (options?.writeBack) {
394
+ if (dagPlaybackStepMsInput) dagPlaybackStepMsInput.value = String(stepMs);
395
+ if (dagPlaybackTotalSInput) dagPlaybackTotalSInput.value = String(totalS);
396
+ }
397
+ return { mode: currentDagReplayPacingMode(), stepMs, totalS };
398
+ }
399
+
400
  /** 切换下拉时更新 `hidden`;样式见 `.gen-attr-dag-replay-value-wrap:not([hidden])`。 */
401
  function applyDagReplaySpeedUi(): void {
402
  const mode = currentDagReplayPacingMode();
 
491
  }
492
 
493
  function hydrateGenAttrExcludePatternsFromGenAttrStorage(): void {
494
+ const savedPrompt = lsGet(GEN_ATTR_EXCLUDE_PROMPT_PATTERNS_STORAGE_KEY);
495
+ if (genAttrExcludePromptPatternsTa) {
496
+ genAttrExcludePromptPatternsTa.value =
497
+ savedPrompt !== null ? savedPrompt : DEFAULT_EXCLUDE_PROMPT_PATTERNS_TEXT;
498
+ }
499
+ if (genAttrExcludePromptPatternsEnable) {
500
+ genAttrExcludePromptPatternsEnable.checked = lsReadBool(
501
+ GEN_ATTR_EXCLUDE_PROMPT_PATTERNS_ENABLED_STORAGE_KEY,
502
+ true,
503
+ { encoding: '1' },
504
+ );
505
+ }
506
 
507
+ const savedGen = lsGet(GEN_ATTR_EXCLUDE_GENERATED_PATTERNS_STORAGE_KEY);
508
+ if (genAttrExcludeGeneratedPatternsTa) {
509
+ genAttrExcludeGeneratedPatternsTa.value =
510
+ savedGen !== null ? savedGen : DEFAULT_EXCLUDE_GENERATED_PATTERNS_TEXT;
511
+ }
512
+ if (genAttrExcludeGeneratedPatternsEnable) {
513
+ genAttrExcludeGeneratedPatternsEnable.checked = lsReadBool(
514
+ GEN_ATTR_EXCLUDE_GENERATED_PATTERNS_ENABLED_STORAGE_KEY,
515
+ true,
516
+ { encoding: '1' },
517
+ );
518
  }
519
  syncGenAttrExcludePatternTextareasDisabled();
520
  }
 
532
  return genAttrExcludeGeneratedPatternsTa?.value ?? '';
533
  }
534
 
 
535
  if (maxTokensInput) maxTokensInput.value = String(readStoredMaxTokens());
536
  const initialDagLayoutMode = readStoredDagLayoutMode();
537
  if (dagLayoutModeSelect) dagLayoutModeSelect.value = initialDagLayoutMode;
 
556
 
557
  const genAttrResultsNode = genAttrResultsEl.node() as HTMLElement | null;
558
  function readStoredDagNodeCiVisualScale(): boolean {
559
+ return lsReadBool(
560
+ GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY,
561
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.nodeCiVisualScaleEnabled,
562
+ { encoding: '1' },
563
+ );
 
 
564
  }
565
  const initialDagNodeCiVisualScale = readStoredDagNodeCiVisualScale();
566
  if (dagNodeCiVisualScaleInput) dagNodeCiVisualScaleInput.checked = initialDagNodeCiVisualScale;
567
  setDagNodeCiVisualScaleEnabled(initialDagNodeCiVisualScale);
568
  dagNodeCiVisualScaleInput?.addEventListener('change', () => {
569
  const enabled = dagNodeCiVisualScaleInput.checked;
570
+ lsWriteBool(GEN_ATTR_DAG_NODE_CI_VISUAL_SCALE_STORAGE_KEY, enabled, '1');
 
 
 
 
571
  setDagNodeCiVisualScaleEnabled(enabled);
572
  tryResetAndReplayDag();
573
  });
574
 
575
  function readStoredDagDecayAttributionToHighSurprisalTarget(): boolean {
576
+ const v = lsGet(GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY);
577
+ if (v !== null) return v === '1';
578
+ const legacy = lsGet(GEN_ATTR_DAG_EDGE_WEAKEN_HIGH_SURPRISAL_STORAGE_KEY_LEGACY);
579
+ if (legacy !== null) return legacy === '1';
 
 
 
 
580
  return DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.decayAttributionToHighSurprisalTargetEnabled;
581
  }
582
  const initialDagDecayAttributionHighSurprisal = readStoredDagDecayAttributionToHighSurprisalTarget();
 
586
  setDagDecayAttributionToHighSurprisalTargetEnabled(initialDagDecayAttributionHighSurprisal);
587
  dagDecayAttributionHighSurprisalInput?.addEventListener('change', () => {
588
  const enabled = dagDecayAttributionHighSurprisalInput.checked;
589
+ lsWriteBool(GEN_ATTR_DAG_DECAY_ATTRIBUTION_HIGH_SURPRISAL_STORAGE_KEY, enabled, '1');
 
 
 
 
590
  setDagDecayAttributionToHighSurprisalTargetEnabled(enabled);
591
+ tryResetAndReplayDag({ refit: false });
592
  });
593
 
594
  function applyDagHideInactiveEdges(hide: boolean): void {
 
596
  genAttrResultsNode.classList.toggle('gen-attr-dag-hide-inactive-edges', hide);
597
  }
598
  function readStoredDagHideInactiveEdges(): boolean {
599
+ return lsReadBool(
600
+ GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY,
601
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.hideInactiveEdges,
602
+ { encoding: '1' },
603
+ );
 
 
604
  }
605
  const initialDagHideInactiveEdges = readStoredDagHideInactiveEdges();
606
  if (dagHideInactiveEdgesInput) dagHideInactiveEdgesInput.checked = initialDagHideInactiveEdges;
607
  applyDagHideInactiveEdges(initialDagHideInactiveEdges);
608
  dagHideInactiveEdgesInput?.addEventListener('change', () => {
609
  const hide = dagHideInactiveEdgesInput.checked;
610
+ lsWriteBool(GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY, hide, '1');
 
 
 
 
611
  applyDagHideInactiveEdges(hide);
612
  });
613
 
614
  function readStoredDagShowDownstreamInfluence(): boolean {
615
+ return lsReadBool(
616
+ GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY,
617
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.showDownstreamInfluence,
618
+ { encoding: '1' },
619
+ );
 
 
620
  }
621
  const initialDagShowDownstreamInfluence = readStoredDagShowDownstreamInfluence();
622
  if (dagShowDownstreamInfluenceInput) {
 
624
  }
625
  dagShowDownstreamInfluenceInput?.addEventListener('change', () => {
626
  const show = dagShowDownstreamInfluenceInput.checked;
627
+ lsWriteBool(GEN_ATTR_DAG_SHOW_DOWNSTREAM_INFLUENCE_STORAGE_KEY, show, '1');
 
 
 
 
628
  dagHandle.setShowDownstreamInfluence(show);
629
  });
630
 
 
644
  }
645
 
646
  function readStoredDagRecursiveAttribution(): boolean {
647
+ return lsReadBool(
648
+ GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY,
649
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.recursiveAttributionEnabled,
650
+ { encoding: '1' },
651
+ );
 
 
652
  }
653
  const initialDagRecursiveAttribution = readStoredDagRecursiveAttribution();
654
  if (dagRecursiveAttributionInput) dagRecursiveAttributionInput.checked = initialDagRecursiveAttribution;
655
 
656
  function readStoredDagRecursiveEdgeAnimationEnabled(): boolean {
657
+ return lsReadBool(
658
+ GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_STORAGE_KEY,
659
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.recursiveEdgeBatchAnimationEnabled,
660
+ { encoding: '1' },
661
+ );
 
 
662
  }
663
 
664
  function readStoredDagRecursiveEdgeAnimationDirection(): DagRecursiveEdgeAnimationDirection {
665
+ return lsReadEnum(
666
+ GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_DIRECTION_STORAGE_KEY,
667
+ ['backward', 'forward'] as const,
668
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.recursiveEdgeBatchAnimationDirection,
669
+ );
 
 
670
  }
671
 
672
  const initialDagRecursiveEdgeAnimationEnabled = readStoredDagRecursiveEdgeAnimationEnabled();
 
679
  applyDagRecursiveAttributionSubmodeUi();
680
  dagRecursiveAttributionInput?.addEventListener('change', () => {
681
  const enabled = dagRecursiveAttributionInput.checked;
682
+ lsWriteBool(GEN_ATTR_DAG_RECURSIVE_ATTRIBUTION_STORAGE_KEY, enabled, '1');
 
 
 
 
683
  applyDagRecursiveAttributionSubmodeUi();
684
  dagHandle.setRecursiveAttributionEnabled(enabled);
685
  });
686
  dagRecursiveEdgeAnimationInput?.addEventListener('change', () => {
687
  const enabled = dagRecursiveEdgeAnimationInput.checked;
688
+ lsWriteBool(GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_STORAGE_KEY, enabled, '1');
 
 
 
 
689
  applyDagRecursiveAttributionSubmodeUi();
690
  dagHandle.setRecursiveEdgeBatchAnimationEnabled(enabled);
691
  });
692
  dagRecursiveEdgeAnimationDirectionSelect?.addEventListener('change', () => {
693
  const direction = currentDagRecursiveEdgeAnimationDirection();
694
  dagRecursiveEdgeAnimationDirectionSelect.value = direction;
695
+ lsWriteString(GEN_ATTR_DAG_RECURSIVE_EDGE_ANIMATION_DIRECTION_STORAGE_KEY, direction);
 
 
 
 
696
  dagHandle.setRecursiveEdgeBatchAnimationDirection(direction);
697
  });
698
 
699
  function readStoredDagHideExcludedTokens(): boolean {
700
+ return lsReadBool(
701
+ GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY,
702
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.hideExcludedTokens,
703
+ { encoding: '1' },
704
+ );
 
 
705
  }
706
  const initialDagHideExcludedTokens = readStoredDagHideExcludedTokens();
707
  if (dagHideExcludedTokensInput) dagHideExcludedTokensInput.checked = initialDagHideExcludedTokens;
708
  function readStoredDagShowTopkOnSelected(): boolean {
709
+ return lsReadBool(
710
+ GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY,
711
+ DEFAULT_GEN_ATTR_DEMO_UI_OPTIONS.showTokenInfoOnSelected,
712
+ { encoding: '1' },
713
+ );
 
 
714
  }
715
  const initialDagShowTopkOnSelected = readStoredDagShowTopkOnSelected();
716
  if (dagShowTopkOnSelectedInput) dagShowTopkOnSelectedInput.checked = initialDagShowTopkOnSelected;
717
  dagHideExcludedTokensInput?.addEventListener('change', () => {
718
  const hide = dagHideExcludedTokensInput.checked;
719
+ lsWriteBool(GEN_ATTR_DAG_HIDE_EXCLUDED_TOKENS_STORAGE_KEY, hide, '1');
 
 
 
 
720
  dagHandle.setHideExcludedTokens(hide);
721
  });
722
 
723
  dagShowTopkOnSelectedInput?.addEventListener('change', () => {
724
  const show = dagShowTopkOnSelectedInput.checked;
725
+ lsWriteBool(GEN_ATTR_DAG_SHOW_TOPK_ON_SELECTED_STORAGE_KEY, show, '1');
 
 
 
 
726
  dagHandle.setShowTokenInfoOnSelected(show);
727
  });
728
 
 
734
  layout_spiral: mode === 'spiral',
735
  propagated: dagRecursiveAttributionInput?.checked ?? false,
736
  propagated_anim: dagRecursiveEdgeAnimationInput?.checked ?? true,
737
+ propagated_anim_backward: currentDagRecursiveEdgeAnimationDirection() === 'backward',
738
  downstream: dagShowDownstreamInfluenceInput?.checked ?? false,
739
  token_tooltip: dagShowTopkOnSelectedInput?.checked ?? false,
740
  };
741
  });
742
 
743
  modelVariantSelect?.addEventListener('change', () => {
744
+ if (!isSkipChatTemplate()) return;
745
+ lsWriteString(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, currentModelVariant());
 
 
 
746
  syncIdleModelMetric();
747
  syncSubmitButtonState();
748
  });
749
 
750
  maxTokensInput?.addEventListener('change', () => {
751
+ lsSet(
752
+ GEN_ATTR_MAX_TOKENS_STORAGE_KEY,
753
+ maxTokensInput?.value ?? String(GEN_ATTR_MAX_TOKENS_DEFAULT),
754
+ );
 
 
 
 
755
  syncSubmitButtonState();
756
  });
757
 
 
762
  ? clampDagPlaybackStepMs(raw)
763
  : GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT;
764
  dagPlaybackStepMsInput.value = String(ms);
765
+ lsSet(GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY, String(ms));
 
 
 
 
766
  });
767
 
768
  dagReplayModeSelect?.addEventListener('change', () => {
769
  const mode = currentDagReplayPacingMode();
770
+ lsWriteString(GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY, mode);
 
 
 
 
771
  applyDagReplaySpeedUi();
772
  });
773
 
 
777
  ? clampDagPlaybackTotalS(raw)
778
  : GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT;
779
  dagPlaybackTotalSInput.value = String(s);
780
+ lsSet(GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY, String(s));
 
 
 
 
781
  });
782
 
783
  function isSkipChatTemplate(): boolean {
 
788
  return genAttrUseSystemPromptInput?.checked ?? true;
789
  }
790
 
791
+ function isEnableThinking(): boolean {
792
+ return genAttrEnableThinkingInput?.checked ?? false;
793
+ }
794
+
795
  function syncGenAttrSystemPromptSuppressedUi(): void {
796
  const on = isGenAttrUseSystemPrompt();
797
  genAttrSystemPromptPanel?.classList.toggle('chat-system-prompt-suppressed', !on);
 
813
  if (chatInputPanel) chatInputPanel.hidden = skip;
814
  }
815
 
816
+ /** Chat template 下 model 恒为 instruct 且下拉仅展示;Raw 下读写 localStorage 偏好。 */
817
+ function syncModelVariantUi(): void {
818
+ if (!modelVariantSelect) return;
819
+ const skip = isSkipChatTemplate();
820
+ if (skip) {
821
+ modelVariantSelect.disabled = false;
822
+ modelVariantSelect.value = readStoredModelVariant();
823
+ } else {
824
+ modelVariantSelect.disabled = true;
825
+ modelVariantSelect.value = 'instruct';
826
+ }
827
+ syncIdleModelMetric();
828
+ }
829
+
830
  function getActivePromptValue(): string {
831
  if (isSkipChatTemplate()) {
832
  return (rawTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
 
878
  system: systemPromptTextarea?.value ?? '',
879
  user: userPromptTextarea?.value ?? '',
880
  useSystem: isGenAttrUseSystemPrompt(),
881
+ enableThinking: isEnableThinking(),
882
  ...tfDraftFields,
883
  };
884
  }
 
1025
  * `fullStepCount` 即生成 token 步数;prompt 帧 → step0 占一段,step0 → step1 占一段,依此类推。
1026
  */
1027
  function resolveDagPlaybackStepDelayMsOnPlay(fullStepCount: number): number {
1028
+ const pacing = readDagReplayPacingFromControls({ writeBack: true });
1029
+ if (pacing.mode === 'step') return pacing.stepMs;
 
 
 
 
 
 
 
 
 
 
 
 
1030
 
1031
  // prompt 帧作为等权第一段,共 fullStepCount 段(比原来的 fullStepCount-1 多一段)
1032
  const transitionCount = Math.max(0, fullStepCount);
1033
  if (transitionCount <= 0) return 0;
1034
+ return Math.round((pacing.totalS * 1000) / transitionCount);
1035
  }
1036
 
1037
  function stopDagPlayback(): void {
 
1152
  recursiveAttributionEnabled: initialDagRecursiveAttribution,
1153
  recursiveEdgeBatchAnimationEnabled: initialDagRecursiveEdgeAnimationEnabled,
1154
  recursiveEdgeBatchAnimationDirection: initialDagRecursiveEdgeAnimationDirection,
1155
+ getReplayPacing: readDagReplayPacingFromControls,
1156
  edgeTopPCoverage: initialDagEdgeTopPCoverage,
1157
  onFullscreenError: (message) => showToast(message, 'error'),
1158
  getEffectiveExcludePromptPatternsText: genAttrEffectiveExcludePromptPatternsText,
 
1161
 
1162
  dagLayoutModeSelect?.addEventListener('change', () => {
1163
  const mode = currentDagLayoutMode();
1164
+ lsWriteString(GEN_ATTR_DAG_LAYOUT_MODE_STORAGE_KEY, mode);
 
 
 
 
1165
  applyDagLayoutModeUi();
1166
  dagHandle.setLayoutMode(mode);
1167
  });
 
1176
  }
1177
 
1178
  /**
1179
+ * 非忙状态下 reset + replay,按需 fit,供各设置项切换后复用。忙时为 no-op。
1180
  * 默认保留 DAG 选中节点;整页重置 UI 等场景传 `preserveNodeSelection: false`。
1181
+ * `refit: false` 时 `reset(true)` 保留 pan/zoom(仅边集/样式类变更)。
1182
  */
1183
+ function tryResetAndReplayDag(opts?: { preserveNodeSelection?: boolean; refit?: boolean }): void {
1184
  if (isDagBusy()) return;
1185
+ const refit = opts?.refit !== false;
1186
  const preserveSelection = opts?.preserveNodeSelection !== false;
1187
  const preservedSelectedId = preserveSelection ? dagHandle.getSelectedNodeId() : null;
1188
  const h = runnerHandle;
1189
+ dagHandle.reset(!refit);
1190
  if (h && h.tokenCount > 0) {
1191
  replayRunnerStepsIntoDag(h, currentRunPromptSpans.length > 0 ? currentRunPromptSpans : undefined);
1192
  }
1193
+ if (refit) {
1194
+ dagHandle.fitViewportToContent();
1195
+ }
1196
  if (preservedSelectedId != null) {
1197
  dagHandle.setSelectedNodeId(preservedSelectedId);
1198
  } else {
 
1206
  ? clampDagMeasureWidth(raw)
1207
  : GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT;
1208
  dagMeasureWidthInput.value = String(w);
1209
+ lsSet(GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY, String(w));
 
 
 
 
1210
  dagHandle.setMeasureWidthPx(w);
1211
  tryResetAndReplayDag();
1212
  });
 
1215
  const raw = parseFloat(dagCompactnessInput.value);
1216
  const c = Number.isFinite(raw) ? clampDagCompactness(raw) : DAG_COMPACTNESS_DEFAULT;
1217
  dagCompactnessInput.value = String(c);
1218
+ lsSet(GEN_ATTR_DAG_COMPACTNESS_STORAGE_KEY, String(c));
 
 
 
 
1219
  dagHandle.setDagCompactness(c);
1220
  tryResetAndReplayDag();
1221
  });
 
1226
  ? clampDagEdgeTopPCoverage(raw)
1227
  : DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
1228
  dagEdgeTopPCoverageInput.value = String(c);
1229
+ lsSet(GEN_ATTR_DAG_EDGE_TOP_P_COVERAGE_STORAGE_KEY, String(c));
 
 
 
 
1230
  dagHandle.setEdgeTopPCoverage(c);
1231
+ tryResetAndReplayDag({ refit: false });
1232
  });
1233
 
1234
  dagLinearArcIntervalInput?.addEventListener('change', () => {
 
1237
  ? clampLinearArcAdjacentGap(raw)
1238
  : LINEAR_ARC_ADJACENT_GAP_DEFAULT;
1239
  dagLinearArcIntervalInput.value = String(n);
1240
+ lsSet(GEN_ATTR_DAG_LINEAR_ARC_GAP_STORAGE_KEY, String(n));
 
 
 
 
1241
  dagHandle.setLinearArcAdjacentGapPx(n, { skipRefit: isDagBusy() });
1242
  });
1243
 
 
1259
  const edgeTopPCoverage = Number.isFinite(rawTop)
1260
  ? clampDagEdgeTopPCoverage(rawTop)
1261
  : DAG_EDGE_TOP_P_COVERAGE_DEFAULT;
1262
+ const {
1263
+ mode: replayPacingMode,
1264
+ stepMs: playbackStepMs,
1265
+ totalS: playbackTotalS,
1266
+ } = readDagReplayPacingFromControls();
 
 
 
1267
  return {
1268
  layoutMode: currentDagLayoutMode(),
1269
  measureWidthPx,
 
1280
  recursiveEdgeBatchAnimationEnabled: dagRecursiveEdgeAnimationInput?.checked ?? true,
1281
  recursiveEdgeBatchAnimationDirection: currentDagRecursiveEdgeAnimationDirection(),
1282
  showTokenInfoOnSelected: dagShowTopkOnSelectedInput?.checked ?? false,
1283
+ replayPacingMode,
1284
  playbackTotalS,
1285
  playbackStepMs,
1286
  excludePromptPatternsEnabled: genAttrExcludePromptPatternsEnable?.checked ?? true,
 
1502
  ];
1503
 
1504
  function removeGenAttrDemoUiOptionsFromLocalStorage(): void {
1505
+ for (const k of GEN_ATTR_DEMO_UI_LOCAL_STORAGE_KEYS) {
1506
+ lsRemove(k);
 
 
 
 
1507
  }
1508
  }
1509
 
 
1544
  enabledKey: string,
1545
  ): void {
1546
  enableEl?.addEventListener('change', () => {
1547
+ if (textEl) lsSet(textKey, textEl.value);
1548
+ lsWriteBool(enabledKey, enableEl.checked, '1');
 
 
1549
  syncGenAttrExcludePatternTextareasDisabled();
1550
  onExcludePatternsEffectiveChange();
1551
  });
1552
  textEl?.addEventListener('blur', () => {
1553
+ lsSet(textKey, textEl.value);
 
 
1554
  onExcludePatternsEffectiveChange();
1555
  });
1556
  }
 
1569
  );
1570
 
1571
  function currentModelVariant(): PredictionAttributeModelVariant {
1572
+ if (!isSkipChatTemplate()) return 'instruct';
1573
  const v = modelVariantSelect?.value;
1574
  return v === 'base' || v === 'instruct' ? v : 'instruct';
1575
  }
 
1622
  useSys: isGenAttrUseSystemPrompt(),
1623
  sys: (systemTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
1624
  user: (userTextField.node() as HTMLTextAreaElement | null)?.value ?? '',
1625
+ think: isEnableThinking(),
1626
  ...runOpts,
1627
  });
1628
  }
 
1631
  inFlight = loading;
1632
  loaderSmall.style('display', loading ? null : 'none');
1633
  genAttrResultsEl.classed('gen-attr-in-flight', loading);
 
 
 
1634
  syncSubmitButtonState();
1635
  }
1636
 
 
1683
  }
1684
 
1685
  if (skipChatTemplateInput) {
1686
+ skipChatTemplateInput.checked = lsReadBool(LS_SKIP_CHAT_TEMPLATE, false);
1687
  skipChatTemplateInput.addEventListener('change', () => {
1688
+ lsWriteBool(LS_SKIP_CHAT_TEMPLATE, skipChatTemplateInput.checked);
1689
  syncPromptPanelVisibility();
1690
  syncGenAttrSystemPromptSuppressedUi();
1691
+ syncModelVariantUi();
1692
+ syncSubmitButtonState();
1693
+ });
1694
+ }
1695
+ if (genAttrEnableThinkingInput) {
1696
+ genAttrEnableThinkingInput.checked = lsReadBool(GEN_ATTR_ENABLE_THINKING_STORAGE_KEY, false);
1697
+ genAttrEnableThinkingInput.addEventListener('change', () => {
1698
+ lsWriteBool(GEN_ATTR_ENABLE_THINKING_STORAGE_KEY, genAttrEnableThinkingInput.checked);
1699
  syncSubmitButtonState();
1700
  });
1701
  }
1702
  syncPromptPanelVisibility();
1703
+ syncModelVariantUi();
1704
  syncGenAttrSystemPromptSuppressedUi();
1705
  genAttrUseSystemPromptInput?.addEventListener('change', () => {
1706
  syncGenAttrSystemPromptSuppressedUi();
 
1816
  }
1817
  if (skipChatTemplateInput) {
1818
  skipChatTemplateInput.checked = false;
1819
+ lsWriteBool(LS_SKIP_CHAT_TEMPLATE, false);
1820
  syncPromptPanelVisibility();
1821
  syncGenAttrSystemPromptSuppressedUi();
1822
+ syncModelVariantUi();
1823
  }
1824
  systemTextField.property('value', draft.system ?? '');
1825
  systemPromptTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1826
  userTextField.property('value', draft.user ?? '');
1827
  userPromptTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1828
+ if (genAttrEnableThinkingInput) {
1829
+ genAttrEnableThinkingInput.checked = draft.enableThinking ?? false;
1830
+ lsWriteBool(
1831
+ GEN_ATTR_ENABLE_THINKING_STORAGE_KEY,
1832
+ genAttrEnableThinkingInput.checked,
1833
+ );
1834
+ }
1835
  } else {
1836
  if (skipChatTemplateInput) {
1837
  skipChatTemplateInput.checked = true;
1838
+ lsWriteBool(LS_SKIP_CHAT_TEMPLATE, true);
1839
  syncPromptPanelVisibility();
1840
+ syncModelVariantUi();
1841
  }
1842
  rawTextField.property('value', rec.initialContext);
1843
  rawTextarea?.dispatchEvent(new Event('input', { bubbles: true }));
1844
  }
1845
 
1846
  // 恢复 model / maxTokens(必须在 getInputSnapshotForRun() 之前,使快照与实际一致)
1847
+ if (draft?.mode === 'raw' && draft.model && modelVariantSelect) {
1848
  modelVariantSelect.value = draft.model;
1849
+ lsWriteString(GEN_ATTR_MODEL_VARIANT_STORAGE_KEY, draft.model);
1850
  }
1851
+ syncModelVariantUi();
1852
  if (draft?.maxTokens != null && maxTokensInput) {
1853
  maxTokensInput.value = String(draft.maxTokens);
1854
  }
 
2020
  refreshGenAttrBundledDemoEntriesList();
2021
  syncGenAttrCachedDemosValueDisplay();
2022
 
2023
+ // --- 指标 ---
 
 
 
 
2024
  /** 首步 `token_attribution.length` ≈ 初始 prompt 子词数(与 Chat 展示同形,无需后端 usage) */
2025
  function initialPromptTokensFromFirstStep(step: TokenGenStep): number | undefined {
2026
  const n = step.response.token_attribution?.length;
 
2122
  const user = (userTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
2123
  const useSystem = isGenAttrUseSystemPrompt();
2124
  const systemRaw = (systemTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
2125
+ const promptReq: { model: string; prompt: string; system?: string; enable_thinking?: boolean } = {
2126
  model: currentModelVariant(),
2127
  prompt: user,
2128
  };
2129
  if (useSystem) {
2130
  promptReq.system = systemRaw;
2131
  }
2132
+ if (isEnableThinking()) {
2133
+ promptReq.enable_thinking = true;
2134
+ }
2135
  const assembled = await postCompletionsPrompt(promptReq, { signal });
2136
  return assembled.prompt_used;
2137
  }
 
2189
  const tokenizeModel = currentModelVariant();
2190
  const runDraft = buildGenAttrRunDraftForCache();
2191
  const prompt = getActivePromptValue();
 
2192
  initialContext = await resolveInitialContext(signal);
2193
  lastRunInitialContext = initialContext;
2194
  lastRunInputSnapshot = getInputSnapshotForRun();
 
2212
  let initialPromptTokens: number | undefined;
2213
  currentRunPromptSpans = [];
2214
  setGenAttrUsageMetric(undefined, 0);
 
2215
 
2216
  dagHandle.reset();
2217
  void fetchTokenize(apiBaseForRequests, initialContext, tokenizeModel).then((spans) => {
 
2242
  const excludeCtx = excludeIntervalContextFromSteps(h.getAllSteps());
2243
  pushDagFromPreprocess(step, stepIndex, true, excludeCtx);
2244
  dagPlaybackNextIndex = stepIndex + 1;
 
2245
  setGenAttrUsageMetric(initialPromptTokens, stepIndex + 1);
2246
  showAttributionForStepIndex(stepIndex);
2247
  },
 
2311
  stopDagPlayback();
2312
  const h = runnerHandle;
2313
  if (!h || h.tokenCount === 0) return;
2314
+ tryResetAndReplayDag({ refit: false });
2315
  }
2316
 
2317
  const themeManager = initThemeManager(
client/src/pages/chat/index.ts CHANGED
@@ -53,9 +53,10 @@ 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 {
57
- readSkipChatTemplateFromStorage,
58
- writeSkipChatTemplateToStorage,
59
  } from '../../features/chat/chatPromptTemplateMode';
60
  import { createToast } from '../../shared/ui/toast';
61
  import { initDensityAttributionSidebar } from '../../shared/prediction_attribution/density_sidebar/densityAttributionSidebar';
@@ -110,6 +111,9 @@ const chatUseSystemPromptInput = document.getElementById(
110
  'chat_use_system_prompt'
111
  ) as HTMLInputElement | null;
112
  const chatSystemPromptPanel = document.getElementById('chat_system_prompt_panel');
 
 
 
113
 
114
  function isSkipChatTemplate(): boolean {
115
  return skipChatTemplateInput?.checked ?? false;
@@ -119,6 +123,10 @@ function isChatUseSystemPrompt(): boolean {
119
  return chatUseSystemPromptInput?.checked ?? true;
120
  }
121
 
 
 
 
 
122
  function syncChatSystemPromptSuppressedUi(): void {
123
  const on = isChatUseSystemPrompt();
124
  chatSystemPromptPanel?.classList.toggle('chat-system-prompt-suppressed', !on);
@@ -450,13 +458,16 @@ const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
450
  const useSystem = isChatUseSystemPrompt();
451
  const systemRaw =
452
  (chatSystemTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
453
- const promptReq: { model: string; prompt: string; system?: string } = {
454
  model: completionModel,
455
  prompt
456
  };
457
  if (useSystem) {
458
  promptReq.system = systemRaw;
459
  }
 
 
 
460
  const assembled = await postCompletionsPrompt(promptReq, {
461
  signal: askAbort.signal
462
  });
@@ -526,14 +537,21 @@ const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
526
  };
527
 
528
  if (skipChatTemplateInput) {
529
- skipChatTemplateInput.checked = readSkipChatTemplateFromStorage();
530
  skipChatTemplateInput.addEventListener('change', () => {
531
- writeSkipChatTemplateToStorage(skipChatTemplateInput.checked);
532
  syncPromptPanelVisibility();
533
  syncChatSystemPromptSuppressedUi();
534
  syncAskButtonState();
535
  });
536
  }
 
 
 
 
 
 
 
537
  syncPromptPanelVisibility();
538
  syncChatSystemPromptSuppressedUi();
539
  chatUseSystemPromptInput?.addEventListener('change', () => {
 
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';
 
111
  'chat_use_system_prompt'
112
  ) as HTMLInputElement | null;
113
  const chatSystemPromptPanel = document.getElementById('chat_system_prompt_panel');
114
+ const enableThinkingInput = document.getElementById(
115
+ 'chat_enable_thinking'
116
+ ) as HTMLInputElement | null;
117
 
118
  function isSkipChatTemplate(): boolean {
119
  return skipChatTemplateInput?.checked ?? false;
 
123
  return chatUseSystemPromptInput?.checked ?? true;
124
  }
125
 
126
+ function isEnableThinking(): boolean {
127
+ return enableThinkingInput?.checked ?? false;
128
+ }
129
+
130
  function syncChatSystemPromptSuppressedUi(): void {
131
  const on = isChatUseSystemPrompt();
132
  chatSystemPromptPanel?.classList.toggle('chat-system-prompt-suppressed', !on);
 
458
  const useSystem = isChatUseSystemPrompt();
459
  const systemRaw =
460
  (chatSystemTextField.node() as HTMLTextAreaElement | null)?.value ?? '';
461
+ const promptReq: { model: string; prompt: string; system?: string; enable_thinking?: boolean } = {
462
  model: completionModel,
463
  prompt
464
  };
465
  if (useSystem) {
466
  promptReq.system = systemRaw;
467
  }
468
+ if (isEnableThinking()) {
469
+ promptReq.enable_thinking = true;
470
+ }
471
  const assembled = await postCompletionsPrompt(promptReq, {
472
  signal: askAbort.signal
473
  });
 
537
  };
538
 
539
  if (skipChatTemplateInput) {
540
+ skipChatTemplateInput.checked = lsReadBool(LS_SKIP_CHAT_TEMPLATE, false);
541
  skipChatTemplateInput.addEventListener('change', () => {
542
+ lsWriteBool(LS_SKIP_CHAT_TEMPLATE, skipChatTemplateInput.checked);
543
  syncPromptPanelVisibility();
544
  syncChatSystemPromptSuppressedUi();
545
  syncAskButtonState();
546
  });
547
  }
548
+ if (enableThinkingInput) {
549
+ enableThinkingInput.checked = lsReadBool(CHAT_ENABLE_THINKING_STORAGE_KEY, false);
550
+ enableThinkingInput.addEventListener('change', () => {
551
+ lsWriteBool(CHAT_ENABLE_THINKING_STORAGE_KEY, enableThinkingInput.checked);
552
+ syncAskButtonState();
553
+ });
554
+ }
555
  syncPromptPanelVisibility();
556
  syncChatSystemPromptSuppressedUi();
557
  chatUseSystemPromptInput?.addEventListener('change', () => {
client/src/pages/home/index.ts CHANGED
@@ -5,7 +5,7 @@ import '../../shared/core/d3-polyfill';
5
  import dagPreviewLight from '../../assets/images/dag.mov';
6
  import dagPreviewDark from '../../assets/images/dag-dark.mov';
7
  import dagSpiralPreview from '../../assets/images/dag-spiral.mov';
8
- import dagCotPreview from '../../assets/images/dag-cot.png';
9
  import '../../css/pages/home.scss';
10
 
11
  import { initThemeManager, type Theme } from '../../shared/ui/theme';
@@ -43,7 +43,7 @@ function applyGenAttributeNavCardHref(): void {
43
  });
44
  }
45
 
46
- const GEN_ATTRIBUTE_BADGE_LINK = 'http://xhslink.com/o/A7VLi99aBvG';
47
 
48
  const DAG_PREVIEW_BY_THEME: Record<Theme, string> = {
49
  light: dagPreviewLight,
@@ -61,14 +61,14 @@ function initGenAttributeCardCarousel(): (theme: Theme) => void {
61
  const dots = card?.querySelectorAll<HTMLButtonElement>('.nav-landing-card-carousel-dots button');
62
  const flowVideo = card?.querySelector<HTMLVideoElement>('[data-slide="flow"] video');
63
  const spiralVideo = card?.querySelector<HTMLVideoElement>('[data-slide="spiral"] video');
64
- const cotImg = card?.querySelector<HTMLImageElement>('[data-slide="cot"] img');
65
 
66
- if (!card || !viewport || slides.length === 0 || !dots?.length || !flowVideo || !spiralVideo || !cotImg) {
67
  return () => {};
68
  }
69
 
70
  spiralVideo.src = dagSpiralPreview;
71
- cotImg.src = dagCotPreview;
72
 
73
  let index = 0;
74
  let paused = false;
@@ -98,10 +98,13 @@ function initGenAttributeCardCarousel(): (theme: Theme) => void {
98
  const syncSlideVideos = (): void => {
99
  flowVideo.pause();
100
  spiralVideo.pause();
 
101
  if (index === 0) {
102
  void flowVideo.play().catch(() => {});
103
  } else if (index === 1) {
104
  void spiralVideo.play().catch(() => {});
 
 
105
  }
106
  };
107
 
 
5
  import dagPreviewLight from '../../assets/images/dag.mov';
6
  import dagPreviewDark from '../../assets/images/dag-dark.mov';
7
  import dagSpiralPreview from '../../assets/images/dag-spiral.mov';
8
+ import dagCotPreview from '../../assets/images/dag-cot.mov';
9
  import '../../css/pages/home.scss';
10
 
11
  import { initThemeManager, type Theme } from '../../shared/ui/theme';
 
43
  });
44
  }
45
 
46
+ const GEN_ATTRIBUTE_BADGE_LINK = 'https://xhslink.com/m/PwoOhuuhtV';
47
 
48
  const DAG_PREVIEW_BY_THEME: Record<Theme, string> = {
49
  light: dagPreviewLight,
 
61
  const dots = card?.querySelectorAll<HTMLButtonElement>('.nav-landing-card-carousel-dots button');
62
  const flowVideo = card?.querySelector<HTMLVideoElement>('[data-slide="flow"] video');
63
  const spiralVideo = card?.querySelector<HTMLVideoElement>('[data-slide="spiral"] video');
64
+ const cotVideo = card?.querySelector<HTMLVideoElement>('[data-slide="cot"] video');
65
 
66
+ if (!card || !viewport || slides.length === 0 || !dots?.length || !flowVideo || !spiralVideo || !cotVideo) {
67
  return () => {};
68
  }
69
 
70
  spiralVideo.src = dagSpiralPreview;
71
+ cotVideo.src = dagCotPreview;
72
 
73
  let index = 0;
74
  let paused = false;
 
98
  const syncSlideVideos = (): void => {
99
  flowVideo.pause();
100
  spiralVideo.pause();
101
+ cotVideo.pause();
102
  if (index === 0) {
103
  void flowVideo.play().catch(() => {});
104
  } else if (index === 1) {
105
  void spiralVideo.play().catch(() => {});
106
+ } else if (index === 2) {
107
+ void cotVideo.play().catch(() => {});
108
  }
109
  };
110
 
client/src/scripts/genAttributeDemoManifestPlugin.js CHANGED
@@ -1,31 +1,106 @@
1
  /**
2
- * 构建前扫描 `assets/demos/causal_flow/*.json`,写入 `features/causal_flow/genAttributeBundledDemoManifest.generated.ts`,供 bundle 内联 slug 列表。
3
- * 该目录注册 contextDependencies,便于 watch 下增减 demo JSON 时触发重编
 
 
4
  */
5
  const path = require('path');
6
  const fs = require('fs');
7
 
8
  const REL_DIR = 'assets/demos/causal_flow';
9
  const GENERATED_BASENAME = 'genAttributeBundledDemoManifest.generated.ts';
 
10
 
11
- function collectSlugs(srcDir) {
 
 
 
 
12
  if (!fs.existsSync(srcDir)) return [];
13
- const files = fs.readdirSync(srcDir).filter((f) => f.endsWith('.json'));
14
- const slugs = files
 
15
  .map((f) => f.replace(/\.json$/i, ''))
16
  .filter((s) => s.length > 0);
17
- // UTF-16 码元序,与 locale 无关,避免不同构建机生成顺序不一致
18
- slugs.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
19
- return slugs;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
  function writeGeneratedModule(srcDir, outPath) {
23
- const slugs = collectSlugs(srcDir);
24
  const content =
25
  '/**\n' +
26
  ' * Generated by GenAttributeDemoManifestPlugin — do not edit.\n' +
27
  ' */\n' +
28
- `export const GEN_ATTRIBUTE_BUNDLED_DEMO_SLUGS: readonly string[] = ${JSON.stringify(slugs)};\n`;
 
29
  if (fs.existsSync(outPath) && fs.readFileSync(outPath, 'utf8') === content) return;
30
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
31
  fs.writeFileSync(outPath, content, 'utf8');
 
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 按 UTF-16 码元序追加到末尾,label 同 slug。
5
+ * order 中的 slug 必须对应目录内已有 demo JSON;重复 slug 亦会在构建时报错。
6
  */
7
  const path = require('path');
8
  const fs = require('fs');
9
 
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));
16
+ }
17
+
18
+ function discoverSlugs(srcDir) {
19
  if (!fs.existsSync(srcDir)) return [];
20
+ return fs
21
+ .readdirSync(srcDir)
22
+ .filter((f) => f.endsWith('.json') && f !== ORDER_FILENAME)
23
  .map((f) => f.replace(/\.json$/i, ''))
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') {
31
+ const slug = entry.trim();
32
+ return slug ? { slug, label: null } : null;
33
+ }
34
+ if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
35
+ const slug = entry.slug.trim();
36
+ if (!slug) return null;
37
+ const label =
38
+ typeof entry.label === 'string' && entry.label.trim().length > 0
39
+ ? entry.label.trim()
40
+ : null;
41
+ return { slug, label };
42
+ }
43
+ throw new Error(
44
+ `${at}: expected a slug string or { "slug": "...", "label"?: "..." }`
45
+ );
46
+ }
47
+
48
+ function readOrderEntries(srcDir) {
49
+ const orderPath = path.join(srcDir, ORDER_FILENAME);
50
+ if (!fs.existsSync(orderPath)) return null;
51
+ let raw;
52
+ try {
53
+ raw = JSON.parse(fs.readFileSync(orderPath, 'utf8'));
54
+ } catch (e) {
55
+ throw new Error(`${ORDER_FILENAME}: invalid JSON (${e.message})`);
56
+ }
57
+ if (!Array.isArray(raw)) {
58
+ throw new Error(`${ORDER_FILENAME}: expected a JSON array`);
59
+ }
60
+ return raw.map((entry, i) => parseOrderEntry(entry, i)).filter(Boolean);
61
+ }
62
+
63
+ 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);
70
+ if (order == null) {
71
+ return utf16Sort([...discovered]).map((slug) => ({
72
+ slug,
73
+ label: slug,
74
+ }));
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
+ }
82
+ if (!discovered.has(slug)) {
83
+ throw new Error(
84
+ `${ORDER_FILENAME}: unknown slug ${JSON.stringify(slug)} (no ${slug}.json under ${REL_DIR})`,
85
+ );
86
+ }
87
+ seen.add(slug);
88
+ result.push({ slug, label: resolveLabel(slug, label) });
89
+ }
90
+ for (const slug of utf16Sort([...discovered].filter((s) => !seen.has(s)))) {
91
+ result.push({ slug, label: slug });
92
+ }
93
+ return result;
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 GenAttributeBundledDemoManifestEntry = { readonly slug: string; readonly label: string };\n' +
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 });
106
  fs.writeFileSync(outPath, content, 'utf8');
client/src/scripts/injectPageMetaIntoHtml.js CHANGED
@@ -97,7 +97,7 @@ function injectPageMeta(html, pageKey, doc) {
97
  : `<div class="nav-landing-card-shot" aria-hidden="true"></div>`;
98
  const badge =
99
  navKey === 'causalFlow'
100
- ? `<span class="nav-landing-card-badge" title="Go to demo on RedNote: xhslink.com/o/A7VLi99aBvG" data-i18n="text,title">100K+ plays on RedNote</span>`
101
  : '';
102
 
103
  if (navKey === 'causalFlow') {
@@ -117,7 +117,7 @@ function injectPageMeta(html, pageKey, doc) {
117
  `<div class="nav-landing-card-carousel-viewport">` +
118
  `<div class="nav-landing-card-slide" data-slide="flow">${slideLink('flow', '<video muted loop playsinline preload="metadata"></video>')}</div>` +
119
  `<div class="nav-landing-card-slide" data-slide="spiral">${slideLink('spiral', '<video muted loop playsinline preload="none"></video>')}</div>` +
120
- `<div class="nav-landing-card-slide" data-slide="cot">${slideLink('cot', '<img alt="" decoding="async" />')}</div>` +
121
  `</div>` +
122
  `<button type="button" class="nav-landing-card-carousel-arrow nav-landing-card-carousel-arrow--prev" aria-label="Previous preview">‹</button>` +
123
  `<button type="button" class="nav-landing-card-carousel-arrow nav-landing-card-carousel-arrow--next" aria-label="Next preview">›</button>` +
 
97
  : `<div class="nav-landing-card-shot" aria-hidden="true"></div>`;
98
  const badge =
99
  navKey === 'causalFlow'
100
+ ? `<span class="nav-landing-card-badge" title="Go to demo on RedNote: xhslink.com" data-i18n="text,title">500K+ plays on RedNote</span>`
101
  : '';
102
 
103
  if (navKey === 'causalFlow') {
 
117
  `<div class="nav-landing-card-carousel-viewport">` +
118
  `<div class="nav-landing-card-slide" data-slide="flow">${slideLink('flow', '<video muted loop playsinline preload="metadata"></video>')}</div>` +
119
  `<div class="nav-landing-card-slide" data-slide="spiral">${slideLink('spiral', '<video muted loop playsinline preload="none"></video>')}</div>` +
120
+ `<div class="nav-landing-card-slide" data-slide="cot">${slideLink('cot', '<video muted loop playsinline preload="none"></video>')}</div>` +
121
  `</div>` +
122
  `<button type="button" class="nav-landing-card-carousel-arrow nav-landing-card-carousel-arrow--prev" aria-label="Previous preview">‹</button>` +
123
  `<button type="button" class="nav-landing-card-carousel-arrow nav-landing-card-carousel-arrow--next" aria-label="Next preview">›</button>` +
client/src/shared/api/completionsClient.ts CHANGED
@@ -68,18 +68,26 @@ export type PostCompletionsPromptOptions = {
68
  * POST /v1/completions/prompt:将用户原文套用 chat template,返回实际送入续写的完整 prompt。
69
  */
70
  export async function postCompletionsPrompt(
71
- body: { model: string; prompt: string; system?: string },
72
  options: PostCompletionsPromptOptions = {}
73
  ): Promise<{ prompt_used: string }> {
74
  const { signal } = options;
75
  const url = URLHandler.basicURL() + COMPLETIONS_PROMPT_PATH;
76
- const payload: { model: string; prompt: string; system?: string } = {
 
 
 
 
 
77
  model: body.model,
78
  prompt: body.prompt
79
  };
80
  if (body.system !== undefined) {
81
  payload.system = body.system;
82
  }
 
 
 
83
  const res = await fetch(url, {
84
  method: 'POST',
85
  headers: { 'Content-Type': 'application/json; charset=UTF-8' },
 
68
  * POST /v1/completions/prompt:将用户原文套用 chat template,返回实际送入续写的完整 prompt。
69
  */
70
  export async function postCompletionsPrompt(
71
+ body: { model: string; prompt: string; system?: string; enable_thinking?: boolean },
72
  options: PostCompletionsPromptOptions = {}
73
  ): Promise<{ prompt_used: string }> {
74
  const { signal } = options;
75
  const url = URLHandler.basicURL() + COMPLETIONS_PROMPT_PATH;
76
+ const payload: {
77
+ model: string;
78
+ prompt: string;
79
+ system?: string;
80
+ enable_thinking?: boolean;
81
+ } = {
82
  model: body.model,
83
  prompt: body.prompt
84
  };
85
  if (body.system !== undefined) {
86
  payload.system = body.system;
87
  }
88
+ if (body.enable_thinking === true) {
89
+ payload.enable_thinking = true;
90
+ }
91
  const res = await fetch(url, {
92
  method: 'POST',
93
  headers: { 'Content-Type': 'application/json; charset=UTF-8' },
client/src/shared/controllers/serverDemoController.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
  buildFolderOptions
13
  } from '../../features/demo/demoPathUtils';
14
  import { tr, trf } from '../../shared/lang/i18n-lite';
 
15
 
16
  /**
17
  * 从保存结果中提取文件名,如果不存在则根据名称生成
@@ -40,7 +41,7 @@ export const showDemoNameInput = (
40
  const folders = result.folders || [];
41
 
42
  // 获取上次保存的路径(从 localStorage)
43
- const lastSavePath = localStorage.getItem(LAST_SAVE_PATH_KEY);
44
 
45
  // 使用统一的 buildFolderOptions 函数
46
  const { options: selectOptions, defaultPath } = buildFolderOptions(folders, lastSavePath);
@@ -59,7 +60,7 @@ export const showDemoNameInput = (
59
  if (value && value.input) {
60
  // 保存选择的路径到 localStorage
61
  const selectedPath = value.select || '/';
62
- localStorage.setItem(LAST_SAVE_PATH_KEY, selectedPath);
63
  resolve({ name: value.input, path: selectedPath });
64
  } else {
65
  resolve(null);
@@ -161,7 +162,7 @@ export const handleServerDemoSave = async (options: ServerDemoSaveOptions): Prom
161
  result = { name: presetSaveInfo.name.trim(), path: normalizedPath };
162
  // 记录最近路径
163
  if (normalizedPath) {
164
- localStorage.setItem(LAST_SAVE_PATH_KEY, normalizedPath);
165
  }
166
  } else {
167
  const defaultName = getDefaultDemoName(currentData, textFieldValue, currentFileName);
 
12
  buildFolderOptions
13
  } from '../../features/demo/demoPathUtils';
14
  import { tr, trf } from '../../shared/lang/i18n-lite';
15
+ import { lsGet, lsSet } from '../../shared/storage/localStorageHelpers';
16
 
17
  /**
18
  * 从保存结果中提取文件名,如果不存在则根据名称生成
 
41
  const folders = result.folders || [];
42
 
43
  // 获取上次保存的路径(从 localStorage)
44
+ const lastSavePath = lsGet(LAST_SAVE_PATH_KEY);
45
 
46
  // 使用统一的 buildFolderOptions 函数
47
  const { options: selectOptions, defaultPath } = buildFolderOptions(folders, lastSavePath);
 
60
  if (value && value.input) {
61
  // 保存选择的路径到 localStorage
62
  const selectedPath = value.select || '/';
63
+ lsSet(LAST_SAVE_PATH_KEY, selectedPath);
64
  resolve({ name: value.input, path: selectedPath });
65
  } else {
66
  resolve(null);
 
162
  result = { name: presetSaveInfo.name.trim(), path: normalizedPath };
163
  // 记录最近路径
164
  if (normalizedPath) {
165
+ lsSet(LAST_SAVE_PATH_KEY, normalizedPath);
166
  }
167
  } else {
168
  const defaultName = getDefaultDemoName(currentData, textFieldValue, currentFileName);
client/src/shared/core/responsive.ts CHANGED
@@ -3,6 +3,8 @@
3
  * 使用 CSS 变量和 matchMedia API 实现单一数据源
4
  */
5
 
 
 
6
  /**
7
  * 从 CSS 变量获取断点值
8
  */
@@ -46,7 +48,7 @@ export const FORCE_NARROW_STORAGE_KEY = 'info_radar_force_narrow';
46
  export const FORCE_NARROW_CHANGE_EVENT = 'force-narrow-change';
47
 
48
  export const getForceNarrowScreen = (): boolean =>
49
- localStorage.getItem(FORCE_NARROW_STORAGE_KEY) === '1';
50
 
51
  export const syncForceNarrowAttribute = (): void => {
52
  const root = document.documentElement;
@@ -70,8 +72,8 @@ export const initForceNarrowFromStorage = (): void => {
70
  };
71
 
72
  export const setForceNarrowScreen = (enabled: boolean): void => {
73
- if (enabled) localStorage.setItem(FORCE_NARROW_STORAGE_KEY, '1');
74
- else localStorage.removeItem(FORCE_NARROW_STORAGE_KEY);
75
  syncForceNarrowAttribute();
76
  window.dispatchEvent(new Event(FORCE_NARROW_CHANGE_EVENT));
77
  window.dispatchEvent(new Event('resize'));
 
3
  * 使用 CSS 变量和 matchMedia API 实现单一数据源
4
  */
5
 
6
+ import { lsGet, lsRemove, lsSet } from '../storage/localStorageHelpers';
7
+
8
  /**
9
  * 从 CSS 变量获取断点值
10
  */
 
48
  export const FORCE_NARROW_CHANGE_EVENT = 'force-narrow-change';
49
 
50
  export const getForceNarrowScreen = (): boolean =>
51
+ lsGet(FORCE_NARROW_STORAGE_KEY) === '1';
52
 
53
  export const syncForceNarrowAttribute = (): void => {
54
  const root = document.documentElement;
 
72
  };
73
 
74
  export const setForceNarrowScreen = (enabled: boolean): void => {
75
+ if (enabled) lsSet(FORCE_NARROW_STORAGE_KEY, '1');
76
+ else lsRemove(FORCE_NARROW_STORAGE_KEY);
77
  syncForceNarrowAttribute();
78
  window.dispatchEvent(new Event(FORCE_NARROW_CHANGE_EVENT));
79
  window.dispatchEvent(new Event('resize'));
client/src/shared/cross/adminManager.ts CHANGED
@@ -2,6 +2,8 @@
2
  * 管理员状态管理模块
3
  */
4
 
 
 
5
  const INFORADAR_ADMIN_TOKEN_KEY = 'admin_token';
6
  const ADMIN_MODE_KEY = 'is_admin_mode';
7
 
@@ -12,8 +14,8 @@ export class AdminManager {
12
 
13
  private constructor() {
14
  // 从localStorage恢复状态
15
- const savedToken = localStorage.getItem(INFORADAR_ADMIN_TOKEN_KEY);
16
- const savedMode = localStorage.getItem(ADMIN_MODE_KEY);
17
 
18
  if (savedToken) {
19
  this.adminToken = savedToken;
@@ -66,8 +68,8 @@ export class AdminManager {
66
  if (result.success) {
67
  this.adminToken = token;
68
  this.isAdminMode = true;
69
- localStorage.setItem(INFORADAR_ADMIN_TOKEN_KEY, token);
70
- localStorage.setItem(ADMIN_MODE_KEY, 'true');
71
  return {
72
  success: true,
73
  message: result.message
@@ -98,8 +100,8 @@ export class AdminManager {
98
  public clearAdminToken(): void {
99
  this.adminToken = null;
100
  this.isAdminMode = false;
101
- localStorage.removeItem(INFORADAR_ADMIN_TOKEN_KEY);
102
- localStorage.removeItem(ADMIN_MODE_KEY);
103
  }
104
 
105
  /**
 
2
  * 管理员状态管理模块
3
  */
4
 
5
+ import { lsGet, lsRemove, lsSet } from '../storage/localStorageHelpers';
6
+
7
  const INFORADAR_ADMIN_TOKEN_KEY = 'admin_token';
8
  const ADMIN_MODE_KEY = 'is_admin_mode';
9
 
 
14
 
15
  private constructor() {
16
  // 从localStorage恢复状态
17
+ const savedToken = lsGet(INFORADAR_ADMIN_TOKEN_KEY);
18
+ const savedMode = lsGet(ADMIN_MODE_KEY);
19
 
20
  if (savedToken) {
21
  this.adminToken = savedToken;
 
68
  if (result.success) {
69
  this.adminToken = token;
70
  this.isAdminMode = true;
71
+ lsSet(INFORADAR_ADMIN_TOKEN_KEY, token);
72
+ lsSet(ADMIN_MODE_KEY, 'true');
73
  return {
74
  success: true,
75
  message: result.message
 
100
  public clearAdminToken(): void {
101
  this.adminToken = null;
102
  this.isAdminMode = false;
103
+ lsRemove(INFORADAR_ADMIN_TOKEN_KEY);
104
+ lsRemove(ADMIN_MODE_KEY);
105
  }
106
 
107
  /**
client/src/shared/cross/digitsMergeManager.ts CHANGED
@@ -1,4 +1,6 @@
1
  /** 数字段合并(digit merge)开关,与 BPE overlap 合并独立;默认开启以保持既有行为 */
 
 
2
  export const DIGITS_MERGE_STORAGE_KEY = 'info_radar_digits_merge_enabled';
3
 
4
  const renderListeners = new Set<() => void>();
@@ -39,12 +41,12 @@ export function addDigitsMergeRenderListener(callback: () => void): void {
39
  }
40
 
41
  export function getDigitsMergeEnabled(): boolean {
42
- const v = localStorage.getItem(DIGITS_MERGE_STORAGE_KEY);
43
  if (v === null) return true;
44
  return v === 'true';
45
  }
46
 
47
  export function setDigitsMergeEnabled(enabled: boolean): void {
48
- localStorage.setItem(DIGITS_MERGE_STORAGE_KEY, enabled ? 'true' : 'false');
49
  notifyDigitsMergeRenderListeners();
50
  }
 
1
  /** 数字段合并(digit merge)开关,与 BPE overlap 合并独立;默认开启以保持既有行为 */
2
+ import { lsGet, lsWriteBool } from '../storage/localStorageHelpers';
3
+
4
  export const DIGITS_MERGE_STORAGE_KEY = 'info_radar_digits_merge_enabled';
5
 
6
  const renderListeners = new Set<() => void>();
 
41
  }
42
 
43
  export function getDigitsMergeEnabled(): boolean {
44
+ const v = lsGet(DIGITS_MERGE_STORAGE_KEY);
45
  if (v === null) return true;
46
  return v === 'true';
47
  }
48
 
49
  export function setDigitsMergeEnabled(enabled: boolean): void {
50
+ lsWriteBool(DIGITS_MERGE_STORAGE_KEY, enabled);
51
  notifyDigitsMergeRenderListeners();
52
  }
client/src/shared/cross/panelSplitStorage.ts CHANGED
@@ -1,5 +1,7 @@
1
  /** 左栏宽度占「主区宽度 − resizer(8px)」的比例,与 LayoutController / chatPanelLayout 内逻辑一致 */
2
 
 
 
3
  const DEFAULT_RATIO = 0.5;
4
  const MIN_RATIO = 0.1;
5
  const MAX_RATIO = 0.9;
@@ -10,26 +12,18 @@ export const PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION = 'info_radar_panel_split_attri
10
  export const PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE = 'info_radar_panel_split_gen_attribute';
11
 
12
  export function readPanelSplitRatio(storageKey: string): number {
13
- try {
14
- const raw = localStorage.getItem(storageKey);
15
- if (raw === null) {
16
- return DEFAULT_RATIO;
17
- }
18
- const n = Number(raw);
19
- if (!Number.isFinite(n)) {
20
- return DEFAULT_RATIO;
21
- }
22
- return Math.max(MIN_RATIO, Math.min(MAX_RATIO, n));
23
- } catch {
24
  return DEFAULT_RATIO;
25
  }
 
26
  }
27
 
28
  export function writePanelSplitRatio(storageKey: string, ratio: number): void {
29
- try {
30
- const clamped = Math.max(MIN_RATIO, Math.min(MAX_RATIO, ratio));
31
- localStorage.setItem(storageKey, String(clamped));
32
- } catch {
33
- // ignore quota / private mode
34
- }
35
  }
 
1
  /** 左栏宽度占「主区宽度 − resizer(8px)」的比例,与 LayoutController / chatPanelLayout 内逻辑一致 */
2
 
3
+ import { lsGet, lsSet } from '../storage/localStorageHelpers';
4
+
5
  const DEFAULT_RATIO = 0.5;
6
  const MIN_RATIO = 0.1;
7
  const MAX_RATIO = 0.9;
 
12
  export const PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE = 'info_radar_panel_split_gen_attribute';
13
 
14
  export function readPanelSplitRatio(storageKey: string): number {
15
+ const raw = lsGet(storageKey);
16
+ if (raw === null) {
17
+ return DEFAULT_RATIO;
18
+ }
19
+ const n = Number(raw);
20
+ if (!Number.isFinite(n)) {
 
 
 
 
 
21
  return DEFAULT_RATIO;
22
  }
23
+ return Math.max(MIN_RATIO, Math.min(MAX_RATIO, n));
24
  }
25
 
26
  export function writePanelSplitRatio(storageKey: string, ratio: number): void {
27
+ const clamped = Math.max(MIN_RATIO, Math.min(MAX_RATIO, ratio));
28
+ lsSet(storageKey, String(clamped));
 
 
 
 
29
  }
client/src/shared/cross/queryHistory.ts CHANGED
@@ -3,6 +3,7 @@
3
  */
4
 
5
  import { tr } from '../../shared/lang/i18n-lite';
 
6
 
7
  /** 首页语义搜索等默认使用 */
8
  export const SEMANTIC_QUERY_HISTORY_KEY = 'info_radar_query_search_history';
@@ -49,7 +50,7 @@ function shouldTouchLinkedMru(applyHistoryOnHover: boolean, fromHover: boolean):
49
 
50
  function load(storageKey: string): string[] {
51
  try {
52
- const raw = localStorage.getItem(storageKey);
53
  if (!raw) return [];
54
  const parsed = JSON.parse(raw);
55
  if (!Array.isArray(parsed)) return [];
@@ -68,12 +69,12 @@ function load(storageKey: string): string[] {
68
 
69
  function remove(storageKey: string, query: string): void {
70
  const list = load(storageKey).filter((s) => s !== query);
71
- localStorage.setItem(storageKey, JSON.stringify(list));
72
  }
73
 
74
  export function saveHistory(query: string, storageKey: string = SEMANTIC_QUERY_HISTORY_KEY): void {
75
  const list = [query, ...load(storageKey).filter((s) => s !== query)].slice(0, MAX);
76
- localStorage.setItem(storageKey, JSON.stringify(list));
77
  }
78
 
79
  export interface InitQueryHistoryDropdownOptions {
 
3
  */
4
 
5
  import { tr } from '../../shared/lang/i18n-lite';
6
+ import { lsGet, lsSet } from '../storage/localStorageHelpers';
7
 
8
  /** 首页语义搜索等默认使用 */
9
  export const SEMANTIC_QUERY_HISTORY_KEY = 'info_radar_query_search_history';
 
50
 
51
  function load(storageKey: string): string[] {
52
  try {
53
+ const raw = lsGet(storageKey);
54
  if (!raw) return [];
55
  const parsed = JSON.parse(raw);
56
  if (!Array.isArray(parsed)) return [];
 
69
 
70
  function remove(storageKey: string, query: string): void {
71
  const list = load(storageKey).filter((s) => s !== query);
72
+ lsSet(storageKey, JSON.stringify(list));
73
  }
74
 
75
  export function saveHistory(query: string, storageKey: string = SEMANTIC_QUERY_HISTORY_KEY): void {
76
  const list = [query, ...load(storageKey).filter((s) => s !== query)].slice(0, MAX);
77
+ lsSet(storageKey, JSON.stringify(list));
78
  }
79
 
80
  export interface InitQueryHistoryDropdownOptions {
client/src/shared/cross/semanticResultCache.ts CHANGED
@@ -3,6 +3,8 @@
3
  * 持久化到 localStorage,刷新后保留。删除查询历史时需调用 removeByQuery 清理对应缓存。
4
  */
5
 
 
 
6
  const MAX_SIZE = 50;
7
  const STORAGE_KEY = 'info_radar_semantic_result_cache';
8
 
@@ -35,7 +37,7 @@ let keyOrder: string[] = [];
35
 
36
  function load(): void {
37
  try {
38
- const raw = localStorage.getItem(STORAGE_KEY);
39
  if (!raw) return;
40
  const parsed = JSON.parse(raw) as { entries?: Record<string, StoredEntry>; keyOrder?: string[] };
41
  if (!parsed?.entries || typeof parsed.entries !== 'object') return;
@@ -55,17 +57,15 @@ function load(): void {
55
  load();
56
 
57
  function persist(): void {
58
- try {
59
- const entries: Record<string, StoredEntry> = {};
60
- for (const [k, v] of cache) entries[k] = v;
61
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries, keyOrder }));
62
- } catch (e) {
63
- const reason =
64
- e instanceof DOMException && e.name === 'QuotaExceededError'
65
- ? 'localStorage 配额已满(Chrome 约 5MB/域名),建议减少 MAX_SIZE 或清理其他站点数据'
66
- : String(e);
67
- console.warn('[semanticResultCache] 持久化失败,刷新后缓存可能丢失。原因:', reason);
68
- }
69
  }
70
 
71
  function evictOne(): void {
 
3
  * 持久化到 localStorage,刷新后保留。删除查询历史时需调用 removeByQuery 清理对应缓存。
4
  */
5
 
6
+ import { lsGet, lsSetCatch } from '../storage/localStorageHelpers';
7
+
8
  const MAX_SIZE = 50;
9
  const STORAGE_KEY = 'info_radar_semantic_result_cache';
10
 
 
37
 
38
  function load(): void {
39
  try {
40
+ const raw = lsGet(STORAGE_KEY);
41
  if (!raw) return;
42
  const parsed = JSON.parse(raw) as { entries?: Record<string, StoredEntry>; keyOrder?: string[] };
43
  if (!parsed?.entries || typeof parsed.entries !== 'object') return;
 
57
  load();
58
 
59
  function persist(): void {
60
+ const entries: Record<string, StoredEntry> = {};
61
+ for (const [k, v] of cache) entries[k] = v;
62
+ const err = lsSetCatch(STORAGE_KEY, JSON.stringify({ entries, keyOrder }));
63
+ if (err === undefined) return;
64
+ const reason =
65
+ err instanceof DOMException && err.name === 'QuotaExceededError'
66
+ ? 'localStorage 配额已满(Chrome 5MB/域名),建议减少 MAX_SIZE 或清理其他站点数据'
67
+ : String(err);
68
+ console.warn('[semanticResultCache] 持久化失败,刷新后缓存可能丢失。原因:', reason);
 
 
69
  }
70
 
71
  function evictOne(): void {
client/src/shared/cross/semanticThresholdManager.ts CHANGED
@@ -1,10 +1,11 @@
1
  /** 语义匹配度阈值:用户可配置,持久化到 localStorage,与 Semantic analysis 同方式 */
2
  import { SEMANTIC_MATCH_THRESHOLD } from '../core/constants';
 
3
 
4
  const KEY = 'info_radar_semantic_match_threshold';
5
 
6
  export function getSemanticMatchThreshold(): number {
7
- const v = localStorage.getItem(KEY);
8
  if (v == null) return SEMANTIC_MATCH_THRESHOLD;
9
  const n = parseFloat(v);
10
  return Number.isFinite(n) && n >= 0 && n <= 1 ? n : SEMANTIC_MATCH_THRESHOLD;
@@ -12,5 +13,5 @@ export function getSemanticMatchThreshold(): number {
12
 
13
  export function setSemanticMatchThreshold(value: number): void {
14
  const clamped = Math.max(0, Math.min(1, value));
15
- localStorage.setItem(KEY, String(clamped));
16
  }
 
1
  /** 语义匹配度阈值:用户可配置,持久化到 localStorage,与 Semantic analysis 同方式 */
2
  import { SEMANTIC_MATCH_THRESHOLD } from '../core/constants';
3
+ import { lsGet, lsSet } from '../storage/localStorageHelpers';
4
 
5
  const KEY = 'info_radar_semantic_match_threshold';
6
 
7
  export function getSemanticMatchThreshold(): number {
8
+ const v = lsGet(KEY);
9
  if (v == null) return SEMANTIC_MATCH_THRESHOLD;
10
  const n = parseFloat(v);
11
  return Number.isFinite(n) && n >= 0 && n <= 1 ? n : SEMANTIC_MATCH_THRESHOLD;
 
13
 
14
  export function setSemanticMatchThreshold(value: number): void {
15
  const clamped = Math.max(0, Math.min(1, value));
16
+ lsSet(KEY, String(clamped));
17
  }
client/src/shared/cross/settingsMenuManager.ts CHANGED
@@ -14,7 +14,10 @@ import { getSemanticAnalysisEnabled, setSemanticAnalysisEnabled } from './semant
14
  import { getDigitsMergeEnabled, setDigitsMergeEnabled } from './digitsMergeManager';
15
  import { getForceNarrowScreen, setForceNarrowScreen, FORCE_NARROW_CHANGE_EVENT } from '../core/responsive';
16
  import { getSemanticMatchThreshold } from './semanticThresholdManager';
17
- import { getInfoDensityRenderDisabled, setInfoDensityRenderDisabled } from '../../features/analysis/infoDensityRenderManager';
 
 
 
18
  import { showVisitStatsDialog } from './visitStatsDialog';
19
  import { showModelManageDialog } from './modelManageDialog';
20
 
 
14
  import { getDigitsMergeEnabled, setDigitsMergeEnabled } from './digitsMergeManager';
15
  import { getForceNarrowScreen, setForceNarrowScreen, FORCE_NARROW_CHANGE_EVENT } from '../core/responsive';
16
  import { getSemanticMatchThreshold } from './semanticThresholdManager';
17
+ import {
18
+ getInfoDensityRenderDisabled,
19
+ setInfoDensityRenderDisabled,
20
+ } from '../../features/analysis/infoDensityRenderManager';
21
  import { showVisitStatsDialog } from './visitStatsDialog';
22
  import { showModelManageDialog } from './modelManageDialog';
23