dqy08 commited on
Commit
12617a9
·
1 Parent(s): 82b33f3

dag增加紧凑度变量配置;增加活跃信息统计

Browse files
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
- return {'success': True}
 
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
- // display-scale节点框大小相对测量几何的缩放;节点标签字号在此基础上再乘 node-text-font-scale(略小于 1 即自带左右留白感)
81
- --gen-attr-dag-display-scale: 0.5;
 
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
+ // compactnessdag界面紧凑程度,范围 [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.25;
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
- * 未设置时用样式表中的变量默认 1,测量层字号、同框比例)。
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', DAG_LINK_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', DAG_LINK_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: