语义搜索插件
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +2 -0
- backend/api/analyze_semantic.py +67 -17
- backend/core/semantic_analyzer.py +144 -30
- backend/platform/access_log.py +31 -7
- backend/platform/inference_ingress.py +1 -1
- backend/platform/inference_response_log.py +21 -3
- backend/platform/logging_config.py +0 -1
- backend/platform/runtime_config.py +1 -0
- backend/tests/test_inference_response_log.py +32 -0
- client/src/analysis.html +1 -1
- client/src/css/base/_theme-vars.scss +37 -0
- client/src/css/components/_semantic-analysis.scss +7 -0
- client/src/css/pages/analysis.scss +18 -0
- client/src/features/analysis/analyzeFlow.ts +3 -4
- client/src/features/analysis/appStateManager.ts +16 -4
- client/src/features/analysis/infoDensityRenderManager.ts +0 -12
- client/src/features/analysis/visualizationUpdater.ts +103 -289
- client/src/features/chat/chatTurnsView.ts +0 -1
- client/src/features/demo/demoBusinessLogic.ts +3 -4
- client/src/pages/analysis/index.ts +58 -11
- client/src/partials/settings-menu-analysis.html +0 -6
- client/src/privacy-policy.html +56 -0
- client/src/shared/controllers/highlightController.ts +4 -3
- client/src/shared/controllers/semanticSearchController.ts +18 -74
- client/src/shared/core/responsive.ts +13 -0
- client/src/shared/core/waitForSmoothScrollEnd.ts +20 -1
- client/src/shared/cross/mergeTokenSpans.ts +17 -0
- client/src/shared/cross/queryHistory.ts +12 -2
- client/src/shared/cross/semanticUtils.ts +1 -1
- client/src/shared/cross/settingsMenuManager.ts +0 -33
- client/src/shared/lang/translations.ts +2 -1
- client/src/shared/vis/GLTR_Text_Box.ts +54 -86
- client/src/shared/vis/HighlightManager.ts +5 -0
- client/src/shared/vis/SvgOverlayManager.ts +1 -1
- client/src/shared/vis/ToolTip.ts +1 -1
- client/src/shared/vis/constants.ts +1 -7
- client/src/webpack.config.js +1 -0
- extension/README.md +69 -0
- extension/articleRoot.js +85 -0
- extension/background.js +186 -0
- extension/config.local.js +35 -0
- extension/config.prod.js +37 -0
- extension/content.css +94 -0
- extension/content.js +1831 -0
- extension/dev-env.sh +25 -0
- extension/icons/dev/icon128.png +3 -0
- extension/icons/dev/icon16.png +3 -0
- extension/icons/dev/icon32.png +3 -0
- extension/icons/dev/icon48.png +3 -0
- 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 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 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 227 |
|
| 228 |
def local_fn():
|
| 229 |
rid = logged["request_id"]
|
| 230 |
if stream:
|
| 231 |
return _analyze_semantic_with_stream(
|
| 232 |
-
query,
|
| 233 |
)
|
| 234 |
return _analyze_semantic_plain(
|
| 235 |
-
query,
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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 |
-
)
|
| 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"};
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 响应日志
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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
|
| 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
|
| 90 |
-
if (
|
| 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
|
| 132 |
if (!this.deps.textMetrics.empty()) {
|
| 133 |
-
|
|
|
|
| 134 |
}
|
| 135 |
}
|
| 136 |
|
|
@@ -155,10 +157,20 @@ export class AppStateManager {
|
|
| 155 |
}
|
| 156 |
// 语义搜索 loading:独立 loader
|
| 157 |
if (updates.isSemanticSearching !== undefined) {
|
| 158 |
-
|
| 159 |
-
|
|
|
|
| 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 |
-
* 获取当前展示数据(
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 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 |
-
|
| 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
|
| 278 |
-
const
|
| 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 |
-
//
|
| 478 |
const resultWithExt = hasThreshold
|
| 479 |
? { ...displayResult, signalProbs, pPwValues, pwScores }
|
| 480 |
: displayResult!;
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
this.deps.
|
| 488 |
-
|
| 489 |
-
|
| 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
|
| 602 |
-
|
|
|
|
|
|
|
| 603 |
: r.rawScoresNormed;
|
| 604 |
-
this.deps.lmf.update({ ...r, pPwValues
|
| 605 |
}
|
| 606 |
|
| 607 |
/** 主题切换时调用:在样式生效后统一重绘直方图与文本(rgba 透出背景,需等新主题生效) */
|
|
@@ -628,16 +587,50 @@ export class VisualizationUpdater {
|
|
| 628 |
this.updateSemanticDebugInfo();
|
| 629 |
}
|
| 630 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
/**
|
| 632 |
-
* 清除语义分析相关数据(直方图、debug、
|
| 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 |
-
|
| 679 |
-
|
| 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 |
-
|
| 706 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 67 |
-
if (
|
| 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 |
-
|
| 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: (
|
| 327 |
-
//
|
|
|
|
| 328 |
const queryEl = document.getElementById('semantic_search_input') as HTMLInputElement | null;
|
| 329 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 & 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
|
| 172 |
-
|
| 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 =
|
| 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 >=
|
| 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:
|
| 211 |
-
endOffset:
|
| 212 |
chunkIndex: i,
|
| 213 |
chunkMatchDegree: matchDegree,
|
| 214 |
});
|
| 215 |
const tokensOffsetAdjusted = tokens.map(t => ({
|
| 216 |
...t,
|
| 217 |
-
offset: [t.offset[0] +
|
| 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
|
| 264 |
-
const firstMatch = chunkInfos.find((c) => c.chunkMatchDegree >= threshold);
|
| 265 |
if (firstMatch) {
|
| 266 |
-
this.deps.lmf.
|
| 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 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
: null;
|
| 199 |
const filteredStrings = list
|
| 200 |
-
? list.filter((s) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 {
|
| 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 底层(透明)
|
| 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 |
-
|
| 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:
|
| 373 |
const latestChunk = rdExt.chunkInfos?.[rdExt.chunkInfos.length - 1];
|
| 374 |
-
const fromTokenIndex =
|
| 375 |
-
|
| 376 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
* 通过
|
| 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
|
| 1096 |
-
if (!
|
| 1097 |
return 0;
|
| 1098 |
}
|
| 1099 |
|
| 1100 |
-
|
| 1101 |
-
const rightPanelWidth = rightPanel.offsetWidth;
|
| 1102 |
-
// LMF 的 offsetWidth(包含 padding 和 border,但不包含滚动条)
|
| 1103 |
const lmfWidth = baseNode.offsetWidth;
|
| 1104 |
-
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1504 |
this._current.highlightedIndices.clear();
|
| 1505 |
-
this._current.chunkCharRange = null;
|
| 1506 |
|
| 1507 |
if (this.highlightManager) {
|
| 1508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
* 用于
|
| 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 |
-
*
|
| 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:
|
| 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
|
extension/icons/dev/icon16.png
ADDED
|
|
Git LFS Details
|
extension/icons/dev/icon32.png
ADDED
|
|
Git LFS Details
|
extension/icons/dev/icon48.png
ADDED
|
|
Git LFS Details
|
extension/icons/icon128.png
ADDED
|
|
Git LFS Details
|