dqy08 commited on
Commit
966d483
·
1 Parent(s): 90d0872

语义搜索插件

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +2 -0
  2. backend/api/analyze_semantic.py +67 -17
  3. backend/core/semantic_analyzer.py +144 -30
  4. backend/platform/access_log.py +31 -7
  5. backend/platform/inference_ingress.py +1 -1
  6. backend/platform/inference_response_log.py +21 -3
  7. backend/platform/logging_config.py +0 -1
  8. backend/platform/runtime_config.py +1 -0
  9. backend/tests/test_inference_response_log.py +32 -0
  10. client/src/analysis.html +1 -1
  11. client/src/css/base/_theme-vars.scss +37 -0
  12. client/src/css/components/_semantic-analysis.scss +7 -0
  13. client/src/css/pages/analysis.scss +18 -0
  14. client/src/features/analysis/analyzeFlow.ts +3 -4
  15. client/src/features/analysis/appStateManager.ts +16 -4
  16. client/src/features/analysis/infoDensityRenderManager.ts +0 -12
  17. client/src/features/analysis/visualizationUpdater.ts +103 -289
  18. client/src/features/chat/chatTurnsView.ts +0 -1
  19. client/src/features/demo/demoBusinessLogic.ts +3 -4
  20. client/src/pages/analysis/index.ts +58 -11
  21. client/src/partials/settings-menu-analysis.html +0 -6
  22. client/src/privacy-policy.html +56 -0
  23. client/src/shared/controllers/highlightController.ts +4 -3
  24. client/src/shared/controllers/semanticSearchController.ts +18 -74
  25. client/src/shared/core/responsive.ts +13 -0
  26. client/src/shared/core/waitForSmoothScrollEnd.ts +20 -1
  27. client/src/shared/cross/mergeTokenSpans.ts +17 -0
  28. client/src/shared/cross/queryHistory.ts +12 -2
  29. client/src/shared/cross/semanticUtils.ts +1 -1
  30. client/src/shared/cross/settingsMenuManager.ts +0 -33
  31. client/src/shared/lang/translations.ts +2 -1
  32. client/src/shared/vis/GLTR_Text_Box.ts +54 -86
  33. client/src/shared/vis/HighlightManager.ts +5 -0
  34. client/src/shared/vis/SvgOverlayManager.ts +1 -1
  35. client/src/shared/vis/ToolTip.ts +1 -1
  36. client/src/shared/vis/constants.ts +1 -7
  37. client/src/webpack.config.js +1 -0
  38. extension/README.md +69 -0
  39. extension/articleRoot.js +85 -0
  40. extension/background.js +186 -0
  41. extension/config.local.js +35 -0
  42. extension/config.prod.js +37 -0
  43. extension/content.css +94 -0
  44. extension/content.js +1831 -0
  45. extension/dev-env.sh +25 -0
  46. extension/icons/dev/icon128.png +3 -0
  47. extension/icons/dev/icon16.png +3 -0
  48. extension/icons/dev/icon32.png +3 -0
  49. extension/icons/dev/icon48.png +3 -0
  50. extension/icons/icon128.png +3 -0
.gitignore CHANGED
@@ -16,6 +16,8 @@ client/src/.cache-loader/
16
  # --- 构建产物 ---
17
  client/dist/
18
  client/dist-github_pages/
 
 
19
  build/
20
  dist/
21
  *.egg-info/
 
16
  # --- 构建产物 ---
17
  client/dist/
18
  client/dist-github_pages/
19
+ extension/dist/
20
+ extension/config.js
21
  build/
22
  dist/
23
  *.egg-info/
backend/api/analyze_semantic.py CHANGED
@@ -19,18 +19,34 @@ from backend.platform.access_log import get_client_ip
19
  from backend.api.analyze import QueueTimeoutError, ANALYSIS_TIMEOUT, LOCK_WAIT_TIMEOUT
20
 
21
 
22
- def _log_request(query, text, client_ip=None):
23
  from backend.platform.access_log import log_analyze_semantic_request
24
- return log_analyze_semantic_request(query, text, client_ip)
25
 
26
 
27
  def _build_success_response(result, debug_info: bool = False):
28
- """构建成功响应。debug_info=True 时包含 debug_info 对象(abbrev、topk_tokens、topk_probs)"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  resp = {
30
  "success": True,
31
  "model": result["model"],
32
  "token_attention": result["token_attention"],
33
  "full_match_degree": result["full_match_degree"],
 
34
  }
35
  if debug_info and "debug_info" in result:
36
  resp["debug_info"] = result["debug_info"]
@@ -126,13 +142,19 @@ def _generate_semantic_events(
126
  from backend.platform.inference_response_log import log_analyze_semantic_response
127
  from backend.platform.model_routing import is_worker
128
 
129
- if is_worker():
130
- log_analyze_semantic_response(
131
- request_id=request_id,
132
- result=analysis_result,
133
- elapsed=elapsed,
134
- for_worker=True,
135
- )
 
 
 
 
 
 
136
  yield send_result_event(_build_success_response(analysis_result, debug_info))
137
  except Exception as e:
138
  import traceback
@@ -202,43 +224,67 @@ def analyze_semantic(semantic_request):
202
  分析原文 token 对 prompt 的关注度。
203
 
204
  Args:
205
- semantic_request: 包含 query、text、stream(可选)、submode(可选)的字典
 
 
 
 
 
 
 
 
 
206
 
207
  Returns:
208
  stream=True 时返回 SSE 响应;否则返回 (响应字典, 状态码) 元组
209
  """
210
  query = (semantic_request.get("query") or "")
211
  text = semantic_request.get("text") or ""
 
212
  stream = semantic_request.get("stream", False)
213
  submode = (semantic_request.get("submode") or "").strip() or None
214
  debug_info = bool(semantic_request.get("debug_info", False))
215
  full_match_degree_only = bool(semantic_request.get("full_match_degree_only", False))
 
216
 
217
  if not query:
218
  return {"success": False, "message": "缺少 query 字段"}, 400
219
- if not text:
220
- return {"success": False, "message": "缺少 text 字段"}, 400
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  client_ip = get_client_ip()
223
  logged: dict = {"request_id": None}
224
 
225
  def log_fn():
226
- logged["request_id"] = _log_request(query, text, client_ip)
227
 
228
  def local_fn():
229
  rid = logged["request_id"]
230
  if stream:
231
  return _analyze_semantic_with_stream(
232
- query, text, submode, debug_info, full_match_degree_only, client_ip, rid
233
  )
234
  return _analyze_semantic_plain(
235
- query, text, submode, debug_info, full_match_degree_only, client_ip, rid
236
  )
237
 
238
  from backend.models.model_manager import ModelSlot
239
  from backend.platform.inference_ingress import ingress_inference
240
  from backend.platform.inference_response_log import make_analyze_semantic_response_logger
 
241
 
 
242
  return ingress_inference(
243
  slot=ModelSlot.INSTRUCT,
244
  api_path="/api/analyze-semantic",
@@ -247,5 +293,9 @@ def analyze_semantic(semantic_request):
247
  timeout=60.0,
248
  log_fn=log_fn,
249
  local_fn=local_fn,
250
- response_log_fn=make_analyze_semantic_response_logger(logged),
 
 
 
 
251
  )
 
19
  from backend.api.analyze import QueueTimeoutError, ANALYSIS_TIMEOUT, LOCK_WAIT_TIMEOUT
20
 
21
 
22
+ def _log_request(query, text, client_ip=None, privacy_mode=False):
23
  from backend.platform.access_log import log_analyze_semantic_request
24
+ return log_analyze_semantic_request(query, text, client_ip, privacy_mode)
25
 
26
 
27
  def _build_success_response(result, debug_info: bool = False):
28
+ """构建成功响应。debug_info=True 时包含 debug_info 对象(abbrev、topk_tokens、topk_probs)。
29
+ result 为 List 时(批量 full_match_degree_only),返回 {"success", "results": [...]}。
30
+ 带 input_token_count:full_match_degree_only 时 token_attention 为空,门户 response 日志靠它打 tokens。
31
+ 分析器必须产出该字段;缺则 KeyError,不静默填默认值。"""
32
+ if isinstance(result, list):
33
+ return {
34
+ "success": True,
35
+ "results": [
36
+ {
37
+ "model": r["model"],
38
+ "full_match_degree": r["full_match_degree"],
39
+ "input_token_count": r["input_token_count"],
40
+ }
41
+ for r in result
42
+ ],
43
+ }
44
  resp = {
45
  "success": True,
46
  "model": result["model"],
47
  "token_attention": result["token_attention"],
48
  "full_match_degree": result["full_match_degree"],
49
+ "input_token_count": result["input_token_count"],
50
  }
51
  if debug_info and "debug_info" in result:
52
  resp["debug_info"] = result["debug_info"]
 
142
  from backend.platform.inference_response_log import log_analyze_semantic_response
143
  from backend.platform.model_routing import is_worker
144
 
145
+ # 本地执行机打完整日志(含 wait/processing);远程代理门户走 response_log_fn
146
+ if isinstance(analysis_result, dict):
147
+ log_result = analysis_result
148
+ else:
149
+ total_tokens = sum(r.get("input_token_count", 0) for r in analysis_result)
150
+ log_result = {"input_token_count": total_tokens}
151
+ log_analyze_semantic_response(
152
+ request_id=request_id,
153
+ result=log_result,
154
+ elapsed=elapsed,
155
+ wait_time=lock_wait_time,
156
+ for_worker=is_worker(),
157
+ )
158
  yield send_result_event(_build_success_response(analysis_result, debug_info))
159
  except Exception as e:
160
  import traceback
 
224
  分析原文 token 对 prompt 的关注度。
225
 
226
  Args:
227
+ semantic_request: 包含 query、text、stream(可选)、submode(可选)的字典。
228
+ texts(可选,字符串数组):批量 full_match_degree_only 预判性能测试用,
229
+ 与 text 互斥,且必须搭配 full_match_degree_only=True。
230
+
231
+ 性能结论(详见 backend/core/semantic_analyzer.py 的
232
+ _analyze_logits_gradient_batch):
233
+ batching 对模型前向计算本身的加速有限(batch=4 约 1.5x,batch=8 约
234
+ 1.6x),线上用客户端总耗时测出的更高加速比主要是摊薄了每次 HTTP
235
+ 请求固定的网络/SSE 开销,并非计算变快;因此若追求性能,合并请求本身
236
+ 比加大 batch 更有效,batch 也不宜设得过大。
237
 
238
  Returns:
239
  stream=True 时返回 SSE 响应;否则返回 (响应字典, 状态码) 元组
240
  """
241
  query = (semantic_request.get("query") or "")
242
  text = semantic_request.get("text") or ""
243
+ texts = semantic_request.get("texts")
244
  stream = semantic_request.get("stream", False)
245
  submode = (semantic_request.get("submode") or "").strip() or None
246
  debug_info = bool(semantic_request.get("debug_info", False))
247
  full_match_degree_only = bool(semantic_request.get("full_match_degree_only", False))
248
+ privacy_mode = bool(semantic_request.get("privacy_mode", False))
249
 
250
  if not query:
251
  return {"success": False, "message": "缺少 query 字段"}, 400
252
+
253
+ if texts is not None:
254
+ if not isinstance(texts, list) or not texts or not all(isinstance(t, str) and t for t in texts):
255
+ return {"success": False, "message": "texts 字段需为非空字符串数组"}, 400
256
+ if not full_match_degree_only:
257
+ return {"success": False, "message": "texts 数组批量模式仅支持 full_match_degree_only=True"}, 400
258
+ content = texts
259
+ log_text = f"[批量 x{len(texts)}] " + texts[0]
260
+ else:
261
+ if not text:
262
+ return {"success": False, "message": "缺少 text 字段"}, 400
263
+ content = text
264
+ log_text = text
265
 
266
  client_ip = get_client_ip()
267
  logged: dict = {"request_id": None}
268
 
269
  def log_fn():
270
+ logged["request_id"] = _log_request(query, log_text, client_ip, privacy_mode)
271
 
272
  def local_fn():
273
  rid = logged["request_id"]
274
  if stream:
275
  return _analyze_semantic_with_stream(
276
+ query, content, submode, debug_info, full_match_degree_only, client_ip, rid
277
  )
278
  return _analyze_semantic_plain(
279
+ query, content, submode, debug_info, full_match_degree_only, client_ip, rid
280
  )
281
 
282
  from backend.models.model_manager import ModelSlot
283
  from backend.platform.inference_ingress import ingress_inference
284
  from backend.platform.inference_response_log import make_analyze_semantic_response_logger
285
+ from backend.platform.model_routing import is_local
286
 
287
+ # 本地执行:handler 已打完整日志;仅远程代理时门户用 response_log_fn 打端到端
288
  return ingress_inference(
289
  slot=ModelSlot.INSTRUCT,
290
  api_path="/api/analyze-semantic",
 
293
  timeout=60.0,
294
  log_fn=log_fn,
295
  local_fn=local_fn,
296
+ response_log_fn=(
297
+ None
298
+ if is_local(ModelSlot.INSTRUCT)
299
+ else make_analyze_semantic_response_logger(logged)
300
+ ),
301
  )
backend/core/semantic_analyzer.py CHANGED
@@ -34,13 +34,22 @@ def _get_logits_gradient_submode() -> str:
34
  return "fill_blank"
35
 
36
 
37
- def _truncate_text_by_tokens(tokenizer, text: str, max_tokens: int) -> str:
38
- """将 text 截断至最多 max_tokens 个 token;超长时打印提示。"""
 
 
 
 
 
 
 
 
 
39
  text_ids = tokenizer.encode(text, add_special_tokens=False)
40
  if len(text_ids) > max_tokens:
41
  print(f"⚠️ 原文过长,已截断至前 {max_tokens} token")
42
- return tokenizer.decode(text_ids[:max_tokens])
43
- return text
44
 
45
 
46
  def _get_gradient_checkpointing() -> bool:
@@ -58,6 +67,37 @@ def _get_verbose() -> bool:
58
  return get_verbose()
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def _analyze_logits_gradient(
62
  query: str,
63
  text: str,
@@ -81,35 +121,16 @@ def _analyze_logits_gradient(
81
 
82
  if progress_callback:
83
  progress_callback(1, TOTAL_STEPS, "encoding", None)
84
- # 根据submodule来决定不同的instruction
85
- # 文档前用 \n\n 分隔,避免 tokenizer 将首字符与空格合并,导致 offset_mapping 计算错误
86
- if submode == "count":
87
- instruction = f"请问下面文字中有多少个词与查询主题({query})相关?文字内容:\n\n"
88
- elif submode == "match_score": # 已废弃
89
- instruction = f"请问下面文字与查询主题({query})的相关程度是多少?请回答0/1/2(2为最高相关)。文字内容:\n\n"
90
- elif submode == "fill_blank":
91
- instruction = f"请问下面文字中哪个词与查询主题({query})最相关?如无相关词则回答“无”。文字内容:\n\n"
92
- else:
93
- raise ValueError(f"未知子模式: {submode}")
94
 
95
  # 截断 text 到 max_length token,再拼
96
- truncated_text = _truncate_text_by_tokens(tokenizer, text, max_length)
97
 
98
  messages = [{"role": "user", "content": instruction + truncated_text}]
99
  formatted = tokenizer.apply_chat_template(
100
  messages, tokenize=False, add_generation_prompt=True,
101
  enable_thinking=False
102
  )
103
- # 生成引导词:chat template 只支持完整消息,引导词需追加到 formatted
104
- if submode == "count":
105
- generation_guide = f"原文中与查询主题({query})相关的词的数量 = **"
106
- elif submode == "match_score": # 已废弃
107
- generation_guide = f"文章和查询主题({query})的相关程度(0-2)打分为:**"
108
- elif submode == "fill_blank":
109
- # “引号是特意为了防止模型生成引号
110
- generation_guide = f"原文中与查询主题({query})最相关的一个词是:**“"
111
- else:
112
- raise ValueError(f"未知子模式: {submode}")
113
  formatted += generation_guide
114
 
115
  # logits_gradient count/fill_blank 的 top-k,影响梯度目标覆盖的候选词数量
@@ -170,7 +191,6 @@ def _analyze_logits_gradient(
170
  if _get_verbose():
171
  print(f"top{LOGITS_GRADIENT_TOPK}: {[f'{t}({p*100:.1f}%)' for t, p in zip(topk_tokens, topk_probs)]}")
172
 
173
- neg_token = "无" if submode == "fill_blank" else "0"
174
  neg_id = tokenizer.encode(neg_token, add_special_tokens=False)[0]
175
  # 全文匹配度:count/match_score(已废弃) 用 1-P("0"),fill_blank 用 1-P("无")
176
  p_neg = probs[0, neg_id].item()
@@ -181,6 +201,7 @@ def _analyze_logits_gradient(
181
  "model": get_instruct_model_display_name(),
182
  "token_attention": [],
183
  "full_match_degree": full_match_degree,
 
184
  }
185
 
186
  if progress_callback:
@@ -239,6 +260,7 @@ def _analyze_logits_gradient(
239
  "model": get_instruct_model_display_name(),
240
  "token_attention": token_attention,
241
  "full_match_degree": full_match_degree,
 
242
  }
243
  if debug_info:
244
  out["debug_info"] = {"abbrev": abbrev, "topk_tokens": topk_tokens, "topk_probs": topk_probs}
@@ -250,28 +272,120 @@ def _analyze_logits_gradient(
250
  DeviceManager.clear_cache(device)
251
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  def analyze_semantic(
254
  query: str,
255
- text: str,
256
  submode_override: Optional[str] = None,
257
  progress_callback: Optional[Callable[[int, int, str, Optional[int]], None]] = None,
258
  debug_info: bool = False,
259
  full_match_degree_only: bool = False,
260
- ) -> Dict:
261
  """
262
  分析原文各 token 与 query 的相关度(使用 logits_gradient 梯度归因)。
263
 
264
  Args:
265
  query: 查询主题
266
- text: 原文
267
  submode_override: 评估时可选覆盖子模式(count/match_score已废弃/fill_blank)
268
  progress_callback: 可选进度回调 (step, total_steps, stage, percentage)
269
  debug_info: 为 True 时返回 debug_abbrev(推理原文缩写);topk_tokens、topk_probs 始终在结果中
270
 
271
  Returns:
272
- {"model", "token_attention"(各 token 归因 score;API 历史字段名), "full_match_degree"};debug_info=True 时包含 debug_info 对象
 
 
273
  """
274
  tokenizer, model, device = ensure_instruct_slot_ready()
 
 
 
 
 
 
 
 
275
  return _analyze_logits_gradient(
276
  query, text, tokenizer, model, device,
277
  submode_override=submode_override,
 
34
  return "fill_blank"
35
 
36
 
37
+ def _truncate_text_by_tokens(tokenizer, text: str, max_tokens: int) -> tuple[str, int]:
38
+ """
39
+ 将 text 截断至最多 max_tokens 个 token;超长时打印提示。
40
+ 返回 (截断后文本, 实际 token 数),token 数供调用方复用(日志等),避免重复分词。
41
+
42
+ 已知问题:截断是静默的,响应里没有任何字段告知调用方发生过截断。前端按字节数切 chunk
43
+ (extension/config.js chunkBytes=800),与这里的 token 上限没有联动;数字/标点/代码等
44
+ token 密度高的内容,800 字节可能远超 max_tokens,导致相关度判断和 fill_blank 高亮只覆盖
45
+ 截断后的前缀。后果是漏检(chunk 被判无关或部分内容不高亮),不是误报。调大 max_tokens
46
+ 只能缓解、不能根治(数字等本就按字符/短片段单独分词,token 密度上限很高)。
47
+ """
48
  text_ids = tokenizer.encode(text, add_special_tokens=False)
49
  if len(text_ids) > max_tokens:
50
  print(f"⚠️ 原文过长,已截断至前 {max_tokens} token")
51
+ return tokenizer.decode(text_ids[:max_tokens]), max_tokens
52
+ return text, len(text_ids)
53
 
54
 
55
  def _get_gradient_checkpointing() -> bool:
 
67
  return get_verbose()
68
 
69
 
70
+ def _build_prompt_parts(submode: str, query: str) -> tuple:
71
+ """
72
+ 按子模式(count / match_score已废弃 / fill_blank)构造 (instruction, generation_guide, neg_token)。
73
+ 单条与批量前向共用,避免同一套子模式分叉在两处重复维护。
74
+
75
+ - instruction:拼在原文前的指令;文档前用 \\n\\n 分隔,避免 tokenizer 将首字符与空格合并导致 offset_mapping 计算错误
76
+ - generation_guide:chat template 只支持完整消息,生成引导词需在 apply_chat_template 之后追加
77
+ - neg_token:计算 full_match_degree(1 - P(neg_token))用的「无关」token
78
+ """
79
+ if submode == "count":
80
+ return (
81
+ f"请问下面文字中有多少个词与查询主题({query})相关?文字内容:\n\n",
82
+ f"原文中与查询主题({query})相关的词的数量 = **",
83
+ "0",
84
+ )
85
+ if submode == "match_score": # 已废弃
86
+ return (
87
+ f"请问下面文字与查询主题({query})的相关程度是多少?请回答0/1/2(2为最高相关)。文字内容:\n\n",
88
+ f"文章和查询主题({query})的相关程度(0-2)打分为:**",
89
+ "0",
90
+ )
91
+ if submode == "fill_blank":
92
+ return (
93
+ f"请问下面文字中哪个词与查询主题({query})最相关?如无相关词则回答“无”。文字内容:\n\n",
94
+ # “引号是特意为了防止模型生成引号
95
+ f"原文中与查询主题({query})最相关的一个词是:**“",
96
+ "无",
97
+ )
98
+ raise ValueError(f"未知子模式: {submode}")
99
+
100
+
101
  def _analyze_logits_gradient(
102
  query: str,
103
  text: str,
 
121
 
122
  if progress_callback:
123
  progress_callback(1, TOTAL_STEPS, "encoding", None)
124
+ instruction, generation_guide, neg_token = _build_prompt_parts(submode, query)
 
 
 
 
 
 
 
 
 
125
 
126
  # 截断 text 到 max_length token,再拼
127
+ truncated_text, input_token_count = _truncate_text_by_tokens(tokenizer, text, max_length)
128
 
129
  messages = [{"role": "user", "content": instruction + truncated_text}]
130
  formatted = tokenizer.apply_chat_template(
131
  messages, tokenize=False, add_generation_prompt=True,
132
  enable_thinking=False
133
  )
 
 
 
 
 
 
 
 
 
 
134
  formatted += generation_guide
135
 
136
  # logits_gradient count/fill_blank 的 top-k,影响梯度目标覆盖的候选词数量
 
191
  if _get_verbose():
192
  print(f"top{LOGITS_GRADIENT_TOPK}: {[f'{t}({p*100:.1f}%)' for t, p in zip(topk_tokens, topk_probs)]}")
193
 
 
194
  neg_id = tokenizer.encode(neg_token, add_special_tokens=False)[0]
195
  # 全文匹配度:count/match_score(已废弃) 用 1-P("0"),fill_blank 用 1-P("无")
196
  p_neg = probs[0, neg_id].item()
 
201
  "model": get_instruct_model_display_name(),
202
  "token_attention": [],
203
  "full_match_degree": full_match_degree,
204
+ "input_token_count": input_token_count,
205
  }
206
 
207
  if progress_callback:
 
260
  "model": get_instruct_model_display_name(),
261
  "token_attention": token_attention,
262
  "full_match_degree": full_match_degree,
263
+ "input_token_count": input_token_count,
264
  }
265
  if debug_info:
266
  out["debug_info"] = {"abbrev": abbrev, "topk_tokens": topk_tokens, "topk_probs": topk_probs}
 
272
  DeviceManager.clear_cache(device)
273
 
274
 
275
+ def _analyze_logits_gradient_batch(
276
+ query: str,
277
+ texts: List[str],
278
+ tokenizer,
279
+ model,
280
+ device,
281
+ submode_override: Optional[str] = None,
282
+ progress_callback: Optional[Callable[[int, int, str, Optional[int]], None]] = None,
283
+ ) -> List[Dict]:
284
+ """
285
+ 批量 full_match_degree_only 前向:多条 text 一次 forward,仅用于 count 预判等纯前向场景的性能测试。
286
+ 无反向传播,不产出 token_attention(与单条 full_match_degree_only=True 时一致)。
287
+
288
+ 性能结论(本地 CPU/线上服务器实测):
289
+ batching 对本函数内前向计算的加速有限,batch=4 约 1.3~1.5x,batch=8 约 1.6x,
290
+ 边际递减明显;MPS 上则几乎无收益。线上用客户端总耗时测得的更高"加速比",
291
+ 大头来自摊薄了每次 HTTP 请求固定的网络/SSE 开销(约 1.2~1.7s,与 batch 无关),
292
+ 并非本函数计算变快。
293
+ """
294
+ TOTAL_STEPS = 2 # 批量只有 encoding / inference 两阶段,无 backward/processing
295
+
296
+ submode = submode_override if submode_override is not None else _get_logits_gradient_submode()
297
+ max_length = get_semantic_max_token_length()
298
+
299
+ if progress_callback:
300
+ progress_callback(1, TOTAL_STEPS, "encoding", None)
301
+ instruction, generation_guide, neg_token = _build_prompt_parts(submode, query)
302
+
303
+ formatted_list = []
304
+ input_token_counts = []
305
+ for text in texts:
306
+ truncated_text, input_token_count = _truncate_text_by_tokens(tokenizer, text, max_length)
307
+ input_token_counts.append(input_token_count)
308
+ messages = [{"role": "user", "content": instruction + truncated_text}]
309
+ formatted = tokenizer.apply_chat_template(
310
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
311
+ )
312
+ formatted_list.append(formatted + generation_guide)
313
+
314
+ # 左 padding:批内各序列长度不同,取 logits[:, -1, :] 时需保证最后一列都是各自真实末尾 token
315
+ # pad_token / padding_side 都是共享 tokenizer 上的可变状态,用完必须还原
316
+ original_pad_token = tokenizer.pad_token
317
+ original_padding_side = tokenizer.padding_side
318
+ try:
319
+ if tokenizer.pad_token_id is None:
320
+ tokenizer.pad_token = tokenizer.eos_token
321
+ tokenizer.padding_side = "left"
322
+ enc = tokenizer(formatted_list, return_tensors="pt", padding=True)
323
+ finally:
324
+ tokenizer.padding_side = original_padding_side
325
+ tokenizer.pad_token = original_pad_token
326
+
327
+ input_ids = enc["input_ids"].to(device)
328
+ attention_mask = enc["attention_mask"].to(device)
329
+
330
+ if progress_callback:
331
+ progress_callback(2, TOTAL_STEPS, "inference", None)
332
+ model.eval()
333
+ with torch.no_grad():
334
+ outputs = model(input_ids=input_ids, attention_mask=attention_mask, output_attentions=False)
335
+ if device.type == "cuda":
336
+ torch.cuda.synchronize(device)
337
+ elif device.type == "mps":
338
+ torch.mps.synchronize()
339
+
340
+ logits = outputs.logits[:, -1, :]
341
+ probs = torch.softmax(logits, dim=-1)
342
+ neg_id = tokenizer.encode(neg_token, add_special_tokens=False)[0]
343
+ full_match_degrees = (1.0 - probs[:, neg_id]).tolist()
344
+
345
+ DeviceManager.clear_cache(device)
346
+ return [
347
+ {
348
+ "model": get_instruct_model_display_name(),
349
+ "token_attention": [],
350
+ "full_match_degree": round(fmd, 4),
351
+ "input_token_count": input_token_count,
352
+ }
353
+ for fmd, input_token_count in zip(full_match_degrees, input_token_counts)
354
+ ]
355
+
356
+
357
  def analyze_semantic(
358
  query: str,
359
+ text,
360
  submode_override: Optional[str] = None,
361
  progress_callback: Optional[Callable[[int, int, str, Optional[int]], None]] = None,
362
  debug_info: bool = False,
363
  full_match_degree_only: bool = False,
364
+ ):
365
  """
366
  分析原文各 token 与 query 的相关度(使用 logits_gradient 梯度归因)。
367
 
368
  Args:
369
  query: 查询主题
370
+ text: 原文;为 List[str] 时走批量前向(仅支持 full_match_degree_only=True,用于性能测试)
371
  submode_override: 评估时可选覆盖子模式(count/match_score已废弃/fill_blank)
372
  progress_callback: 可选进度回调 (step, total_steps, stage, percentage)
373
  debug_info: 为 True 时返回 debug_abbrev(推理原文缩写);topk_tokens、topk_probs 始终在结果中
374
 
375
  Returns:
376
+ text 为 str 时:{"model", "token_attention"(各 token 归因 score;API 历史字段名), "full_match_degree"};
377
+ debug_info=True 时包含 debug_info 对象。
378
+ text 为 List[str] 时:上述字典组成的 List(无 debug_info)。
379
  """
380
  tokenizer, model, device = ensure_instruct_slot_ready()
381
+ if isinstance(text, list):
382
+ if not full_match_degree_only:
383
+ raise ValueError("text 为数组(批量)时仅支持 full_match_degree_only=True")
384
+ return _analyze_logits_gradient_batch(
385
+ query, text, tokenizer, model, device,
386
+ submode_override=submode_override,
387
+ progress_callback=progress_callback,
388
+ )
389
  return _analyze_logits_gradient(
390
  query, text, tokenizer, model, device,
391
  submode_override=submode_override,
backend/platform/access_log.py CHANGED
@@ -160,9 +160,28 @@ def log_analyze_semantic_start(request_id: int, wait_time: float, stream_mode: b
160
  print(f"\t🔄 API analyze_semantic {mode_str} start: req_id={request_id}, wait_time={wait_time:.2f}s")
161
 
162
 
163
- def log_analyze_semantic_request(query: str, text: str, client_ip: str = None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  """
165
- 记录收到 semantic 分析请求
 
166
 
167
  Returns:
168
  int: 请求ID
@@ -173,11 +192,16 @@ def log_analyze_semantic_request(query: str, text: str, client_ip: str = None):
173
  _request_counter += 1
174
  request_id = _request_counter
175
 
176
- preview = 50
177
- q_preview = _log_str_preview(query, preview)
178
- t_preview = _log_str_preview(text, preview)
179
- details = f"req_id={request_id}, query='{q_preview}', text='{t_preview}', chars={len(text)}"
180
- log_request("📥 semantic 分析请求", details, client_ip)
 
 
 
 
 
181
 
182
  _hit_api("analyze_semantic")
183
  return request_id
 
160
  print(f"\t🔄 API analyze_semantic {mode_str} start: req_id={request_id}, wait_time={wait_time:.2f}s")
161
 
162
 
163
+ _visitor_seq: dict = {}
164
+ _visitor_seq_lock = threading.Lock()
165
+
166
+
167
+ def _visitor_seq_no(ip: Optional[str]) -> int:
168
+ """
169
+ 进程内递增的访客序号:同一 IP 复用同一序号,日志里只留这个号、不落 IP 本身。
170
+ 映射表只在内存里,随进程重启清空,不写盘。
171
+ """
172
+ key = ip or "unknown"
173
+ with _visitor_seq_lock:
174
+ seq = _visitor_seq.get(key)
175
+ if seq is None:
176
+ seq = len(_visitor_seq) + 1
177
+ _visitor_seq[key] = seq
178
+ return seq
179
+
180
+
181
+ def log_analyze_semantic_request(query: str, text: str, client_ip: str = None, privacy_mode: bool = False):
182
  """
183
+ 记录收到 semantic 分析请求。
184
+ privacy_mode=True(插件请求固定如此)时不落 query/text 内容与明文 IP,仅记字符数和 IP 摘要。
185
 
186
  Returns:
187
  int: 请求ID
 
192
  _request_counter += 1
193
  request_id = _request_counter
194
 
195
+ if privacy_mode:
196
+ visitor = f"visitor=#{_visitor_seq_no(client_ip)}"
197
+ details = f"req_id={request_id}, query_chars={len(query)}, text_chars={len(text)}"
198
+ log_request("📥 semantic 分析请求(隐私模式)", details, client_ip=visitor)
199
+ else:
200
+ preview = 50
201
+ q_preview = _log_str_preview(query, preview)
202
+ t_preview = _log_str_preview(text, preview)
203
+ details = f"req_id={request_id}, query='{q_preview}', text='{t_preview}', chars={len(text)}"
204
+ log_request("📥 semantic 分析请求", details, client_ip)
205
 
206
  _hit_api("analyze_semantic")
207
  return request_id
backend/platform/inference_ingress.py CHANGED
@@ -75,7 +75,7 @@ def ingress_inference(
75
  顺序:access_log / bump_api(log_fn)→ 分流 → response_log_fn(非 worker)。
76
 
77
  Worker 上未启用的槽位直接 404;remote 槽位 HTTP 代理到对应 Space。
78
- 门户本地与代理共用同一 response 日志回调。
79
  """
80
  if is_worker() and not slot_enabled(slot):
81
  return _slot_unavailable(slot)
 
75
  顺序:access_log / bump_api(log_fn)→ 分流 → response_log_fn(非 worker)。
76
 
77
  Worker 上未启用的槽位直接 404;remote 槽位 HTTP 代理到对应 Space。
