dag增加紧凑度变量配置;增加活跃信息统计
Browse files- backend/access_log.py +13 -1
- backend/api/client_activity.py +47 -0
- backend/api/demo.py +4 -11
- backend/visit_stats.py +88 -0
- client/src/css/gen_attribute.scss +4 -2
- client/src/ts/appInitializer.ts +2 -0
- client/src/ts/attribution/genAttributeDagView.ts +6 -7
- client/src/ts/utils/clientActivityPing.ts +53 -0
- server.py +3 -0
- server.yaml +32 -0
backend/access_log.py
CHANGED
|
@@ -6,11 +6,18 @@ from urllib.parse import unquote
|
|
| 6 |
from flask import request
|
| 7 |
import threading
|
| 8 |
|
|
|
|
| 9 |
# 全局请求计数器和锁
|
| 10 |
_request_counter = 0
|
| 11 |
_request_counter_lock = threading.Lock()
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def _get_client_ip():
|
| 15 |
"""获取请求来源IP"""
|
| 16 |
try:
|
|
@@ -81,7 +88,8 @@ def log_analyze_request(text: str, stream_mode: bool = False, client_ip: str = N
|
|
| 81 |
|
| 82 |
details = f"req_id={request_id}, text='{text_preview}', chars={char_count}, bytes={byte_count}"
|
| 83 |
_log_request(f"📥 收到请求{mode_str}", details, client_ip)
|
| 84 |
-
|
|
|
|
| 85 |
return request_id
|
| 86 |
|
| 87 |
|
|
@@ -138,6 +146,8 @@ def log_analyze_semantic_request(query: str, text: str, client_ip: str = None):
|
|
| 138 |
t_preview = text[:preview] + "..." if len(text) > preview else text
|
| 139 |
details = f"req_id={request_id}, query='{q_preview}', text='{t_preview}', chars={len(text)}"
|
| 140 |
_log_request("📥 semantic 分析请求", details, client_ip)
|
|
|
|
|
|
|
| 141 |
return request_id
|
| 142 |
|
| 143 |
|
|
@@ -207,6 +217,8 @@ def log_prediction_attribute_request(
|
|
| 207 |
f"context_chars={len(context)}"
|
| 208 |
)
|
| 209 |
_log_request("📥 prediction_attribute 请求", details, client_ip)
|
|
|
|
|
|
|
| 210 |
return request_id
|
| 211 |
|
| 212 |
|
|
|
|
| 6 |
from flask import request
|
| 7 |
import threading
|
| 8 |
|
| 9 |
+
|
| 10 |
# 全局请求计数器和锁
|
| 11 |
_request_counter = 0
|
| 12 |
_request_counter_lock = threading.Lock()
|
| 13 |
|
| 14 |
|
| 15 |
+
def _hit_api(kind: str) -> None:
|
| 16 |
+
from backend.visit_stats import bump_api
|
| 17 |
+
|
| 18 |
+
bump_api(kind)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
def _get_client_ip():
|
| 22 |
"""获取请求来源IP"""
|
| 23 |
try:
|
|
|
|
| 88 |
|
| 89 |
details = f"req_id={request_id}, text='{text_preview}', chars={char_count}, bytes={byte_count}"
|
| 90 |
_log_request(f"📥 收到请求{mode_str}", details, client_ip)
|
| 91 |
+
|
| 92 |
+
_hit_api("analyze")
|
| 93 |
return request_id
|
| 94 |
|
| 95 |
|
|
|
|
| 146 |
t_preview = text[:preview] + "..." if len(text) > preview else text
|
| 147 |
details = f"req_id={request_id}, query='{q_preview}', text='{t_preview}', chars={len(text)}"
|
| 148 |
_log_request("📥 semantic 分析请求", details, client_ip)
|
| 149 |
+
|
| 150 |
+
_hit_api("analyze_semantic")
|
| 151 |
return request_id
|
| 152 |
|
| 153 |
|
|
|
|
| 217 |
f"context_chars={len(context)}"
|
| 218 |
)
|
| 219 |
_log_request("📥 prediction_attribute 请求", details, client_ip)
|
| 220 |
+
|
| 221 |
+
_hit_api("prediction_attribute")
|
| 222 |
return request_id
|
| 223 |
|
| 224 |
|
backend/api/client_activity.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from urllib.parse import unquote
|
| 2 |
+
|
| 3 |
+
from backend.access_log import _log_request, get_client_ip
|
| 4 |
+
from backend.visit_stats import record_page_active
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _sparse_page_activity_log_cum(cum: int) -> bool:
|
| 8 |
+
"""无服务端状态:累计秒为 2、10、20(前 20s 内与前端档位一致)或 40、80、160…(40·2^k)时打访问日志。"""
|
| 9 |
+
if cum in (2, 10, 20):
|
| 10 |
+
return True
|
| 11 |
+
if cum < 40 or cum % 40:
|
| 12 |
+
return False
|
| 13 |
+
q = cum // 40
|
| 14 |
+
return q > 0 and (q & (q - 1)) == 0
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def client_activity_report(activity_body=None):
|
| 18 |
+
d = activity_body if isinstance(activity_body, dict) else {}
|
| 19 |
+
p = str(d.get("page_path") or "")[:512].strip()
|
| 20 |
+
try:
|
| 21 |
+
cum = int(d.get("total_active_sec"))
|
| 22 |
+
dlt = int(d.get("delta_active_sec"))
|
| 23 |
+
if not p or cum < 1 or dlt < 0:
|
| 24 |
+
return {"ok": True}
|
| 25 |
+
except (TypeError, ValueError):
|
| 26 |
+
return {"ok": True}
|
| 27 |
+
path_only = p.split("?", 1)[0].split("#", 1)[0].strip()
|
| 28 |
+
page_key = path_only.rstrip("/").split("/")[-1] or path_only
|
| 29 |
+
if not page_key:
|
| 30 |
+
return {"ok": True}
|
| 31 |
+
if "?" in p:
|
| 32 |
+
qs = p.split("?", 1)[1].split("#", 1)[0]
|
| 33 |
+
log_path = f"{page_key}?{unquote(qs)}" if qs else page_key
|
| 34 |
+
else:
|
| 35 |
+
log_path = page_key
|
| 36 |
+
|
| 37 |
+
raw_os = d.get("client_os")
|
| 38 |
+
client_os = str(raw_os).strip() if raw_os is not None else None
|
| 39 |
+
|
| 40 |
+
ip = get_client_ip()
|
| 41 |
+
record_page_active(ip, page_key, dlt, cum, client_os)
|
| 42 |
+
if _sparse_page_activity_log_cum(cum):
|
| 43 |
+
_log_request(
|
| 44 |
+
"📄 页面活跃",
|
| 45 |
+
f"path={log_path!r} total_sec={cum} delta_sec={dlt}",
|
| 46 |
+
)
|
| 47 |
+
return {"ok": True}
|
backend/api/demo.py
CHANGED
|
@@ -15,6 +15,7 @@ from backend.api.utils import (
|
|
| 15 |
validate_admin_token,
|
| 16 |
)
|
| 17 |
from backend.access_log import log_check_admin
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def list_demos(path: str = ""):
|
|
@@ -158,23 +159,15 @@ def rename_demo(rename_request):
|
|
| 158 |
|
| 159 |
|
| 160 |
def check_admin(check_request):
|
| 161 |
-
"""
|
| 162 |
-
检查管理员token是否有效
|
| 163 |
-
请求格式: { token: string }
|
| 164 |
-
"""
|
| 165 |
from flask import request
|
| 166 |
-
|
| 167 |
-
# 从请求体或请求头获取token
|
| 168 |
request_token = check_request.get('token') or request.headers.get('X-Admin-Token')
|
| 169 |
-
|
| 170 |
-
# 验证token
|
| 171 |
is_valid, error_message = validate_admin_token(request_token)
|
| 172 |
-
|
| 173 |
-
# 记录管理员权限检查
|
| 174 |
log_check_admin(is_valid, token=request_token)
|
| 175 |
|
| 176 |
if is_valid:
|
| 177 |
-
|
|
|
|
| 178 |
else:
|
| 179 |
return {
|
| 180 |
'success': False,
|
|
|
|
| 15 |
validate_admin_token,
|
| 16 |
)
|
| 17 |
from backend.access_log import log_check_admin
|
| 18 |
+
from backend.visit_stats import print_visit_summary
|
| 19 |
|
| 20 |
|
| 21 |
def list_demos(path: str = ""):
|
|
|
|
| 159 |
|
| 160 |
|
| 161 |
def check_admin(check_request):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
from flask import request
|
| 163 |
+
|
|
|
|
| 164 |
request_token = check_request.get('token') or request.headers.get('X-Admin-Token')
|
|
|
|
|
|
|
| 165 |
is_valid, error_message = validate_admin_token(request_token)
|
|
|
|
|
|
|
| 166 |
log_check_admin(is_valid, token=request_token)
|
| 167 |
|
| 168 |
if is_valid:
|
| 169 |
+
print_visit_summary()
|
| 170 |
+
return {"success": True}
|
| 171 |
else:
|
| 172 |
return {
|
| 173 |
'success': False,
|
backend/visit_stats.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import time
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
|
| 5 |
+
from flask import request
|
| 6 |
+
|
| 7 |
+
from backend.access_log import get_client_ip
|
| 8 |
+
|
| 9 |
+
_LOCK = threading.Lock()
|
| 10 |
+
_t0 = time.monotonic()
|
| 11 |
+
_seen: set = set()
|
| 12 |
+
_active: set = set()
|
| 13 |
+
_page_sec = defaultdict(int)
|
| 14 |
+
_api = defaultdict(int)
|
| 15 |
+
_ip_os: dict[str, str] = {}
|
| 16 |
+
_VALID_CLIENT_OS = frozenset({"ios", "android", "windows", "macos", "linux", "unknown"})
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _is_page_route_request() -> bool:
|
| 20 |
+
"""与 static 中首页重定向、HTML 页面下发一致,不把 /api、静态 js/css 等算进访问 IP。"""
|
| 21 |
+
if request.method != "GET":
|
| 22 |
+
return False
|
| 23 |
+
path = request.path or ""
|
| 24 |
+
if path == "/":
|
| 25 |
+
return True
|
| 26 |
+
if path.startswith("/client/"):
|
| 27 |
+
return path.endswith(".html")
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def record_incoming_ip():
|
| 32 |
+
with _LOCK:
|
| 33 |
+
_seen.add(get_client_ip())
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def record_page_active(ip: str, page_key: str, delta: int, cum: int, client_os: str | None = None):
|
| 37 |
+
if cum < 1 or delta < 0 or not ip:
|
| 38 |
+
return
|
| 39 |
+
with _LOCK:
|
| 40 |
+
_active.add(ip)
|
| 41 |
+
if delta > 0:
|
| 42 |
+
_page_sec[page_key] += delta
|
| 43 |
+
if ip not in _ip_os and client_os is not None:
|
| 44 |
+
key = client_os.strip().lower()
|
| 45 |
+
_ip_os[ip] = key if key in _VALID_CLIENT_OS else "unknown"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def bump_api(kind: str):
|
| 49 |
+
with _LOCK:
|
| 50 |
+
_api[kind] += 1
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def print_visit_summary():
|
| 54 |
+
with _LOCK:
|
| 55 |
+
h = (time.monotonic() - _t0) / 3600
|
| 56 |
+
n_ip, n_act = len(_seen), len(_active)
|
| 57 |
+
pg = [f" {k}: {v}" for k, v in sorted(_page_sec.items(), key=lambda kv: (-kv[1], kv[0]))]
|
| 58 |
+
apis = dict(_api)
|
| 59 |
+
os_cnt = defaultdict(int)
|
| 60 |
+
for aip in _active:
|
| 61 |
+
o = _ip_os.get(aip)
|
| 62 |
+
if o:
|
| 63 |
+
os_cnt[o] += 1
|
| 64 |
+
os_order = ("ios", "android", "windows", "macos", "linux", "unknown")
|
| 65 |
+
os_pg = [f" {k}: {os_cnt[k]}" for k in os_order if os_cnt[k]]
|
| 66 |
+
|
| 67 |
+
body = ["========== [访问统计] ==========",
|
| 68 |
+
f"进程约 {h:.2f}h | 页面访问IP:{n_ip} | 真实活跃IP:{n_act}", "--- 活跃IP中OS统计 ---",
|
| 69 |
+
*(os_pg or [" (尚无)"]), "--- 页面活跃时间统计(秒) ---",
|
| 70 |
+
*(pg or [" (尚无)"]), "--- 分析API调用统计 ---",
|
| 71 |
+
*[f" {k}: {apis.get(k, 0)}" for k in ("analyze", "analyze_semantic", "prediction_attribute")],
|
| 72 |
+
"=" * 42]
|
| 73 |
+
print("\n".join(body), flush=True)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _hourly():
|
| 77 |
+
while True:
|
| 78 |
+
time.sleep(3600)
|
| 79 |
+
print_visit_summary()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def register_visit_stats(app):
|
| 83 |
+
@app.app.before_request
|
| 84 |
+
def _():
|
| 85 |
+
if _is_page_route_request():
|
| 86 |
+
record_incoming_ip()
|
| 87 |
+
|
| 88 |
+
threading.Thread(target=_hourly, daemon=True).start()
|
client/src/css/gen_attribute.scss
CHANGED
|
@@ -77,9 +77,11 @@
|
|
| 77 |
// 初始 d3.zoom 缩放为 1/本变量,抵消 display-scale 在屏上的整体缩小感(见 genAttributeDagView)
|
| 78 |
// 边色:`--dag-normal-line-color`、`--dag-highlight-line-color-in` / `out` 与节点描边 `--token-hover-outline` 同在 start.scss(见 genAttributeDagView)。
|
| 79 |
#results.gen-attr-results-surface.LMF .gen-attr-dag-stack {
|
| 80 |
-
//
|
| 81 |
-
--gen-attr-dag-
|
|
|
|
| 82 |
--gen-attr-dag-node-text-font-scale: 0.9;
|
|
|
|
| 83 |
// --gen-attr-dag-prompt-node-fill: rgba(221, 179, 120, 0.29);
|
| 84 |
// #99BFD1 浅蓝
|
| 85 |
--gen-attr-dag-prompt-node-fill: rgba(0, 255, 225, 0.28);
|
|
|
|
| 77 |
// 初始 d3.zoom 缩放为 1/本变量,抵消 display-scale 在屏上的整体缩小感(见 genAttributeDagView)
|
| 78 |
// 边色:`--dag-normal-line-color`、`--dag-highlight-line-color-in` / `out` 与节点描边 `--token-hover-outline` 同在 start.scss(见 genAttributeDagView)。
|
| 79 |
#results.gen-attr-results-surface.LMF .gen-attr-dag-stack {
|
| 80 |
+
// compactness:dag界面紧凑程度,范围 [0, 1];1时和普通文本排版一致,最紧凑;0.5为默认。
|
| 81 |
+
--gen-attr-dag-compactness: 0.5;
|
| 82 |
+
--gen-attr-dag-display-scale: var(--gen-attr-dag-compactness);
|
| 83 |
--gen-attr-dag-node-text-font-scale: 0.9;
|
| 84 |
+
--gen-attr-dag-link-stroke-width: calc(2px * var(--gen-attr-dag-compactness));
|
| 85 |
// --gen-attr-dag-prompt-node-fill: rgba(221, 179, 120, 0.29);
|
| 86 |
// #99BFD1 浅蓝
|
| 87 |
--gen-attr-dag-prompt-node-fill: rgba(0, 255, 225, 0.28);
|
client/src/ts/appInitializer.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { SimpleEventHandler } from './utils/SimpleEventHandler';
|
|
| 8 |
import { TextAnalysisAPI } from './api/GLTR_API';
|
| 9 |
import { initForceNarrowFromStorage } from './utils/responsive';
|
| 10 |
import { getTokenSurprisalColor, getByteSurprisalColor, HISTOGRAM_MIN_ALPHA } from './utils/SurprisalColorConfig';
|
|
|
|
| 11 |
|
| 12 |
/**
|
| 13 |
* 公共初始化返回对象
|
|
@@ -28,6 +29,7 @@ export interface CommonAppContext {
|
|
| 28 |
*/
|
| 29 |
export function initializeCommonApp(apiPrefix: string = '', element?: Element): CommonAppContext {
|
| 30 |
initForceNarrowFromStorage();
|
|
|
|
| 31 |
|
| 32 |
// 使用传入的元素或默认 body 元素
|
| 33 |
const targetElement = element || document.body;
|
|
|
|
| 8 |
import { TextAnalysisAPI } from './api/GLTR_API';
|
| 9 |
import { initForceNarrowFromStorage } from './utils/responsive';
|
| 10 |
import { getTokenSurprisalColor, getByteSurprisalColor, HISTOGRAM_MIN_ALPHA } from './utils/SurprisalColorConfig';
|
| 11 |
+
import { initClientActivityPing } from './utils/clientActivityPing';
|
| 12 |
|
| 13 |
/**
|
| 14 |
* 公共初始化返回对象
|
|
|
|
| 29 |
*/
|
| 30 |
export function initializeCommonApp(apiPrefix: string = '', element?: Element): CommonAppContext {
|
| 31 |
initForceNarrowFromStorage();
|
| 32 |
+
initClientActivityPing(apiPrefix);
|
| 33 |
|
| 34 |
// 使用传入的元素或默认 body 元素
|
| 35 |
const targetElement = element || document.body;
|
client/src/ts/attribution/genAttributeDagView.ts
CHANGED
|
@@ -125,10 +125,12 @@ function stackLayoutViewportPx(stackEl: HTMLElement): { w: number; h: number } {
|
|
| 125 |
}
|
| 126 |
|
| 127 |
/** 在「抵消 display-scale」的基准上再放大,作为 DAG 默认初始视图(d3 zoom 的 k) */
|
| 128 |
-
const DAG_INITIAL_ZOOM_BOOST = 1.
|
| 129 |
|
| 130 |
/** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-display-scale` 一致 */
|
| 131 |
const CSS_VAR_DISPLAY_SCALE = '--gen-attr-dag-display-scale';
|
|
|
|
|
|
|
| 132 |
|
| 133 |
/** 与 {@link start.scss} `--dag-normal-line-color` 一致(普通边:线 stroke + 箭头 marker stroke) */
|
| 134 |
const CSS_VAR_DAG_NORMAL_LINE_COLOR = '--dag-normal-line-color';
|
|
@@ -137,9 +139,6 @@ const CSS_VAR_DAG_HIGHLIGHT_LINE_IN = '--dag-highlight-line-color-in';
|
|
| 137 |
/** 与 {@link start.scss} `--dag-highlight-line-color-out` 一致(出边:从焦点出发) */
|
| 138 |
const CSS_VAR_DAG_HIGHLIGHT_LINE_OUT = '--dag-highlight-line-color-out';
|
| 139 |
|
| 140 |
-
/** 可见边与开放箭头描边宽度(px,与 marker 中 `vector-effect: non-scaling-stroke` 配合) */
|
| 141 |
-
const DAG_LINK_STROKE_WIDTH = 1;
|
| 142 |
-
|
| 143 |
/** 弱化:未排除的 prompt 无出边,或(prompt/生成区)邻域外且存在悬停/选中焦点时 */
|
| 144 |
const DAG_NODE_WEAKEN_OPACITY = 0.5;
|
| 145 |
/** 隐藏:节点 span 完全落在 exclude 规则命中区间内(prompt 与生成区各一套模式) */
|
|
@@ -373,7 +372,7 @@ export type InitGenAttributeDagViewOptions = {
|
|
| 373 |
onDagRefresh?: () => void;
|
| 374 |
/**
|
| 375 |
* 写入 `.gen-attr-dag-stack` 的 `--gen-attr-dag-display-scale`(矩形与节点文字同时缩放)。
|
| 376 |
-
* 未设置时
|
| 377 |
*/
|
| 378 |
displayScale?: number;
|
| 379 |
/**
|
|
@@ -715,7 +714,7 @@ export function initGenAttributeDagView(
|
|
| 715 |
.attr('d', 'M0,-5 L10,0 L0,5')
|
| 716 |
.attr('fill', 'none')
|
| 717 |
.attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
|
| 718 |
-
.attr('stroke-width',
|
| 719 |
.attr('vector-effect', 'non-scaling-stroke')
|
| 720 |
.attr('stroke-linecap', 'round')
|
| 721 |
.attr('stroke-linejoin', 'round');
|
|
@@ -734,7 +733,7 @@ export function initGenAttributeDagView(
|
|
| 734 |
el.append('line')
|
| 735 |
.attr('class', 'gen-attr-dag-link-visible')
|
| 736 |
.attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
|
| 737 |
-
.attr('stroke-width',
|
| 738 |
.attr('pointer-events', 'stroke')
|
| 739 |
.attr('marker-end', `url(#${mkId})`);
|
| 740 |
});
|
|
|
|
| 125 |
}
|
| 126 |
|
| 127 |
/** 在「抵消 display-scale」的基准上再放大,作为 DAG 默认初始视图(d3 zoom 的 k) */
|
| 128 |
+
const DAG_INITIAL_ZOOM_BOOST = 1.5;
|
| 129 |
|
| 130 |
/** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-display-scale` 一致 */
|
| 131 |
const CSS_VAR_DISPLAY_SCALE = '--gen-attr-dag-display-scale';
|
| 132 |
+
/** 与 {@link gen_attribute.scss} `.gen-attr-dag-stack` 中 `--gen-attr-dag-link-stroke-width` 一致 */
|
| 133 |
+
const CSS_VAR_DAG_LINK_STROKE_WIDTH = '--gen-attr-dag-link-stroke-width';
|
| 134 |
|
| 135 |
/** 与 {@link start.scss} `--dag-normal-line-color` 一致(普通边:线 stroke + 箭头 marker stroke) */
|
| 136 |
const CSS_VAR_DAG_NORMAL_LINE_COLOR = '--dag-normal-line-color';
|
|
|
|
| 139 |
/** 与 {@link start.scss} `--dag-highlight-line-color-out` 一致(出边:从焦点出发) */
|
| 140 |
const CSS_VAR_DAG_HIGHLIGHT_LINE_OUT = '--dag-highlight-line-color-out';
|
| 141 |
|
|
|
|
|
|
|
|
|
|
| 142 |
/** 弱化:未排除的 prompt 无出边,或(prompt/生成区)邻域外且存在悬停/选中焦点时 */
|
| 143 |
const DAG_NODE_WEAKEN_OPACITY = 0.5;
|
| 144 |
/** 隐藏:节点 span 完全落在 exclude 规则命中区间内(prompt 与生成区各一套模式) */
|
|
|
|
| 372 |
onDagRefresh?: () => void;
|
| 373 |
/**
|
| 374 |
* 写入 `.gen-attr-dag-stack` 的 `--gen-attr-dag-display-scale`(矩形与节点文字同时缩放)。
|
| 375 |
+
* 未设置时沿用样式表(`--gen-attr-dag-display-scale` 与 `--gen-attr-dag-compactness` 同源)。
|
| 376 |
*/
|
| 377 |
displayScale?: number;
|
| 378 |
/**
|
|
|
|
| 714 |
.attr('d', 'M0,-5 L10,0 L0,5')
|
| 715 |
.attr('fill', 'none')
|
| 716 |
.attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
|
| 717 |
+
.attr('stroke-width', `var(${CSS_VAR_DAG_LINK_STROKE_WIDTH})`)
|
| 718 |
.attr('vector-effect', 'non-scaling-stroke')
|
| 719 |
.attr('stroke-linecap', 'round')
|
| 720 |
.attr('stroke-linejoin', 'round');
|
|
|
|
| 733 |
el.append('line')
|
| 734 |
.attr('class', 'gen-attr-dag-link-visible')
|
| 735 |
.attr('stroke', `var(${CSS_VAR_DAG_NORMAL_LINE_COLOR})`)
|
| 736 |
+
.attr('stroke-width', `var(${CSS_VAR_DAG_LINK_STROKE_WIDTH})`)
|
| 737 |
.attr('pointer-events', 'stroke')
|
| 738 |
.attr('marker-end', `url(#${mkId})`);
|
| 739 |
});
|
client/src/ts/utils/clientActivityPing.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import URLHandler from './URLHandler';
|
| 2 |
+
import { AdminManager } from './adminManager';
|
| 3 |
+
|
| 4 |
+
const S = 10, FIRST = 2;
|
| 5 |
+
|
| 6 |
+
export type ReportedClientOs = 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown';
|
| 7 |
+
|
| 8 |
+
/** UA 粗略归类;仅在首轮心跳(cum === FIRST)顺带上报一次。 */
|
| 9 |
+
function detectInitialClientOs(): ReportedClientOs {
|
| 10 |
+
const ua = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
|
| 11 |
+
if (/iPad|iPhone|iPod/i.test(ua)) return 'ios';
|
| 12 |
+
if (/Android/i.test(ua)) return 'android';
|
| 13 |
+
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
| 14 |
+
const p = nav?.platform ?? '';
|
| 15 |
+
const tp = nav && typeof nav.maxTouchPoints === 'number' ? nav.maxTouchPoints : 0;
|
| 16 |
+
// iPadOS 13+ 桌面 UA 常为 Macintosh
|
| 17 |
+
if (p === 'MacIntel' && tp > 1) return 'ios';
|
| 18 |
+
if (/Win/i.test(ua)) return 'windows';
|
| 19 |
+
if (/Mac/i.test(ua)) return 'macos';
|
| 20 |
+
if (/Linux/i.test(ua)) return 'linux';
|
| 21 |
+
return 'unknown';
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function initClientActivityPing(apiPrefix: string | null | undefined): void {
|
| 25 |
+
if (typeof window === 'undefined') return;
|
| 26 |
+
if (AdminManager.getInstance().isInAdminMode()) return;
|
| 27 |
+
const u = `${apiPrefix || URLHandler.basicURL()}/api/client-activity`;
|
| 28 |
+
let n = 0, reportedTotal = 0, id: number | undefined;
|
| 29 |
+
const vis = () => document.visibilityState === 'visible';
|
| 30 |
+
const tick = () => {
|
| 31 |
+
if (!vis()) return;
|
| 32 |
+
n += 1;
|
| 33 |
+
if (n !== FIRST && n % S !== 0) return;
|
| 34 |
+
const cum = n;
|
| 35 |
+
const delta_active_sec = Math.max(cum - reportedTotal, 0);
|
| 36 |
+
const payload: Record<string, unknown> = {
|
| 37 |
+
page_path: location.pathname + location.search,
|
| 38 |
+
total_active_sec: cum,
|
| 39 |
+
delta_active_sec,
|
| 40 |
+
};
|
| 41 |
+
if (cum === FIRST) payload.client_os = detectInitialClientOs();
|
| 42 |
+
const body = JSON.stringify(payload);
|
| 43 |
+
void fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true })
|
| 44 |
+
.then((r) => { if (r.ok) reportedTotal = cum; })
|
| 45 |
+
.catch(() => {});
|
| 46 |
+
};
|
| 47 |
+
const sync = () => {
|
| 48 |
+
if (id !== undefined) { clearInterval(id); id = undefined; }
|
| 49 |
+
if (vis()) id = window.setInterval(tick, 1000);
|
| 50 |
+
};
|
| 51 |
+
document.addEventListener('visibilitychange', sync);
|
| 52 |
+
sync();
|
| 53 |
+
}
|
server.py
CHANGED
|
@@ -16,6 +16,7 @@ os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
| 16 |
import connexion
|
| 17 |
from backend.logging_config import configure_logging
|
| 18 |
from backend.api.static import register_static_routes
|
|
|
|
| 19 |
|
| 20 |
# 导入 API 函数供 server.yaml 使用
|
| 21 |
from backend.api.analyze import analyze # noqa: F401
|
|
@@ -34,6 +35,7 @@ from backend.api.folder import ( # noqa: F401
|
|
| 34 |
create_folder_api,
|
| 35 |
)
|
| 36 |
from backend.api.fetch_url import fetch_url # noqa: F401
|
|
|
|
| 37 |
from backend.api.analyze_semantic import analyze_semantic # noqa: F401
|
| 38 |
from backend.api.prediction_attribute import prediction_attribute # noqa: F401
|
| 39 |
from backend.api.model_switch import ( # noqa: F401
|
|
@@ -55,6 +57,7 @@ app = connexion.App(__name__)
|
|
| 55 |
|
| 56 |
# 配置日志
|
| 57 |
configure_logging(app)
|
|
|
|
| 58 |
|
| 59 |
# 注册路由
|
| 60 |
register_static_routes(app)
|
|
|
|
| 16 |
import connexion
|
| 17 |
from backend.logging_config import configure_logging
|
| 18 |
from backend.api.static import register_static_routes
|
| 19 |
+
from backend.visit_stats import register_visit_stats
|
| 20 |
|
| 21 |
# 导入 API 函数供 server.yaml 使用
|
| 22 |
from backend.api.analyze import analyze # noqa: F401
|
|
|
|
| 35 |
create_folder_api,
|
| 36 |
)
|
| 37 |
from backend.api.fetch_url import fetch_url # noqa: F401
|
| 38 |
+
from backend.api.client_activity import client_activity_report # noqa: F401
|
| 39 |
from backend.api.analyze_semantic import analyze_semantic # noqa: F401
|
| 40 |
from backend.api.prediction_attribute import prediction_attribute # noqa: F401
|
| 41 |
from backend.api.model_switch import ( # noqa: F401
|
|
|
|
| 57 |
|
| 58 |
# 配置日志
|
| 59 |
configure_logging(app)
|
| 60 |
+
register_visit_stats(app)
|
| 61 |
|
| 62 |
# 注册路由
|
| 63 |
register_static_routes(app)
|
server.yaml
CHANGED
|
@@ -374,6 +374,38 @@ paths:
|
|
| 374 |
500:
|
| 375 |
description: failed to fetch or extract text from URL
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
/check_admin:
|
| 378 |
post:
|
| 379 |
tags:
|
|
|
|
| 374 |
500:
|
| 375 |
description: failed to fetch or extract text from URL
|
| 376 |
|
| 377 |
+
/client-activity:
|
| 378 |
+
post:
|
| 379 |
+
tags:
|
| 380 |
+
- all
|
| 381 |
+
summary: anonymous client activity heartbeat
|
| 382 |
+
operationId: server.client_activity_report
|
| 383 |
+
parameters:
|
| 384 |
+
- in: body
|
| 385 |
+
name: activity_body
|
| 386 |
+
required: false
|
| 387 |
+
schema:
|
| 388 |
+
type: object
|
| 389 |
+
properties:
|
| 390 |
+
page_path:
|
| 391 |
+
type: string
|
| 392 |
+
description: pathname+search;汇总按去掉 ?/# 的路径末段,逐条日志里 path 仍带 query(unquote)
|
| 393 |
+
total_active_sec:
|
| 394 |
+
type: integer
|
| 395 |
+
delta_active_sec:
|
| 396 |
+
type: integer
|
| 397 |
+
client_os:
|
| 398 |
+
type: string
|
| 399 |
+
description: 首轮心跳附带;ios / android / windows / macos / linux / unknown
|
| 400 |
+
responses:
|
| 401 |
+
200:
|
| 402 |
+
description: acknowledged
|
| 403 |
+
schema:
|
| 404 |
+
type: object
|
| 405 |
+
properties:
|
| 406 |
+
ok:
|
| 407 |
+
type: boolean
|
| 408 |
+
|
| 409 |
/check_admin:
|
| 410 |
post:
|
| 411 |
tags:
|