78
+ response_log_fn:远程代理端到端日志;本地执行若已在 handler 打完整日志可传 None。
79
  """
80
  if is_worker() and not slot_enabled(slot):
81
  return _slot_unavailable(slot)
backend/platform/inference_response_log.py CHANGED
@@ -1,4 +1,9 @@
1
- """推理 API 响应日志:ingress 统一回调;handler 内仅 worker 本地路径打印。"""
 
 
 
 
 
2
  from __future__ import annotations
3
 
4
  from collections.abc import Callable, Mapping
@@ -43,14 +48,17 @@ def log_analyze_semantic_response(
43
  request_id: int | None,
44
  result: dict,
45
  elapsed: float,
 
46
  for_worker: bool = False,
47
  ) -> None:
48
  if not _should_emit(for_worker=for_worker):
49
  return
50
- tokens = len(result.get("token_attention", []))
51
  msg = f"\t📤 API analyze_semantic (stream) response:"
52
  msg += _req_id_part(request_id)
53
  msg += f" tokens={tokens}, response_time={elapsed:.4f}s"
 
 
54
  print(msg)
55
 
56
 
@@ -171,12 +179,22 @@ def make_analyze_response_logger(
171
  def make_analyze_semantic_response_logger(
172
  logged: Mapping[str, int | None],
173
  ) -> PortalResponseLogger:
 
 
174
  def _log(data: Any, elapsed: float, _status: int) -> None:
175
  if not isinstance(data, dict):
176
  return
 
 
 
 
 
 
 
 
177
  log_analyze_semantic_response(
178
  request_id=logged.get("request_id"),
179
- result=data,
180
  elapsed=elapsed,
181
  )
182
 
 
1
+ """推理 API 响应日志。
2
+
3
+ - 远程代理:门户 ingress `response_log_fn` 打端到端。
4
+ - 本地执行(门户本地槽位或 worker):handler 内打完整日志(可含 wait/processing);
5
+ 用 `for_worker` 区分进程,避免与门户回调重复。
6
+ """
7
  from __future__ import annotations
8
 
9
  from collections.abc import Callable, Mapping
 
48
  request_id: int | None,
49
  result: dict,
50
  elapsed: float,
51
+ wait_time: float | None = None,
52
  for_worker: bool = False,
53
  ) -> None:
54
  if not _should_emit(for_worker=for_worker):
55
  return
56
+ tokens = result.get("input_token_count", len(result.get("token_attention", [])))
57
  msg = f"\t📤 API analyze_semantic (stream) response:"
58
  msg += _req_id_part(request_id)
59
  msg += f" tokens={tokens}, response_time={elapsed:.4f}s"
60
+ if wait_time is not None:
61
+ msg += f" (wait={wait_time:.4f}s, processing={elapsed - wait_time:.4f}s)"
62
  print(msg)
63
 
64
 
 
179
  def make_analyze_semantic_response_logger(
180
  logged: Mapping[str, int | None],
181
  ) -> PortalResponseLogger:
182
+ """远程代理路径:从响应体取 input_token_count(含批量 results 汇总),无 wait。"""
183
+
184
  def _log(data: Any, elapsed: float, _status: int) -> None:
185
  if not isinstance(data, dict):
186
  return
187
+ results = data.get("results")
188
+ if isinstance(results, list):
189
+ total = sum(
190
+ r.get("input_token_count", 0) for r in results if isinstance(r, dict)
191
+ )
192
+ result = {"input_token_count": total}
193
+ else:
194
+ result = data
195
  log_analyze_semantic_response(
196
  request_id=logged.get("request_id"),
197
+ result=result,
198
  elapsed=elapsed,
199
  )
200
 
backend/platform/logging_config.py CHANGED
@@ -16,7 +16,6 @@ def configure_logging(app=None):
16
  # 屏蔽第三方库的日志
17
  logging.getLogger('werkzeug').setLevel(logging.CRITICAL)
18
  logging.getLogger('connexion').setLevel(logging.CRITICAL)
19
- logging.getLogger('flask_cors').setLevel(logging.CRITICAL)
20
  logging.getLogger('flask').setLevel(logging.CRITICAL)
21
  logging.getLogger('urllib3').setLevel(logging.CRITICAL)
22
  logging.getLogger('transformers').setLevel(logging.CRITICAL)
 
16
  # 屏蔽第三方库的日志
17
  logging.getLogger('werkzeug').setLevel(logging.CRITICAL)
18
  logging.getLogger('connexion').setLevel(logging.CRITICAL)
 
19
  logging.getLogger('flask').setLevel(logging.CRITICAL)
20
  logging.getLogger('urllib3').setLevel(logging.CRITICAL)
21
  logging.getLogger('transformers').setLevel(logging.CRITICAL)
backend/platform/runtime_config.py CHANGED
@@ -86,6 +86,7 @@ RUNTIME_CONFIGS = {
86
 
87
  # ============= 语义分析运行时配置(仅 max_token_length) =============
88
  # 按平台配置,语义分析独立于信息密度模型
 
89
 
90
  SEMANTIC_RUNTIME_CONFIGS = {
91
  "default_cpu_machine": {"max_token_length": 300},
 
86
 
87
  # ============= 语义分析运行时配置(仅 max_token_length) =============
88
  # 按平台配置,语义分析独立于信息密度模型
89
+ # 已知问题:与前端 chunkBytes 无联动,超限时静默截断导致漏检,见 semantic_analyzer._truncate_text_by_tokens
90
 
91
  SEMANTIC_RUNTIME_CONFIGS = {
92
  "default_cpu_machine": {"max_token_length": 300},
backend/tests/test_inference_response_log.py CHANGED
@@ -64,3 +64,35 @@ def test_extract_completions_ttft_from_result():
64
  }
65
  p, c, ttft = inference_response_log.extract_completions_usage(data)
66
  assert (p, c, ttft) == (10, 5, 0.25)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
  p, c, ttft = inference_response_log.extract_completions_usage(data)
66
  assert (p, c, ttft) == (10, 5, 0.25)
67
+
68
+
69
+ def test_semantic_response_uses_input_token_count_when_attention_empty(capsys):
70
+ inference_response_log.log_analyze_semantic_response(
71
+ request_id=19,
72
+ result={"token_attention": [], "input_token_count": 142},
73
+ elapsed=1.564,
74
+ wait_time=0.012,
75
+ )
76
+ out = capsys.readouterr().out
77
+ assert "tokens=142" in out
78
+ assert "wait=0.0120s" in out
79
+ assert "processing=" in out
80
+
81
+
82
+ def test_semantic_portal_logger_sums_batch_input_token_count(capsys):
83
+ logged = {"request_id": 7}
84
+ log_fn = inference_response_log.make_analyze_semantic_response_logger(logged)
85
+ log_fn(
86
+ {
87
+ "success": True,
88
+ "results": [
89
+ {"full_match_degree": 0.5, "input_token_count": 10},
90
+ {"full_match_degree": 0.2, "input_token_count": 20},
91
+ ],
92
+ },
93
+ 0.8,
94
+ 200,
95
+ )
96
+ out = capsys.readouterr().out
97
+ assert "req_id=7" in out
98
+ assert "tokens=30" in out
client/src/analysis.html CHANGED
@@ -81,7 +81,7 @@
81
  <span class="semantic-submode-group">
82
  <label class="semantic-submode-label" for="semantic_analysis_toggle">
83
  <input type="checkbox" id="semantic_analysis_toggle" />
84
- <span data-i18n>Semantic Query(Beta)</span>
85
  </label>
86
  </span>
87
  </div>
 
81
  <span class="semantic-submode-group">
82
  <label class="semantic-submode-label" for="semantic_analysis_toggle">
83
  <input type="checkbox" id="semantic_analysis_toggle" />
84
+ <span data-i18n>Semantic Find(Beta)</span>
85
  </label>
86
  </span>
87
  </div>
client/src/css/base/_theme-vars.scss CHANGED
@@ -56,6 +56,25 @@
56
  --accent-glow: color-mix(in srgb, var(--accent-color) 60%, transparent);
57
  --accent-shadow: color-mix(in srgb, var(--accent-color) 65%, transparent);
58
  --accent-stroke: color-mix(in srgb, var(--accent-color) 90%, transparent);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  --text-disabled: #999;
60
  --text-area-bg: #fff;
61
  --textarea-actions-button-row-min-height: 3.5rem;
@@ -108,6 +127,24 @@
108
  --accent-glow: color-mix(in srgb, var(--accent-color) 80%, transparent);
109
  --accent-shadow: var(--accent-glow);
110
  --accent-stroke: color-mix(in srgb, var(--accent-color) 90%, transparent);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  --primary-color: var(--accent-color);
112
  --text-disabled: #666;
113
  --text-area-bg: #191919;
 
56
  --accent-glow: color-mix(in srgb, var(--accent-color) 60%, transparent);
57
  --accent-shadow: color-mix(in srgb, var(--accent-color) 65%, transparent);
58
  --accent-stroke: color-mix(in srgb, var(--accent-color) 90%, transparent);
59
+ // 语义 AI 主题(参考 Gemini 彩虹渐变,区别于交互蓝 --accent-color)
60
+ --semantic-ai-gradient: linear-gradient(
61
+ 90deg,
62
+ #4285f4 0%,
63
+ #9b72cb 28%,
64
+ #d96570 52%,
65
+ #fbbc04 78%,
66
+ #4285f4 100%
67
+ );
68
+ --semantic-ai-gradient-muted: linear-gradient(
69
+ 90deg,
70
+ color-mix(in srgb, #4285f4 48%, #9aa0a6) 0%,
71
+ color-mix(in srgb, #9b72cb 48%, #9aa0a6) 28%,
72
+ color-mix(in srgb, #d96570 48%, #9aa0a6) 52%,
73
+ color-mix(in srgb, #fbbc04 48%, #9aa0a6) 78%,
74
+ color-mix(in srgb, #4285f4 48%, #9aa0a6) 100%
75
+ );
76
+ --semantic-ai-glow: color-mix(in srgb, #9b72cb 42%, transparent);
77
+ --semantic-ai-glow-muted: color-mix(in srgb, #9b72cb 22%, transparent);
78
  --text-disabled: #999;
79
  --text-area-bg: #fff;
80
  --textarea-actions-button-row-min-height: 3.5rem;
 
127
  --accent-glow: color-mix(in srgb, var(--accent-color) 80%, transparent);
128
  --accent-shadow: var(--accent-glow);
129
  --accent-stroke: color-mix(in srgb, var(--accent-color) 90%, transparent);
130
+ --semantic-ai-gradient: linear-gradient(
131
+ 90deg,
132
+ #8ab4f8 0%,
133
+ #c58af9 28%,
134
+ #f28b82 52%,
135
+ #fdd663 78%,
136
+ #8ab4f8 100%
137
+ );
138
+ --semantic-ai-gradient-muted: linear-gradient(
139
+ 90deg,
140
+ color-mix(in srgb, #8ab4f8 45%, #5f6368) 0%,
141
+ color-mix(in srgb, #c58af9 45%, #5f6368) 28%,
142
+ color-mix(in srgb, #f28b82 45%, #5f6368) 52%,
143
+ color-mix(in srgb, #fdd663 45%, #5f6368) 78%,
144
+ color-mix(in srgb, #8ab4f8 45%, #5f6368) 100%
145
+ );
146
+ --semantic-ai-glow: color-mix(in srgb, #c58af9 50%, transparent);
147
+ --semantic-ai-glow-muted: color-mix(in srgb, #c58af9 24%, transparent);
148
  --primary-color: var(--accent-color);
149
  --text-disabled: #666;
150
  --text-area-bg: #191919;
client/src/css/components/_semantic-analysis.scss CHANGED
@@ -199,6 +199,13 @@
199
  border-radius: 4px;
200
  background: var(--input-bg);
201
  color: var(--text-color);
 
 
 
 
 
 
 
202
  }
203
 
204
  .semantic-search-clear {
 
199
  border-radius: 4px;
200
  background: var(--input-bg);
201
  color: var(--text-color);
202
+
203
+ // 搜索中 readOnly:不显示编辑光标、不可点入
204
+ &:read-only {
205
+ caret-color: transparent;
206
+ cursor: default;
207
+ pointer-events: none;
208
+ }
209
  }
210
 
211
  .semantic-search-clear {
client/src/css/pages/analysis.scss CHANGED
@@ -1,7 +1,25 @@
1
  // Analysis 页专有样式(intro / 统计图 / 分析进度)
2
  @use 'app-pages';
 
3
  @use '../base/narrow-ios-form-font-tail' as ios-form-font;
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  // 介绍区域样式
6
  .intro-section {
7
  margin-bottom: 15px;
 
1
  // Analysis 页专有样式(intro / 统计图 / 分析进度)
2
  @use 'app-pages';
3
+ @use '../base/breakpoints' as *;
4
  @use '../base/narrow-ios-form-font-tail' as ios-form-font;
5
 
6
+ @media (min-width: $breakpoint-tablet) {
7
+ html:not([data-force-narrow]) {
8
+ .right_panel {
9
+ overflow: hidden;
10
+ }
11
+
12
+ .right_panel > #results {
13
+ flex: 1 1 auto;
14
+ min-height: 0;
15
+ height: 100%;
16
+ max-height: 100%;
17
+ overflow-x: hidden;
18
+ overflow-y: auto;
19
+ }
20
+ }
21
+ }
22
+
23
  // 介绍区域样式
24
  .intro-section {
25
  margin-bottom: 15px;
client/src/features/analysis/analyzeFlow.ts CHANGED
@@ -17,6 +17,7 @@ import { handleServerDemoSave } from '../../shared/controllers/serverDemoControl
17
  // 国际化
18
  import { tr } from '../../shared/lang/i18n-lite';
19
  import { playAnalysisCompleteSound } from '../../shared/cross/soundNotification';
 
20
 
21
  /**
22
  * 分析进度回调
@@ -86,10 +87,8 @@ export class AnalyzeFlowManager {
86
  */
87
  private scrollToTop(): void {
88
  requestAnimationFrame(() => {
89
- const rightPanel = document.querySelector('.right_panel') as HTMLElement;
90
- if (rightPanel) {
91
- rightPanel.scrollTop = 0;
92
- }
93
  });
94
  }
95
 
 
17
  // 国际化
18
  import { tr } from '../../shared/lang/i18n-lite';
19
  import { playAnalysisCompleteSound } from '../../shared/cross/soundNotification';
20
+ import { getDesktopTextScrollRoot } from '../../shared/core/responsive';
21
 
22
  /**
23
  * 分析进度回调
 
87
  */
88
  private scrollToTop(): void {
89
  requestAnimationFrame(() => {
90
+ const root = getDesktopTextScrollRoot();
91
+ if (root) root.scrollTop = 0;
 
 
92
  });
93
  }
94
 
client/src/features/analysis/appStateManager.ts CHANGED
@@ -4,6 +4,7 @@
4
  */
5
 
6
  import * as d3 from 'd3';
 
7
 
8
  /**
9
  * 应用状态对象
@@ -128,9 +129,10 @@ export class AppStateManager {
128
  }
129
  }
130
 
131
- // TextMetrics显示:有有效数据(hasValidData = true 时,stats 一定不为 null)
132
  if (!this.deps.textMetrics.empty()) {
133
- this.deps.textMetrics.classed('is-hidden', !this.state.hasValidData);
 
134
  }
135
  }
136
 
@@ -155,10 +157,20 @@ export class AppStateManager {
155
  }
156
  // 语义搜索 loading:独立 loader
157
  if (updates.isSemanticSearching !== undefined) {
158
- d3.select('#semantic_search_loader').style('visibility', updates.isSemanticSearching ? 'visible' : 'hidden');
159
- if (!updates.isSemanticSearching) {
 
160
  d3.select('#semantic_progress').text('').style('display', 'none');
161
  }
 
 
 
 
 
 
 
 
 
162
  }
163
  }
164
 
 
4
  */
5
 
6
  import * as d3 from 'd3';
7
+ import { getSemanticAnalysisEnabled } from '../../shared/cross/semanticAnalysisManager';
8
 
9
  /**
10
  * 应用状态对象
 
129
  }
130
  }
131
 
132
+ // TextMetrics:仅信息密度模式且有有效数据时显示(语义模式互斥隐藏)
133
  if (!this.deps.textMetrics.empty()) {
134
+ const showMetrics = this.state.hasValidData && !getSemanticAnalysisEnabled();
135
+ this.deps.textMetrics.classed('is-hidden', !showMetrics);
136
  }
137
  }
138
 
 
157
  }
158
  // 语义搜索 loading:独立 loader
159
  if (updates.isSemanticSearching !== undefined) {
160
+ const searching = updates.isSemanticSearching;
161
+ d3.select('#semantic_search_loader').style('visibility', searching ? 'visible' : 'hidden');
162
+ if (!searching) {
163
  d3.select('#semantic_progress').text('').style('display', 'none');
164
  }
165
+ // 搜索中退出编辑态:失焦、只读、藏光标;结束恢复可编
166
+ const el = document.getElementById('semantic_search_input') as HTMLInputElement | null;
167
+ if (el) {
168
+ el.readOnly = searching;
169
+ if (searching) el.blur();
170
+ }
171
+ if (searching) {
172
+ document.getElementById('semantic_search_history_dropdown')?.classList.remove('is-visible');
173
+ }
174
  }
175
  }
176
 
client/src/features/analysis/infoDensityRenderManager.ts DELETED
@@ -1,12 +0,0 @@
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/analysis/visualizationUpdater.ts CHANGED
@@ -45,14 +45,6 @@ import { getDigitsMergeEnabled } from '../../shared/cross/digitsMergeManager';
45
  import { getSemanticMatchThreshold } from '../../shared/cross/semanticThresholdManager';
46
  import { applySemanticDebugInfoPanel } from '../../shared/prediction_attribution/core/semanticDebugInfo';
47
 
48
- /** Token 边界不一致时抛出,用于中断联合展示 */
49
- export class TokenBoundaryInconsistentError extends Error {
50
- constructor() {
51
- super('Tokenizer results inconsistent: semantic and info-density token boundaries differ.');
52
- this.name = 'TokenBoundaryInconsistentError';
53
- }
54
- }
55
-
56
  /**
57
  * P(signal | raw_score_normed = s) 复用 findSignalThreshold 的 bins
58
  * 每个样本 s 落入对应 bin,P(signal) = (obsInBin - expInBin) / obsInBin
@@ -81,6 +73,8 @@ export interface VisualizationDependencies {
81
  stats_match_score_progress: ScatterPlot;
82
  appStateManager: AppStateManager;
83
  surprisalColorScale: d3.ScaleSequential<string>;
 
 
84
  }
85
 
86
  /** 语义分析原始数据(独立存储) */
@@ -119,7 +113,7 @@ function hasSemanticData(data: { token_attention?: unknown[]; chunkInfos?: unkno
119
 
120
  /**
121
  * 当前数据状态
122
- * 信息密度与语义分析独立存储,展示时根据一致性决定单独或联合
123
  */
124
  export interface CurrentDataState {
125
  /** 信息密度分析结果(独立) */
@@ -168,7 +162,7 @@ export class VisualizationUpdater {
168
  }
169
 
170
  /**
171
- * 获取当前展示数据(由 infoDensityData 与 semanticData 按展示逻辑计算)
172
  */
173
  getCurrentData(): AnalyzeResponse | null {
174
  const display = this.computeDisplayResult();
@@ -176,6 +170,24 @@ export class VisualizationUpdater {
176
  return { request: { text: display.originalText }, result: display };
177
  }
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  /**
180
  * 获取当前 surprisal 数据
181
  */
@@ -193,12 +205,12 @@ export class VisualizationUpdater {
193
  /**
194
  * 清除高亮
195
  */
196
- private clearHighlights(): void {
197
- this.deps.highlightController.clearHighlights();
198
  }
199
 
200
  /**
201
- * 计算展示结果:仅信息密度 / 仅语义 / 联合(两者一致时)
202
  */
203
  private computeDisplayResult(): (FrontendAnalyzeResult & {
204
  rawScoresNormed?: number[];
@@ -208,64 +220,26 @@ export class VisualizationUpdater {
208
  const info = this.currentState.infoDensityData;
209
  const sem = this.currentState.semanticData;
210
  const infoResult = info?.result as FrontendAnalyzeResult | undefined;
211
- const infoText = info?.request?.text ?? infoResult?.originalText ?? '';
212
- const semText = sem?.text ?? '';
213
-
214
- if (infoResult && sem && infoText === semText && hasSemanticData(sem)) {
215
- const infoMerged = infoResult.bpeBpeMergedTokens ?? infoResult.bpe_strings;
216
- if (infoMerged?.length) {
217
- // 有 token_attention 时校验边界;仅 chunkInfos 时跳过(无语义着色)
218
- if (sem.token_attention?.length) {
219
- const boundaryError = this.checkSemanticAlignsWithInfo(sem.token_attention, infoMerged, semText);
220
- if (boundaryError) {
221
- const { aSample, bSample, aNext, bNext, textBefore, textAt, textAfter } = boundaryError;
222
- console.warn(
223
- '[联合模式] 两种分析的分词token边界不一致:\n' +
224
- ' 语义分析:', aSample, '\n' +
225
- ' 信息密度:', bSample, '\n' +
226
- ' 语义后一个:', aNext, '\n' +
227
- ' 信息后一个:', bNext, '\n' +
228
- ' 位置附近原文:', JSON.stringify(textBefore), '|', JSON.stringify(textAt), '|', JSON.stringify(textAfter)
229
- );
230
- showAlertDialog(tr('Error'), tr('Tokenizer results inconsistent: semantic and info-density token boundaries differ.'));
231
- this.currentState.semanticData = null;
232
- throw new TokenBoundaryInconsistentError();
233
- }
234
- }
235
- // 联合模式:bpeMerged 与语义 tokens 超出部分合并为并集,使 rect/渲染范围与截断边界一致
236
- const semanticTokens = sem.token_attention ?? [];
237
- const { unionTokens, scoresForUnion, rawScoresForUnion } = semanticTokens.length
238
- ? this.mergeBpeWithSemanticBeyond(infoMerged, semanticTokens)
239
- : (() => {
240
- const m = this.mapSemanticTokensToMerged(infoMerged, []);
241
- return {
242
- unionTokens: infoMerged,
243
- scoresForUnion: m.scores,
244
- rawScoresForUnion: m.rawScores,
245
- };
246
- })();
247
- return {
248
- ...infoResult,
249
- bpeBpeMergedTokens: unionTokens,
250
- bpe_strings: unionTokens,
251
- rawScoresNormed: scoresForUnion,
252
- tokenRawScores: rawScoresForUnion,
253
- chunkInfos: sem.chunkInfos,
254
- };
255
  }
 
256
  }
257
- // 有语义数据(token_attention 或 chunkInfos)时用 buildSemanticOnlyResult
258
- if (sem && hasSemanticData(sem)) {
259
- return this.buildSemanticOnlyResult({ model: sem.model }, sem.token_attention, sem.text, sem.chunkInfos);
260
- }
261
- if (infoResult) return { ...infoResult, chunkInfos: sem?.chunkInfos ?? undefined };
262
  return null;
263
  }
264
 
265
  /**
266
- * 分析开始前更新直方图显示/隐藏:基于「已有数据 + 将要得到的数据」判断各统计图是否有意义
267
  * @param mode 即将进行的分析类型
268
- * @param text 即将分析的文本(用于判断与已有数据是否一致、能否联合展示)
269
  * @param willBeChunked 语义分析时:true 表示将走分块模式,直方图不显示
270
  */
271
  public updateHistogramVisibilityForPending(mode: 'infoDensity' | 'semantic', text: string, willBeChunked?: boolean): void {
@@ -274,26 +248,8 @@ export class VisualizationUpdater {
274
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
275
  const matchScoreProgressItem = document.getElementById('match_score_progress_item');
276
 
277
- const infoText = this.currentState.infoDensityData?.request?.text ?? '';
278
- const semText = this.currentState.semanticData?.text ?? '';
279
- const semanticQueryOn = getSemanticAnalysisEnabled();
280
-
281
- let showInfoDensity = false;
282
- let showSemantic = false;
283
-
284
- if (mode === 'infoDensity') {
285
- /** Semantic Query 勾选时统计区不出现信息密度图占位 */
286
- showInfoDensity = !semanticQueryOn;
287
- showSemantic =
288
- semanticQueryOn &&
289
- hasSemanticData(this.currentState.semanticData) &&
290
- semText === text;
291
- } else {
292
- showSemantic = true;
293
- showInfoDensity =
294
- !semanticQueryOn &&
295
- !!(this.currentState.infoDensityData && infoText === text);
296
- }
297
 
298
  if (tokenHistogramItem) tokenHistogramItem.style.display = showInfoDensity ? '' : 'none';
299
  if (surprisalProgressItem) surprisalProgressItem.style.display = showInfoDensity ? '' : 'none';
@@ -474,20 +430,21 @@ export class VisualizationUpdater {
474
  : colorSource === 'pw_score' ? pwScores
475
  : (displayResult!.rawScoresNormed ?? []);
476
 
477
- // 联合模式下 tooltip 需要 pPwValues/pwScores 显示语义匹配信息,即使 fitResult 为 null 也要传递
478
  const resultWithExt = hasThreshold
479
  ? { ...displayResult, signalProbs, pPwValues, pwScores }
480
  : displayResult!;
481
- if (fitResult != null) {
482
- this.deps.highlightController.updateCurrentData({ result: resultWithExt, signalProbs, pPwValues, pwScores });
483
- if (!skipLmfUpdate) {
484
- this.deps.lmf.update({ ...resultWithExt, pwScores, colorScores: scoresForColor } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] });
485
- }
486
- } else {
487
- this.deps.highlightController.updateCurrentData({ result: resultWithExt });
488
- if (!skipLmfUpdate) {
489
- this.deps.lmf.update({ ...resultWithExt, colorScores: scoresForColor } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] });
490
- }
 
491
  }
492
 
493
  /** 直方图仅在整段模式显示,chunk 模式下不统计、不显示 */
@@ -594,14 +551,16 @@ export class VisualizationUpdater {
594
  /** 仅更新语义着色源(color source 切换时调用,不重新拟合) */
595
  public updateSemanticColorSource(): void {
596
  const cd = this.deps.highlightController.getCurrentData();
597
- const r = cd?.result as (FrontendAnalyzeResult & { rawScoresNormed?: number[] }) | undefined;
598
  if (!r?.rawScoresNormed?.length) return;
599
  const el = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null;
600
  const v = el?.value ?? 'pw_score';
601
- const scoresForColor = v === 'signal_probability' ? (cd!.pPwValues ?? [])
602
- : v === 'pw_score' ? (cd!.pwScores ?? [])
 
 
603
  : r.rawScoresNormed;
604
- this.deps.lmf.update({ ...r, pPwValues: cd!.pPwValues, pwScores: cd!.pwScores, colorScores: scoresForColor } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] });
605
  }
606
 
607
  /** 主题切换时调用:在样式生效后统一重绘直方图与文本(rgba 透出背景,需等新主题生效) */
@@ -628,16 +587,50 @@ export class VisualizationUpdater {
628
  this.updateSemanticDebugInfo();
629
  }
630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  /**
632
- * 清除语义分析相关数据(直方图、debug、semanticData),用于打开模式时初始化
633
  */
634
  public clearSemanticState(): void {
 
635
  this.currentState.semanticData = null;
636
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
637
  if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none';
638
  const matchScoreProgressItem = document.getElementById('match_score_progress_item');
639
  if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none';
640
  this.updateSemanticDebugInfo();
 
 
 
 
641
  }
642
 
643
  /**
@@ -675,52 +668,25 @@ export class VisualizationUpdater {
675
  this.currentState.currentTokenAvg = computeAverage(mergedSurprisals);
676
  this.currentState.currentTokenP90 = computeP90(mergedSurprisals);
677
  }
678
- let displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>;
679
- try {
680
- displayResult = this.computeDisplayResult();
681
- } catch (e) {
682
- if (e instanceof TokenBoundaryInconsistentError) {
683
- displayResult = this.computeDisplayResult();
684
- } else {
685
- console.error(e);
686
- return;
687
- }
688
- }
689
- this.deps.highlightController.updateCurrentData(displayResult ? { result: displayResult } : null);
690
- this.deps.lmf.clearHighlight();
691
- if (displayResult) this.deps.lmf.update(displayResult);
692
- this.updateVisualizationInternal();
693
  this.deps.appStateManager.updateButtonStates();
 
694
  }
695
 
696
  /**
697
  * 根据语义分析配置同步 UI 状态(查询输入框、文本渲染模式等)
698
- * 界面完全由配置决定,不因数据有无而改变
699
  */
700
  public syncSemanticUiFromConfig(): void {
701
  const enabled = getSemanticAnalysisEnabled();
702
  const el = document.getElementById('semantic_analysis_section');
703
  if (el) el.style.display = enabled ? '' : 'none';
704
  this.deps.lmf.updateOptions({ semanticAnalysisMode: enabled }, false);
705
- if (!enabled) {
706
- // 关闭时清除语义数据;统计图由下方 updateVisualizationInternal 统一刷新
707
- this.currentState.semanticData = null;
708
- const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
709
- if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none';
710
- const matchScoreProgressItem = document.getElementById('match_score_progress_item');
711
- if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none';
712
- this.updateSemanticDebugInfo();
713
- const displayResult = this.computeDisplayResult();
714
- this.deps.highlightController.updateCurrentData(displayResult ? { result: displayResult } : null);
715
- if (!displayResult) {
716
- d3.select('#all_result').style('opacity', 0);
717
- this.deps.appStateManager.updateState({ hasValidData: false });
718
- }
719
- }
720
- /** 勾选 / 关闭 Semantic Query 后立即刷新统计图显隐(与 getSemanticAnalysisEnabled 一致) */
721
- this.updateVisualizationInternal(false);
722
- // 语义分析配置影响 Upload/Save 的 dataReadyForSave 条件,需始终更新按钮状态
723
  this.deps.appStateManager.updateButtonStates();
 
724
  }
725
 
726
  /**
@@ -816,16 +782,7 @@ export class VisualizationUpdater {
816
  this.currentState.infoDensityData = data;
817
  this.currentState.rawApiResponse = rawSnapshot;
818
  this.updateSemanticDebugInfo();
819
- let displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>;
820
- try {
821
- displayResult = this.computeDisplayResult();
822
- } catch (e) {
823
- if (e instanceof TokenBoundaryInconsistentError) {
824
- displayResult = this.computeDisplayResult();
825
- } else {
826
- throw e;
827
- }
828
- }
829
  this.deps.highlightController.updateCurrentData(displayResult ? { result: displayResult } : null);
830
 
831
  this.deps.lmf.clearHighlight();
@@ -963,11 +920,6 @@ export class VisualizationUpdater {
963
  displayResult = this.computeDisplayResult();
964
  } catch (e) {
965
  this.currentState.semanticData = null;
966
- if (e instanceof TokenBoundaryInconsistentError) {
967
- this.deps.lmf.hideLoading();
968
- this.rerenderHistograms();
969
- return false;
970
- }
971
  showAlertDialog(tr('Error'), e instanceof Error ? e.message : String(e));
972
  return false;
973
  }
@@ -975,8 +927,8 @@ export class VisualizationUpdater {
975
  d3.select('#all_result').style('opacity', 1).style('display', null);
976
  this.deps.lmf.hideLoading();
977
  this.deps.highlightController.updateCurrentData({ result: displayResult });
978
- this.deps.lmf.clearHighlight();
979
- this.clearHighlights();
980
  this.updateVisualizationInternal();
981
 
982
  this.updateSemanticDebugInfo(res.debug_info);
@@ -1025,142 +977,4 @@ export class VisualizationUpdater {
1025
  chunkInfos
1026
  };
1027
  }
1028
-
1029
- /**
1030
- * 检查 semantic token_attention 的边界是否与 info 一致;允许稀疏覆盖(semantic 不必覆盖全文)
1031
- * @returns 不一致时返回错误描述(含前后文本),一致时返回 null
1032
- */
1033
- private checkSemanticAlignsWithInfo(
1034
- semanticTokens: Array<{ offset: [number, number]; raw?: string }>,
1035
- infoMerged: Array<{ offset: [number, number] }>,
1036
- text: string
1037
- ): { firstBadIdx: number; aSample: string; bSample: string; aNext: string; bNext: string; textBefore: string; textAt: string; textAfter: string } | null {
1038
- const boundaries = new Set<number>([0]);
1039
- for (const t of infoMerged) boundaries.add(t.offset[1]);
1040
- const infoEnd = infoMerged.length > 0 ? infoMerged[infoMerged.length - 1]!.offset[1] : 0;
1041
- const totalChars = text.length;
1042
- const ctx = 30;
1043
- const esc = (s: string) => JSON.stringify(s).slice(1, -1);
1044
- const fmt = (t: { offset: [number, number]; raw?: string }, idx: number) => {
1045
- const raw = (t as { raw?: string }).raw ?? text.slice(t.offset[0], t.offset[1]);
1046
- const s = raw.slice(0, 20) + (raw.length > 20 ? '…' : '');
1047
- return `第${idx}个token分词 [字符${t.offset[0]}-${t.offset[1]}] "${esc(s)}"`;
1048
- };
1049
- for (let i = 0; i < semanticTokens.length; i++) {
1050
- const [as, ae] = semanticTokens[i].offset;
1051
- if (as < 0 || ae > totalChars || ae <= as) continue; // 由 validateTokenConsistency 处理
1052
- if (ae > infoEnd) continue; // 超出双方重叠范围,不参与检查
1053
- if (!boundaries.has(as) || !boundaries.has(ae)) {
1054
- const raw = (semanticTokens[i] as { raw?: string }).raw ?? '';
1055
- const infoIdx = infoMerged.findIndex(t => t.offset[0] <= as && as < t.offset[1]);
1056
- const infoAt = infoIdx >= 0 ? infoMerged[infoIdx]! : null;
1057
- const rawShort = (raw || text.slice(as, ae)).slice(0, 20);
1058
- const infoRaw = infoAt ? (text.slice(infoAt.offset[0], infoAt.offset[1]).slice(0, 20) || '') : '';
1059
- const nextSem = semanticTokens[i + 1];
1060
- const nextInfo = infoIdx >= 0 && infoIdx + 1 < infoMerged.length ? infoMerged[infoIdx + 1]! : null;
1061
- return {
1062
- firstBadIdx: i,
1063
- aSample: `第${i}个token分词 [字符${as}-${ae}] "${esc(rawShort)}${rawShort.length >= 20 ? '…' : ''}"`,
1064
- bSample: infoAt ? `同一位置token分词 [字符${infoAt.offset[0]}-${infoAt.offset[1]}] "${esc(infoRaw)}${infoRaw.length >= 20 ? '…' : ''}"` : '无对应',
1065
- aNext: nextSem ? fmt(nextSem, i + 1) : '无',
1066
- bNext: nextInfo ? fmt(nextInfo, infoIdx + 1) : '无',
1067
- textBefore: text.slice(Math.max(0, as - ctx), as),
1068
- textAt: text.slice(as, ae),
1069
- textAfter: text.slice(ae, Math.min(totalChars, ae + ctx)),
1070
- };
1071
- }
1072
- }
1073
- return null;
1074
- }
1075
-
1076
- /**
1077
- * 联合模式:将 bpeMergedTokens 与超出信息密度范围的语义 tokens 合并为并集,用于 rect/渲染范围与截断边界一致。
1078
- * @returns { unionTokens, scoresForUnion }
1079
- */
1080
- private mergeBpeWithSemanticBeyond(
1081
- bpeMerged: FrontendToken[],
1082
- semanticTokens: Array<{
1083
- offset: [number, number];
1084
- raw: string;
1085
- score: number;
1086
- rawScore?: number;
1087
- }>
1088
- ): {
1089
- unionTokens: FrontendToken[];
1090
- scoresForUnion: (number | undefined)[];
1091
- rawScoresForUnion: (number | undefined)[];
1092
- } {
1093
- const infoEnd = bpeMerged.length > 0 ? bpeMerged[bpeMerged.length - 1]!.offset[1] : 0;
1094
- const beyond = semanticTokens.filter((t) => t.offset[0] >= infoEnd);
1095
- if (beyond.length === 0) {
1096
- const { scores, rawScores } = this.mapSemanticTokensToMerged(bpeMerged, semanticTokens);
1097
- return {
1098
- unionTokens: bpeMerged,
1099
- scoresForUnion: scores,
1100
- rawScoresForUnion: rawScores,
1101
- };
1102
- }
1103
- /** beyond 已在 handleSemanticResponse 中 overlap+digit 合并;段内用原始 score 重新归一化 */
1104
- const beyondRenormed = normalizeTokenScores(beyond.map((t) => ({ ...t, score: getTokenRawScore(t) })));
1105
- const semanticAsFrontend: FrontendToken[] = beyondRenormed.map((t) => ({
1106
- offset: [t.offset[0], t.offset[1]],
1107
- raw: t.raw,
1108
- real_topk: [0, 1] as [number, number],
1109
- pred_topk: [],
1110
- }));
1111
- const unionTokens = [...bpeMerged, ...semanticAsFrontend];
1112
- const { scores: infoScores, rawScores: infoRawScores } = this.mapSemanticTokensToMerged(
1113
- bpeMerged,
1114
- semanticTokens
1115
- );
1116
- const beyondScores: (number | undefined)[] = beyondRenormed.map((t) =>
1117
- Number.isFinite(t.score) ? t.score : undefined
1118
- );
1119
- const beyondRawScores: (number | undefined)[] = beyondRenormed.map((t) => {
1120
- const r = getTokenRawScore(t);
1121
- return Number.isFinite(r) ? r : undefined;
1122
- });
1123
- const scoresForUnion = [...infoScores, ...beyondScores];
1124
- const rawScoresForUnion = [...infoRawScores, ...beyondRawScores];
1125
- return { unionTokens, scoresForUnion, rawScoresForUnion };
1126
- }
1127
-
1128
- /**
1129
- * 将语义 API 的 token score 条目(offset 为原文字符偏移)映射到 merged tokens,双指针 O(N+M)。
1130
- * 前提:两个数组均按 offset 升序排列。
1131
- */
1132
- private mapSemanticTokensToMerged(
1133
- bpeBpeMergedTokens: Array<{ offset: [number, number] }>,
1134
- semanticTokens: Array<{ offset: [number, number]; score: number; rawScore?: number }>
1135
- ): {
1136
- scores: (number | undefined)[];
1137
- rawScores: (number | undefined)[];
1138
- } {
1139
- const n = bpeBpeMergedTokens.length;
1140
- const scores: number[] = new Array(n).fill(0);
1141
- const rawScores: number[] = new Array(n).fill(0);
1142
- const weights: number[] = new Array(n).fill(0);
1143
-
1144
- let j = 0; // 跳过所有在当前 semantic token 之前结束的 merged token
1145
- for (const semToken of semanticTokens) {
1146
- const [as, ae] = semToken.offset;
1147
- const rawPart = getTokenRawScore(semToken);
1148
- while (j < n && bpeBpeMergedTokens[j].offset[1] <= as) j++;
1149
- for (let k = j; k < n && bpeBpeMergedTokens[k].offset[0] < ae; k++) {
1150
- const [s, e] = bpeBpeMergedTokens[k].offset;
1151
- // j/k 的推进条件已保证 e > as 且 s < ae,overlap 必然 > 0
1152
- const overlap = Math.min(e, ae) - Math.max(s, as);
1153
- scores[k] += semToken.score * overlap;
1154
- rawScores[k] += rawPart * overlap;
1155
- weights[k] += overlap;
1156
- }
1157
- }
1158
-
1159
- const norm = (vals: number[]) => vals.map((v, i) => (weights[i] > 0 ? v / weights[i] : undefined));
1160
- return {
1161
- scores: norm(scores),
1162
- rawScores: norm(rawScores),
1163
- };
1164
- }
1165
  }
1166
-
 
45
  import { getSemanticMatchThreshold } from '../../shared/cross/semanticThresholdManager';
46
  import { applySemanticDebugInfoPanel } from '../../shared/prediction_attribution/core/semanticDebugInfo';
47
 
 
 
 
 
 
 
 
 
48
  /**
49
  * P(signal | raw_score_normed = s) 复用 findSignalThreshold 的 bins
50
  * 每个样本 s 落入对应 bin,P(signal) = (obsInBin - expInBin) / obsInBin
 
73
  stats_match_score_progress: ScatterPlot;
74
  appStateManager: AppStateManager;
75
  surprisalColorScale: d3.ScaleSequential<string>;
76
+ /** 语义/密度模式切换时同步 Analyze·上传·保存·metrics 等 chrome 显隐 */
77
+ syncModeChrome?: (semanticEnabled: boolean) => void;
78
  }
79
 
80
  /** 语义分析原始数据(独立存储) */
 
113
 
114
  /**
115
  * 当前数据状态
116
+ * 信息密度与语义分析独立存储;展示由当前模式二选一,互不合并
117
  */
118
  export interface CurrentDataState {
119
  /** 信息密度分析结果(独立) */
 
162
  }
163
 
164
  /**
165
+ * 获取当前展示数据(按当前模式取 infoDensityData 或 semanticData,互不合并)
166
  */
167
  getCurrentData(): AnalyzeResponse | null {
168
  const display = this.computeDisplayResult();
 
170
  return { request: { text: display.originalText }, result: display };
171
  }
172
 
173
+ /** 语义分块中达到 Match threshold 的 chunk(供 Find 浮条 ↑↓) */
174
+ getMatchedChunks(): Array<{ startOffset: number; endOffset: number; chunkIndex: number; chunkMatchDegree: number }> {
175
+ const chunkInfos = this.currentState.semanticData?.chunkInfos;
176
+ if (!chunkInfos?.length) return [];
177
+ const threshold = getSemanticMatchThreshold();
178
+ return chunkInfos.filter((c) => c.chunkMatchDegree >= threshold);
179
+ }
180
+
181
+ /** 切回语义模式时恢复 Match 文案:分块取 max(chunkMatchDegree),整段取 full_match_degree */
182
+ peekSemanticMatchDegree(): number | null {
183
+ const sem = this.currentState.semanticData;
184
+ if (!sem || !hasSemanticData(sem)) return null;
185
+ if (sem.chunkInfos?.length) {
186
+ return Math.max(...sem.chunkInfos.map((c) => c.chunkMatchDegree));
187
+ }
188
+ return typeof sem.full_match_degree === 'number' ? sem.full_match_degree : null;
189
+ }
190
+
191
  /**
192
  * 获取当前 surprisal 数据
193
  */
 
205
  /**
206
  * 清除高亮
207
  */
208
+ private clearHighlights(options?: { preserveChunkInterval?: boolean }): void {
209
+ this.deps.highlightController.clearHighlights(options);
210
  }
211
 
212
  /**
213
+ * 计算展示结果:按当前模式二选一(语义 / 信息密度),不合并
214
  */
215
  private computeDisplayResult(): (FrontendAnalyzeResult & {
216
  rawScoresNormed?: number[];
 
220
  const info = this.currentState.infoDensityData;
221
  const sem = this.currentState.semanticData;
222
  const infoResult = info?.result as FrontendAnalyzeResult | undefined;
223
+
224
+ if (getSemanticAnalysisEnabled()) {
225
+ if (sem && hasSemanticData(sem)) {
226
+ return this.buildSemanticOnlyResult(
227
+ { model: sem.model },
228
+ sem.token_attention,
229
+ sem.text,
230
+ sem.chunkInfos
231
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  }
233
+ return null;
234
  }
235
+ if (infoResult) return { ...infoResult };
 
 
 
 
236
  return null;
237
  }
238
 
239
  /**
240
+ * 分析开始前更新直方图显示/隐藏:基于当前模式 + 将要得到的数据
241
  * @param mode 即将进行的分析类型
242
+ * @param text 即将分析的文本
243
  * @param willBeChunked 语义分析时:true 表示将走分块模式,直方图不显示
244
  */
245
  public updateHistogramVisibilityForPending(mode: 'infoDensity' | 'semantic', text: string, willBeChunked?: boolean): void {
 
248
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
249
  const matchScoreProgressItem = document.getElementById('match_score_progress_item');
250
 
251
+ const showInfoDensity = mode === 'infoDensity';
252
+ const showSemantic = mode === 'semantic';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
  if (tokenHistogramItem) tokenHistogramItem.style.display = showInfoDensity ? '' : 'none';
255
  if (surprisalProgressItem) surprisalProgressItem.style.display = showInfoDensity ? '' : 'none';
 
430
  : colorSource === 'pw_score' ? pwScores
431
  : (displayResult!.rawScoresNormed ?? []);
432
 
433
+ // tooltip / color source 切换需要 pPwValues/pwScores,即使 fitResult 为 null(如 P90 回退)也要传递
434
  const resultWithExt = hasThreshold
435
  ? { ...displayResult, signalProbs, pPwValues, pwScores }
436
  : displayResult!;
437
+ this.deps.highlightController.updateCurrentData(
438
+ hasThreshold
439
+ ? { result: resultWithExt, signalProbs, pPwValues, pwScores }
440
+ : { result: resultWithExt }
441
+ );
442
+ if (!skipLmfUpdate) {
443
+ this.deps.lmf.update({
444
+ ...resultWithExt,
445
+ ...(hasThreshold ? { pwScores } : {}),
446
+ colorScores: scoresForColor,
447
+ } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] });
448
  }
449
 
450
  /** 直方图仅在整段模式显示,chunk 模式下不统计、不显示 */
 
551
  /** 仅更新语义着色源(color source 切换时调用,不重新拟合) */
552
  public updateSemanticColorSource(): void {
553
  const cd = this.deps.highlightController.getCurrentData();
554
+ const r = cd?.result as (FrontendAnalyzeResult & { rawScoresNormed?: number[]; pPwValues?: number[]; pwScores?: number[] }) | undefined;
555
  if (!r?.rawScoresNormed?.length) return;
556
  const el = document.getElementById('semantic_color_source_select') as HTMLSelectElement | null;
557
  const v = el?.value ?? 'pw_score';
558
+ const pPwValues = cd!.pPwValues ?? r.pPwValues;
559
+ const pwScores = cd!.pwScores ?? r.pwScores;
560
+ const scoresForColor = v === 'signal_probability' ? (pPwValues ?? [])
561
+ : v === 'pw_score' ? (pwScores ?? [])
562
  : r.rawScoresNormed;
563
+ this.deps.lmf.update({ ...r, pPwValues, pwScores, colorScores: scoresForColor } as FrontendAnalyzeResult & { pPwValues?: number[]; pwScores?: number[]; colorScores?: number[] });
564
  }
565
 
566
  /** 主题切换时调用:在样式生效后统一重绘直方图与文本(rgba 透出背景,需等新主题生效) */
 
587
  this.updateSemanticDebugInfo();
588
  }
589
 
590
+ /** 正文重绘回退用原文:语义 → 信息密度 → 左侧输入 */
591
+ private resolvePlainTextFallback(): string {
592
+ const infoResult = this.currentState.infoDensityData?.result as FrontendAnalyzeResult | undefined;
593
+ return (
594
+ this.currentState.semanticData?.text
595
+ ?? this.currentState.infoDensityData?.request?.text
596
+ ?? infoResult?.originalText
597
+ ?? this.deps.textInputController.getTextValue()
598
+ ?? ''
599
+ );
600
+ }
601
+
602
+ /**
603
+ * 统一正文刷新:有分析结果走完整着色管线,否则 showPlainText 剥掉语义着色。
604
+ */
605
+ private refreshTextAfterDataChange(
606
+ displayResult: ReturnType<VisualizationUpdater['computeDisplayResult']>,
607
+ plainTextFallback: string
608
+ ): void {
609
+ this.deps.lmf.clearHighlight();
610
+ if (displayResult) {
611
+ this.updateVisualizationInternal(false);
612
+ } else {
613
+ this.deps.highlightController.updateCurrentData(null);
614
+ this.deps.lmf.showPlainText(plainTextFallback);
615
+ this.updateVisualizationInternal(true);
616
+ }
617
+ }
618
+
619
  /**
620
+ * 清除语义分析相关数据并重绘(直方图、debug、正文着色),使界面与「无语义搜索结果」一致
621
  */
622
  public clearSemanticState(): void {
623
+ const plainTextFallback = this.resolvePlainTextFallback();
624
  this.currentState.semanticData = null;
625
  const rawScoreNormedItem = document.getElementById('raw_score_normed_histogram_item');
626
  if (rawScoreNormedItem) rawScoreNormedItem.style.display = 'none';
627
  const matchScoreProgressItem = document.getElementById('match_score_progress_item');
628
  if (matchScoreProgressItem) matchScoreProgressItem.style.display = 'none';
629
  this.updateSemanticDebugInfo();
630
+ const displayResult = this.computeDisplayResult();
631
+ this.refreshTextAfterDataChange(displayResult, plainTextFallback);
632
+ this.deps.appStateManager.updateButtonStates();
633
+ this.deps.syncModeChrome?.(getSemanticAnalysisEnabled());
634
  }
635
 
636
  /**
 
668
  this.currentState.currentTokenAvg = computeAverage(mergedSurprisals);
669
  this.currentState.currentTokenP90 = computeP90(mergedSurprisals);
670
  }
671
+ const displayResult = this.computeDisplayResult();
672
+ this.refreshTextAfterDataChange(displayResult, this.resolvePlainTextFallback());
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  this.deps.appStateManager.updateButtonStates();
674
+ this.deps.syncModeChrome?.(getSemanticAnalysisEnabled());
675
  }
676
 
677
  /**
678
  * 根据语义分析配置同步 UI 状态(查询输入框、文本渲染模式等)
679
+ * 界面完全由配置决定;切换模式时保留另一边内存数据,仅切换展示
680
  */
681
  public syncSemanticUiFromConfig(): void {
682
  const enabled = getSemanticAnalysisEnabled();
683
  const el = document.getElementById('semantic_analysis_section');
684
  if (el) el.style.display = enabled ? '' : 'none';
685
  this.deps.lmf.updateOptions({ semanticAnalysisMode: enabled }, false);
686
+ const displayResult = this.computeDisplayResult();
687
+ this.refreshTextAfterDataChange(displayResult, this.resolvePlainTextFallback());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  this.deps.appStateManager.updateButtonStates();
689
+ this.deps.syncModeChrome?.(enabled);
690
  }
691
 
692
  /**
 
782
  this.currentState.infoDensityData = data;
783
  this.currentState.rawApiResponse = rawSnapshot;
784
  this.updateSemanticDebugInfo();
785
+ const displayResult = this.computeDisplayResult();
 
 
 
 
 
 
 
 
 
786
  this.deps.highlightController.updateCurrentData(displayResult ? { result: displayResult } : null);
787
 
788
  this.deps.lmf.clearHighlight();
 
920
  displayResult = this.computeDisplayResult();
921
  } catch (e) {
922
  this.currentState.semanticData = null;
 
 
 
 
 
923
  showAlertDialog(tr('Error'), e instanceof Error ? e.message : String(e));
924
  return false;
925
  }
 
927
  d3.select('#all_result').style('opacity', 1).style('display', null);
928
  this.deps.lmf.hideLoading();
929
  this.deps.highlightController.updateCurrentData({ result: displayResult });
930
+ // 流式 chunk 更新时保留 jumpToChunkHighlight 的区间下划线及其 hold/fade
931
+ this.clearHighlights({ preserveChunkInterval: true });
932
  this.updateVisualizationInternal();
933
 
934
  this.updateSemanticDebugInfo(res.debug_info);
 
977
  chunkInfos
978
  };
979
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  }
 
client/src/features/chat/chatTurnsView.ts CHANGED
@@ -25,7 +25,6 @@ const GLTR_OPTIONS = {
25
  enableRenderAnimation: false,
26
  enableMinimap: false,
27
  overlayTokenRenderStyle: 'classic' as const,
28
- overlayIgnoreGlobalInfoDensityDisable: true,
29
  surprisalColorMax: CHAT_SURPRISAL_COLOR_MAP_MAX,
30
  };
31
 
 
25
  enableRenderAnimation: false,
26
  enableMinimap: false,
27
  overlayTokenRenderStyle: 'classic' as const,
 
28
  surprisalColorMax: CHAT_SURPRISAL_COLOR_MAP_MAX,
29
  };
30
 
client/src/features/demo/demoBusinessLogic.ts CHANGED
@@ -10,6 +10,7 @@ import type { AppState } from '../analysis/appStateManager';
10
  import { LocalDemoCache } from '../../shared/storage/localDemoCache';
11
  import URLHandler from '../../shared/core/URLHandler';
12
  import { getDemoName } from '../../shared/core/pathUtils';
 
13
 
14
  /**
15
  * Demo 渲染选项
@@ -63,10 +64,8 @@ export class DemoBusinessLogic {
63
  */
64
  scrollToTop(): void {
65
  requestAnimationFrame(() => {
66
- const rightPanel = document.querySelector('.right_panel') as HTMLElement;
67
- if (rightPanel) {
68
- rightPanel.scrollTop = 0;
69
- }
70
  });
71
  }
72
 
 
10
  import { LocalDemoCache } from '../../shared/storage/localDemoCache';
11
  import URLHandler from '../../shared/core/URLHandler';
12
  import { getDemoName } from '../../shared/core/pathUtils';
13
+ import { getDesktopTextScrollRoot } from '../../shared/core/responsive';
14
 
15
  /**
16
  * Demo 渲染选项
 
64
  */
65
  scrollToTop(): void {
66
  requestAnimationFrame(() => {
67
+ const root = getDesktopTextScrollRoot();
68
+ if (root) root.scrollTop = 0;
 
 
69
  });
70
  }
71
 
client/src/pages/analysis/index.ts CHANGED
@@ -222,6 +222,16 @@ window.onload = () => {
222
  initHighlightClearListeners(clearHighlights);
223
 
224
  // 创建可视化更新器
 
 
 
 
 
 
 
 
 
 
225
  const visualizationUpdater = new VisualizationUpdater({
226
  lmf,
227
  highlightController,
@@ -231,7 +241,8 @@ window.onload = () => {
231
  stats_surprisal_progress,
232
  stats_match_score_progress,
233
  appStateManager,
234
- surprisalColorScale: tokenSurprisalColorScale as d3.ScaleSequential<string>
 
235
  });
236
 
237
  addDigitsMergeRenderListener(() => {
@@ -311,9 +322,7 @@ window.onload = () => {
311
  api,
312
  () => {
313
  // 根据管理员模式更新写按钮(进/退 admin 会整页 reload,无需在此处理 Compare 链)
314
- const isAdmin = adminManager.isInAdminMode();
315
- analyzeSaveBtn.style('display', isAdmin ? null : 'none');
316
- saveBtn.style('display', isAdmin ? null : 'none');
317
  },
318
  {
319
  onMinimapToggle: (enabled: boolean) => {
@@ -323,10 +332,11 @@ window.onload = () => {
323
  }, false);
324
  lsWriteBool('minimap_enabled', enableMinimap, '1');
325
  },
326
- onSemanticAnalysisToggle: (_enabled: boolean) => {
327
- // 打开/关闭时都清除 query,并将 submode/chunked/color/阈值 重置为默认值并写回 localStorage
 
328
  const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
329
- if (queryEl) queryEl.value = '';
330
  const submodeEl = document.getElementById('semantic_submode_select') as HTMLSelectElement | null;
331
  if (submodeEl) submodeEl.value = 'hybrid';
332
  const chunkedEl = document.getElementById('semantic_chunked_mode') as HTMLInputElement | null;
@@ -337,12 +347,28 @@ window.onload = () => {
337
  const thresholdEl = document.getElementById('semantic_threshold_input') as HTMLInputElement | null;
338
  if (thresholdEl) thresholdEl.value = String(SEMANTIC_MATCH_THRESHOLD);
339
  const params = URLHandler.parameters;
340
- delete params['semantic_query'];
 
 
 
 
 
 
 
341
  URLHandler.updateUrl(params, false);
342
  syncSemanticOptionsToStorage();
343
- appStateManager.setLastSearchedQuery(null);
344
- visualizationUpdater.clearSemanticState();
345
  visualizationUpdater.syncSemanticUiFromConfig();
 
 
 
 
 
 
 
 
 
 
 
346
  },
347
  },
348
  themeManager,
@@ -676,6 +702,8 @@ window.onload = () => {
676
  // 注意:Clear按钮状态和字数统计由TextInputController内部自动管理
677
  // 使用原生 addEventListener 监听 input 事件,避免覆盖 TextInputController 的监听器
678
  const textFieldNode = textField.node() as HTMLTextAreaElement | null;
 
 
679
  if (textFieldNode) {
680
  textFieldNode.addEventListener('input', (event: Event) => {
681
  // 检查是否是匹配分析结果的文本填入
@@ -683,6 +711,7 @@ window.onload = () => {
683
 
684
  if (!isMatchingAnalysis) {
685
  // 单方面的文本修改(用户输入、预填充等),清除数据标记并重置状态(视为新的分析阶段)
 
686
  visualizationUpdater.clearDataOnTextChange();
687
  appStateManager.updateState({
688
  hasValidData: false,
@@ -697,6 +726,15 @@ window.onload = () => {
697
  // 注意:文本修改时不清除文件名显示和URL参数(与远程demo行为一致)
698
  // 只有点击analyze按钮时才会清除这些状态
699
  });
 
 
 
 
 
 
 
 
 
700
  }
701
  // 初始化时更新业务逻辑相关的按钮状态
702
  appStateManager.updateButtonStates();
@@ -886,6 +924,15 @@ window.onload = () => {
886
  mdEl.style('display', 'none');
887
  }
888
  };
 
 
 
 
 
 
 
 
 
889
  const semanticSearchController = new SemanticSearchController({
890
  getQuery: () => semanticSearchInput?.value ?? '',
891
  getText: () => (textField.property('value') ?? visualizationUpdater.getCurrentData()?.request?.text ?? '').toString(),
@@ -918,7 +965,7 @@ window.onload = () => {
918
  initQueryHistoryDropdown({
919
  input: semanticSearchInput,
920
  dropdownId: 'semantic_search_history_dropdown',
921
- onSelect: () => appStateManager.updateButtonStates(),
922
  onHistorySelect: runSemanticSearchOrChunked,
923
  onRemove: removeSemanticCacheByQuery
924
  });
 
222
  initHighlightClearListeners(clearHighlights);
223
 
224
  // 创建可视化更新器
225
+ const syncAnalyzeModeChrome = (semanticEnabled: boolean) => {
226
+ const isAdmin = adminManager.isInAdminMode();
227
+ submitBtn.style('display', semanticEnabled ? 'none' : null);
228
+ loadUrlBtn.style('display', semanticEnabled ? 'none' : null);
229
+ analyzeSaveBtn.style('display', semanticEnabled || !isAdmin ? 'none' : null);
230
+ saveBtn.style('display', semanticEnabled || !isAdmin ? 'none' : null);
231
+ saveLocalBtn.style('display', semanticEnabled ? 'none' : null);
232
+ // metrics 显隐由 appStateManager(hasValidData ∧ ¬semantic)统一处理
233
+ appStateManager.updateButtonStates();
234
+ };
235
  const visualizationUpdater = new VisualizationUpdater({
236
  lmf,
237
  highlightController,
 
241
  stats_surprisal_progress,
242
  stats_match_score_progress,
243
  appStateManager,
244
+ surprisalColorScale: tokenSurprisalColorScale as d3.ScaleSequential<string>,
245
+ syncModeChrome: syncAnalyzeModeChrome,
246
  });
247
 
248
  addDigitsMergeRenderListener(() => {
 
322
  api,
323
  () => {
324
  // 根据管理员模式更新写按钮(进/退 admin 会整页 reload,无需在此处理 Compare 链)
325
+ syncAnalyzeModeChrome(getSemanticAnalysisEnabled());
 
 
326
  },
327
  {
328
  onMinimapToggle: (enabled: boolean) => {
 
332
  }, false);
333
  lsWriteBool('minimap_enabled', enableMinimap, '1');
334
  },
335
+ onSemanticAnalysisToggle: (enabled: boolean) => {
336
+ // submode/chunked/color/阈值重置为默认;不清除另一边分析数据,切回可恢复展示
337
+ // query:切回语义且仍有 lastSearchedQuery 时回填,避免空输入框配旧高亮
338
  const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
339
+ const lastQ = appStateManager.getState().lastSearchedQuery;
340
  const submodeEl = document.getElementById('semantic_submode_select') as HTMLSelectElement | null;
341
  if (submodeEl) submodeEl.value = 'hybrid';
342
  const chunkedEl = document.getElementById('semantic_chunked_mode') as HTMLInputElement | null;
 
347
  const thresholdEl = document.getElementById('semantic_threshold_input') as HTMLInputElement | null;
348
  if (thresholdEl) thresholdEl.value = String(SEMANTIC_MATCH_THRESHOLD);
349
  const params = URLHandler.parameters;
350
+ if (enabled && lastQ) {
351
+ if (queryEl) queryEl.value = lastQ;
352
+ params['semantic_query'] = lastQ;
353
+ } else {
354
+ if (queryEl) queryEl.value = '';
355
+ delete params['semantic_query'];
356
+ d3.select('#semantic_match_degree').style('display', 'none');
357
+ }
358
  URLHandler.updateUrl(params, false);
359
  syncSemanticOptionsToStorage();
 
 
360
  visualizationUpdater.syncSemanticUiFromConfig();
361
+ if (enabled && lastQ) {
362
+ const md = visualizationUpdater.peekSemanticMatchDegree();
363
+ const mdEl = d3.select('#semantic_match_degree');
364
+ if (md !== null) {
365
+ mdEl.text(tr('Match: {0}%').replace('{0}', (md * 100).toFixed(1)))
366
+ .style('display', 'inline-block')
367
+ .style('color', md < getSemanticMatchThreshold() ? 'var(--error-color, #e74c3c)' : null);
368
+ } else {
369
+ mdEl.style('display', 'none');
370
+ }
371
+ }
372
  },
373
  },
374
  themeManager,
 
702
  // 注意:Clear按钮状态和字数统计由TextInputController内部自动管理
703
  // 使用原生 addEventListener 监听 input 事件,避免覆盖 TextInputController 的监听器
704
  const textFieldNode = textField.node() as HTMLTextAreaElement | null;
705
+ /** 用户单方面改过文本,失焦时需把纯文本同步到右侧 */
706
+ let textDirtyForPlainSync = false;
707
  if (textFieldNode) {
708
  textFieldNode.addEventListener('input', (event: Event) => {
709
  // 检查是否是匹配分析结果的文本填入
 
711
 
712
  if (!isMatchingAnalysis) {
713
  // 单方面的文本修改(用户输入、预填充等),清除数据标记并重置状态(视为新的分析阶段)
714
+ textDirtyForPlainSync = true;
715
  visualizationUpdater.clearDataOnTextChange();
716
  appStateManager.updateState({
717
  hasValidData: false,
 
726
  // 注意:文本修改时不清除文件名显示和URL参数(与远程demo行为一致)
727
  // 只有点击analyze按钮时才会清除这些状态
728
  });
729
+ // 失焦:用户改过字则同步纯文本到右侧(busy 时不消费 dirty,下次失焦再试)
730
+ textFieldNode.addEventListener('blur', () => {
731
+ if (!textDirtyForPlainSync) return;
732
+ const s = appStateManager.getState();
733
+ if (s.isAnalyzing || s.isSemanticSearching || s.isGlobalLoading) return;
734
+ textDirtyForPlainSync = false;
735
+ // dirty 表示已清分析数据;以用户编辑为准同步(showPlainText 会负责显示 #all_result)
736
+ lmf.showPlainText(textInputController.getTextValue());
737
+ });
738
  }
739
  // 初始化时更新业务逻辑相关的按钮状态
740
  appStateManager.updateButtonStates();
 
924
  mdEl.style('display', 'none');
925
  }
926
  };
927
+ /** 搜索框为空时:语义高亮与 query 对齐(清空) */
928
+ const onSemanticQuerySelect = () => {
929
+ appStateManager.updateButtonStates();
930
+ if ((semanticSearchInput?.value ?? '').length > 0) return;
931
+ appStateManager.setLastSearchedQuery(null);
932
+ d3.select('#semantic_match_degree').style('display', 'none');
933
+ syncSemanticQueryToUrl();
934
+ visualizationUpdater.clearSemanticState();
935
+ };
936
  const semanticSearchController = new SemanticSearchController({
937
  getQuery: () => semanticSearchInput?.value ?? '',
938
  getText: () => (textField.property('value') ?? visualizationUpdater.getCurrentData()?.request?.text ?? '').toString(),
 
965
  initQueryHistoryDropdown({
966
  input: semanticSearchInput,
967
  dropdownId: 'semantic_search_history_dropdown',
968
+ onSelect: onSemanticQuerySelect,
969
  onHistorySelect: runSemanticSearchOrChunked,
970
  onRemove: removeSemanticCacheByQuery
971
  });
client/src/partials/settings-menu-analysis.html CHANGED
@@ -8,12 +8,6 @@
8
  <span class="settings-menu-label">Token render style:</span>
9
  <div id="token_render_style_dropdown" class="settings-menu-control settings-dropdown-in-menu"></div>
10
  </div>
11
- <div id="disable_info_density_item" class="settings-menu-item settings-menu-row" data-admin-only style="display: none;">
12
- <span class="settings-menu-label">Disable info density:</span>
13
- <label class="settings-menu-control settings-menu-checkbox" style="cursor: pointer;">
14
- <input type="checkbox" id="disable_info_density_toggle">
15
- </label>
16
- </div>
17
  <div id="weaken_surprisal_color_item" class="settings-menu-item settings-menu-row" data-admin-only style="display: none;">
18
  <span class="settings-menu-label">Weaken surprisal color:</span>
19
  <label class="settings-menu-control settings-menu-checkbox" style="cursor: pointer;">
 
8
  <span class="settings-menu-label">Token render style:</span>
9
  <div id="token_render_style_dropdown" class="settings-menu-control settings-dropdown-in-menu"></div>
10
  </div>
 
 
 
 
 
 
11
  <div id="weaken_surprisal_color_item" class="settings-menu-item settings-menu-row" data-admin-only style="display: none;">
12
  <span class="settings-menu-label">Weaken surprisal color:</span>
13
  <label class="settings-menu-control settings-menu-checkbox" style="cursor: pointer;">
client/src/privacy-policy.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>Info Lens: Semantic Highlight — Privacy Policy</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ </head>
9
+
10
+ <body style="max-width:700px;margin:40px auto;padding:0 20px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#222;">
11
+ <h1>Privacy Policy for Info Lens: Semantic Highlight</h1>
12
+ <p><em>Last updated: July 2026</em></p>
13
+
14
+ <h2>Data We Process</h2>
15
+ <p>When you use the extension to search a page, it sends two things to our backend server (hosted at dqy08-infolens.hf.space):</p>
16
+ <ul>
17
+ <li>The search query you type</li>
18
+ <li>The visible text of the article/body content of the page you are currently viewing</li>
19
+ </ul>
20
+ <p>This data is sent only when you actively invoke the extension (via the toolbar icon or the Ctrl+Shift+F shortcut) on the page you are viewing at that moment. The extension does not run in the background and does not access any other tabs or pages.</p>
21
+
22
+ <h2>How We Use It</h2>
23
+ <p>The query and page text are used solely to compute semantic similarity and return matching results, which the extension then highlights on the page. No other use is made of this data.</p>
24
+
25
+ <h2>Data We Do Not Collect</h2>
26
+ <p>We do not collect or store: names, email addresses, passwords, financial information, health information, personal communications, or any other personally identifiable information. We do not track your browsing history across pages or sessions.</p>
27
+
28
+ <h2>Data Storage &amp; Retention</h2>
29
+ <ul>
30
+ <li>Query text and page content are processed transiently to generate search results and are not stored on our server after the response is returned.</li>
31
+ <li>Our server keeps a temporary, in-memory counter to distinguish visitor sessions for basic operational purposes (e.g., diagnosing issues). This counter is not written to disk, is not linked to any other data, and is cleared whenever the server restarts.</li>
32
+ <li>Your recent search queries are saved locally in your browser (via the extension's local storage) so you can reuse them. This data never leaves your device.</li>
33
+ </ul>
34
+
35
+ <h2>Third-Party Sharing</h2>
36
+ <p>We do not share, sell, or transfer any data to third parties. We do not use your data for advertising.</p>
37
+
38
+ <h2>Security</h2>
39
+ <p>All data is transmitted over HTTPS.</p>
40
+
41
+ <h2>Permissions</h2>
42
+ <p>The extension requests the minimum permissions needed for its single purpose (semantic search on the active page):</p>
43
+ <ul>
44
+ <li><strong>activeTab / scripting</strong>: to read and highlight text on the page you are currently viewing, only when you invoke the extension</li>
45
+ <li><strong>storage</strong>: to save your recent search queries locally in your browser</li>
46
+ </ul>
47
+ <p>The extension does <strong>not</strong> declare host permissions. Query and page text are sent to our backend by the extension’s background service worker via a standard cross-origin HTTPS request; the backend allows this with CORS. The page you are viewing never talks to our server directly.</p>
48
+
49
+ <h2>Changes to This Policy</h2>
50
+ <p>If our data practices change, we will update this policy and the Chrome Web Store listing accordingly.</p>
51
+
52
+ <h2>Contact</h2>
53
+ <p>For questions about this policy, please contact <a href="mailto:xiaoyundqy@qq.com">xiaoyundqy@qq.com</a>.</p>
54
+ </body>
55
+
56
+ </html>
client/src/shared/controllers/highlightController.ts CHANGED
@@ -28,13 +28,14 @@ export class HighlightController {
28
  }
29
 
30
  /**
31
- * 清除所有高亮(文本与直方图)
 
32
  */
33
- public clearHighlights(): void {
34
  this.options.stats_frac.clearSelection();
35
  this.options.stats_raw_score_normed?.clearSelection();
36
  this.options.stats_match_score_progress?.clearSelection();
37
- this.options.lmf.clearHighlight();
38
  }
39
 
40
  /**
 
28
  }
29
 
30
  /**
31
+ * 清除高亮(直方图选中 + 文本)。
32
+ * @param options.preserveChunkInterval 为 true 时保留 chunk 区间下划线及其 hold/fade
33
  */
34
+ public clearHighlights(options?: { preserveChunkInterval?: boolean }): void {
35
  this.options.stats_frac.clearSelection();
36
  this.options.stats_raw_score_normed?.clearSelection();
37
  this.options.stats_match_score_progress?.clearSelection();
38
+ this.options.lmf.clearHighlight(options);
39
  }
40
 
41
  /**
client/src/shared/controllers/semanticSearchController.ts CHANGED
@@ -18,33 +18,8 @@ import {
18
  normalizeTokenScores,
19
  splitTextToChunks,
20
  } from '../cross/semanticUtils';
 
21
  import type { signalFitResult } from '../../features/analysis/signalThresholdDetector';
22
- import { CHUNK_SEARCH_HOLD_MS } from '../vis/constants';
23
- import * as semanticResultCache from '../cross/semanticResultCache';
24
-
25
- function isChunkSemanticallyCached(chunkText: string, query: string, submode?: string): boolean {
26
- if (submode === 'hybrid') {
27
- return !!semanticResultCache.get(chunkText, query, 'count')
28
- && !!semanticResultCache.get(chunkText, query, 'fill_blank');
29
- }
30
- return !!semanticResultCache.get(chunkText, query, submode);
31
- }
32
-
33
- /** 可中止的短时等待(abort 时提前结束,不抛错) */
34
- function delayAbortable(ms: number, signal: AbortSignal): Promise<void> {
35
- return new Promise((resolve) => {
36
- const id = window.setTimeout(resolve, ms);
37
- const onAbort = () => {
38
- window.clearTimeout(id);
39
- resolve();
40
- };
41
- if (signal.aborted) {
42
- onAbort();
43
- return;
44
- }
45
- signal.addEventListener('abort', onAbort, { once: true });
46
- });
47
- }
48
 
49
  export interface SemanticSearchControllerDeps {
50
  getQuery: () => string;
@@ -143,6 +118,10 @@ export class SemanticSearchController {
143
  }
144
  }
145
 
 
 
 
 
146
  private async runChunked(params: { query: string; text: string; submode: string | undefined; signal: AbortSignal }): Promise<void> {
147
  const { query, text, submode, signal } = params;
148
  const chunks = splitTextToChunks(text, SEMANTIC_CHUNK_BYTES);
@@ -163,27 +142,15 @@ export class SemanticSearchController {
163
  let allFromCache = true;
164
  let aborted = false;
165
  let lastChunkFromCache = false;
166
- /** 上一块上色后的 hold 期间已预发起的下一块分析 */
167
- let pendingNextAnalysis: ReturnType<TextAnalysisAPI['analyzeSemantic']> | null = null;
168
- /** hold 结束后已滚到下一块,本轮循环开头无需再滚 */
169
- let scrollDoneForIndex: number | null = null;
170
 
171
- const needsAutoScroll = chunks.some((c) => !isChunkSemanticallyCached(c.text, query, submode));
172
- if (needsAutoScroll) {
173
- this.deps.lmf.beginChunkSearchAutoScroll();
174
- }
175
- try {
176
  for (let i = 0; i < chunks.length; i++) {
177
  if (signal.aborted) break;
178
  const chunk = chunks[i];
179
  d3.select('#semantic_progress').text(`Chunk ${i + 1}/${chunks.length}`).style('display', 'inline-block');
180
 
181
- const res = pendingNextAnalysis
182
- ? await pendingNextAnalysis
183
- : await this.deps.api.analyzeSemantic(query, chunk.text, { submode, signal });
184
- pendingNextAnalysis = null;
185
- // 上色/直方图仍以本块返回的 isSemanticFromCache(res) 为准,从首个非缓存块起才刷新 UI。
186
- // isChunkSemanticallyCached 仅用于滚动跟随与预取,与 API 读同一套 semanticResultCache。
187
  if (signal.aborted) {
188
  aborted = true;
189
  break;
@@ -197,7 +164,7 @@ export class SemanticSearchController {
197
  if (!lastChunkFromCache) allFromCache = false;
198
  const matchDegree = res.full_match_degree ?? 0;
199
  maxMatchDegree = Math.max(maxMatchDegree, matchDegree);
200
- const matched = matchDegree >= getSemanticMatchThreshold();
201
  const merged = mergeTokenSpansFullyForRendering(res.token_attention ?? [], chunk.text, {
202
  digitMerge: getDigitsMergeEnabled(),
203
  });
@@ -206,22 +173,22 @@ export class SemanticSearchController {
206
  ? normalized
207
  : normalized.map((t) => ({ ...t, rawScore: getTokenRawScore(t), score: 0 }));
208
 
 
 
 
209
  chunkInfos.push({
210
- startOffset: chunk.startOffset,
211
- endOffset: chunk.startOffset + chunk.text.length,
212
  chunkIndex: i,
213
  chunkMatchDegree: matchDegree,
214
  });
215
  const tokensOffsetAdjusted = tokens.map(t => ({
216
  ...t,
217
- offset: [t.offset[0] + chunk.startOffset, t.offset[1] + chunk.startOffset] as [number, number],
218
  }));
219
  allChunkProcessedTokens.push(...tokensOffsetAdjusted);
 
220
  if (!lastChunkFromCache) {
221
- if (scrollDoneForIndex !== i) {
222
- this.deps.lmf.followSearchingChunk(chunk.startOffset);
223
- }
224
- scrollDoneForIndex = null;
225
  if (!this.deps.visualizationUpdater.handleSemanticResponse(
226
  { token_attention: allChunkProcessedTokens, chunkInfos, debug_info: undefined },
227
  text,
@@ -231,20 +198,6 @@ export class SemanticSearchController {
231
  this.deps.showSemanticError();
232
  break;
233
  }
234
- const nextIndex = i + 1;
235
- if (nextIndex < chunks.length) {
236
- const nextChunk = chunks[nextIndex]!;
237
- pendingNextAnalysis = this.deps.api.analyzeSemantic(query, nextChunk.text, { submode, signal });
238
- await delayAbortable(CHUNK_SEARCH_HOLD_MS, signal);
239
- if (signal.aborted) {
240
- aborted = true;
241
- break;
242
- }
243
- if (!isChunkSemanticallyCached(nextChunk.text, query, submode)) {
244
- this.deps.lmf.followSearchingChunk(nextChunk.startOffset);
245
- scrollDoneForIndex = nextIndex;
246
- }
247
- }
248
  }
249
  }
250
 
@@ -256,22 +209,13 @@ export class SemanticSearchController {
256
  undefined
257
  );
258
  }
259
- if (!allFromCache) {
260
- await delayAbortable(CHUNK_SEARCH_HOLD_MS, signal);
261
- }
262
  if (!signal.aborted) {
263
- const threshold = getSemanticMatchThreshold();
264
- const firstMatch = chunkInfos.find((c) => c.chunkMatchDegree >= threshold);
265
  if (firstMatch) {
266
- this.deps.lmf.scrollToChunkStart(firstMatch.startOffset);
267
  }
268
  this.deps.finishSemanticSearch(query, maxMatchDegree, allFromCache);
269
  }
270
  }
271
- } finally {
272
- if (needsAutoScroll) {
273
- this.deps.lmf.endChunkSearchAutoScroll();
274
- }
275
- }
276
  }
277
  }
 
18
  normalizeTokenScores,
19
  splitTextToChunks,
20
  } from '../cross/semanticUtils';
21
+ import { codePointLength, utf16IndexToCodePointIndex } from '../cross/mergeTokenSpans';
22
  import type { signalFitResult } from '../../features/analysis/signalThresholdDetector';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  export interface SemanticSearchControllerDeps {
25
  getQuery: () => string;
 
118
  }
119
  }
120
 
121
+ /**
122
+ * 分块搜索(demo):严格串行——await 分析 → 上色 → 下一块;无预取/hold/follow;结束滚到首个匹配。
123
+ * 产品决策:站内节奏刻意简化;扩展侧仍保留预取/hold/follow(见 extension/content.js),两边不必对齐。
124
+ */
125
  private async runChunked(params: { query: string; text: string; submode: string | undefined; signal: AbortSignal }): Promise<void> {
126
  const { query, text, submode, signal } = params;
127
  const chunks = splitTextToChunks(text, SEMANTIC_CHUNK_BYTES);
 
142
  let allFromCache = true;
143
  let aborted = false;
144
  let lastChunkFromCache = false;
 
 
 
 
145
 
146
+ const matchThreshold = () => getSemanticMatchThreshold();
147
+
 
 
 
148
  for (let i = 0; i < chunks.length; i++) {
149
  if (signal.aborted) break;
150
  const chunk = chunks[i];
151
  d3.select('#semantic_progress').text(`Chunk ${i + 1}/${chunks.length}`).style('display', 'inline-block');
152
 
153
+ const res = await this.deps.api.analyzeSemantic(query, chunk.text, { submode, signal });
 
 
 
 
 
154
  if (signal.aborted) {
155
  aborted = true;
156
  break;
 
164
  if (!lastChunkFromCache) allFromCache = false;
165
  const matchDegree = res.full_match_degree ?? 0;
166
  maxMatchDegree = Math.max(maxMatchDegree, matchDegree);
167
+ const matched = matchDegree >= matchThreshold();
168
  const merged = mergeTokenSpansFullyForRendering(res.token_attention ?? [], chunk.text, {
169
  digitMerge: getDigitsMergeEnabled(),
170
  });
 
173
  ? normalized
174
  : normalized.map((t) => ({ ...t, rawScore: getTokenRawScore(t), score: 0 }));
175
 
176
+ // splitTextToChunks.startOffset 为 UTF-16;token/chunkInfos/渲染均为码点
177
+ const chunkCpStart = utf16IndexToCodePointIndex(text, chunk.startOffset);
178
+ const chunkCpEnd = chunkCpStart + codePointLength(chunk.text);
179
  chunkInfos.push({
180
+ startOffset: chunkCpStart,
181
+ endOffset: chunkCpEnd,
182
  chunkIndex: i,
183
  chunkMatchDegree: matchDegree,
184
  });
185
  const tokensOffsetAdjusted = tokens.map(t => ({
186
  ...t,
187
+ offset: [t.offset[0] + chunkCpStart, t.offset[1] + chunkCpStart] as [number, number],
188
  }));
189
  allChunkProcessedTokens.push(...tokensOffsetAdjusted);
190
+
191
  if (!lastChunkFromCache) {
 
 
 
 
192
  if (!this.deps.visualizationUpdater.handleSemanticResponse(
193
  { token_attention: allChunkProcessedTokens, chunkInfos, debug_info: undefined },
194
  text,
 
198
  this.deps.showSemanticError();
199
  break;
200
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  }
202
  }
203
 
 
209
  undefined
210
  );
211
  }
 
 
 
212
  if (!signal.aborted) {
213
+ const firstMatch = chunkInfos.find((c) => c.chunkMatchDegree >= matchThreshold());
 
214
  if (firstMatch) {
215
+ this.deps.lmf.jumpToChunkHighlight(firstMatch.startOffset, firstMatch.endOffset);
216
  }
217
  this.deps.finishSemanticSearch(query, maxMatchDegree, allFromCache);
218
  }
219
  }
 
 
 
 
 
220
  }
221
  }
client/src/shared/core/responsive.ts CHANGED
@@ -83,6 +83,19 @@ export const setForceNarrowScreen = (enabled: boolean): void => {
83
  export const isNarrowScreen = (): boolean =>
84
  getMobileMediaQuery().matches || getForceNarrowScreen();
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  /**
87
  * 检测是否为移动端设备(基于设备能力)
88
  * 移动端:有触屏支持,且没有鼠标或没有悬浮支持
 
83
  export const isNarrowScreen = (): boolean =>
84
  getMobileMediaQuery().matches || getForceNarrowScreen();
85
 
86
+ /**
87
+ * 桌面端正文滚动根。
88
+ * analysis 页把滚动下放到 `#results`(浮条留在 `.right_panel` 外);其它页仍滚 `.right_panel`。
89
+ */
90
+ export const getDesktopTextScrollRoot = (): HTMLElement | null => {
91
+ const results = document.getElementById('results');
92
+ if (results) {
93
+ const oy = getComputedStyle(results).overflowY;
94
+ if (oy === 'auto' || oy === 'scroll') return results;
95
+ }
96
+ return document.querySelector('.right_panel') as HTMLElement | null;
97
+ };
98
+
99
  /**
100
  * 检测是否为移动端设备(基于设备能力)
101
  * 移动端:有触屏支持,且没有鼠标或没有悬浮支持
client/src/shared/core/waitForSmoothScrollEnd.ts CHANGED
@@ -1,10 +1,20 @@
1
- /** 等待 smooth scroll 结束:`scrollend` + 超时兜底。返回取消函数。 */
 
 
 
 
 
2
  export function waitForSmoothScrollEnd(
3
  target: Window | HTMLElement,
4
  onDone: () => void,
5
  maxWaitMs = 5000
6
  ): () => void {
7
  let settled = false;
 
 
 
 
 
8
  const settle = () => {
9
  if (settled) return;
10
  settled = true;
@@ -13,12 +23,21 @@ export function waitForSmoothScrollEnd(
13
  };
14
 
15
  const onScrollEnd = () => settle();
 
16
  const dispose = () => {
17
  window.clearTimeout(timeoutId);
18
  target.removeEventListener('scrollend', onScrollEnd);
19
  };
20
 
21
  target.addEventListener('scrollend', onScrollEnd, { once: true });
 
 
 
 
 
 
 
 
22
  const timeoutId = window.setTimeout(settle, maxWaitMs);
23
 
24
  return () => {
 
1
+ /**
2
+ * 等待 smooth scroll 结束。返回取消函数。
3
+ *
4
+ * 仅靠 `scrollend` 不够:无位移时浏览器常不触发该事件,会空等到 maxWait。
5
+ * 语义:滚动已结束,或确认未产生滚动。
6
+ */
7
  export function waitForSmoothScrollEnd(
8
  target: Window | HTMLElement,
9
  onDone: () => void,
10
  maxWaitMs = 5000
11
  ): () => void {
12
  let settled = false;
13
+
14
+ const getTop = () =>
15
+ target === window ? window.scrollY : (target as HTMLElement).scrollTop;
16
+ const startTop = getTop();
17
+
18
  const settle = () => {
19
  if (settled) return;
20
  settled = true;
 
23
  };
24
 
25
  const onScrollEnd = () => settle();
26
+
27
  const dispose = () => {
28
  window.clearTimeout(timeoutId);
29
  target.removeEventListener('scrollend', onScrollEnd);
30
  };
31
 
32
  target.addEventListener('scrollend', onScrollEnd, { once: true });
33
+
34
+ // 两帧后位置仍未变 → scrollTo 未产生运动,scrollend 不会来
35
+ requestAnimationFrame(() => {
36
+ requestAnimationFrame(() => {
37
+ if (Math.abs(getTop() - startTop) < 1) settle();
38
+ });
39
+ });
40
+
41
  const timeoutId = window.setTimeout(settle, maxWaitMs);
42
 
43
  return () => {
client/src/shared/cross/mergeTokenSpans.ts CHANGED
@@ -15,6 +15,23 @@ export function codePointLength(text: string): number {
15
  return Array.from(text || '').length;
16
  }
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  /**
19
  * 按码点下标 [start, end) 取子串;与校验、合并管线一致。
20
  */
 
15
  return Array.from(text || '').length;
16
  }
17
 
18
+ /**
19
+ * UTF-16 码元下标 → Unicode 码点下标(与扩展 content.js `utf16ToCp` 同语义)。
20
+ * 分块器 `startOffset` 为 UTF-16;API / 渲染 offset 为码点,拼接前须转换。
21
+ */
22
+ export function utf16IndexToCodePointIndex(text: string, utf16Index: number): number {
23
+ const s = text || '';
24
+ const limit = Math.max(0, Math.min(utf16Index, s.length));
25
+ let i = 0;
26
+ let cps = 0;
27
+ while (i < limit) {
28
+ const cp = s.codePointAt(i)!;
29
+ i += cp > 0xffff ? 2 : 1;
30
+ cps += 1;
31
+ }
32
+ return cps;
33
+ }
34
+
35
  /**
36
  * 按码点下标 [start, end) 取子串;与校验、合并管线一致。
37
  */
client/src/shared/cross/queryHistory.ts CHANGED
@@ -188,16 +188,26 @@ export function initQueryHistoryDropdown(options: InitQueryHistoryDropdownOption
188
  window.matchMedia('(hover: hover) and (pointer: fine)').matches;
189
 
190
  // 列表过滤:与输入框一致不 trim;存盘与选中回填均为完整字符串
 
191
  const filter =
192
  filterHistoryByInput && input ? (input.value ?? '').toLowerCase() : '';
 
193
  const useEntries = getHistoryEntries != null;
194
  const entryRows = useEntries ? getHistoryEntries!() : null;
195
  const list = !useEntries ? (getHistoryItems ? getHistoryItems() : load(storageKey)) : null;
196
  const filteredEntries = entryRows
197
- ? entryRows.filter((e) => !filter || e.label.toLowerCase().includes(filter))
 
 
 
 
198
  : null;
199
  const filteredStrings = list
200
- ? list.filter((s) => !filter || s.toLowerCase().includes(filter))
 
 
 
 
201
  : null;
202
  dropdown.innerHTML = '';
203
  const filtered = filteredEntries ?? filteredStrings ?? [];
 
188
  window.matchMedia('(hover: hover) and (pointer: fine)').matches;
189
 
190
  // 列表过滤:与输入框一致不 trim;存盘与选中回填均为完整字符串
191
+ // 完全匹配当前输入的候选不展示(已是当前值,无选择意义)
192
  const filter =
193
  filterHistoryByInput && input ? (input.value ?? '').toLowerCase() : '';
194
+ const currentLower = input ? (input.value ?? '').toLowerCase() : null;
195
  const useEntries = getHistoryEntries != null;
196
  const entryRows = useEntries ? getHistoryEntries!() : null;
197
  const list = !useEntries ? (getHistoryItems ? getHistoryItems() : load(storageKey)) : null;
198
  const filteredEntries = entryRows
199
+ ? entryRows.filter((e) => {
200
+ const labelLower = e.label.toLowerCase();
201
+ if (currentLower !== null && labelLower === currentLower) return false;
202
+ return !filter || labelLower.includes(filter);
203
+ })
204
  : null;
205
  const filteredStrings = list
206
+ ? list.filter((s) => {
207
+ const sLower = s.toLowerCase();
208
+ if (currentLower !== null && sLower === currentLower) return false;
209
+ return !filter || sLower.includes(filter);
210
+ })
211
  : null;
212
  dropdown.innerHTML = '';
213
  const filtered = filteredEntries ?? filteredStrings ?? [];
client/src/shared/cross/semanticUtils.ts CHANGED
@@ -196,7 +196,7 @@ export function mergeTokenSpansFullyForRendering<T extends { offset: [number, nu
196
  return mergeTokenDigitSpans(overlapped, text);
197
  }
198
 
199
- /** bytesPerChunk:UTF-8 字节数;startOffset:字符索引。 */
200
  export function splitTextToChunks(text: string, bytesPerChunk: number): Array<{ text: string; startOffset: number }> {
201
  if (bytesPerChunk <= 0) {
202
  throw new Error("分块字节上限必须大于 0,当前值: " + bytesPerChunk);
 
196
  return mergeTokenDigitSpans(overlapped, text);
197
  }
198
 
199
+ /** bytesPerChunk:UTF-8 字节数;startOffset:UTF-16 码元下标(非码点;拼到 API offset 前须转码点)。扩展侧副本:`extension/splitTextToChunks.js`(改此处请同步)。 */
200
  export function splitTextToChunks(text: string, bytesPerChunk: number): Array<{ text: string; startOffset: number }> {
201
  if (bytesPerChunk <= 0) {
202
  throw new Error("分块字节上限必须大于 0,当前值: " + bytesPerChunk);
client/src/shared/cross/settingsMenuManager.ts CHANGED
@@ -14,10 +14,6 @@ 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 {
18
- getInfoDensityRenderDisabled,
19
- setInfoDensityRenderDisabled,
20
- } from '../../features/analysis/infoDensityRenderManager';
21
  import {
22
  getSurprisalColorWeakened,
23
  setSurprisalColorWeakened,
@@ -48,7 +44,6 @@ export class SettingsMenuManager {
48
  private semanticThresholdInput: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
49
  private semanticThresholdItem: d3.Selection<HTMLElement, unknown, HTMLElement, any>;
50
  private semanticSubmodeRow: d3.Selection<HTMLElement, unknown, HTMLElement, any>;
51
- private disableInfoDensityToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
52
  private weakenSurprisalColorToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
53
  private themeDropdownContainer: d3.Selection<Element, unknown, HTMLElement, any>;
54
  private adminManager: AdminManager;
@@ -86,7 +81,6 @@ export class SettingsMenuManager {
86
  this.semanticThresholdInput = d3.select<HTMLInputElement, any>('#semantic_threshold_input');
87
  this.semanticThresholdItem = d3.select<HTMLElement, any>('#semantic_threshold_item');
88
  this.semanticSubmodeRow = d3.select<HTMLElement, any>('#semantic_submode_row');
89
- this.disableInfoDensityToggle = d3.select<HTMLInputElement, any>('#disable_info_density_toggle');
90
  this.weakenSurprisalColorToggle = d3.select<HTMLInputElement, any>('#weaken_surprisal_color_toggle');
91
  this.themeDropdownContainer = d3.select('#theme_dropdown');
92
  this.adminManager = adminManager;
@@ -149,23 +143,12 @@ export class SettingsMenuManager {
149
  setSemanticAnalysisEnabled(enabled);
150
  this.updateSemanticThresholdVisibility();
151
  this.updateSemanticSubmodeRowVisibility();
152
- setInfoDensityRenderDisabled(enabled);
153
- this.setDisableInfoDensity(enabled);
154
- window.dispatchEvent(new CustomEvent('info-density-render-change'));
155
  if (this.callbacks.onSemanticAnalysisToggle) {
156
  this.callbacks.onSemanticAnalysisToggle(enabled);
157
  }
158
  });
159
  }
160
 
161
- if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) {
162
- this.disableInfoDensityToggle.on('change', () => {
163
- const disabled = (this.disableInfoDensityToggle.node() as HTMLInputElement)?.checked || false;
164
- setInfoDensityRenderDisabled(disabled);
165
- window.dispatchEvent(new CustomEvent('info-density-render-change'));
166
- });
167
- }
168
-
169
  if (this.menuContext === 'analysis' && this.weakenSurprisalColorToggle.node()) {
170
  this.weakenSurprisalColorToggle.on('change', () => {
171
  const weakened = (this.weakenSurprisalColorToggle.node() as HTMLInputElement)?.checked || false;
@@ -210,9 +193,6 @@ export class SettingsMenuManager {
210
  if (this.menuContext === 'analysis' && this.semanticThresholdInput.node()) {
211
  this.setSemanticThresholdValue(getSemanticMatchThreshold());
212
  }
213
- if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) {
214
- this.setDisableInfoDensity(getInfoDensityRenderDisabled());
215
- }
216
  if (this.menuContext === 'analysis' && this.weakenSurprisalColorToggle.node()) {
217
  this.setWeakenSurprisalColor(getSurprisalColorWeakened());
218
  }
@@ -275,9 +255,6 @@ export class SettingsMenuManager {
275
  this.setSemanticThresholdValue(getSemanticMatchThreshold());
276
  this.updateSemanticThresholdVisibility();
277
  }
278
- if (this.menuContext === 'analysis' && this.disableInfoDensityToggle.node()) {
279
- this.setDisableInfoDensity(getInfoDensityRenderDisabled());
280
- }
281
  if (this.menuContext === 'analysis' && this.weakenSurprisalColorToggle.node()) {
282
  this.setWeakenSurprisalColor(getSurprisalColorWeakened());
283
  }
@@ -340,16 +317,6 @@ export class SettingsMenuManager {
340
  }
341
  }
342
 
343
- /**
344
- * 设置 disable info density 的初始状态
345
- */
346
- public setDisableInfoDensity(disabled: boolean): void {
347
- const checkbox = this.disableInfoDensityToggle.node() as HTMLInputElement | null;
348
- if (checkbox) {
349
- checkbox.checked = disabled;
350
- }
351
- }
352
-
353
  public setWeakenSurprisalColor(weakened: boolean): void {
354
  const checkbox = this.weakenSurprisalColorToggle.node() as HTMLInputElement | null;
355
  if (checkbox) {
 
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
  getSurprisalColorWeakened,
19
  setSurprisalColorWeakened,
 
44
  private semanticThresholdInput: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
45
  private semanticThresholdItem: d3.Selection<HTMLElement, unknown, HTMLElement, any>;
46
  private semanticSubmodeRow: d3.Selection<HTMLElement, unknown, HTMLElement, any>;
 
47
  private weakenSurprisalColorToggle: d3.Selection<HTMLInputElement, unknown, HTMLElement, any>;
48
  private themeDropdownContainer: d3.Selection<Element, unknown, HTMLElement, any>;
49
  private adminManager: AdminManager;
 
81
  this.semanticThresholdInput = d3.select<HTMLInputElement, any>('#semantic_threshold_input');
82
  this.semanticThresholdItem = d3.select<HTMLElement, any>('#semantic_threshold_item');
83
  this.semanticSubmodeRow = d3.select<HTMLElement, any>('#semantic_submode_row');
 
84
  this.weakenSurprisalColorToggle = d3.select<HTMLInputElement, any>('#weaken_surprisal_color_toggle');
85
  this.themeDropdownContainer = d3.select('#theme_dropdown');
86
  this.adminManager = adminManager;
 
143
  setSemanticAnalysisEnabled(enabled);
144
  this.updateSemanticThresholdVisibility();
145
  this.updateSemanticSubmodeRowVisibility();
 
 
 
146
  if (this.callbacks.onSemanticAnalysisToggle) {
147
  this.callbacks.onSemanticAnalysisToggle(enabled);
148
  }
149
  });
150
  }
151
 
 
 
 
 
 
 
 
 
152
  if (this.menuContext === 'analysis' && this.weakenSurprisalColorToggle.node()) {
153
  this.weakenSurprisalColorToggle.on('change', () => {
154
  const weakened = (this.weakenSurprisalColorToggle.node() as HTMLInputElement)?.checked || false;
 
193
  if (this.menuContext === 'analysis' && this.semanticThresholdInput.node()) {
194
  this.setSemanticThresholdValue(getSemanticMatchThreshold());
195
  }
 
 
 
196
  if (this.menuContext === 'analysis' && this.weakenSurprisalColorToggle.node()) {
197
  this.setWeakenSurprisalColor(getSurprisalColorWeakened());
198
  }
 
255
  this.setSemanticThresholdValue(getSemanticMatchThreshold());
256
  this.updateSemanticThresholdVisibility();
257
  }
 
 
 
258
  if (this.menuContext === 'analysis' && this.weakenSurprisalColorToggle.node()) {
259
  this.setWeakenSurprisalColor(getSurprisalColorWeakened());
260
  }
 
317
  }
318
  }
319
 
 
 
 
 
 
 
 
 
 
 
320
  public setWeakenSurprisalColor(weakened: boolean): void {
321
  const checkbox = this.weakenSurprisalColorToggle.node() as HTMLInputElement | null;
322
  if (checkbox) {
client/src/shared/lang/translations.ts CHANGED
@@ -486,8 +486,9 @@ export const translations: Translations = {
486
  '无法使用加密API(crypto.subtle),保存到本地缓存功能不可用。',
487
  'URL text extraction failed': 'URL 文本提取失败',
488
  'Semantic analysis failed': '语义分析失败',
489
- 'Semantic Query(Beta)': '语义查询(Beta)',
490
  'Enter query question or topic': '请输入查询问题或主题',
 
491
  'Tokenizer results inconsistent: semantic and info-density token boundaries differ.': 'Tokenizer 结果不一致:语义分析与信息密度的 token 边界存在差异,属预期外情况。',
492
  'No data to analyze, please analyze text first': '没有可分析的数据,请先分析文本',
493
  'User cancelled file selection': '用户取消了文件选择',
 
486
  '无法使用加密API(crypto.subtle),保存到本地缓存功能不可用。',
487
  'URL text extraction failed': 'URL 文本提取失败',
488
  'Semantic analysis failed': '语义分析失败',
489
+ 'Semantic Find(Beta)': '语义查找(Beta)',
490
  'Enter query question or topic': '请输入查询问题或主题',
491
+ 'Semantic find': '语义查找',
492
  'Tokenizer results inconsistent: semantic and info-density token boundaries differ.': 'Tokenizer 结果不一致:语义分析与信息密度的 token 边界存在差异,属预期外情况。',
493
  'No data to analyze, please analyze text first': '没有可分析的数据,请先分析文本',
494
  'User cancelled file selection': '用户取消了文件选择',
client/src/shared/vis/GLTR_Text_Box.ts CHANGED
@@ -10,11 +10,10 @@ import {TokenPositionCalculator} from "./TokenPositionCalculator";
10
  import {ResizeHandler} from "./ResizeHandler";
11
  import {TokenFragmentRect, HighlightStyle} from "./types";
12
  import { waitForSmoothScrollEnd } from '../core/waitForSmoothScrollEnd';
13
- import { CHUNK_SEARCH_FOLLOW_VIEWPORT_Y_RATIO, HIGHLIGHT_CONSTANTS } from './constants';
14
  import {ScrollbarMinimap} from "./ScrollbarMinimap";
15
- import {isNarrowScreen} from "../core/responsive";
16
  import {getTokenRenderStyle} from "../cross/tokenRenderStyle";
17
- import { getInfoDensityRenderDisabled } from "../../features/analysis/infoDensityRenderManager";
18
  import type { FrontendToken } from "../../shared/api/GLTR_API";
19
 
20
  /**
@@ -106,10 +105,8 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
106
  surprisalColorMax: undefined as number | undefined,
107
  /** 若设置则仅覆盖 SVG 底色的 density/classic;未设置则与全局 getTokenRenderStyle() 一致 */
108
  overlayTokenRenderStyle: undefined as 'density' | 'classic' | undefined,
109
- /** 为 true 时 SVG 底色不受全局「关闭信息密度」影响(如 Chat 需始终显示 token surprisal 底色) */
110
- overlayIgnoreGlobalInfoDensityDisable: false,
111
  /**
112
- * 为 true 时本实例始终不画信息密度/classic 底层(透明),不受全局开关与 overlayIgnoreGlobalInfoDensityDisable 影响。
113
  * 用于仅展示语义叠加层(如归因页)而无需伪造 real_topk。
114
  */
115
  overlayForceDisableInfoDensityRender: false,
@@ -122,13 +119,7 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
122
 
123
  /** SVG 信息密度/classic 底色是否应关闭(透明) */
124
  private getOverlayDisableInfoDensityRender(): boolean {
125
- if (this.options.overlayForceDisableInfoDensityRender) {
126
- return true;
127
- }
128
- if (this.options.overlayIgnoreGlobalInfoDensityDisable) {
129
- return false;
130
- }
131
- return getInfoDensityRenderDisabled();
132
  }
133
 
134
  /**
@@ -174,9 +165,6 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
174
  private highlightManager?: HighlightManager;
175
  private chunkScrollEndCancel: (() => void) | undefined;
176
  private chunkHighlightHoldTimer: number | undefined;
177
- /** 分块语义搜索:用户已手动滚动,后续不再自动跟随 */
178
- private chunkSearchAutoScrollUserCancelled = false;
179
- private chunkSearchAutoScrollCleanup: (() => void) | undefined;
180
 
181
  // 下划线元素缓存,用于第二个直方图的高亮样式(由HighlightManager管理,但需要在这里初始化)
182
  private underlineCache: Map<string, SVGLineElement> = new Map();
@@ -202,7 +190,6 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
202
  }
203
  };
204
  private _onTokenRenderStyleChange = (): void => this._refreshBaseRectColorsOrFullRender();
205
- private _onInfoDensityRenderChange = (): void => this._refreshBaseRectColorsOrFullRender();
206
  private _onSurprisalColorWeakenChange = (): void => {
207
  this._refreshBaseRectColorsOrFullRender();
208
  if (this.options.enableMinimap && this.cachedPositions && this.currentRenderData) {
@@ -251,7 +238,6 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
251
  // 监听主题变化
252
  this.setupThemeListener();
253
  window.addEventListener('token-render-style-change', this._onTokenRenderStyleChange);
254
- window.addEventListener('info-density-render-change', this._onInfoDensityRenderChange);
255
  window.addEventListener('surprisal-color-weaken-change', this._onSurprisalColorWeakenChange);
256
 
257
  // 初始化颜色scale
@@ -369,11 +355,12 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
369
  this.cachedPositionsTokenCount = rd.bpe_strings.length;
370
  }
371
 
372
- // Step 3: 更新本 chunk 范围内的语义颜色
373
  const latestChunk = rdExt.chunkInfos?.[rdExt.chunkInfos.length - 1];
374
- const fromTokenIndex = latestChunk
375
- ? Math.max(0, rd.bpe_strings.findIndex(t => t.offset[0] >= latestChunk.startOffset))
376
- : 0;
 
377
  this.svgOverlayManager!.updateSemanticColors(colorScores!, fromTokenIndex);
378
 
379
  // chunk 增量渲染路径也需要同步刷新 minimap,否则会出现刷新滞后
@@ -659,14 +646,15 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
659
  }
660
 
661
  /**
662
- * 仅显示文本内容,不渲染颜色标记
663
- * 用于在等待后端返回时立即显示文本,提升用户体验
664
- * @param text 要显示的文本内容
665
  */
666
- public setTextOnly(text: string): void {
667
  const baseNode = this.base.node();
668
  if (!baseNode) return;
669
 
 
 
 
670
  // 文本切换(如 demo 切换)期间先清空 minimap,避免显示旧数据
671
  if (this.options.enableMinimap && this.minimapManager) {
672
  this.minimapManager.clear();
@@ -705,11 +693,30 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
705
  } else {
706
  this.createLoadingOverlay();
707
  }
 
708
 
709
- // 显示加载状态
 
 
 
 
 
 
710
  this.showLoading();
711
  }
712
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  /**
714
  * 显示加载状态
715
  */
@@ -1083,7 +1090,7 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
1083
 
1084
  /**
1085
  * 计算系统经典滚动条的宽度
1086
- * 通过 right_panel 和 LMF 的宽度差来判断滚动条是否占用布局空间
1087
  * @returns 滚动条宽度(px),如果为0则表示使用覆盖式滚动条或无滚动条
1088
  */
1089
  private calculateTraditionalScrollbarWidth(): number {
@@ -1092,19 +1099,15 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
1092
  return 0;
1093
  }
1094
 
1095
- const rightPanel = document.querySelector('.right_panel') as HTMLElement;
1096
- if (!rightPanel) {
1097
  return 0;
1098
  }
1099
 
1100
- // right_panel 的 offsetWidth(包含滚动条,如果滚动条占用布局空间)
1101
- const rightPanelWidth = rightPanel.offsetWidth;
1102
- // LMF 的 offsetWidth(包含 padding 和 border,但不包含滚动条)
1103
  const lmfWidth = baseNode.offsetWidth;
1104
- // 计算滚动条宽度:right_panel 宽度 - LMF 宽度
1105
- const scrollbarWidth = rightPanelWidth - lmfWidth;
1106
 
1107
- // 返回滚动条宽度(如果小于等于0,表示使用覆盖式滚动条或无滚动条)
1108
  return scrollbarWidth > 0 ? scrollbarWidth : 0;
1109
  }
1110
 
@@ -1173,7 +1176,6 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
1173
  // 清理SVG引用
1174
  this.currentSvgOverlay = undefined;
1175
  window.removeEventListener('token-render-style-change', this._onTokenRenderStyleChange);
1176
- window.removeEventListener('info-density-render-change', this._onInfoDensityRenderChange);
1177
  window.removeEventListener('surprisal-color-weaken-change', this._onSurprisalColorWeakenChange);
1178
 
1179
  // 调用父类的destroy方法
@@ -1281,53 +1283,11 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
1281
  this.highlightManager.setCharIntervalUnderlines(segments);
1282
  }
1283
 
1284
- /** 分块语义搜索开始:wheel / 触摸即视为用户接管滚动 */
1285
- beginChunkSearchAutoScroll(): void {
1286
- this.endChunkSearchAutoScroll();
1287
- this.chunkSearchAutoScrollUserCancelled = false;
1288
-
1289
- const container = isNarrowScreen()
1290
- ? window
1291
- : (document.querySelector('.right_panel') as HTMLElement | null);
1292
- if (!container) return;
1293
-
1294
- const opts = { passive: true, capture: true } as const;
1295
- const onUserScroll = () => {
1296
- if (this.chunkSearchAutoScrollUserCancelled) return;
1297
- this.chunkSearchAutoScrollUserCancelled = true;
1298
- this.chunkScrollEndCancel?.();
1299
- this.chunkScrollEndCancel = undefined;
1300
- };
1301
-
1302
- container.addEventListener('wheel', onUserScroll, opts);
1303
- container.addEventListener('touchstart', onUserScroll, opts);
1304
- this.chunkSearchAutoScrollCleanup = () => {
1305
- container.removeEventListener('wheel', onUserScroll, opts);
1306
- container.removeEventListener('touchstart', onUserScroll, opts);
1307
- };
1308
- }
1309
-
1310
- endChunkSearchAutoScroll(): void {
1311
- this.chunkSearchAutoScrollCleanup?.();
1312
- this.chunkSearchAutoScrollCleanup = undefined;
1313
- this.chunkSearchAutoScrollUserCancelled = false;
1314
- }
1315
-
1316
  /** 滚到 chunk 起点(分析结束、直方图 bin 点击,视口 0.2) */
1317
  scrollToChunkStart(charOffset: number, onScrollEnd?: () => void): void {
1318
  this.scrollToUnicodeCharOffset(Math.max(0, Math.floor(charOffset)), onScrollEnd);
1319
  }
1320
 
1321
- /** 分块语义搜索进行中:滚动跟随当前 chunk 起点(视口 0.6) */
1322
- followSearchingChunk(charOffset: number): void {
1323
- if (this.chunkSearchAutoScrollUserCancelled) return;
1324
- this.scrollToUnicodeCharOffset(
1325
- Math.max(0, Math.floor(charOffset)),
1326
- undefined,
1327
- CHUNK_SEARCH_FOLLOW_VIEWPORT_Y_RATIO
1328
- );
1329
- }
1330
-
1331
  /**
1332
  * semantic match per chunk:高亮区间 → 滚到 chunk 起点 → 滚完保持 → 淡出
1333
  */
@@ -1412,7 +1372,7 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
1412
  }
1413
 
1414
  /**
1415
- * 滚动至 Unicode 偏移;桌面滚动 `.right_panel`,窄屏为 `window`
1416
  * @param viewportYRatio 目标点在视口中的纵向位置(0=顶部,1=底部),默认 0.2
1417
  */
1418
  scrollToUnicodeCharOffset(charOffset: number, onScrollEnd?: () => void, viewportYRatio = 0.2): void {
@@ -1457,7 +1417,7 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
1457
  return;
1458
  }
1459
 
1460
- const panel = document.querySelector('.right_panel') as HTMLElement | null;
1461
  if (!panel) {
1462
  onScrollEnd?.();
1463
  return;
@@ -1497,15 +1457,23 @@ export class GLTR_Text_Box extends VComponent<FrontendAnalyzeResult> {
1497
  }
1498
 
1499
  /**
1500
- * 清除所有高亮
 
1501
  */
1502
- clearHighlight() {
1503
- this.cancelChunkHighlightFade();
 
 
 
 
1504
  this._current.highlightedIndices.clear();
1505
- this._current.chunkCharRange = null;
1506
 
1507
  if (this.highlightManager) {
1508
- this.highlightManager.clearHighlight();
 
 
 
 
1509
  }
1510
  }
1511
 
 
10
  import {ResizeHandler} from "./ResizeHandler";
11
  import {TokenFragmentRect, HighlightStyle} from "./types";
12
  import { waitForSmoothScrollEnd } from '../core/waitForSmoothScrollEnd';
13
+ import { HIGHLIGHT_CONSTANTS } from './constants';
14
  import {ScrollbarMinimap} from "./ScrollbarMinimap";
15
+ import {isNarrowScreen, getDesktopTextScrollRoot} from "../core/responsive";
16
  import {getTokenRenderStyle} from "../cross/tokenRenderStyle";
 
17
  import type { FrontendToken } from "../../shared/api/GLTR_API";
18
 
19
  /**
 
105
  surprisalColorMax: undefined as number | undefined,
106
  /** 若设置则仅覆盖 SVG 底色的 density/classic;未设置则与全局 getTokenRenderStyle() 一致 */
107
  overlayTokenRenderStyle: undefined as 'density' | 'classic' | undefined,
 
 
108
  /**
109
+ * 为 true 时本实例始终不画信息密度/classic 底层(透明)。
110
  * 用于仅展示语义叠加层(如归因页)而无需伪造 real_topk。
111
  */
112
  overlayForceDisableInfoDensityRender: false,
 
119
 
120
  /** SVG 信息密度/classic 底色是否应关闭(透明) */
121
  private getOverlayDisableInfoDensityRender(): boolean {
122
+ return !!this.options.overlayForceDisableInfoDensityRender;
 
 
 
 
 
 
123
  }
124
 
125
  /**
 
165
  private highlightManager?: HighlightManager;
166
  private chunkScrollEndCancel: (() => void) | undefined;
167
  private chunkHighlightHoldTimer: number | undefined;
 
 
 
168
 
169
  // 下划线元素缓存,用于第二个直方图的高亮样式(由HighlightManager管理,但需要在这里初始化)
170
  private underlineCache: Map<string, SVGLineElement> = new Map();
 
190
  }
191
  };
192
  private _onTokenRenderStyleChange = (): void => this._refreshBaseRectColorsOrFullRender();
 
193
  private _onSurprisalColorWeakenChange = (): void => {
194
  this._refreshBaseRectColorsOrFullRender();
195
  if (this.options.enableMinimap && this.cachedPositions && this.currentRenderData) {
 
238
  // 监听主题变化
239
  this.setupThemeListener();
240
  window.addEventListener('token-render-style-change', this._onTokenRenderStyleChange);
 
241
  window.addEventListener('surprisal-color-weaken-change', this._onSurprisalColorWeakenChange);
242
 
243
  // 初始化颜色scale
 
355
  this.cachedPositionsTokenCount = rd.bpe_strings.length;
356
  }
357
 
358
+ // Step 3: 语义颜色。仅「chunk 流式追加」时只刷最新 chunk;同 token 数(如 color source 切换)必须全量刷,否则前文颜色会滞后
359
  const latestChunk = rdExt.chunkInfos?.[rdExt.chunkInfos.length - 1];
360
+ const fromTokenIndex =
361
+ chunkAppendOnly && latestChunk
362
+ ? Math.max(0, rd.bpe_strings.findIndex(t => t.offset[0] >= latestChunk.startOffset))
363
+ : 0;
364
  this.svgOverlayManager!.updateSemanticColors(colorScores!, fromTokenIndex);
365
 
366
  // chunk 增量渲染路径也需要同步刷新 minimap,否则会出现刷新滞后
 
646
  }
647
 
648
  /**
649
+ * 清空可视化 DOM,仅铺纯文本层(不改 loading / currentRenderData)
 
 
650
  */
651
+ private applyPlainTextDom(text: string): void {
652
  const baseNode = this.base.node();
653
  if (!baseNode) return;
654
 
655
+ // DOM 将重建,先清掉高亮状态,避免后续流式更新误保留旧 chunk 区间
656
+ this.clearHighlight();
657
+
658
  // 文本切换(如 demo 切换)期间先清空 minimap,避免显示旧数据
659
  if (this.options.enableMinimap && this.minimapManager) {
660
  this.minimapManager.clear();
 
693
  } else {
694
  this.createLoadingOverlay();
695
  }
696
+ }
697
 
698
+ /**
699
+ * 仅显示文本内容,不渲染颜色标记
700
+ * 用于在等待后端返回时立即显示文本,提升用户体验
701
+ * @param text 要显示的文本内容
702
+ */
703
+ public setTextOnly(text: string): void {
704
+ this.applyPlainTextDom(text);
705
  this.showLoading();
706
  }
707
 
708
+ /**
709
+ * 仅显示纯文本(无 loading、无分析结果缓存),用于输入失焦同步等非分析场景。
710
+ * 负责把分析结果区(#all_result)重新显示:调用方在改字时可能已将其藏起。
711
+ */
712
+ public showPlainText(text: string): void {
713
+ this.currentRenderData = undefined;
714
+ this.applyPlainTextDom(text);
715
+ this.hideLoading();
716
+ // 无节点时(非 analysis 页)为空选,no-op
717
+ d3.select('#all_result').style('opacity', 1).style('display', null);
718
+ }
719
+
720
  /**
721
  * 显示加载状态
722
  */
 
1090
 
1091
  /**
1092
  * 计算系统经典滚动条的宽度
1093
+ * 通过正文滚动根与 LMF 的宽度差判断滚动条是否占用布局空间
1094
  * @returns 滚动条宽度(px),如果为0则表示使用覆盖式滚动条或无滚动条
1095
  */
1096
  private calculateTraditionalScrollbarWidth(): number {
 
1099
  return 0;
1100
  }
1101
 
1102
+ const scrollRoot = getDesktopTextScrollRoot();
1103
+ if (!scrollRoot) {
1104
  return 0;
1105
  }
1106
 
1107
+ const scrollRootWidth = scrollRoot.offsetWidth;
 
 
1108
  const lmfWidth = baseNode.offsetWidth;
1109
+ const scrollbarWidth = scrollRootWidth - lmfWidth;
 
1110
 
 
1111
  return scrollbarWidth > 0 ? scrollbarWidth : 0;
1112
  }
1113
 
 
1176
  // 清理SVG引用
1177
  this.currentSvgOverlay = undefined;
1178
  window.removeEventListener('token-render-style-change', this._onTokenRenderStyleChange);
 
1179
  window.removeEventListener('surprisal-color-weaken-change', this._onSurprisalColorWeakenChange);
1180
 
1181
  // 调用父类的destroy方法
 
1283
  this.highlightManager.setCharIntervalUnderlines(segments);
1284
  }
1285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1286
  /** 滚到 chunk 起点(分析结束、直方图 bin 点击,视口 0.2) */
1287
  scrollToChunkStart(charOffset: number, onScrollEnd?: () => void): void {
1288
  this.scrollToUnicodeCharOffset(Math.max(0, Math.floor(charOffset)), onScrollEnd);
1289
  }
1290
 
 
 
 
 
 
 
 
 
 
 
1291
  /**
1292
  * semantic match per chunk:高亮区间 → 滚到 chunk 起点 → 滚完保持 → 淡出
1293
  */
 
1372
  }
1373
 
1374
  /**
1375
+ * 滚动至 Unicode 偏移;桌面滚正文滚动根(analysis 为 `#results`,其它页多为 `.right_panel`),窄屏为 `window`
1376
  * @param viewportYRatio 目标点在视口中的纵向位置(0=顶部,1=底部),默认 0.2
1377
  */
1378
  scrollToUnicodeCharOffset(charOffset: number, onScrollEnd?: () => void, viewportYRatio = 0.2): void {
 
1417
  return;
1418
  }
1419
 
1420
+ const panel = getDesktopTextScrollRoot();
1421
  if (!panel) {
1422
  onScrollEnd?.();
1423
  return;
 
1457
  }
1458
 
1459
  /**
1460
+ * 清除高亮。
1461
+ * @param options.preserveChunkInterval 为 true 时保留 chunk 区间下划线及其 hold/fade(语义流式更新用)
1462
  */
1463
+ clearHighlight(options?: { preserveChunkInterval?: boolean }) {
1464
+ const preserve = options?.preserveChunkInterval === true;
1465
+ if (!preserve) {
1466
+ this.cancelChunkHighlightFade();
1467
+ this._current.chunkCharRange = null;
1468
+ }
1469
  this._current.highlightedIndices.clear();
 
1470
 
1471
  if (this.highlightManager) {
1472
+ if (preserve) {
1473
+ this.highlightManager.clearTokenHighlights();
1474
+ } else {
1475
+ this.highlightManager.clearHighlight();
1476
+ }
1477
  }
1478
  }
1479
 
client/src/shared/vis/HighlightManager.ts CHANGED
@@ -157,6 +157,11 @@ export class HighlightManager {
157
  this.removeIntervalSvgLines();
158
  }
159
 
 
 
 
 
 
160
  /** 仅清除 token 矩形上的高亮样式(保留字符区间线,供内部组合使用) */
161
  private clearRectHighlightsOnly(): void {
162
  this.rectCache.forEach(({ rect }, rectKey) => {
 
157
  this.removeIntervalSvgLines();
158
  }
159
 
160
+ /** 仅清除 token 高亮,保留 chunk 区间下划线及其 fade */
161
+ clearTokenHighlights(): void {
162
+ this.clearRectHighlightsOnly();
163
+ }
164
+
165
  /** 仅清除 token 矩形上的高亮样式(保留字符区间线,供内部组合使用) */
166
  private clearRectHighlightsOnly(): void {
167
  this.rectCache.forEach(({ rect }, rectKey) => {
client/src/shared/vis/SvgOverlayManager.ts CHANGED
@@ -165,7 +165,7 @@ export class SvgOverlayManager {
165
 
166
  /**
167
  * 更新 base rect(信息密度/classic 底色)颜色,不重建 SVG。
168
- * 用于 Disable info density / token render style 切换时,增量路径下刷新底色。
169
  */
170
  updateBaseRectColors(
171
  rd: FrontendAnalyzeResult,
 
165
 
166
  /**
167
  * 更新 base rect(信息密度/classic 底色)颜色,不重建 SVG。
168
+ * 用于 token render style 切换时,增量路径下刷新底色。
169
  */
170
  updateBaseRectColors(
171
  rd: FrontendAnalyzeResult,
client/src/shared/vis/ToolTip.ts CHANGED
@@ -312,7 +312,7 @@ export class ToolTip {
312
 
313
  /**
314
  * 更新tooltip内容
315
- * 统一结构:语义区块(上) + 分隔线 + 信息密度区块(下,含汇总指标 + top-k 表格)
316
  */
317
  private _updateContent(ri: GLTR_RenderItem, augment?: ToolTipUpdateAugment): void {
318
  const { selectedColor, detailColor, valueColor } = this.themeColors;
 
312
 
313
  /**
314
  * 更新tooltip内容
315
+ * 结构:语义区块(上)与信息密度区块(下)按数据有无拼接;互斥模式下通常只出现一侧
316
  */
317
  private _updateContent(ri: GLTR_RenderItem, augment?: ToolTipUpdateAugment): void {
318
  const { selectedColor, detailColor, valueColor } = this.themeColors;
client/src/shared/vis/constants.ts CHANGED
@@ -19,14 +19,8 @@ export const HIGHLIGHT_CONSTANTS = {
19
  /** 下划线宽度 */
20
  UNDERLINE_WIDTH: '2',
21
  /** chunk 进度图跳转:滚动结束后保持高亮时长 */
22
- CHUNK_HIGHLIGHT_HOLD_MS: 1000,
23
  /** chunk 进度图跳转:保持结束后的淡出时长 */
24
  CHUNK_HIGHLIGHT_FADE_MS: 1400,
25
  } as const;
26
 
27
- /** 分块语义搜索:每块上色后、滚到下一块前的停留时长 */
28
- export const CHUNK_SEARCH_HOLD_MS = 400;
29
-
30
- /** 分块语义搜索跟随:chunk 起点在视口中的纵向位置(0=顶,1=底) */
31
- export const CHUNK_SEARCH_FOLLOW_VIEWPORT_Y_RATIO = 0.6;
32
-
 
19
  /** 下划线宽度 */
20
  UNDERLINE_WIDTH: '2',
21
  /** chunk 进度图跳转:滚动结束后保持高亮时长 */
22
+ CHUNK_HIGHLIGHT_HOLD_MS: 200,
23
  /** chunk 进度图跳转:保持结束后的淡出时长 */
24
  CHUNK_HIGHLIGHT_FADE_MS: 1400,
25
  } as const;
26
 
 
 
 
 
 
 
client/src/webpack.config.js CHANGED
@@ -173,6 +173,7 @@ module.exports = {
173
  copyHtmlWithIncludesAndPageMeta('chat.html', 'chat.html', 'chat'),
174
  copyHtmlWithIncludesAndPageMeta('attribution.html', 'attribution.html', 'attribution'),
175
  copyHtmlWithIncludesAndPageMeta('causal_flow.html', 'causal_flow.html', 'causalFlow'),
 
176
  ]
177
  }),
178
  new GenAttributeDemoManifestPlugin(),
 
173
  copyHtmlWithIncludesAndPageMeta('chat.html', 'chat.html', 'chat'),
174
  copyHtmlWithIncludesAndPageMeta('attribution.html', 'attribution.html', 'attribution'),
175
  copyHtmlWithIncludesAndPageMeta('causal_flow.html', 'causal_flow.html', 'causalFlow'),
176
+ { from: 'privacy-policy.html', to: 'privacy-policy.html' },
177
  ]
178
  }),
179
  new GenAttributeDemoManifestPlugin(),
extension/README.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # InfoLens Semantic Find(可行性 demo)
2
+
3
+ Chrome MV3 扩展:对当前网页做语义检索,叠层高亮(chunk 下划线 + token 染色)。
4
+ 与 `backend/`、`client/` 同仓并列;只消费 API + 操作 DOM。
5
+
6
+ Find bar 权威源在 `extension/ui/`,站内为手工副本。挂 Shadow DOM;主题跟系统 `prefers-color-scheme`。
7
+ 正文:Readability 定根 + DOM 映回;失败即放弃。权限:`activeTab` + 手势后再注入(无 `<all_urls>`)。
8
+
9
+ ---
10
+
11
+ ## 开发
12
+
13
+ ```bash
14
+ ./extension/dev-env.sh prod # 或 local;生成 gitignore 的 config.js(clone 后至少一次)
15
+ ```
16
+
17
+ Chrome → `chrome://extensions` → 开发者模式 → 加载已解压 → 选 `extension/`(绿角标 = dev 版)。
18
+ 普通 `https` 页 → 工具栏图标或 `Ctrl+Shift+F`(Mac `⌘⇧F`)→ 输入 query → Enter。
19
+
20
+ 改配置改源头,再生成(**不要手改** `config.js`):
21
+
22
+ ```bash
23
+ ./extension/dev-env.sh local # apiBase=127.0.0.1:5001(需本地后端,且未传 --no_cors)
24
+ ./extension/dev-env.sh prod # apiBase=HF Spaces
25
+ ```
26
+
27
+ 改完扩展页「重新加载」。浮条改 `extension/ui/`,需站内一致再手工同步 client。
28
+
29
+ ### 上架
30
+
31
+ ```bash
32
+ ./extension/pack.sh
33
+ ```
34
+
35
+ 临时目录打 zip(正式图标 + `config.prod.js` → 包内 `config.js`),不改工作树。产出在 `extension/dist/`(gitignore)。
36
+
37
+ ### 配置字段
38
+
39
+ | 字段 | 含义 |
40
+ |------|------|
41
+ | `apiBase` | API 根;local / prod 由 `dev-env.sh` 切换 |
42
+ | `chunkBytes` | 分块字节上限 |
43
+ | `maxChunks` | demo 最多请求块数 |
44
+ | `matchThreshold` | 计入 ↑↓ 的 match 阈值 |
45
+ | `submode` | 默认 `hybrid`(前端 count→fill_blank) |
46
+ | `pwScorePercentile` | 简化 pw 分位 τ(默认 0.9) |
47
+ | `domDebug` | `true` 只划正文范围 |
48
+ | `followSearching` | `true` 全程跟随最新 chunk |
49
+
50
+ ### 排查:`Frame with ID 0 was removed`
51
+
52
+ 受限页 / 未加载完 / 标签休眠。换普通文章页 → 加载完 → 重载扩展 → 再点图标。
53
+
54
+ ---
55
+
56
+ ## 目录要点
57
+
58
+ ```text
59
+ manifest.json # unpacked;图标 icons/dev/
60
+ icons/icon*.png # 上架正式图标
61
+ pack.sh / dev-env.sh
62
+ config.prod.js # 上架默认(源头);pack 打进包
63
+ config.local.js # 本地(源头)
64
+ config.js # gitignore;dev-env 生成
65
+ ui/ # Find bar 权威源
66
+ articleRoot.js # Readability 定根
67
+ splitTextToChunks.js / mergeTokenSpans.js # SYNC ← client
68
+ vendor/Readability.js
69
+ ```
extension/articleRoot.js ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 定根:Chrome 纯文本查看器(body 下仅一个 <pre>)直接用 body;
3
+ * 其余走 Readability(预打标 → clone 上跑算法 → data-il-rid 映回)。
4
+ * Readability 失败抛错,不回退启发式。
5
+ */
6
+ (() => {
7
+ const ATTR = 'data-il-rid';
8
+
9
+ /** 扩展注入的宿主,不算页面内容子节点 */
10
+ function isExtensionHost(el) {
11
+ const id = el.id;
12
+ return id === 'il-find-root' || id === 'il-overlay-host';
13
+ }
14
+
15
+ /**
16
+ * Chrome/WebKit 对 text/plain 等的包装:<body><pre>…</pre></body>
17
+ * @param {Document} doc
18
+ * @returns {Element | null}
19
+ */
20
+ function findPlainTextRoot(doc) {
21
+ const body = doc.body;
22
+ if (!body) return null;
23
+ const kids = [];
24
+ for (const el of body.children) {
25
+ if (!isExtensionHost(el)) kids.push(el);
26
+ }
27
+ if (kids.length === 1 && kids[0].tagName === 'PRE') return body;
28
+ return null;
29
+ }
30
+
31
+ function mark(doc) {
32
+ if (!doc.body) return;
33
+ let seq = 0;
34
+ for (const el of doc.body.querySelectorAll('*')) {
35
+ el.setAttribute(ATTR, String(++seq));
36
+ }
37
+ }
38
+
39
+ function unmark(doc) {
40
+ if (!doc.body) return;
41
+ for (const el of doc.body.querySelectorAll(`[${ATTR}]`)) {
42
+ el.removeAttribute(ATTR);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {Document} doc
48
+ * @returns {Element}
49
+ */
50
+ function findArticleRoot(doc) {
51
+ if (!doc?.body) {
52
+ throw new Error('document.body missing');
53
+ }
54
+
55
+ const plain = findPlainTextRoot(doc);
56
+ if (plain) return plain;
57
+
58
+ if (typeof Readability !== 'function') {
59
+ throw new Error('Readability missing — inject vendor/Readability.js first');
60
+ }
61
+
62
+ mark(doc);
63
+ try {
64
+ const clone = doc.cloneNode(true);
65
+ const reader = new Readability(clone);
66
+ const parsed = reader.parse();
67
+ if (!parsed) {
68
+ throw new Error('Readability: parse failed');
69
+ }
70
+ const rid = reader._ilArticleRootRid;
71
+ if (rid == null || rid === '') {
72
+ throw new Error('Readability: no mappable article root');
73
+ }
74
+ const root = doc.querySelector(`[${ATTR}="${CSS.escape(String(rid))}"]`);
75
+ if (!root) {
76
+ throw new Error(`Readability: live root not found (rid=${rid})`);
77
+ }
78
+ return root;
79
+ } finally {
80
+ unmark(doc);
81
+ }
82
+ }
83
+
84
+ globalThis.IL_findArticleRoot = findArticleRoot;
85
+ })();
extension/background.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 工具栏点击 → 注入 content(activeTab 手势)。
3
+ * API 走 SW fetch:不声明 host_permissions,依赖服务端 CORS(见 run.py CORSMiddleware)。
4
+ */
5
+
6
+ importScripts('config.js');
7
+
8
+ const CONTENT_CSS = ['content.css'];
9
+ const CONTENT_JS = [
10
+ 'config.js',
11
+ 'vendor/Readability.js',
12
+ 'articleRoot.js',
13
+ 'splitTextToChunks.js',
14
+ 'mergeTokenSpans.js',
15
+ 'content.js',
16
+ ];
17
+
18
+ function isRestrictedUrl(url) {
19
+ if (!url) return true;
20
+ let u;
21
+ try {
22
+ u = new URL(url);
23
+ } catch {
24
+ return true;
25
+ }
26
+ const proto = u.protocol;
27
+ if (
28
+ proto === 'chrome:' ||
29
+ proto === 'chrome-extension:' ||
30
+ proto === 'chrome-search:' ||
31
+ proto === 'chrome-untrusted:' ||
32
+ proto === 'devtools:' ||
33
+ proto === 'edge:' ||
34
+ proto === 'about:' ||
35
+ proto === 'view-source:'
36
+ ) {
37
+ return true;
38
+ }
39
+ // Chrome PDF viewer / Web Store:主 frame 常无法注入
40
+ if (proto === 'file:' && /\.pdf$/i.test(u.pathname)) return true;
41
+ if (/\.pdf$/i.test(u.pathname)) return true;
42
+ if (u.hostname === 'chrome.google.com' && u.pathname.startsWith('/webstore')) return true;
43
+ if (u.hostname === 'chromewebstore.google.com') return true;
44
+ return false;
45
+ }
46
+
47
+ function sleep(ms) {
48
+ return new Promise((r) => setTimeout(r, ms));
49
+ }
50
+
51
+ /** 等到 status=complete;已 complete 则立即返回最新 tab */
52
+ async function waitTabComplete(tabId, timeoutMs = 20000) {
53
+ const cur = await chrome.tabs.get(tabId);
54
+ if (cur.status === 'complete') return cur;
55
+
56
+ return new Promise((resolve, reject) => {
57
+ const timer = setTimeout(() => {
58
+ chrome.tabs.onUpdated.removeListener(onUpdated);
59
+ reject(new Error(`tab ${tabId} load timeout (${timeoutMs}ms)`));
60
+ }, timeoutMs);
61
+
62
+ function onUpdated(id, info) {
63
+ if (id !== tabId || info.status !== 'complete') return;
64
+ clearTimeout(timer);
65
+ chrome.tabs.onUpdated.removeListener(onUpdated);
66
+ chrome.tabs.get(tabId).then(resolve, reject);
67
+ }
68
+ chrome.tabs.onUpdated.addListener(onUpdated);
69
+ });
70
+ }
71
+
72
+ function isTransientFrameError(err) {
73
+ const msg = String(err?.message || err);
74
+ return (
75
+ msg.includes('Frame with ID') ||
76
+ msg.includes('No frame with id') ||
77
+ msg.includes('Frame does not exist') ||
78
+ msg.includes('The tab was closed') ||
79
+ msg.includes('cannot be scripted now')
80
+ );
81
+ }
82
+
83
+ async function injectOnce(tabId) {
84
+ // 显式 frameIds:[0],避免对已消失子 frame 误操作;主文档未就绪时由上层重试
85
+ await chrome.scripting.insertCSS({
86
+ target: { tabId, frameIds: [0] },
87
+ files: CONTENT_CSS,
88
+ });
89
+ await chrome.scripting.executeScript({
90
+ target: { tabId, frameIds: [0] },
91
+ files: CONTENT_JS,
92
+ });
93
+ }
94
+
95
+ async function injectWithRetry(tabId) {
96
+ let lastErr;
97
+ for (let attempt = 1; attempt <= 4; attempt++) {
98
+ try {
99
+ const tab = await waitTabComplete(tabId);
100
+ if (isRestrictedUrl(tab.url)) {
101
+ throw new Error(`restricted page: ${tab.url || '(no url)'}`);
102
+ }
103
+ // 丢弃休眠的 tab:先激活再注
104
+ if (tab.discarded) {
105
+ await chrome.tabs.reload(tabId);
106
+ await waitTabComplete(tabId);
107
+ }
108
+ await injectOnce(tabId);
109
+ return tab;
110
+ } catch (err) {
111
+ lastErr = err;
112
+ if (!isTransientFrameError(err) || attempt === 4) throw err;
113
+ console.warn(`[InfoLens] inject attempt ${attempt} failed, retry…`, err?.message || err);
114
+ await sleep(100 * attempt);
115
+ }
116
+ }
117
+ throw lastErr;
118
+ }
119
+
120
+ async function setBadgeError(brief) {
121
+ try {
122
+ await chrome.action.setBadgeBackgroundColor({ color: '#c0392b' });
123
+ await chrome.action.setBadgeText({ text: '!' });
124
+ await chrome.action.setTitle({ title: `InfoLens: ${brief}` });
125
+ setTimeout(() => {
126
+ chrome.action.setBadgeText({ text: '' });
127
+ chrome.action.setTitle({ title: 'InfoLens Semantic Find' });
128
+ }, 5000);
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ }
133
+
134
+ async function activateTab(tab) {
135
+ if (!tab?.id) return;
136
+
137
+ // 手势当下立刻读一次 url;无 url 时仍尝试 get(activeTab 授权后)
138
+ try {
139
+ const fresh = await chrome.tabs.get(tab.id);
140
+ if (isRestrictedUrl(fresh.url || tab.url)) {
141
+ const url = fresh.url || tab.url || '(empty)';
142
+ console.warn('[InfoLens] cannot run on this page:', url);
143
+ await setBadgeError('bad page');
144
+ return;
145
+ }
146
+
147
+ const okTab = await injectWithRetry(tab.id);
148
+ console.info('[InfoLens] injected into', okTab.url);
149
+ await chrome.action.setBadgeText({ text: '' });
150
+ } catch (err) {
151
+ console.error('[InfoLens] inject failed', err);
152
+ console.error('[InfoLens] tip: use a normal http(s) article tab (not chrome://, PDF, Web Store); reload extension, then click again after the page finishes loading.');
153
+ await setBadgeError('inject');
154
+ }
155
+ }
156
+
157
+ chrome.action.onClicked.addListener(activateTab);
158
+
159
+ chrome.commands.onCommand.addListener(async (command) => {
160
+ if (command !== 'toggle-find') return;
161
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
162
+ await activateTab(tab);
163
+ });
164
+
165
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
166
+ if (msg?.type !== 'il-analyze-semantic') return;
167
+ (async () => {
168
+ try {
169
+ const apiBase = msg.apiBase || IL_CONFIG.apiBase;
170
+ const res = await fetch(`${String(apiBase).replace(/\/$/, '')}/api/analyze-semantic`, {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify(msg.body),
174
+ });
175
+ const data = await res.json().catch(() => ({}));
176
+ if (!res.ok || data?.success === false) {
177
+ const detail = data?.message || data?.detail || `HTTP ${res.status}`;
178
+ throw new Error(detail);
179
+ }
180
+ sendResponse({ ok: true, data });
181
+ } catch (err) {
182
+ sendResponse({ ok: false, error: String(err?.message || err) });
183
+ }
184
+ })();
185
+ return true;
186
+ });
extension/config.local.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 本地调试变体(源头):apiBase 打本地后端;用 dev-env.sh local 生成 config.js。
3
+ * 除 apiBase(及有意的本地差异如 maxChunks)外与 config.prod.js 对齐。
4
+ */
5
+ var IL_CONFIG = {
6
+ apiBase: 'http://127.0.0.1:5001',
7
+ /**
8
+ * SYNC: client/src/shared/core/constants.ts → SEMANTIC_CHUNK_BYTES;算法见 splitTextToChunks.js
9
+ * 已知问题:见 config.prod.js 同名字段注释(与后端 token 截断无联动,密集内容会漏检)。
10
+ */
11
+ chunkBytes: 800,
12
+ /** demo 最多请求的 chunk 数,避免一页打爆本地推理 */
13
+ maxChunks: 32,
14
+ /** SYNC: client/src/shared/core/constants.ts → SEMANTIC_MATCH_THRESHOLD */
15
+ matchThreshold: 0.1,
16
+ /** SYNC: analysis 默认 hybrid(前端组合 count→fill_blank,非后端枚举值) */
17
+ submode: 'hybrid',
18
+ /**
19
+ * SYNC 近似:
20
+ * - 站内 τ:signalThresholdDetector(失败回退 P90,见 textStatistics.computeP90)
21
+ * - 站内 pw:visualizationUpdater.ts → pw_score = score×P_pw×matchDegree
22
+ * 扩展无 signal-fit,用该分位作 τ(默认 0.9 ≈ P90);见 content.js prepareChunkTokens
23
+ */
24
+ pwScorePercentile: 0.9,
25
+ /**
26
+ * DOM 调试:true = 点击抽正文并下划线;再点取消;
27
+ * false = 正常 Find bar + 语义搜索。
28
+ */
29
+ domDebug: false,
30
+ /**
31
+ * 扩展侧跟随策略(站内 demo 已无 Follow UI / 搜索中跟随):
32
+ * true = 全程跟随最新 chunk;false = 无匹配时跟随,首个匹配后停下并划线。
33
+ */
34
+ followSearching: false,
35
+ };
extension/config.prod.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 上架默认配置(源头)。pack.sh 会拷成包内 config.js;本地用 dev-env.sh prod 生成。
3
+ * 挂到 globalThis(background importScripts / content 注入顺序加载)。
4
+ */
5
+ var IL_CONFIG = {
6
+ apiBase: 'https://dqy08-infolens.hf.space',
7
+ /**
8
+ * SYNC: client/src/shared/core/constants.ts → SEMANTIC_CHUNK_BYTES;算法见 splitTextToChunks.js
9
+ * 已知问题:与后端 SEMANTIC_RUNTIME_CONFIGS 的 max_token_length(300~1000 token,按平台)无联动。
10
+ * 数字/标点/代码等 token 密度高的内容,800 字节可能超出后端 token 限,被静默截断(仅日志提示),
11
+ * 导致该 chunk 的相关度判断只基于截断后的前缀 —— 后果是漏检,非误报。无法靠调大固定 token 数根治。
12
+ */
13
+ chunkBytes: 800,
14
+ /** demo 最多请求的 chunk 数,避免一页打爆本地推理 */
15
+ maxChunks: 32,
16
+ /** SYNC: client/src/shared/core/constants.ts → SEMANTIC_MATCH_THRESHOLD */
17
+ matchThreshold: 0.1,
18
+ /** SYNC: analysis 默认 hybrid(前端组合 count→fill_blank,非后端枚举值) */
19
+ submode: 'hybrid',
20
+ /**
21
+ * SYNC 近似:
22
+ * - 站内 τ:signalThresholdDetector(失败回退 P90,见 textStatistics.computeP90)
23
+ * - 站内 pw:visualizationUpdater.ts → pw_score = score×P_pw×matchDegree
24
+ * 扩展无 signal-fit,用该分位作 τ(默认 0.9 ≈ P90);见 content.js prepareChunkTokens
25
+ */
26
+ pwScorePercentile: 0.9,
27
+ /**
28
+ * DOM 调试:true = 点击抽正文并下划线;再点取消;
29
+ * false = 正常 Find bar + 语义搜索。
30
+ */
31
+ domDebug: false,
32
+ /**
33
+ * 扩展侧跟随策略(站内 demo 已无 Follow UI / 搜索中跟随):
34
+ * true = 全程跟随最新 chunk;false = 无匹配时跟随,首个匹配后停下并划线。
35
+ */
36
+ followSearching: false,
37
+ };
extension/content.css ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Extension-only: underline overlay + Custom Highlight.
2
+ * Find bar lives in Shadow DOM (see content.js).
3
+ *
4
+ * token / truncated:CSS Custom Highlight API(不改 DOM、不垫字下)
5
+ * underline:#il-overlay-host 绝对定位盖层(不必垫字下)
6
+ */
7
+
8
+ #il-overlay-host {
9
+ position: absolute;
10
+ left: 0;
11
+ top: 0;
12
+ width: 0;
13
+ height: 0;
14
+ margin: 0;
15
+ padding: 0;
16
+ border: 0;
17
+ overflow: visible;
18
+ pointer-events: none;
19
+ z-index: 2147483644;
20
+ }
21
+
22
+ /* SYNC: client/src/shared/vis/constants.ts → HIGHLIGHT_CONSTANTS
23
+ * UNDERLINE_WIDTH=2, HIGHLIGHT_COLOR fallback #1e6fff */
24
+ .il-chunk-underline {
25
+ position: absolute;
26
+ left: 0;
27
+ top: 0;
28
+ pointer-events: none;
29
+ height: 2px;
30
+ background: #1e6fff;
31
+ border-radius: 2px;
32
+ }
33
+
34
+ /*
35
+ * token:16 档 background-color。
36
+ * SYNC: SurprisalColorConfig → SURPRISAL_RED_RGB / SURPRISAL_MAX_ALPHA(0.7)
37
+ * alpha = ((level+1)/16) * 0.7
38
+ */
39
+ ::highlight(il-token-0) {
40
+ background-color: rgba(255, 71, 64, 0.04375);
41
+ }
42
+ ::highlight(il-token-1) {
43
+ background-color: rgba(255, 71, 64, 0.0875);
44
+ }
45
+ ::highlight(il-token-2) {
46
+ background-color: rgba(255, 71, 64, 0.13125);
47
+ }
48
+ ::highlight(il-token-3) {
49
+ background-color: rgba(255, 71, 64, 0.175);
50
+ }
51
+ ::highlight(il-token-4) {
52
+ background-color: rgba(255, 71, 64, 0.21875);
53
+ }
54
+ ::highlight(il-token-5) {
55
+ background-color: rgba(255, 71, 64, 0.2625);
56
+ }
57
+ ::highlight(il-token-6) {
58
+ background-color: rgba(255, 71, 64, 0.30625);
59
+ }
60
+ ::highlight(il-token-7) {
61
+ background-color: rgba(255, 71, 64, 0.35);
62
+ }
63
+ ::highlight(il-token-8) {
64
+ background-color: rgba(255, 71, 64, 0.39375);
65
+ }
66
+ ::highlight(il-token-9) {
67
+ background-color: rgba(255, 71, 64, 0.4375);
68
+ }
69
+ ::highlight(il-token-10) {
70
+ background-color: rgba(255, 71, 64, 0.48125);
71
+ }
72
+ ::highlight(il-token-11) {
73
+ background-color: rgba(255, 71, 64, 0.525);
74
+ }
75
+ ::highlight(il-token-12) {
76
+ background-color: rgba(255, 71, 64, 0.56875);
77
+ }
78
+ ::highlight(il-token-13) {
79
+ background-color: rgba(255, 71, 64, 0.6125);
80
+ }
81
+ ::highlight(il-token-14) {
82
+ background-color: rgba(255, 71, 64, 0.65625);
83
+ }
84
+ ::highlight(il-token-15) {
85
+ background-color: rgba(255, 71, 64, 0.7);
86
+ }
87
+
88
+ /*
89
+ * 未分析后缀置灰:统一灰(不跟链接/标题各自字色)。
90
+ * CanvasText / Canvas 随色彩方案变,深浅色均可读。
91
+ */
92
+ ::highlight(il-truncated) {
93
+ color: color-mix(in srgb, CanvasText 40%, Canvas 60%);
94
+ }
extension/content.js ADDED
@@ -0,0 +1,1831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * InfoLens semantic find — feasibility content script.
3
+ * Validates: article extract ↔ DOM map, API call, token paint, chunk underline.
4
+ *
5
+ * token / truncated:CSS Custom Highlight API(不改 DOM;truncated 为 CanvasText×Canvas 统一灰)。
6
+ * underline:#il-overlay-host 绝对定位盖层(不垫字下)。
7
+ *
8
+ * IL_CONFIG.domDebug:点击后只抽正文并下划线,便于目测提取范围(不唤起 Find bar)。
9
+ */
10
+ (() => {
11
+ // 已注入过:依赖齐全则只 reopen;缺 merge 副本说明上次注入过期(未重载扩展),拆掉重装
12
+ if (window.__IL_SEMANTIC_DEMO__) {
13
+ if (typeof globalThis.IL_mergeTokenSpansFullyForRendering === 'function') {
14
+ window.__IL_SEMANTIC_DEMO__.open();
15
+ return;
16
+ }
17
+ try {
18
+ window.__IL_SEMANTIC_DEMO__.destroy();
19
+ } catch {
20
+ /* ignore */
21
+ }
22
+ window.__IL_SEMANTIC_DEMO__ = undefined;
23
+ }
24
+
25
+ const CFG = globalThis.IL_CONFIG || {
26
+ apiBase: 'http://127.0.0.1:5001',
27
+ chunkBytes: 800,
28
+ maxChunks: 3,
29
+ matchThreshold: 0.1,
30
+ submode: 'hybrid',
31
+ // SYNC 默认:站内 signal-fit 失败回退 P90;扩展用分位近似(见 prepareChunkTokens)
32
+ pwScorePercentile: 0.9,
33
+ domDebug: true,
34
+ // 扩展侧节奏:默认 false = 无匹配时跟随、首个匹配后停下并划线(站内 demo 已无 Follow UI)
35
+ followSearching: false,
36
+ };
37
+ const DOM_DEBUG = !!CFG.domDebug;
38
+
39
+ // SYNC: client/src/shared/cross/SurprisalColorConfig.ts → SURPRISAL_RED / MAX_ALPHA(色值见 content.css ::highlight)
40
+ /** token 背景量化档数(::highlight(il-token-0..15)) */
41
+ const TOKEN_LEVELS = 16;
42
+ const HL_TOKEN_PREFIX = 'il-token-';
43
+ const HL_TRUNCATED = 'il-truncated';
44
+ // SYNC: client/src/shared/vis/constants.ts → HIGHLIGHT_CONSTANTS
45
+ const CHUNK_HIGHLIGHT_HOLD_MS = 200;
46
+ const CHUNK_HIGHLIGHT_FADE_MS = 1400;
47
+ // SYNC: client/src/shared/vis/GLTR_Text_Box.ts → scrollToUnicodeCharOffset 默认 viewportYRatio
48
+ const CHUNK_JUMP_VIEWPORT_Y_RATIO = 0.2;
49
+ // 分块语义搜索:最快节奏下限——两次渲染之间至少间隔这么久(无论是否有滚动)
50
+ const CHUNK_SEARCH_MIN_CYCLE_MS = 900;
51
+ // 有后续滚动时:染色后先停留一半,另一半留给滚动 settle;无滚动(已 park)时整段都算这里,见下方用法
52
+ const CHUNK_SEARCH_HOLD_MS = CHUNK_SEARCH_MIN_CYCLE_MS / 2;
53
+ const CHUNK_SEARCH_FOLLOW_VIEWPORT_Y_RATIO = 0.6;
54
+ // 自动滚动落点(预滚到下一块 / 首次命中跳转)至少展示这么久,再进入下一块渲染
55
+ const CHUNK_SEARCH_SCROLL_SETTLE_MS = CHUNK_SEARCH_MIN_CYCLE_MS / 2;
56
+
57
+ /** @type {{ node: Text, start: number, end: number }[]} */
58
+ let pieces = [];
59
+ /** pieces 的节点集合缓存,供 mutationTouchesPieces 做 O(1) 命中判断,避免逐条 mutation 都线性扫 pieces */
60
+ /** @type {Set<Text>} */
61
+ let pieceNodeSet = new Set();
62
+ let extractedText = '';
63
+ /** @type {{ start: number, end: number, matchDegree: number }[]} */
64
+ let matchedChunks = [];
65
+ /** @type {{ start: number, end: number, matchDegree: number }[]} */
66
+ let semanticMatchProgress = [];
67
+ /** 进度条分母(码点数):maxChunks 截断时用实际搜索覆盖长度,而非全文长度;0 = 未搜索,回退全文 */
68
+ let progressTextLength = 0;
69
+ let selectedProgressChunkStart = null;
70
+ let hoveredProgressChunkStart = null;
71
+ let progressSelectionFading = false;
72
+ let matchIndex = -1;
73
+ /**
74
+ * 逻辑区间(去重键);与 DOM 节点分离。
75
+ * token:CSS Highlight;underline:overlay rect(reflow 时按 spec 重绘,勿按 rect 条数累积)。
76
+ * @type {{ kind: 'token' | 'underline', cp0: number, cp1: number, level?: number }[]}
77
+ */
78
+ let paintSpecs = [];
79
+ /** @type {HTMLElement[]} 仅 underline DOM */
80
+ let overlayEls = [];
81
+ /** 未分析后缀置灰:已分析码点终点;null = 无置灰 */
82
+ let truncatedAnalyzedCpEnd = null;
83
+ /**
84
+ * 长度 1 的搜索结果缓存(含 Stop 半成品)。close 清高亮但保留;
85
+ * open 时若输入与正文未变则还原,避免重复请求。
86
+ * @type {null | {
87
+ * query: string,
88
+ * text: string,
89
+ * paintSpecs: typeof paintSpecs,
90
+ * matchedChunks: typeof matchedChunks,
91
+ * semanticMatchProgress: typeof semanticMatchProgress,
92
+ * progressTextLength: number,
93
+ * matchIndex: number,
94
+ * truncatedAnalyzedCpEnd: number | null,
95
+ * }}
96
+ */
97
+ let lastResult = null;
98
+ /** @type {Element | null} 正文根(定滚动根用) */
99
+ let extractRoot = null;
100
+ /** @type {HTMLElement | null} underline 叠层宿主 + 布局监听哨兵 */
101
+ let paintMount = null;
102
+ /** @type {Element | null} 曾被设为 relative 的节点 */
103
+ let paintPosTarget = null;
104
+ /** 若曾把 paintPosTarget 从 static 改为 relative,清理时还原 */
105
+ let paintPosRestore = null;
106
+ /** @type {ResizeObserver | null} */
107
+ let paintResizeObserver = null;
108
+ /** 监听正文子树的文字改写(如翻译插件原地换字),弥补 ResizeObserver 只对尺寸变化敏感的盲区 */
109
+ /** @type {MutationObserver | null} */
110
+ let contentMutationObserver = null;
111
+ let scrollSyncTimer = 0;
112
+ let searching = false;
113
+ let abortWanted = false;
114
+ let reflowQueued = false;
115
+ let underlineHoldTimer = 0;
116
+ let underlineFadeTimer = 0;
117
+ /** @type {(() => void) | null} */
118
+ let scrollEndCancel = null;
119
+ let underlineFadeGen = 0;
120
+ let chunkSearchAutoScrollUserCancelled = false;
121
+ /** @type {(() => void) | undefined} */
122
+ let chunkSearchAutoScrollCleanup;
123
+
124
+ // ---------- extract(纯文本查看器 / Readability 定根;后者失败不回退) ----------
125
+
126
+ function pickArticleRoot() {
127
+ const find = globalThis.IL_findArticleRoot;
128
+ if (typeof find !== 'function') {
129
+ throw new Error('IL_findArticleRoot missing — inject articleRoot.js before content.js');
130
+ }
131
+ return find(document);
132
+ }
133
+
134
+ function collectTextMap(root) {
135
+ const out = [];
136
+ let text = '';
137
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
138
+ acceptNode(node) {
139
+ if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
140
+ const p = node.parentElement;
141
+ if (!p) return NodeFilter.FILTER_REJECT;
142
+ const tag = p.tagName;
143
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA' || tag === 'SVG') {
144
+ return NodeFilter.FILTER_REJECT;
145
+ }
146
+ if (p.closest('#il-find-root, #il-overlay-host, .il-chunk-underline')) {
147
+ return NodeFilter.FILTER_REJECT;
148
+ }
149
+ return NodeFilter.FILTER_ACCEPT;
150
+ },
151
+ });
152
+ let n;
153
+ while ((n = walker.nextNode())) {
154
+ const value = n.nodeValue;
155
+ const start = text.length;
156
+ text += value;
157
+ out.push({ node: n, start, end: text.length });
158
+ }
159
+ return { text, pieces: out, root };
160
+ }
161
+
162
+ function setPieces(newPieces) {
163
+ pieces = newPieces;
164
+ pieceNodeSet = new Set(pieces.map((p) => p.node));
165
+ }
166
+
167
+ function refreshExtract() {
168
+ clearOverlays();
169
+ const root = pickArticleRoot();
170
+ extractRoot = root;
171
+ ensurePaintMount(root);
172
+ const mapped = collectTextMap(root);
173
+ extractedText = mapped.text;
174
+ setPieces(mapped.pieces);
175
+ matchedChunks = [];
176
+ semanticMatchProgress = [];
177
+ matchIndex = -1;
178
+ return { root, length: extractedText.length };
179
+ }
180
+
181
+ // ---------- offsets (API = code points; piece map = UTF-16) ----------
182
+
183
+ function cpToUtf16(str, cpIndex) {
184
+ let i = 0;
185
+ let cps = 0;
186
+ while (cps < cpIndex && i < str.length) {
187
+ const cp = str.codePointAt(i);
188
+ i += cp > 0xffff ? 2 : 1;
189
+ cps += 1;
190
+ }
191
+ return i;
192
+ }
193
+
194
+ function utf16ToCp(str, utf16Index) {
195
+ let i = 0;
196
+ let cps = 0;
197
+ while (i < utf16Index && i < str.length) {
198
+ const cp = str.codePointAt(i);
199
+ i += cp > 0xffff ? 2 : 1;
200
+ cps += 1;
201
+ }
202
+ return cps;
203
+ }
204
+
205
+ function findPiece(utf16Offset) {
206
+ let lo = 0;
207
+ let hi = pieces.length - 1;
208
+ while (lo <= hi) {
209
+ const mid = (lo + hi) >> 1;
210
+ const p = pieces[mid];
211
+ if (utf16Offset < p.start) hi = mid - 1;
212
+ else if (utf16Offset >= p.end) lo = mid + 1;
213
+ else return p;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ function findPieceIndex(utf16Offset) {
219
+ let lo = 0;
220
+ let hi = pieces.length - 1;
221
+ while (lo <= hi) {
222
+ const mid = (lo + hi) >> 1;
223
+ const p = pieces[mid];
224
+ if (utf16Offset < p.start) hi = mid - 1;
225
+ else if (utf16Offset >= p.end) lo = mid + 1;
226
+ else return mid;
227
+ }
228
+ return -1;
229
+ }
230
+
231
+ /** 最近块级容器;跨块的 Range 会让 getClientRects 多出整块盒子 */
232
+ function nearestBlock(node, cache) {
233
+ let el = node.parentElement;
234
+ while (el) {
235
+ const cached = cache?.get(el);
236
+ if (cached !== undefined) return cached;
237
+ const display = getComputedStyle(el).display;
238
+ if (
239
+ display === 'block' ||
240
+ display === 'flex' ||
241
+ display === 'grid' ||
242
+ display === 'list-item' ||
243
+ display === 'table' ||
244
+ display === 'table-row' ||
245
+ display === 'table-cell' ||
246
+ display === 'flow-root'
247
+ ) {
248
+ cache?.set(el, el);
249
+ return el;
250
+ }
251
+ el = el.parentElement;
252
+ }
253
+ return document.body;
254
+ }
255
+
256
+ /** 整段 Range(滚动定位等);绘制请用 rangesFromCpOffsets */
257
+ function rangeFromCpOffsets(cp0, cp1) {
258
+ if (!extractedText || cp1 <= cp0) return null;
259
+ const u0 = cpToUtf16(extractedText, cp0);
260
+ const u1 = cpToUtf16(extractedText, cp1);
261
+ if (u1 <= u0) return null;
262
+
263
+ const startPiece = findPiece(u0);
264
+ const endPiece = findPiece(u1 - 1);
265
+ if (!startPiece || !endPiece) return null;
266
+ if (!startPiece.node.isConnected || !endPiece.node.isConnected) return null;
267
+
268
+ const range = document.createRange();
269
+ try {
270
+ range.setStart(startPiece.node, u0 - startPiece.start);
271
+ range.setEnd(endPiece.node, u1 - endPiece.start);
272
+ } catch {
273
+ return null;
274
+ }
275
+ return range;
276
+ }
277
+
278
+ /**
279
+ * 按块级容器切开,避免单个 Range 跨 <p> 等时 getClientRects 叠出双线。
280
+ * @returns {Range[]}
281
+ */
282
+ function rangesFromCpOffsets(cp0, cp1) {
283
+ if (!extractedText || cp1 <= cp0) return [];
284
+ const u0 = cpToUtf16(extractedText, cp0);
285
+ const u1 = cpToUtf16(extractedText, cp1);
286
+ if (u1 <= u0) return [];
287
+
288
+ let i = findPieceIndex(u0);
289
+ if (i < 0) return [];
290
+
291
+ const blockCache = new WeakMap();
292
+ /** @type {Range[]} */
293
+ const out = [];
294
+ /** @type {Range | null} */
295
+ let cur = null;
296
+ /** @type {Element | null} */
297
+ let curBlock = null;
298
+
299
+ for (; i < pieces.length; i++) {
300
+ const p = pieces[i];
301
+ if (p.start >= u1) break;
302
+ if (!p.node.isConnected) continue;
303
+ const seg0 = Math.max(u0, p.start);
304
+ const seg1 = Math.min(u1, p.end);
305
+ if (seg1 <= seg0) continue;
306
+ // 纯空白片段不画(空白行等);截断当前 Range,避免跨过空白仍被 setEnd 包进区间
307
+ if (!/\S/.test(extractedText.slice(seg0, seg1))) {
308
+ if (cur && !cur.collapsed) out.push(cur);
309
+ cur = null;
310
+ curBlock = null;
311
+ continue;
312
+ }
313
+
314
+ const block = nearestBlock(p.node, blockCache);
315
+ try {
316
+ if (!cur || block !== curBlock) {
317
+ if (cur && !cur.collapsed) out.push(cur);
318
+ cur = document.createRange();
319
+ cur.setStart(p.node, seg0 - p.start);
320
+ cur.setEnd(p.node, seg1 - p.start);
321
+ curBlock = block;
322
+ } else {
323
+ cur.setEnd(p.node, seg1 - p.start);
324
+ }
325
+ } catch {
326
+ if (cur && !cur.collapsed) out.push(cur);
327
+ cur = null;
328
+ curBlock = null;
329
+ }
330
+ }
331
+ if (cur && !cur.collapsed) out.push(cur);
332
+ return out;
333
+ }
334
+
335
+ // ---------- chunking(SYNC 副本:splitTextToChunks.js ← semanticUtils.splitTextToChunks) ----------
336
+
337
+ function splitChunks(text, byteLimit) {
338
+ const split = globalThis.IL_splitTextToChunks;
339
+ if (typeof split !== 'function') {
340
+ throw new Error('IL_splitTextToChunks missing — inject splitTextToChunks.js before content.js');
341
+ }
342
+ return split(text, byteLimit).map((c) => ({
343
+ start: c.startOffset,
344
+ end: c.startOffset + c.text.length,
345
+ text: c.text,
346
+ }));
347
+ }
348
+
349
+ /** 全空白 chunk 不送 API / 不作为语义匹配目标 */
350
+ function chunkHasContent(chunk) {
351
+ return /\S/.test(chunk.text);
352
+ }
353
+
354
+ // ---------- paint:token/truncated = CSS Highlight;underline = overlay host ----------
355
+
356
+ /** score∈(0,1] → 0..TOKEN_LEVELS-1;≤0 不画 */
357
+ function scoreToLevel(score01) {
358
+ if (!Number.isFinite(score01) || !(score01 > 0)) return -1;
359
+ const t = Math.max(0, Math.min(1, score01));
360
+ return Math.min(TOKEN_LEVELS - 1, Math.floor(t * TOKEN_LEVELS));
361
+ }
362
+
363
+ /**
364
+ * SYNC: client/src/shared/cross/textStatistics.ts → computeP90(同插值:index = (n-1)*p;站内固定 p=0.9)
365
+ * @param {number[]} values
366
+ * @param {number} p 0~1
367
+ * @returns {number | null}
368
+ */
369
+ function computePercentile(values, p) {
370
+ if (!values?.length) return null;
371
+ if (!(p >= 0 && p <= 1)) {
372
+ throw new Error(`pwScorePercentile must be in [0,1], got ${p}`);
373
+ }
374
+ const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
375
+ const n = sorted.length;
376
+ if (n === 0) return null;
377
+ const index = (n - 1) * p;
378
+ const lower = Math.floor(index);
379
+ const upper = Math.ceil(index);
380
+ const weight = index - lower;
381
+ if (lower === upper) return sorted[lower];
382
+ return sorted[lower] * (1 - weight) + sorted[upper] * weight;
383
+ }
384
+
385
+ /**
386
+ * SYNC 语义(近似):
387
+ * - merge/normalize:client/src/shared/controllers/semanticSearchController.ts
388
+ * + semanticUtils.mergeTokenSpansFullyForRendering / normalizeTokenScores
389
+ * - pw_score:client/src/features/analysis/visualizationUpdater.ts
390
+ * → pw_score = score×P_pw×matchDegree(P_pw:score>τ→1)
391
+ * - τ:站内为 signal-fit(失败回退 P90);扩展无 fit,用可配置分位代替(默认 0.9 ≈ P90)
392
+ */
393
+ function prepareChunkTokens(tokenAttention, chunkText, matchDegree) {
394
+ const merge = globalThis.IL_mergeTokenSpansFullyForRendering;
395
+ const normalize = globalThis.IL_normalizeTokenScores;
396
+ if (typeof merge !== 'function' || typeof normalize !== 'function') {
397
+ throw new Error(
398
+ 'IL_mergeTokenSpansFullyForRendering missing — 请在 chrome://extensions 重新加载本扩展后再点图标'
399
+ );
400
+ }
401
+ const normalized = normalize(merge(tokenAttention || [], chunkText));
402
+ const p = CFG.pwScorePercentile ?? 0.9;
403
+ const tau = computePercentile(
404
+ normalized.map((t) => t.score),
405
+ p
406
+ );
407
+ if (tau == null) return [];
408
+ const degree = Number.isFinite(matchDegree) ? matchDegree : 0;
409
+ return normalized.map((t) => ({
410
+ ...t,
411
+ score: t.score > tau ? t.score * degree : 0,
412
+ }));
413
+ }
414
+
415
+ function isScrollableEl(el) {
416
+ if (!(el instanceof Element)) return false;
417
+ const st = getComputedStyle(el);
418
+ const y = st.overflowY;
419
+ const x = st.overflowX;
420
+ const canY =
421
+ (y === 'auto' || y === 'scroll' || y === 'overlay') && el.scrollHeight > el.clientHeight + 1;
422
+ const canX =
423
+ (x === 'auto' || x === 'scroll' || x === 'overlay') && el.scrollWidth > el.clientWidth + 1;
424
+ return canY || canX;
425
+ }
426
+
427
+ /** 正文所在的最近滚动根(仅用于监听 scrollend) */
428
+ function findScrollRoot(from) {
429
+ let node = from instanceof Element ? from : from?.parentElement;
430
+ while (node && node !== document.documentElement) {
431
+ if (isScrollableEl(node)) return node;
432
+ node = node.parentElement;
433
+ }
434
+ return document.scrollingElement || document.documentElement;
435
+ }
436
+
437
+ function stopPaintLayoutWatch() {
438
+ paintResizeObserver?.disconnect();
439
+ paintResizeObserver = null;
440
+ contentMutationObserver?.disconnect();
441
+ contentMutationObserver = null;
442
+ if (scrollSyncTimer) {
443
+ clearTimeout(scrollSyncTimer);
444
+ scrollSyncTimer = 0;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * 只关心落在已提取文本节点(pieces)上的变动,忽略正文里跟已提取内容无关的动态
450
+ * (广告刷新、评论计数、相关阅读懒加载等)。本扩展自己挂的 overlay/下划线 DOM 从不
451
+ * 包含任何 pieces 节点,天然会被判定为不相关,无需再单独过滤。
452
+ */
453
+ function mutationTouchesPieces(record) {
454
+ if (record.type === 'characterData') {
455
+ return pieceNodeSet.has(record.target);
456
+ }
457
+ const changed = [...record.addedNodes, ...record.removedNodes];
458
+ if (changed.some((n) => pieceNodeSet.has(n))) return true;
459
+ // 兜底:整段容器(含某个已提取节点)被摘除/替换,命中率低,允许退化为线性扫描
460
+ return changed.some((n) => n.nodeType === 1 && pieces.some((p) => n.contains(p.node)));
461
+ }
462
+
463
+ function releasePaintMount() {
464
+ stopPaintLayoutWatch();
465
+ if (paintMount) {
466
+ paintMount.remove();
467
+ paintMount = null;
468
+ }
469
+ if (paintPosTarget && paintPosRestore !== null) {
470
+ paintPosTarget.style.position = paintPosRestore;
471
+ }
472
+ paintPosTarget = null;
473
+ paintPosRestore = null;
474
+ }
475
+
476
+ /** underline 叠层挂在正文根内;与字同滚动,停稳后再重测坐标 */
477
+ function ensurePaintMount(articleRoot) {
478
+ if (paintMount?.isConnected && paintMount.parentElement === articleRoot) return paintMount;
479
+
480
+ releasePaintMount();
481
+
482
+ const st = getComputedStyle(articleRoot);
483
+ if (st.position === 'static') {
484
+ paintPosTarget = articleRoot;
485
+ paintPosRestore = articleRoot.style.position;
486
+ articleRoot.style.position = 'relative';
487
+ }
488
+
489
+ const host = document.createElement('div');
490
+ host.id = 'il-overlay-host';
491
+ articleRoot.appendChild(host);
492
+ paintMount = host;
493
+ startPaintLayoutWatch(articleRoot);
494
+ return paintMount;
495
+ }
496
+
497
+ /** 视口 rect → 相对 #il-overlay-host */
498
+ function clientRectToMountPos(rect) {
499
+ if (!paintMount) throw new Error('paint mount missing');
500
+ const hr = paintMount.getBoundingClientRect();
501
+ return {
502
+ x: rect.left - hr.left,
503
+ y: rect.bottom - 2 - hr.top,
504
+ };
505
+ }
506
+
507
+ function requireHighlightApi() {
508
+ if (!CSS.highlights || typeof Highlight !== 'function') {
509
+ throw new Error('CSS Custom Highlight API missing');
510
+ }
511
+ }
512
+
513
+ /** 注册 il-token-0..15 / il-truncated;已存在则复用 */
514
+ function ensureHighlightRegistry() {
515
+ requireHighlightApi();
516
+ for (let i = 0; i < TOKEN_LEVELS; i++) {
517
+ const name = HL_TOKEN_PREFIX + i;
518
+ if (!CSS.highlights.has(name)) {
519
+ const h = new Highlight();
520
+ h.priority = i;
521
+ CSS.highlights.set(name, h);
522
+ }
523
+ }
524
+ if (!CSS.highlights.has(HL_TRUNCATED)) {
525
+ const h = new Highlight();
526
+ h.priority = TOKEN_LEVELS + 1;
527
+ CSS.highlights.set(HL_TRUNCATED, h);
528
+ }
529
+ }
530
+
531
+ function clearTokenHighlights() {
532
+ if (!CSS.highlights) return;
533
+ for (let i = 0; i < TOKEN_LEVELS; i++) {
534
+ CSS.highlights.get(HL_TOKEN_PREFIX + i)?.clear();
535
+ }
536
+ }
537
+
538
+ function clearAllCustomHighlights() {
539
+ clearTokenHighlights();
540
+ CSS.highlights?.get(HL_TRUNCATED)?.clear();
541
+ }
542
+
543
+ /** 把 cp 区间加到指定 Highlight(按块切开,跳过纯空白) */
544
+ function addCpRangeToHighlight(highlight, cp0, cp1) {
545
+ for (const range of rangesFromCpOffsets(cp0, cp1)) {
546
+ if (!/\S/.test(range.toString())) continue;
547
+ highlight.add(range);
548
+ }
549
+ }
550
+
551
+ function renderTokenHighlights() {
552
+ ensureHighlightRegistry();
553
+ clearTokenHighlights();
554
+ for (const s of paintSpecs) {
555
+ if (s.kind !== 'token') continue;
556
+ const level = s.level;
557
+ if (level == null || level < 0 || level >= TOKEN_LEVELS) continue;
558
+ const h = CSS.highlights.get(HL_TOKEN_PREFIX + level);
559
+ if (!h) throw new Error(`highlight missing: ${HL_TOKEN_PREFIX}${level}`);
560
+ addCpRangeToHighlight(h, s.cp0, s.cp1);
561
+ }
562
+ }
563
+
564
+ function applyTruncatedHighlight() {
565
+ ensureHighlightRegistry();
566
+ const h = CSS.highlights.get(HL_TRUNCATED);
567
+ if (!h) throw new Error('highlight missing: il-truncated');
568
+ h.clear();
569
+ if (truncatedAnalyzedCpEnd == null || !extractedText || !extractRoot?.isConnected) return;
570
+ const fullCp = utf16ToCp(extractedText, extractedText.length);
571
+ const cp0 = Math.max(0, Math.min(truncatedAnalyzedCpEnd, fullCp));
572
+ if (cp0 >= fullCp) return;
573
+ addCpRangeToHighlight(h, cp0, fullCp);
574
+ }
575
+
576
+ function clearOverlayEls(options) {
577
+ const preserveUnderline = options?.preserveUnderline === true;
578
+ if (!preserveUnderline) {
579
+ for (const el of overlayEls) el.remove();
580
+ overlayEls = [];
581
+ return;
582
+ }
583
+ // SYNC: GLTR_Text_Box.clearHighlight({ preserveChunkInterval: true }) — 留下划线 DOM(含 fade)
584
+ overlayEls = overlayEls.filter((el) => {
585
+ if (el.classList.contains('il-chunk-underline')) return true;
586
+ el.remove();
587
+ return false;
588
+ });
589
+ }
590
+
591
+ /** SYNC: client/src/shared/vis/GLTR_Text_Box.ts → cancelChunkHighlightFade */
592
+ function cancelUnderlineFade() {
593
+ underlineFadeGen += 1;
594
+ progressSelectionFading = false;
595
+ if (underlineHoldTimer) {
596
+ clearTimeout(underlineHoldTimer);
597
+ underlineHoldTimer = 0;
598
+ }
599
+ if (underlineFadeTimer) {
600
+ clearTimeout(underlineFadeTimer);
601
+ underlineFadeTimer = 0;
602
+ }
603
+ scrollEndCancel?.();
604
+ scrollEndCancel = null;
605
+ }
606
+
607
+ function clearOverlays() {
608
+ cancelUnderlineFade();
609
+ endChunkSearchAutoScroll();
610
+ clearTruncatedHighlight();
611
+ clearTokenHighlights();
612
+ clearOverlayEls();
613
+ paintSpecs = [];
614
+ releasePaintMount();
615
+ extractRoot = null;
616
+ }
617
+
618
+ /**
619
+ * 清当前会话的渲染与搜索进度。
620
+ * @param {{ clearCache?: boolean }} [options] clearCache=true 时连 lastResult 一并丢弃(改 query / giveUp)
621
+ */
622
+ function resetSearchSession({ clearCache = false } = {}) {
623
+ clearOverlays();
624
+ matchedChunks = [];
625
+ semanticMatchProgress = [];
626
+ progressTextLength = 0;
627
+ selectedProgressChunkStart = null;
628
+ hoveredProgressChunkStart = null;
629
+ progressSelectionFading = false;
630
+ matchIndex = -1;
631
+ if (clearCache) lastResult = null;
632
+ updateNav();
633
+ renderSemanticMatchProgress();
634
+ }
635
+
636
+ /**
637
+ * 正文被第三方(如翻译插件)意外改写、或提取根本身被替换/移除时的止损:
638
+ * 不尝试修复或用旧偏移套新文本,直接放弃本次结果,回到未搜索状态(等价于清空输入框)。
639
+ * 下次搜索会走 refreshExtract() 全量重新提取,天然恢复正常。
640
+ */
641
+ function giveUp() {
642
+ abortWanted = true;
643
+ resetSearchSession({ clearCache: true });
644
+ setSearching(false);
645
+ }
646
+
647
+ /** 覆盖写入长度 1 结果缓存(specs 浅拷贝,避免后续 clear 连带清空) */
648
+ function snapshotLastResult(query) {
649
+ if (!query || extractRoot == null) return;
650
+ lastResult = {
651
+ query,
652
+ text: extractedText,
653
+ paintSpecs: paintSpecs.map((s) => ({ ...s })),
654
+ matchedChunks: matchedChunks.map((c) => ({ ...c })),
655
+ semanticMatchProgress: semanticMatchProgress.map((c) => ({ ...c })),
656
+ progressTextLength,
657
+ matchIndex,
658
+ truncatedAnalyzedCpEnd,
659
+ selectedProgressChunkStart,
660
+ };
661
+ }
662
+
663
+ /** @returns {boolean} 是否已还原 */
664
+ function tryRestoreLastResult(query) {
665
+ if (!lastResult || !query) return false;
666
+ if (query !== lastResult.query || extractedText !== lastResult.text) return false;
667
+ paintSpecs = lastResult.paintSpecs.map((s) => ({ ...s }));
668
+ matchedChunks = lastResult.matchedChunks.map((c) => ({ ...c }));
669
+ semanticMatchProgress = lastResult.semanticMatchProgress.map((c) => ({ ...c }));
670
+ progressTextLength = lastResult.progressTextLength;
671
+ matchIndex = lastResult.matchIndex;
672
+ selectedProgressChunkStart = lastResult.selectedProgressChunkStart ?? null;
673
+ if (lastResult.truncatedAnalyzedCpEnd != null) {
674
+ setTruncatedHighlight(lastResult.truncatedAnalyzedCpEnd);
675
+ }
676
+ renderAllSpecs({
677
+ preserveUnderline: paintSpecs.some((s) => s.kind === 'underline'),
678
+ });
679
+ updateNav();
680
+ renderSemanticMatchProgress();
681
+ return true;
682
+ }
683
+
684
+ /**
685
+ * ChatGPT 等会在滚动时改布局/换节点;滚动停稳或尺寸变化后重绑 Range / 重测 underline。
686
+ */
687
+ function syncPaintAfterLayout() {
688
+ if (!extractRoot?.isConnected) {
689
+ // 提取根本身被换掉/摘除(如翻译插件重建了整个容器):有残留结果才需要放弃,否则什么都没画,无需处理
690
+ if (
691
+ paintSpecs.length > 0 ||
692
+ truncatedAnalyzedCpEnd != null ||
693
+ matchedChunks.length > 0 ||
694
+ semanticMatchProgress.length > 0
695
+ ) {
696
+ giveUp();
697
+ }
698
+ return;
699
+ }
700
+ if (paintSpecs.length === 0 && truncatedAnalyzedCpEnd == null) return;
701
+
702
+ const mapped = collectTextMap(extractRoot);
703
+ const stale = pieces.some((p) => !p.node.isConnected);
704
+ if (mapped.text !== extractedText) {
705
+ // DOM_DEBUG 只是目测提取范围的调试预览,永远反映"当前"文本,文本变了就重新按当前内容分块展示
706
+ if (DOM_DEBUG) {
707
+ extractedText = mapped.text;
708
+ setPieces(mapped.pieces);
709
+ if (!extractedText.trim()) {
710
+ clearOverlayEls();
711
+ clearAllCustomHighlights();
712
+ return;
713
+ }
714
+ const allChunks = splitChunks(extractedText, CFG.chunkBytes).filter(chunkHasContent);
715
+ matchedChunks = allChunks.map((chunk) => ({
716
+ start: utf16ToCp(extractedText, chunk.start),
717
+ end: utf16ToCp(extractedText, chunk.end),
718
+ matchDegree: 1,
719
+ }));
720
+ matchIndex = -1;
721
+ paintAllUnderlines();
722
+ return;
723
+ }
724
+ // 正文内容真的变了(非仅节点重排):旧的语义偏移量已经和新文本对不上,放弃而不是套用错位渲染
725
+ giveUp();
726
+ return;
727
+ }
728
+ if (stale) {
729
+ // 内容没变、只是节点被重新挂载(如虚拟滚动列表换节点):重绑节点引用即可,照常渲染
730
+ setPieces(mapped.pieces);
731
+ }
732
+ renderAllSpecs({
733
+ preserveUnderline: paintSpecs.some((s) => s.kind === 'underline'),
734
+ });
735
+ }
736
+
737
+ function scheduleSyncPaintAfterLayout() {
738
+ if (scrollSyncTimer) clearTimeout(scrollSyncTimer);
739
+ scrollSyncTimer = window.setTimeout(() => {
740
+ scrollSyncTimer = 0;
741
+ syncPaintAfterLayout();
742
+ }, 120);
743
+ }
744
+
745
+ function startPaintLayoutWatch(articleRoot) {
746
+ stopPaintLayoutWatch();
747
+ paintResizeObserver = new ResizeObserver(() => scheduleSyncPaintAfterLayout());
748
+ paintResizeObserver.observe(articleRoot);
749
+ const scrollRoot = findScrollRoot(articleRoot);
750
+ if (scrollRoot && scrollRoot !== document.documentElement && scrollRoot !== document.body) {
751
+ paintResizeObserver.observe(scrollRoot);
752
+ }
753
+ // 尺寸不变的原地换字(翻译插件常见)不会触发 ResizeObserver,靠这个兜底触发同一套 sync 判断
754
+ contentMutationObserver = new MutationObserver((records) => {
755
+ if (records.some(mutationTouchesPieces)) scheduleSyncPaintAfterLayout();
756
+ });
757
+ contentMutationObserver.observe(articleRoot, { childList: true, characterData: true, subtree: true });
758
+ }
759
+
760
+ function specKey(s) {
761
+ return `${s.kind}|${s.cp0}|${s.cp1}|${s.level ?? ''}`;
762
+ }
763
+
764
+ function upsertSpec(spec) {
765
+ const key = specKey(spec);
766
+ const i = paintSpecs.findIndex((s) => specKey(s) === key);
767
+ if (i >= 0) paintSpecs[i] = spec;
768
+ else paintSpecs.push(spec);
769
+ }
770
+
771
+ /** underline:挂 #il-overlay-host,盖在内容上 */
772
+ function appendUnderlineRect(rect) {
773
+ if (!paintMount) throw new Error('paint mount missing');
774
+ const el = document.createElement('div');
775
+ el.className = 'il-chunk-underline';
776
+ const { x, y } = clientRectToMountPos(rect);
777
+ el.style.left = `${x}px`;
778
+ el.style.top = `${y}px`;
779
+ el.style.width = `${rect.width}px`;
780
+ paintMount.appendChild(el);
781
+ overlayEls.push(el);
782
+ }
783
+
784
+ /**
785
+ * 按 paintSpecs 重绑 Highlight + 重画 underline。
786
+ * @param {{ preserveUnderline?: boolean }} [options]
787
+ * preserveUnderline:对齐站内 preserveChunkInterval — 不拆下划线 DOM(hold/fade 不被流式 token 更新打断)
788
+ */
789
+ function renderAllSpecs(options) {
790
+ const preserveUnderline = options?.preserveUnderline === true;
791
+ clearOverlayEls({ preserveUnderline });
792
+ if (!extractRoot?.isConnected) return 0;
793
+ ensurePaintMount(extractRoot);
794
+ renderTokenHighlights();
795
+ applyTruncatedHighlight();
796
+ let rectCount = overlayEls.length;
797
+ for (const s of paintSpecs) {
798
+ if (s.kind !== 'underline') continue;
799
+ if (preserveUnderline) continue;
800
+ for (const range of rangesFromCpOffsets(s.cp0, s.cp1)) {
801
+ if (!/\S/.test(range.toString())) continue;
802
+ for (const r of range.getClientRects()) {
803
+ if (r.width < 1 || r.height < 1) continue;
804
+ appendUnderlineRect(r);
805
+ rectCount += 1;
806
+ }
807
+ }
808
+ }
809
+ return rectCount;
810
+ }
811
+
812
+ function paintSpec(kind, cp0, cp1, level) {
813
+ const before = paintSpecs.length;
814
+ const spec = kind === 'token' ? { kind, cp0, cp1, level } : { kind, cp0, cp1 };
815
+ upsertSpec(spec);
816
+ return paintSpecs.length > before ? 1 : 0;
817
+ }
818
+
819
+ function clearTruncatedHighlight() {
820
+ truncatedAnalyzedCpEnd = null;
821
+ CSS.highlights?.get(HL_TRUNCATED)?.clear();
822
+ }
823
+
824
+ /**
825
+ * SYNC:站内 truncated-text 只改字色。
826
+ * 扩展:::highlight(il-truncated),统一灰 = CanvasText × Canvas(不跟各段自身字色)。
827
+ */
828
+ function setTruncatedHighlight(analyzedCpEnd) {
829
+ truncatedAnalyzedCpEnd = analyzedCpEnd;
830
+ applyTruncatedHighlight();
831
+ }
832
+
833
+ function scheduleReflow() {
834
+ if (reflowQueued) return;
835
+ reflowQueued = true;
836
+ requestAnimationFrame(() => {
837
+ reflowQueued = false;
838
+ renderAllSpecs({
839
+ preserveUnderline: paintSpecs.some((s) => s.kind === 'underline'),
840
+ });
841
+ });
842
+ }
843
+
844
+ /** DOM 调试:画出全部提取 chunk 下划线(非语义 match 导航) */
845
+ function paintAllUnderlines() {
846
+ cancelUnderlineFade();
847
+ paintSpecs = paintSpecs.filter((s) => s.kind !== 'underline');
848
+ matchedChunks.forEach((c) => {
849
+ upsertSpec({ kind: 'underline', cp0: c.start, cp1: c.end });
850
+ });
851
+ renderAllSpecs();
852
+ }
853
+
854
+ /**
855
+ * SYNC: client/src/shared/vis/GLTR_Text_Box.ts → setChunkCharRangeHighlight
856
+ * 语义导航只画当前 match 一条下划线(paintAllUnderlines 仅 DOM 调试,非站内语义行为)
857
+ */
858
+ function setCurrentUnderline(chunk) {
859
+ cancelUnderlineFade();
860
+ paintSpecs = paintSpecs.filter((s) => s.kind !== 'underline');
861
+ if (chunk) {
862
+ upsertSpec({ kind: 'underline', cp0: chunk.start, cp1: chunk.end });
863
+ }
864
+ renderAllSpecs();
865
+ }
866
+
867
+ /**
868
+ * SYNC: client/src/shared/vis/HighlightManager.ts → fadeOutCharIntervalUnderlines
869
+ * + GLTR_Text_Box.fadeCurrentChunkHighlight
870
+ */
871
+ function fadeCurrentUnderline() {
872
+ const gen = ++underlineFadeGen;
873
+ const fadingProgressChunkStart = selectedProgressChunkStart;
874
+ const lines = overlayEls.filter((el) => el.classList.contains('il-chunk-underline'));
875
+ if (!lines.length) {
876
+ paintSpecs = paintSpecs.filter((s) => s.kind !== 'underline');
877
+ if (fadingProgressChunkStart != null) {
878
+ selectedProgressChunkStart = null;
879
+ progressSelectionFading = false;
880
+ renderSemanticMatchProgress();
881
+ }
882
+ return;
883
+ }
884
+ if (fadingProgressChunkStart != null) {
885
+ progressSelectionFading = true;
886
+ renderSemanticMatchProgress();
887
+ }
888
+ for (const el of lines) {
889
+ el.style.transition = '';
890
+ el.style.opacity = '1';
891
+ }
892
+ requestAnimationFrame(() => {
893
+ if (gen !== underlineFadeGen) return;
894
+ for (const el of lines) {
895
+ el.style.transition = `opacity ${CHUNK_HIGHLIGHT_FADE_MS}ms ease-out`;
896
+ el.style.opacity = '0';
897
+ }
898
+ underlineFadeTimer = window.setTimeout(() => {
899
+ underlineFadeTimer = 0;
900
+ if (gen !== underlineFadeGen) return;
901
+ paintSpecs = paintSpecs.filter((s) => s.kind !== 'underline');
902
+ renderAllSpecs();
903
+ if (selectedProgressChunkStart === fadingProgressChunkStart) {
904
+ selectedProgressChunkStart = null;
905
+ progressSelectionFading = false;
906
+ renderSemanticMatchProgress();
907
+ }
908
+ }, CHUNK_HIGHLIGHT_FADE_MS);
909
+ });
910
+ }
911
+
912
+ /**
913
+ * SYNC: client/src/shared/core/waitForSmoothScrollEnd.ts
914
+ * 等滚动停稳或确认未滚动;勿仅依赖 scrollend(无位移时常不触发 → 空等 maxWait)。
915
+ */
916
+ /**
917
+ * SYNC: client/src/shared/core/waitForSmoothScrollEnd.ts
918
+ * scrollend + 两帧后位置未变则结束 + maxWait;勿仅依赖 scrollend。
919
+ */
920
+ function waitForSmoothScrollEnd(target, onDone, maxWaitMs = 5000) {
921
+ let settled = false;
922
+ const getTop = () => (target === window ? window.scrollY : target.scrollTop);
923
+ const startTop = getTop();
924
+
925
+ const settle = () => {
926
+ if (settled) return;
927
+ settled = true;
928
+ dispose();
929
+ onDone();
930
+ };
931
+
932
+ const onScrollEnd = () => settle();
933
+ const dispose = () => {
934
+ window.clearTimeout(timeoutId);
935
+ target.removeEventListener('scrollend', onScrollEnd);
936
+ };
937
+
938
+ target.addEventListener('scrollend', onScrollEnd, { once: true });
939
+ requestAnimationFrame(() => {
940
+ requestAnimationFrame(() => {
941
+ if (Math.abs(getTop() - startTop) < 1) settle();
942
+ });
943
+ });
944
+ const timeoutId = window.setTimeout(settle, maxWaitMs);
945
+
946
+ return () => {
947
+ settled = true;
948
+ dispose();
949
+ };
950
+ }
951
+
952
+ /**
953
+ * SYNC: client/src/shared/vis/GLTR_Text_Box.ts → scrollToUnicodeCharOffset
954
+ * (宿主页用 findScrollRoot 代替站内 panel)
955
+ */
956
+ function scrollToCpOffset(cp0, onScrollEnd, viewportYRatio = CHUNK_JUMP_VIEWPORT_Y_RATIO) {
957
+ scrollEndCancel?.();
958
+ scrollEndCancel = null;
959
+ requestAnimationFrame(() => {
960
+ const range = rangeFromCpOffsets(cp0, cp0 + 1) || rangeFromCpOffsets(cp0, Math.max(cp0 + 1, cp0));
961
+ let rect = null;
962
+ if (range) {
963
+ rect = range.getBoundingClientRect();
964
+ if (rect.width === 0 && rect.height === 0) {
965
+ const rects = range.getClientRects();
966
+ rect = rects.length ? rects[0] : null;
967
+ }
968
+ }
969
+ if (!rect || !extractRoot) {
970
+ onScrollEnd?.();
971
+ return;
972
+ }
973
+ const scrollRoot = findScrollRoot(extractRoot);
974
+ const isWindow =
975
+ scrollRoot === document.scrollingElement ||
976
+ scrollRoot === document.documentElement ||
977
+ scrollRoot === document.body;
978
+ if (isWindow) {
979
+ const y = window.scrollY + rect.top - window.innerHeight * viewportYRatio;
980
+ window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' });
981
+ if (onScrollEnd) scrollEndCancel = waitForSmoothScrollEnd(window, onScrollEnd);
982
+ return;
983
+ }
984
+ const panel = /** @type {HTMLElement} */ (scrollRoot);
985
+ const panelRect = panel.getBoundingClientRect();
986
+ const topInPanel = rect.top - panelRect.top + panel.scrollTop;
987
+ const target = topInPanel - panel.clientHeight * viewportYRatio;
988
+ const maxScroll = Math.max(0, panel.scrollHeight - panel.clientHeight);
989
+ panel.scrollTo({ top: Math.max(0, Math.min(target, maxScroll)), behavior: 'smooth' });
990
+ if (onScrollEnd) scrollEndCancel = waitForSmoothScrollEnd(panel, onScrollEnd);
991
+ });
992
+ }
993
+
994
+ /** SYNC: GLTR_Text_Box.scrollToChunkStart(视口 0.2) */
995
+ function scrollToChunkStart(cp0, onScrollEnd) {
996
+ scrollToCpOffset(cp0, onScrollEnd, CHUNK_JUMP_VIEWPORT_Y_RATIO);
997
+ }
998
+
999
+ /** 分块搜索进行中:滚到 chunk 起点(视口 0.6);站内 demo 已去掉跟随,仅扩展使用 */
1000
+ function followSearchingChunk(cp0) {
1001
+ if (chunkSearchAutoScrollUserCancelled) return;
1002
+ scrollToCpOffset(cp0, undefined, CHUNK_SEARCH_FOLLOW_VIEWPORT_Y_RATIO);
1003
+ }
1004
+
1005
+ /** 分块搜索:wheel/touch 取消自动跟随;站内 demo 已去掉,仅扩展使用 */
1006
+ function beginChunkSearchAutoScroll() {
1007
+ endChunkSearchAutoScroll();
1008
+ chunkSearchAutoScrollUserCancelled = false;
1009
+ if (!extractRoot) return;
1010
+ const scrollRoot = findScrollRoot(extractRoot);
1011
+ const isWindow =
1012
+ scrollRoot === document.scrollingElement ||
1013
+ scrollRoot === document.documentElement ||
1014
+ scrollRoot === document.body;
1015
+ const target = isWindow ? window : scrollRoot;
1016
+ const opts = { passive: true, capture: true };
1017
+ const onUserScroll = () => {
1018
+ if (chunkSearchAutoScrollUserCancelled) return;
1019
+ chunkSearchAutoScrollUserCancelled = true;
1020
+ scrollEndCancel?.();
1021
+ scrollEndCancel = null;
1022
+ };
1023
+ target.addEventListener('wheel', onUserScroll, opts);
1024
+ target.addEventListener('touchstart', onUserScroll, opts);
1025
+ chunkSearchAutoScrollCleanup = () => {
1026
+ target.removeEventListener('wheel', onUserScroll, opts);
1027
+ target.removeEventListener('touchstart', onUserScroll, opts);
1028
+ };
1029
+ }
1030
+
1031
+ function endChunkSearchAutoScroll() {
1032
+ chunkSearchAutoScrollCleanup?.();
1033
+ chunkSearchAutoScrollCleanup = undefined;
1034
+ chunkSearchAutoScrollUserCancelled = false;
1035
+ }
1036
+
1037
+ function delayMs(ms) {
1038
+ return new Promise((resolve) => setTimeout(resolve, ms));
1039
+ }
1040
+
1041
+ /**
1042
+ * SYNC: client/src/shared/vis/GLTR_Text_Box.ts → jumpToChunkHighlight
1043
+ * + client/src/features/analysis/semanticFindBar.ts → jumpTo
1044
+ * 下划线 → 滚到起点 → hold → fade
1045
+ */
1046
+ function jumpToMatch(index) {
1047
+ if (!matchedChunks.length) return;
1048
+ const n = matchedChunks.length;
1049
+ matchIndex = ((index % n) + n) % n;
1050
+ revealChunk(matchedChunks[matchIndex]);
1051
+ updateNav();
1052
+ }
1053
+
1054
+ /**
1055
+ * 选中并展示一个 chunk:进度图线变蓝 + 下划线 → 滚到起点 → hold → fade。
1056
+ * 由「点击进度线」(selectProgressChunk)和「上下按钮跳转」(jumpToMatch)共用,
1057
+ * 保证两者对进度图选中态的表现始终一致。
1058
+ */
1059
+ function revealChunk(chunk) {
1060
+ setCurrentUnderline(chunk);
1061
+ selectedProgressChunkStart = chunk.start;
1062
+ progressSelectionFading = false;
1063
+ renderSemanticMatchProgress();
1064
+ // 导航态需要跟着刷新快照,否则关闭再打开搜索栏时,下划线(回退到旧快照)
1065
+ // 会和进度图选中线(读实时变量)错位
1066
+ if (lastResult) snapshotLastResult(lastResult.query);
1067
+ scrollToChunkStart(chunk.start, () => {
1068
+ underlineHoldTimer = window.setTimeout(() => {
1069
+ underlineHoldTimer = 0;
1070
+ fadeCurrentUnderline();
1071
+ }, CHUNK_HIGHLIGHT_HOLD_MS);
1072
+ });
1073
+ }
1074
+
1075
+ // ---------- API ----------
1076
+
1077
+ /** 底层 JSON 请求(submode 须为后端合法值,不可传 hybrid) */
1078
+ function analyzeSemanticRaw(query, text, opts) {
1079
+ const body = {
1080
+ query,
1081
+ text,
1082
+ stream: false,
1083
+ privacy_mode: true,
1084
+ ...(opts?.submode ? { submode: opts.submode } : {}),
1085
+ ...(opts?.fullMatchDegreeOnly ? { full_match_degree_only: true } : {}),
1086
+ };
1087
+ return new Promise((resolve, reject) => {
1088
+ chrome.runtime.sendMessage(
1089
+ {
1090
+ type: 'il-analyze-semantic',
1091
+ apiBase: CFG.apiBase,
1092
+ body,
1093
+ },
1094
+ (resp) => {
1095
+ if (chrome.runtime.lastError) {
1096
+ reject(new Error(chrome.runtime.lastError.message));
1097
+ return;
1098
+ }
1099
+ if (!resp?.ok) reject(new Error(resp?.error || 'request failed'));
1100
+ else resolve(resp.data);
1101
+ }
1102
+ );
1103
+ });
1104
+ }
1105
+
1106
+ /**
1107
+ * SYNC: client GLTR_API.analyzeSemantic — hybrid 为前端组合:
1108
+ * count(full_match_degree_only) → 未达阈值则空 token;达则再 fill_blank,保留 count 的 matchDegree。
1109
+ */
1110
+ async function analyzeSemantic(query, text) {
1111
+ const submode = CFG.submode || 'hybrid';
1112
+ if (submode === 'hybrid') {
1113
+ const r1 = await analyzeSemanticRaw(query, text, {
1114
+ submode: 'count',
1115
+ fullMatchDegreeOnly: true,
1116
+ });
1117
+ if (r1?.success === false) return r1;
1118
+ if ((r1.full_match_degree ?? 0) < CFG.matchThreshold) {
1119
+ return { ...r1, token_attention: [] };
1120
+ }
1121
+ const r2 = await analyzeSemanticRaw(query, text, { submode: 'fill_blank' });
1122
+ return { ...r2, full_match_degree: r1.full_match_degree };
1123
+ }
1124
+ return analyzeSemanticRaw(query, text, { submode });
1125
+ }
1126
+
1127
+ // ---------- UI(同源 HTML/CSS,Shadow DOM 隔离宿主页样式) ----------
1128
+
1129
+ const STOP_ICON =
1130
+ '<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor"/></svg>';
1131
+
1132
+ const HOST_CSS = `
1133
+ :host {
1134
+ all: initial;
1135
+ position: fixed;
1136
+ top: 12px;
1137
+ right: 12px;
1138
+ z-index: 2147483646;
1139
+ display: block;
1140
+ pointer-events: none;
1141
+ }
1142
+ .semantic-find-bar-host {
1143
+ position: static;
1144
+ margin: 0;
1145
+ padding: 0;
1146
+ z-index: auto;
1147
+ }
1148
+ .semantic-find-bar {
1149
+ pointer-events: auto;
1150
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
1151
+ }
1152
+ `;
1153
+
1154
+ /** @type {ShadowRoot | null} */
1155
+ let uiShadow = null;
1156
+ let barWired = false;
1157
+ /** 串行化首次构建,避免双击工具栏时 barWired 抢跑 */
1158
+ /** @type {Promise<HTMLElement> | null} */
1159
+ let barReady = null;
1160
+
1161
+ function ui$(id) {
1162
+ return uiShadow?.getElementById(id) ?? null;
1163
+ }
1164
+
1165
+ function uiQuery(sel) {
1166
+ return uiShadow?.querySelector(sel) ?? null;
1167
+ }
1168
+
1169
+ function resolveBarTheme() {
1170
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1171
+ }
1172
+
1173
+ function applyBarTheme(bar) {
1174
+ if (!bar) return;
1175
+ bar.setAttribute('data-theme', resolveBarTheme());
1176
+ }
1177
+
1178
+ function watchBarTheme(bar) {
1179
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
1180
+ const onScheme = () => applyBarTheme(bar);
1181
+ mq.addEventListener('change', onScheme);
1182
+ }
1183
+
1184
+ // ---------- 输入历史(对齐站内 queryHistory:聚焦/输入弹出、过滤、点选回填并搜索) ----------
1185
+ const HISTORY_KEY = 'il_semantic_find_history';
1186
+ const HISTORY_MAX = 8;
1187
+ /** @type {string[]} */
1188
+ let historyList = [];
1189
+ /** @type {Promise<void> | null} */
1190
+ let historyReady = null;
1191
+
1192
+ function ensureHistory() {
1193
+ if (!historyReady) {
1194
+ historyReady = chrome.storage.local.get(HISTORY_KEY).then((data) => {
1195
+ const raw = data[HISTORY_KEY];
1196
+ historyList = Array.isArray(raw)
1197
+ ? raw.filter((s) => typeof s === 'string').slice(0, HISTORY_MAX)
1198
+ : [];
1199
+ });
1200
+ }
1201
+ return historyReady;
1202
+ }
1203
+
1204
+ function persistHistory() {
1205
+ return chrome.storage.local.set({ [HISTORY_KEY]: historyList });
1206
+ }
1207
+
1208
+ function saveHistory(query) {
1209
+ if (!query) return;
1210
+ historyList = [query, ...historyList.filter((s) => s !== query)].slice(0, HISTORY_MAX);
1211
+ void persistHistory();
1212
+ }
1213
+
1214
+ function removeHistory(query) {
1215
+ historyList = historyList.filter((s) => s !== query);
1216
+ void persistHistory();
1217
+ }
1218
+
1219
+ function hideHistoryDropdown() {
1220
+ ui$('semantic_find_history_dropdown')?.classList.remove('is-visible');
1221
+ }
1222
+
1223
+ function renderHistoryDropdown() {
1224
+ const dropdown = ui$('semantic_find_history_dropdown');
1225
+ const input = /** @type {HTMLInputElement | null} */ (ui$('semantic_find_input'));
1226
+ if (!dropdown || !input || searching) {
1227
+ hideHistoryDropdown();
1228
+ return;
1229
+ }
1230
+ // 列表过滤:与站内一致不 trim;完全匹配当前输入的候选不展示
1231
+ const filter = (input.value ?? '').toLowerCase();
1232
+ const filtered = historyList.filter((s) => {
1233
+ const sLower = s.toLowerCase();
1234
+ if (sLower === filter) return false;
1235
+ return !filter || sLower.includes(filter);
1236
+ });
1237
+ dropdown.innerHTML = '';
1238
+ if (filtered.length === 0) {
1239
+ hideHistoryDropdown();
1240
+ return;
1241
+ }
1242
+ for (const q of filtered) {
1243
+ const li = document.createElement('li');
1244
+ const span = document.createElement('span');
1245
+ span.className = 'history-text';
1246
+ span.textContent = q;
1247
+ span.title = q;
1248
+ span.addEventListener('click', () => {
1249
+ hideHistoryDropdown();
1250
+ input.value = q;
1251
+ input.dispatchEvent(new Event('input', { bubbles: true }));
1252
+ void runSearch();
1253
+ });
1254
+ const btn = document.createElement('button');
1255
+ btn.className = 'demo-delete-btn';
1256
+ btn.type = 'button';
1257
+ btn.textContent = '×';
1258
+ btn.title = 'Remove';
1259
+ btn.addEventListener('click', (e) => {
1260
+ e.stopPropagation();
1261
+ removeHistory(q);
1262
+ renderHistoryDropdown();
1263
+ });
1264
+ li.appendChild(span);
1265
+ li.appendChild(btn);
1266
+ dropdown.appendChild(li);
1267
+ }
1268
+ dropdown.classList.add('is-visible');
1269
+ }
1270
+
1271
+ function wireBar() {
1272
+ if (barWired) return;
1273
+ const findInput = /** @type {HTMLInputElement | null} */ (ui$('semantic_find_input'));
1274
+ if (!findInput) throw new Error('find bar input missing');
1275
+ barWired = true;
1276
+
1277
+ void ensureHistory();
1278
+
1279
+ ui$('semantic_find_prev')?.addEventListener('click', () =>
1280
+ jumpToMatch(matchIndex < 0 ? matchedChunks.length - 1 : matchIndex - 1)
1281
+ );
1282
+ ui$('semantic_find_next')?.addEventListener('click', () =>
1283
+ jumpToMatch(matchIndex < 0 ? 0 : matchIndex + 1)
1284
+ );
1285
+ ui$('semantic_find_close')?.addEventListener('click', () => close());
1286
+ ui$('semantic_find_clear')?.addEventListener('click', (e) => {
1287
+ e.stopPropagation();
1288
+ if (searching) {
1289
+ abortWanted = true;
1290
+ // 按钮立即回退,不等最后一个 chunk 的请求返回
1291
+ syncClearButton(false);
1292
+ return;
1293
+ }
1294
+ findInput.value = '';
1295
+ findInput.dispatchEvent(new Event('input', { bubbles: true }));
1296
+ findInput.focus();
1297
+ });
1298
+ findInput.addEventListener('focus', () => {
1299
+ void ensureHistory().then(renderHistoryDropdown);
1300
+ });
1301
+ findInput.addEventListener('input', () => {
1302
+ if (searching) return;
1303
+ // 输入框内容一变(无论是手改还是点 × 清空),旧的渲染状态即视为过期,统一清掉
1304
+ resetSearchSession({ clearCache: true });
1305
+ syncClearButton(false);
1306
+ if (uiShadow?.activeElement === findInput) renderHistoryDropdown();
1307
+ });
1308
+ findInput.addEventListener('keydown', (e) => {
1309
+ if (e.key === 'Enter' && !e.isComposing) {
1310
+ e.preventDefault();
1311
+ hideHistoryDropdown();
1312
+ void runSearch();
1313
+ } else if (e.key === 'Escape') {
1314
+ e.preventDefault();
1315
+ e.stopPropagation();
1316
+ close();
1317
+ }
1318
+ });
1319
+ document.addEventListener('click', (e) => {
1320
+ const bar = ui$('semantic_find_bar');
1321
+ if (!bar || e.composedPath().includes(bar)) return;
1322
+ hideHistoryDropdown();
1323
+ });
1324
+ }
1325
+
1326
+ function syncClearButton(searchingOn) {
1327
+ const btn = ui$('semantic_find_clear');
1328
+ const input = /** @type {HTMLInputElement | null} */ (ui$('semantic_find_input'));
1329
+ if (!btn) return;
1330
+ if (searchingOn) {
1331
+ btn.classList.add('is-stop', 'is-visible');
1332
+ btn.innerHTML = STOP_ICON;
1333
+ btn.title = 'Stop';
1334
+ btn.setAttribute('aria-label', 'Stop');
1335
+ } else {
1336
+ btn.classList.remove('is-stop');
1337
+ btn.textContent = '×';
1338
+ btn.title = 'Clear';
1339
+ btn.setAttribute('aria-label', 'Clear');
1340
+ btn.classList.toggle('is-visible', (input?.value ?? '').length > 0);
1341
+ }
1342
+ }
1343
+
1344
+ async function buildBar() {
1345
+ let host = document.getElementById('il-find-root');
1346
+ const existing = /** @type {HTMLElement | null} */ (
1347
+ host?.shadowRoot?.getElementById('semantic_find_bar') ?? null
1348
+ );
1349
+ if (existing) {
1350
+ uiShadow = host.shadowRoot;
1351
+ applyBarTheme(existing);
1352
+ wireBar();
1353
+ return existing;
1354
+ }
1355
+ // 上次构建失败可能留下空 host
1356
+ host?.remove();
1357
+
1358
+ host = document.createElement('div');
1359
+ host.id = 'il-find-root';
1360
+ document.documentElement.appendChild(host);
1361
+ uiShadow = host.attachShadow({ mode: 'open' });
1362
+
1363
+ try {
1364
+ const [cssText, html] = await Promise.all([
1365
+ fetch(chrome.runtime.getURL('ui/semantic-find-bar.css')).then((r) => {
1366
+ if (!r.ok) throw new Error(`failed to load find bar css (${r.status})`);
1367
+ return r.text();
1368
+ }),
1369
+ fetch(chrome.runtime.getURL('ui/semantic-find-bar.html')).then((r) => {
1370
+ if (!r.ok) throw new Error(`failed to load find bar html (${r.status})`);
1371
+ return r.text();
1372
+ }),
1373
+ ]);
1374
+
1375
+ const style = document.createElement('style');
1376
+ style.textContent = HOST_CSS + '\n' + cssText;
1377
+ uiShadow.appendChild(style);
1378
+
1379
+ const wrap = document.createElement('div');
1380
+ wrap.innerHTML = html.trim();
1381
+ const bar = /** @type {HTMLElement} */ (wrap.firstElementChild);
1382
+ if (!bar) throw new Error('find bar html empty');
1383
+ applyBarTheme(bar);
1384
+ watchBarTheme(bar);
1385
+ uiShadow.appendChild(bar);
1386
+
1387
+ wireBar();
1388
+ return bar;
1389
+ } catch (err) {
1390
+ host.remove();
1391
+ uiShadow = null;
1392
+ barWired = false;
1393
+ throw err;
1394
+ }
1395
+ }
1396
+
1397
+ function ensureBar() {
1398
+ if (!barReady) {
1399
+ barReady = buildBar().catch((err) => {
1400
+ barReady = null;
1401
+ throw err;
1402
+ });
1403
+ }
1404
+ return barReady;
1405
+ }
1406
+
1407
+ function updateNav() {
1408
+ const prev = /** @type {HTMLButtonElement | null} */ (ui$('semantic_find_prev'));
1409
+ const next = /** @type {HTMLButtonElement | null} */ (ui$('semantic_find_next'));
1410
+ const disabled = matchedChunks.length === 0;
1411
+ if (prev) prev.disabled = disabled;
1412
+ if (next) next.disabled = disabled;
1413
+ }
1414
+
1415
+ /** 简版 semantic match progress:全文位置 × chunk 匹配度。 */
1416
+ function renderSemanticMatchProgress() {
1417
+ const chart = ui$('semantic_match_progress');
1418
+ const lines = ui$('semantic_match_progress_lines');
1419
+ if (!(chart instanceof SVGSVGElement) || !(lines instanceof SVGGElement)) return;
1420
+
1421
+ const hidden = semanticMatchProgress.length === 0 || extractedText.length === 0;
1422
+ chart.toggleAttribute('hidden', hidden);
1423
+ if (hidden) {
1424
+ lines.replaceChildren();
1425
+ return;
1426
+ }
1427
+
1428
+ const width = Math.max(100, Math.round(chart.clientWidth));
1429
+ const height = Math.max(42, Math.round(chart.clientHeight));
1430
+ chart.setAttribute('viewBox', `0 0 ${width} ${height}`);
1431
+ const x0 = 4;
1432
+ const x1 = width - 4;
1433
+ const y0 = height - 7;
1434
+ const y1 = 4;
1435
+ const textLength = progressTextLength || [...extractedText].length;
1436
+ const groupsByStart = new Map(
1437
+ [...lines.children]
1438
+ .filter((el) => el instanceof SVGGElement && el.dataset.progressStart != null)
1439
+ .map((el) => [Number(el.dataset.progressStart), el])
1440
+ );
1441
+ const liveStarts = new Set();
1442
+
1443
+ for (const chunk of semanticMatchProgress) {
1444
+ const degree = Math.max(0, Math.min(1, Number(chunk.matchDegree) || 0));
1445
+ const start = x0 + ((x1 - x0) * chunk.start) / textLength;
1446
+ const end = x0 + ((x1 - x0) * chunk.end) / textLength;
1447
+ const y = y0 - (y0 - y1) * degree;
1448
+ liveStarts.add(chunk.start);
1449
+ let group = groupsByStart.get(chunk.start);
1450
+ if (!group) {
1451
+ group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1452
+ group.dataset.progressStart = String(chunk.start);
1453
+
1454
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1455
+ line.classList.add('semantic-match-progress-line');
1456
+ group.appendChild(line);
1457
+
1458
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
1459
+ label.classList.add('semantic-match-progress-label');
1460
+ label.setAttribute('text-anchor', 'middle');
1461
+ label.setAttribute('hidden', '');
1462
+ group.appendChild(label);
1463
+
1464
+ const hitArea = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1465
+ hitArea.classList.add('semantic-match-progress-hit-area');
1466
+ hitArea.addEventListener('mouseenter', () => {
1467
+ setHoveredProgressChunk(chunk.start);
1468
+ });
1469
+ hitArea.addEventListener('mouseleave', () => {
1470
+ if (hoveredProgressChunkStart === chunk.start) setHoveredProgressChunk(null);
1471
+ });
1472
+ hitArea.addEventListener('pointerdown', (event) => {
1473
+ event.preventDefault();
1474
+ event.stopPropagation();
1475
+ /** @type {HTMLInputElement | null} */ (ui$('semantic_find_input'))?.blur();
1476
+ selectProgressChunk(chunk.start);
1477
+ });
1478
+ group.appendChild(hitArea);
1479
+ lines.appendChild(group);
1480
+ }
1481
+
1482
+ const line = /** @type {SVGPathElement} */ (group.querySelector('.semantic-match-progress-line'));
1483
+ const label = /** @type {SVGTextElement} */ (group.querySelector('.semantic-match-progress-label'));
1484
+ const hitArea = /** @type {SVGRectElement} */ (group.querySelector('.semantic-match-progress-hit-area'));
1485
+ line.classList.toggle('is-below-threshold', degree < CFG.matchThreshold);
1486
+ line.classList.toggle('is-selected', selectedProgressChunkStart === chunk.start);
1487
+ line.classList.toggle('is-hovered', hoveredProgressChunkStart === chunk.start);
1488
+ line.classList.toggle(
1489
+ 'is-fading',
1490
+ progressSelectionFading && selectedProgressChunkStart === chunk.start
1491
+ );
1492
+ const lineStart = start;
1493
+ const lineEnd = end;
1494
+ line.setAttribute('d', `M${lineStart} ${y}H${lineEnd}`);
1495
+ label.setAttribute('x', String((start + end) / 2));
1496
+ label.setAttribute('y', String(Math.max(y1 + 10, y - 4)));
1497
+ label.textContent = `${Math.round(degree * 100)}%`;
1498
+ label.toggleAttribute('hidden', hoveredProgressChunkStart !== chunk.start);
1499
+ hitArea.setAttribute('x', String(start));
1500
+ hitArea.setAttribute('y', String(y1));
1501
+ hitArea.setAttribute('width', String(Math.max(0.35, end - start)));
1502
+ hitArea.setAttribute('height', String(y0 - y1));
1503
+ }
1504
+ for (const [start, group] of groupsByStart) {
1505
+ if (!liveStarts.has(start)) group.remove();
1506
+ }
1507
+ }
1508
+
1509
+ function setHoveredProgressChunk(start) {
1510
+ if (hoveredProgressChunkStart === start) return;
1511
+ const lines = ui$('semantic_match_progress_lines');
1512
+ if (!(lines instanceof SVGGElement)) return;
1513
+ const previous = hoveredProgressChunkStart;
1514
+ hoveredProgressChunkStart = start;
1515
+ for (const chunkStart of [previous, start]) {
1516
+ if (chunkStart == null) continue;
1517
+ const group = [...lines.children].find((el) => el.dataset.progressStart === String(chunkStart));
1518
+ group
1519
+ ?.querySelector('.semantic-match-progress-line')
1520
+ ?.classList.toggle('is-hovered', chunkStart === start);
1521
+ const label = group?.querySelector('.semantic-match-progress-label');
1522
+ label?.toggleAttribute('hidden', chunkStart !== start);
1523
+ }
1524
+ }
1525
+
1526
+ function selectProgressChunk(start) {
1527
+ const chunk = semanticMatchProgress.find((item) => item.start === start);
1528
+ if (!chunk) return;
1529
+ if (selectedProgressChunkStart === start) {
1530
+ selectedProgressChunkStart = null;
1531
+ setCurrentUnderline(null);
1532
+ renderSemanticMatchProgress();
1533
+ if (lastResult) snapshotLastResult(lastResult.query);
1534
+ } else {
1535
+ revealChunk(chunk);
1536
+ }
1537
+ }
1538
+
1539
+ function setSearching(on) {
1540
+ searching = on;
1541
+ const chromeBar = uiQuery('.semantic-find-bar');
1542
+ chromeBar?.classList.toggle('is-searching', on);
1543
+ const input = /** @type {HTMLInputElement | null} */ (ui$('semantic_find_input'));
1544
+ if (input) {
1545
+ input.readOnly = on;
1546
+ if (on) input.blur();
1547
+ }
1548
+ ui$('semantic_find_history_dropdown')?.classList.remove('is-visible');
1549
+ chromeBar?.classList.remove('is-input-active');
1550
+ syncClearButton(on);
1551
+ }
1552
+
1553
+ async function runSearch() {
1554
+ if (searching) {
1555
+ abortWanted = true;
1556
+ syncClearButton(false);
1557
+ return;
1558
+ }
1559
+ const query = /** @type {HTMLInputElement} */ (ui$('semantic_find_input'))?.value?.trim() || '';
1560
+ if (!query) {
1561
+ return;
1562
+ }
1563
+
1564
+ await ensureHistory();
1565
+ saveHistory(query);
1566
+
1567
+ abortWanted = false;
1568
+ setSearching(true);
1569
+ resetSearchSession();
1570
+
1571
+ try {
1572
+ refreshExtract();
1573
+ } catch (err) {
1574
+ console.error('[InfoLens] extract aborted:', err?.message || err);
1575
+ setSearching(false);
1576
+ return;
1577
+ }
1578
+ if (!extractedText.trim()) {
1579
+ console.error('[InfoLens] no article text found');
1580
+ setSearching(false);
1581
+ return;
1582
+ }
1583
+
1584
+ try {
1585
+ // 全空白 chunk 不送 API;先滤再截断,避免 maxChunks 被空白占满
1586
+ const allChunks = splitChunks(extractedText, CFG.chunkBytes)
1587
+ .filter(chunkHasContent)
1588
+ .slice(0, CFG.maxChunks);
1589
+ progressTextLength = allChunks.length
1590
+ ? utf16ToCp(extractedText, allChunks[allChunks.length - 1].end)
1591
+ : 0;
1592
+
1593
+ // SYNC:站内 truncated-text — 搜索开始全文置灰,随已分析边界后移恢复原色
1594
+ setTruncatedHighlight(0);
1595
+
1596
+ // 产品决策:扩展保留预取/hold/follow;站内 demo 刻意简化为「上色 → 结束跳首个匹配」(见 semanticSearchController),两边不必对齐。
1597
+ // followSearching=true 全程跟随;false=无 match 前跟随、首个 match 停靠划线
1598
+ const followAll = !!CFG.followSearching;
1599
+ let parkedOnFirstMatch = false;
1600
+ let analyzedCpEnd = 0;
1601
+ beginChunkSearchAutoScroll();
1602
+
1603
+ // 双循环流水线:抓取循环只管领先渲染 FETCH_AHEAD 步尽早发请求,不含任何延迟;
1604
+ // 渲染循环按 chunk 顺序消费结果、维持 CHUNK_SEARCH_MIN_CYCLE_MS 展示节奏——二者互不阻塞,
1605
+ // 停留只影响「多久展示下一块」,不再卡住下一次请求的发出时机
1606
+ const FETCH_AHEAD = 1;
1607
+ const pendingChunkResults = new Map();
1608
+ let fetchCursor = 0;
1609
+ const prefetchChunk = (idx) => {
1610
+ const p = analyzeSemantic(query, allChunks[idx].text);
1611
+ p.catch(() => {}); // 真正的错误仍会在被 await 时抛出,这里只是防止预取阶段的悬空 rejection 噪音
1612
+ pendingChunkResults.set(idx, p);
1613
+ };
1614
+ const advanceFetch = (renderIndex) => {
1615
+ while (!abortWanted && fetchCursor < allChunks.length && fetchCursor <= renderIndex + FETCH_AHEAD) {
1616
+ prefetchChunk(fetchCursor);
1617
+ fetchCursor++;
1618
+ }
1619
+ };
1620
+ advanceFetch(0); // 预热:抓取 [0, FETCH_AHEAD]
1621
+
1622
+ try {
1623
+ for (let i = 0; i < allChunks.length; i++) {
1624
+ if (abortWanted) {
1625
+ break;
1626
+ }
1627
+ const chunk = allChunks[i];
1628
+ const res = await pendingChunkResults.get(i);
1629
+ pendingChunkResults.delete(i);
1630
+ if (abortWanted) break;
1631
+
1632
+ const degree = res.full_match_degree ?? 0;
1633
+ // SYNC: semanticSearchController — matched = degree >= threshold;未匹配块不上色
1634
+ const matched = degree >= CFG.matchThreshold;
1635
+ const chunkCpStart = utf16ToCp(extractedText, chunk.start);
1636
+ const chunkCpEnd = utf16ToCp(extractedText, chunk.end);
1637
+ analyzedCpEnd = Math.max(analyzedCpEnd, chunkCpEnd);
1638
+ semanticMatchProgress.push({
1639
+ start: chunkCpStart,
1640
+ end: chunkCpEnd,
1641
+ matchDegree: degree,
1642
+ });
1643
+ renderSemanticMatchProgress();
1644
+
1645
+ // 跟随判定用「本块入列前」是否已有匹配
1646
+ const hadMatchBefore = matchedChunks.length > 0;
1647
+ // 未勾选 Follow:首个匹配处停下并划线;勾选则全程跟随,结束再跳首个匹配
1648
+ const willJump = !followAll && matched && !parkedOnFirstMatch;
1649
+
1650
+ if (matched) {
1651
+ matchedChunks.push({
1652
+ start: chunkCpStart,
1653
+ end: chunkCpEnd,
1654
+ matchDegree: degree,
1655
+ });
1656
+ }
1657
+
1658
+ // SYNC: semanticSearchController — 仅匹配块上色(未匹配等价 score:0)
1659
+ const tokens = matched ? prepareChunkTokens(res.token_attention || [], chunk.text, degree) : [];
1660
+ for (const t of tokens) {
1661
+ if (!t.offset || !(t.score > 0)) continue;
1662
+ const [a, b] = t.offset;
1663
+ const level = scoreToLevel(t.score);
1664
+ if (level < 0) continue;
1665
+ paintSpec('token', chunkCpStart + a, chunkCpStart + b, level);
1666
+ }
1667
+ setTruncatedHighlight(analyzedCpEnd);
1668
+ // SYNC: visualizationUpdater 流式更新 → clearHighlights({ preserveChunkInterval: true })
1669
+ renderAllSpecs({
1670
+ preserveUnderline: paintSpecs.some((s) => s.kind === 'underline'),
1671
+ });
1672
+
1673
+ // followThis = followAll || !hadMatchBefore;willJump 块滚动延后到 hold 后(扩展节奏,非站内)
1674
+ if ((followAll || !hadMatchBefore) && !willJump) {
1675
+ followSearchingChunk(chunkCpStart);
1676
+ }
1677
+ // 每块后快照:close 中途清高亮时仍保留上一版
1678
+ snapshotLastResult(query);
1679
+
1680
+ // 渲染完立即放行抓取循环(不等 hold),避免网络/服务器因停留而空闲
1681
+ advanceFetch(i + 1);
1682
+
1683
+ // 首个匹配块也要先经过与其它块一致的 hold,再触发自动滚动,
1684
+ // 避免着色刚出现就被立刻滚走——即使命中的正好是最后一块
1685
+ const nextIndex = i + 1;
1686
+ // shouldFollow = followAll || !hasMatch;park 后不再滚动/settle,hold 撑满 MIN_CYCLE(扩展节奏)
1687
+ const willSettleAfterHold =
1688
+ willJump || (nextIndex < allChunks.length && (followAll || !parkedOnFirstMatch));
1689
+ if (willJump || nextIndex < allChunks.length) {
1690
+ await delayMs(willSettleAfterHold ? CHUNK_SEARCH_HOLD_MS : CHUNK_SEARCH_MIN_CYCLE_MS);
1691
+ if (abortWanted) break;
1692
+ }
1693
+ if (willJump) {
1694
+ jumpToMatch(0);
1695
+ parkedOnFirstMatch = true;
1696
+ // 跳转落点同样要停留够 CHUNK_SEARCH_SCROLL_SETTLE_MS,避免划线刚定位就被下一块的渲染打断
1697
+ await delayMs(CHUNK_SEARCH_SCROLL_SETTLE_MS);
1698
+ if (abortWanted) break;
1699
+ }
1700
+ if (nextIndex < allChunks.length && (followAll || !parkedOnFirstMatch)) {
1701
+ const nextChunk = allChunks[nextIndex];
1702
+ followSearchingChunk(utf16ToCp(extractedText, nextChunk.start));
1703
+ // 滚动后的新位置也要停留够 CHUNK_SEARCH_SCROLL_SETTLE_MS,
1704
+ // 否则分析过快时会出现「刚滚过去就立刻变色」的突变感;抓取循环已提前放行,不受影响
1705
+ await delayMs(CHUNK_SEARCH_SCROLL_SETTLE_MS);
1706
+ if (abortWanted) break;
1707
+ }
1708
+ }
1709
+ } finally {
1710
+ endChunkSearchAutoScroll();
1711
+ }
1712
+
1713
+ if (extractRoot != null) {
1714
+ setTruncatedHighlight(analyzedCpEnd);
1715
+ renderAllSpecs({
1716
+ preserveUnderline: paintSpecs.some((s) => s.kind === 'underline'),
1717
+ });
1718
+ if (!abortWanted) {
1719
+ if (matchedChunks.length && !parkedOnFirstMatch) jumpToMatch(0);
1720
+ else updateNav();
1721
+ }
1722
+ }
1723
+ snapshotLastResult(query);
1724
+ } catch (err) {
1725
+ console.error('[InfoLens]', err?.message || err);
1726
+ snapshotLastResult(query);
1727
+ } finally {
1728
+ setSearching(false);
1729
+ abortWanted = false;
1730
+ }
1731
+ }
1732
+
1733
+ /** 目测提取:按 chunk 分块,走 paintAllUnderlines;定根失败则放弃 */
1734
+ function previewExtractUnderlines() {
1735
+ let info;
1736
+ try {
1737
+ info = refreshExtract();
1738
+ } catch (err) {
1739
+ clearOverlays();
1740
+ extractedText = '';
1741
+ setPieces([]);
1742
+ matchedChunks = [];
1743
+ matchIndex = -1;
1744
+ console.error('[InfoLens] extract aborted:', err?.message || err);
1745
+ return null;
1746
+ }
1747
+ if (!extractedText.trim()) {
1748
+ console.error('[InfoLens] extract aborted: empty article text');
1749
+ return info;
1750
+ }
1751
+ const allChunks = splitChunks(extractedText, CFG.chunkBytes).filter(chunkHasContent);
1752
+ matchedChunks = allChunks.map((chunk) => ({
1753
+ start: utf16ToCp(extractedText, chunk.start),
1754
+ end: utf16ToCp(extractedText, chunk.end),
1755
+ matchDegree: 1,
1756
+ }));
1757
+ matchIndex = -1;
1758
+ paintAllUnderlines();
1759
+ console.info(
1760
+ `[InfoLens] extract preview · ~${info.length} chars · ${allChunks.length} chunk(s) · ${overlayEls.length} rects`,
1761
+ { root: info.root, scrollRoot: findScrollRoot(info.root) }
1762
+ );
1763
+ return info;
1764
+ }
1765
+
1766
+ async function open() {
1767
+ try {
1768
+ if (DOM_DEBUG) {
1769
+ // 再次点击:取消下划线
1770
+ if (paintSpecs.length > 0 || overlayEls.length > 0) {
1771
+ close();
1772
+ console.info('[InfoLens] extract preview cleared');
1773
+ return;
1774
+ }
1775
+ previewExtractUnderlines();
1776
+ return;
1777
+ }
1778
+ const bar = await ensureBar();
1779
+ if (!bar) throw new Error('find bar missing');
1780
+ bar.hidden = false;
1781
+ const input = /** @type {HTMLInputElement} */ (ui$('semantic_find_input'));
1782
+ // 仅同步 UI(× 按钮可见性),不走 'input' 统一清空路径:reopen 要保留上次渲染结果给下面 tryRestoreLastResult 用
1783
+ syncClearButton(false);
1784
+ input?.focus();
1785
+ input?.select();
1786
+ try {
1787
+ refreshExtract();
1788
+ renderSemanticMatchProgress();
1789
+ tryRestoreLastResult(input?.value?.trim() || '');
1790
+ } catch (err) {
1791
+ console.error('[InfoLens] extract aborted:', err?.message || err);
1792
+ throw err;
1793
+ }
1794
+ } catch (err) {
1795
+ console.error('[InfoLens]', err);
1796
+ }
1797
+ }
1798
+
1799
+ function close() {
1800
+ abortWanted = true;
1801
+ resetSearchSession();
1802
+ hideHistoryDropdown();
1803
+ const bar = ui$('semantic_find_bar');
1804
+ if (bar) {
1805
+ bar.hidden = true;
1806
+ uiQuery('.semantic-find-bar')?.classList.remove('is-input-active', 'is-searching');
1807
+ }
1808
+ setSearching(false);
1809
+ }
1810
+
1811
+ /** 上次注入已过期(重装扩展未刷新页面)需整个丢弃时调用:close() 之外,摘掉本实例注册在 window 上的监听器 */
1812
+ function destroy() {
1813
+ close();
1814
+ window.removeEventListener('scroll', scheduleSyncPaintAfterLayout, true);
1815
+ window.removeEventListener('scrollend', syncPaintAfterLayout, true);
1816
+ window.removeEventListener('resize', scheduleReflow);
1817
+ window.visualViewport?.removeEventListener('resize', scheduleReflow);
1818
+ }
1819
+
1820
+ // underline 与正文同层滚动;token/truncated 为 CSS Highlight。布局漂移时重绑 Range / 重测 underline。
1821
+ window.addEventListener('scroll', scheduleSyncPaintAfterLayout, true);
1822
+ window.addEventListener('scrollend', syncPaintAfterLayout, true);
1823
+ window.addEventListener('resize', () => {
1824
+ scheduleReflow();
1825
+ renderSemanticMatchProgress();
1826
+ });
1827
+ window.visualViewport?.addEventListener('resize', scheduleReflow);
1828
+
1829
+ window.__IL_SEMANTIC_DEMO__ = { open, close, destroy };
1830
+ void open();
1831
+ })();
extension/dev-env.sh ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # 仅切换 apiBase(local=本机后端 / prod=HF Spaces)。两者都属于 unpacked 开发版,
3
+ # 图标与商店版无关:本目录固定用 icons/dev/(绿角标),见 manifest.json。
4
+ # config.js 由本脚本生成(gitignore),Chrome unpacked 实际加载它;
5
+ # 源头是 config.prod.js / config.local.js。上架包由 pack.sh 直接用 config.prod.js。
6
+ # 用法:
7
+ # ./dev-env.sh local # 生成本地变体
8
+ # ./dev-env.sh prod # 生成上架默认值
9
+ set -euo pipefail
10
+ cd "$(dirname "${BASH_SOURCE[0]}")"
11
+
12
+ case "${1:-}" in
13
+ local)
14
+ cp config.local.js config.js
15
+ echo "已切到 local:apiBase=127.0.0.1:5001。去 chrome://extensions 重新加载生效。"
16
+ ;;
17
+ prod)
18
+ cp config.prod.js config.js
19
+ echo "已切回 prod:apiBase=HF Spaces。去 chrome://extensions 重新加载生效。"
20
+ ;;
21
+ *)
22
+ echo "用法: $0 local|prod" >&2
23
+ exit 1
24
+ ;;
25
+ esac
extension/icons/dev/icon128.png ADDED

Git LFS Details

  • SHA256: 72d179a10b425106b7987e7ceefe4d47786a6d72847ab4b10a1bf09de7ff3765
  • Pointer size: 129 Bytes
  • Size of remote file: 9.08 kB
extension/icons/dev/icon16.png ADDED

Git LFS Details

  • SHA256: 1506495036dca5494b12ddb6831242db381080e724987e00746bf1da6641ba0f
  • Pointer size: 128 Bytes
  • Size of remote file: 720 Bytes
extension/icons/dev/icon32.png ADDED

Git LFS Details

  • SHA256: 4192f2b84f41ccc78ed7ea6e3f48228a14af7e11df0acc367078c6ecaecb0776
  • Pointer size: 129 Bytes
  • Size of remote file: 1.73 kB
extension/icons/dev/icon48.png ADDED

Git LFS Details

  • SHA256: 81c1ddf0fd01c2ed39710affe5565fe9392928b2057b5e807359098072895a4e
  • Pointer size: 129 Bytes
  • Size of remote file: 2.87 kB
extension/icons/icon128.png ADDED

Git LFS Details

  • SHA256: 2b36f329d6dbaa02000aee49ec9cc4205a0897a887c546669edfdb75fc15620d
  • Pointer size: 129 Bytes
  • Size of remote file: 9.32 kB