增加「当前在线人数」估计
Browse files- backend/api/client_activity.py +25 -17
- backend/platform/online_presence.py +33 -0
- backend/platform/visit_stats.py +4 -0
- client/src/analysis.html +1 -3
- client/src/attribution.html +1 -3
- client/src/causal_flow.html +1 -3
- client/src/chat.html +1 -3
- client/src/css/components/_settings-menu.scss +26 -1
- client/src/index.html +1 -3
- client/src/pages/analysis/index.ts +5 -0
- client/src/pages/attribution/index.ts +3 -0
- client/src/pages/causal_flow/index.ts +3 -0
- client/src/pages/chat/index.ts +3 -0
- client/src/partials/settings-toolbar-controls.html +7 -0
- client/src/shared/api/GLTR_API.ts +2 -3
- client/src/shared/bootstrap.ts +5 -1
- client/src/shared/core/activitySession.ts +16 -0
- client/src/shared/core/clientActivityPing.ts +56 -23
- client/src/shared/cross/onlineCountDisplay.ts +17 -0
- client/src/shared/cross/visitStatsDialog.ts +1 -0
- server.yaml +14 -1
backend/api/client_activity.py
CHANGED
|
@@ -1,9 +1,15 @@
|
|
| 1 |
from urllib.parse import unquote
|
| 2 |
|
|
|
|
| 3 |
from backend.platform.access_log import log_request
|
|
|
|
| 4 |
from backend.platform.visit_stats import normalize_page_key, record_activity_report, record_gen_attr_opt_sec
|
| 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):
|
|
@@ -21,13 +27,13 @@ def client_activity_report(activity_body=None):
|
|
| 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
|
| 25 |
except (TypeError, ValueError):
|
| 26 |
-
return
|
| 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
|
| 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
|
|
@@ -36,17 +42,19 @@ def client_activity_report(activity_body=None):
|
|
| 36 |
|
| 37 |
page_key = normalize_page_key(page_key)
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
if
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
| 1 |
from urllib.parse import unquote
|
| 2 |
|
| 3 |
+
from backend.api.utils import request_has_valid_admin
|
| 4 |
from backend.platform.access_log import log_request
|
| 5 |
+
from backend.platform.online_presence import WINDOW_SEC, get_online_now, record_heartbeat
|
| 6 |
from backend.platform.visit_stats import normalize_page_key, record_activity_report, record_gen_attr_opt_sec
|
| 7 |
|
| 8 |
|
| 9 |
+
def _activity_response() -> dict:
|
| 10 |
+
return {"ok": True, "online_now": get_online_now(), "online_window_sec": WINDOW_SEC}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
def _sparse_page_activity_log_cum(cum: int) -> bool:
|
| 14 |
"""无服务端状态:累计秒为 2、10、20(前 20s 内与前端档位一致)或 40、80、160…(40·2^k)时打访问日志。"""
|
| 15 |
if cum in (2, 10, 20):
|
|
|
|
| 27 |
cum = int(d.get("total_active_sec"))
|
| 28 |
dlt = int(d.get("delta_active_sec"))
|
| 29 |
if not p or cum < 1 or dlt < 0:
|
| 30 |
+
return _activity_response()
|
| 31 |
except (TypeError, ValueError):
|
| 32 |
+
return _activity_response()
|
| 33 |
path_only = p.split("?", 1)[0].split("#", 1)[0].strip()
|
| 34 |
page_key = path_only.rstrip("/").split("/")[-1] or path_only
|
| 35 |
if not page_key:
|
| 36 |
+
return _activity_response()
|
| 37 |
if "?" in p:
|
| 38 |
qs = p.split("?", 1)[1].split("#", 1)[0]
|
| 39 |
log_path = f"{page_key}?{unquote(qs)}" if qs else page_key
|
|
|
|
| 42 |
|
| 43 |
page_key = normalize_page_key(page_key)
|
| 44 |
|
| 45 |
+
if not request_has_valid_admin():
|
| 46 |
+
raw_os = d.get("client_os")
|
| 47 |
+
client_os = str(raw_os).strip() if raw_os is not None else None
|
| 48 |
+
|
| 49 |
+
record_heartbeat()
|
| 50 |
+
record_activity_report(page_key, dlt, cum, client_os)
|
| 51 |
+
if page_key == "causal_flow.html":
|
| 52 |
+
raw_opts = d.get("page_opts")
|
| 53 |
+
if isinstance(raw_opts, dict):
|
| 54 |
+
record_gen_attr_opt_sec(dlt, {k: bool(v) for k, v in raw_opts.items() if isinstance(k, str)})
|
| 55 |
+
if _sparse_page_activity_log_cum(cum):
|
| 56 |
+
log_request(
|
| 57 |
+
"📄 页面活跃",
|
| 58 |
+
f"path(sampled)={log_path!r} total_sec={cum} delta_sec={dlt}",
|
| 59 |
+
)
|
| 60 |
+
return _activity_response()
|
backend/platform/online_presence.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""由 /api/client-activity 心跳估算当前在线(滑动窗口,无会话 ID)。"""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from collections import deque
|
| 7 |
+
|
| 8 |
+
WINDOW_SEC = 20
|
| 9 |
+
_HEARTBEAT_PERIOD_SEC = 10
|
| 10 |
+
|
| 11 |
+
_LOCK = threading.Lock()
|
| 12 |
+
_beats: deque[float] = deque()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def record_heartbeat() -> None:
|
| 16 |
+
now = time.monotonic()
|
| 17 |
+
with _LOCK:
|
| 18 |
+
_beats.append(now)
|
| 19 |
+
_prune_locked(now)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_online_now() -> int:
|
| 23 |
+
now = time.monotonic()
|
| 24 |
+
with _LOCK:
|
| 25 |
+
_prune_locked(now)
|
| 26 |
+
n = len(_beats)
|
| 27 |
+
return math.ceil(n * _HEARTBEAT_PERIOD_SEC / WINDOW_SEC)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _prune_locked(now: float) -> None:
|
| 31 |
+
cutoff = now - WINDOW_SEC
|
| 32 |
+
while _beats and _beats[0] < cutoff:
|
| 33 |
+
_beats.popleft()
|
backend/platform/visit_stats.py
CHANGED
|
@@ -515,8 +515,12 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
|
|
| 515 |
|
| 516 |
|
| 517 |
def get_stats_snapshot() -> dict:
|
|
|
|
|
|
|
| 518 |
sample = _sample_locked_counters()
|
| 519 |
public, _, _ = _merge_from_sample(sample)
|
|
|
|
|
|
|
| 520 |
public["server_platform"] = _get_server_platform()
|
| 521 |
public["startup_base"] = _startup_base
|
| 522 |
if _process_start_at is not None:
|
|
|
|
| 515 |
|
| 516 |
|
| 517 |
def get_stats_snapshot() -> dict:
|
| 518 |
+
from backend.platform.online_presence import WINDOW_SEC, get_online_now
|
| 519 |
+
|
| 520 |
sample = _sample_locked_counters()
|
| 521 |
public, _, _ = _merge_from_sample(sample)
|
| 522 |
+
public["online_now"] = get_online_now()
|
| 523 |
+
public["online_window_sec"] = WINDOW_SEC
|
| 524 |
public["server_platform"] = _get_server_platform()
|
| 525 |
public["startup_base"] = _startup_base
|
| 526 |
if _process_start_at is not None:
|
client/src/analysis.html
CHANGED
|
@@ -21,9 +21,7 @@
|
|
| 21 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 22 |
<a href="compare.html?showTextRender=1&demos=/quick-start-1.json,/quick-start-2.json" target="_blank" class="compare-link" style="display: none;" title="Compare analysis results" data-i18n="text,title">Compare results</a>
|
| 23 |
<div class="settings-menu-wrapper">
|
| 24 |
-
<
|
| 25 |
-
<span class="settings-icon">⚙️</span>
|
| 26 |
-
</button>
|
| 27 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 28 |
<!-- INCLUDE partials/settings-menu-analysis.html -->
|
| 29 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
|
|
|
| 21 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 22 |
<a href="compare.html?showTextRender=1&demos=/quick-start-1.json,/quick-start-2.json" target="_blank" class="compare-link" style="display: none;" title="Compare analysis results" data-i18n="text,title">Compare results</a>
|
| 23 |
<div class="settings-menu-wrapper">
|
| 24 |
+
<!-- INCLUDE partials/settings-toolbar-controls.html -->
|
|
|
|
|
|
|
| 25 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 26 |
<!-- INCLUDE partials/settings-menu-analysis.html -->
|
| 27 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
client/src/attribution.html
CHANGED
|
@@ -18,9 +18,7 @@
|
|
| 18 |
<div class="app-page-toolbar-actions">
|
| 19 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 20 |
<div class="settings-menu-wrapper">
|
| 21 |
-
<
|
| 22 |
-
<span class="settings-icon">⚙️</span>
|
| 23 |
-
</button>
|
| 24 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 25 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 26 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
|
|
|
| 18 |
<div class="app-page-toolbar-actions">
|
| 19 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 20 |
<div class="settings-menu-wrapper">
|
| 21 |
+
<!-- INCLUDE partials/settings-toolbar-controls.html -->
|
|
|
|
|
|
|
| 22 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 23 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 24 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
client/src/causal_flow.html
CHANGED
|
@@ -18,9 +18,7 @@
|
|
| 18 |
<div class="app-page-toolbar-actions">
|
| 19 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 20 |
<div class="settings-menu-wrapper">
|
| 21 |
-
<
|
| 22 |
-
<span class="settings-icon">⚙️</span>
|
| 23 |
-
</button>
|
| 24 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 25 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 26 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
|
|
|
| 18 |
<div class="app-page-toolbar-actions">
|
| 19 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 20 |
<div class="settings-menu-wrapper">
|
| 21 |
+
<!-- INCLUDE partials/settings-toolbar-controls.html -->
|
|
|
|
|
|
|
| 22 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 23 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 24 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
client/src/chat.html
CHANGED
|
@@ -18,9 +18,7 @@
|
|
| 18 |
<div class="app-page-toolbar-actions">
|
| 19 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 20 |
<div class="settings-menu-wrapper">
|
| 21 |
-
<
|
| 22 |
-
<span class="settings-icon">⚙️</span>
|
| 23 |
-
</button>
|
| 24 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 25 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 26 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
|
|
|
| 18 |
<div class="app-page-toolbar-actions">
|
| 19 |
<a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
|
| 20 |
<div class="settings-menu-wrapper">
|
| 21 |
+
<!-- INCLUDE partials/settings-toolbar-controls.html -->
|
|
|
|
|
|
|
| 22 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 23 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 24 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
client/src/css/components/_settings-menu.scss
CHANGED
|
@@ -1,12 +1,37 @@
|
|
| 1 |
// 设置菜单样式(Theme/Language 下拉等共用)
|
| 2 |
.settings-menu-wrapper {
|
| 3 |
position: relative;
|
| 4 |
-
display: inline-
|
|
|
|
|
|
|
| 5 |
font-family: var(--font-family-ui);
|
| 6 |
font-size: var(--font-size-base);
|
| 7 |
line-height: 1.4;
|
| 8 |
}
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
.settings-btn {
|
| 11 |
display: inline-flex;
|
| 12 |
align-items: center;
|
|
|
|
| 1 |
// 设置菜单样式(Theme/Language 下拉等共用)
|
| 2 |
.settings-menu-wrapper {
|
| 3 |
position: relative;
|
| 4 |
+
display: inline-flex;
|
| 5 |
+
align-items: center;
|
| 6 |
+
gap: 8px;
|
| 7 |
font-family: var(--font-family-ui);
|
| 8 |
font-size: var(--font-size-base);
|
| 9 |
line-height: 1.4;
|
| 10 |
}
|
| 11 |
|
| 12 |
+
.online-count {
|
| 13 |
+
display: flex;
|
| 14 |
+
flex-direction: column;
|
| 15 |
+
align-items: center;
|
| 16 |
+
gap: 1px;
|
| 17 |
+
color: var(--text-muted, var(--text-color));
|
| 18 |
+
opacity: 0.85;
|
| 19 |
+
font-size: 11px;
|
| 20 |
+
line-height: 1.15;
|
| 21 |
+
user-select: none;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.online-count-label,
|
| 25 |
+
.online-count-value {
|
| 26 |
+
white-space: nowrap;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.online-count-value {
|
| 30 |
+
font-variant-numeric: tabular-nums;
|
| 31 |
+
min-width: 1.5ch;
|
| 32 |
+
text-align: center;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
.settings-btn {
|
| 36 |
display: inline-flex;
|
| 37 |
align-items: center;
|
client/src/index.html
CHANGED
|
@@ -13,9 +13,7 @@
|
|
| 13 |
<body class="nav-landing-page">
|
| 14 |
|
| 15 |
<div class="nav-landing-settings settings-menu-wrapper">
|
| 16 |
-
<
|
| 17 |
-
<span class="settings-icon">⚙️</span>
|
| 18 |
-
</button>
|
| 19 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 20 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 21 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
|
|
|
| 13 |
<body class="nav-landing-page">
|
| 14 |
|
| 15 |
<div class="nav-landing-settings settings-menu-wrapper">
|
| 16 |
+
<!-- INCLUDE partials/settings-toolbar-controls.html -->
|
|
|
|
|
|
|
| 17 |
<div id="settings_menu" class="settings-menu" style="display: none;">
|
| 18 |
<!-- INCLUDE partials/settings-menu-common-mid.html -->
|
| 19 |
<!-- INCLUDE partials/settings-menu-trailing-admin.html -->
|
client/src/pages/analysis/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {PANEL_SPLIT_STORAGE_KEY_START} from '../../shared/cross/panelSplitStorag
|
|
| 32 |
import {handleServerDemoSave} from '../../shared/controllers/serverDemoController';
|
| 33 |
// 公共初始化模块
|
| 34 |
import {initializeCommonApp} from '../../shared/bootstrap';
|
|
|
|
| 35 |
// 工具函数
|
| 36 |
import {ensureJsonExtension} from '../../shared/cross/localFileUtils';
|
| 37 |
import {extractErrorMessage} from '../../shared/core/errorUtils';
|
|
@@ -193,6 +194,10 @@ window.onload = () => {
|
|
| 193 |
getSemanticSearchQuery: () => (document.getElementById('semantic_search_input') as HTMLInputElement | null)?.value ?? '',
|
| 194 |
tr
|
| 195 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
// 创建GLTR文本可视化实例
|
| 198 |
const lmf = new GLTR_Text_Box(d3.select("#results"), eventHandler);
|
|
|
|
| 32 |
import {handleServerDemoSave} from '../../shared/controllers/serverDemoController';
|
| 33 |
// 公共初始化模块
|
| 34 |
import {initializeCommonApp} from '../../shared/bootstrap';
|
| 35 |
+
import { registerPageBusy } from '../../shared/core/activitySession';
|
| 36 |
// 工具函数
|
| 37 |
import {ensureJsonExtension} from '../../shared/cross/localFileUtils';
|
| 38 |
import {extractErrorMessage} from '../../shared/core/errorUtils';
|
|
|
|
| 194 |
getSemanticSearchQuery: () => (document.getElementById('semantic_search_input') as HTMLInputElement | null)?.value ?? '',
|
| 195 |
tr
|
| 196 |
});
|
| 197 |
+
registerPageBusy(() => {
|
| 198 |
+
const s = appStateManager.getState();
|
| 199 |
+
return appStateManager.getIsAnalyzing() || s.isSemanticSearching;
|
| 200 |
+
});
|
| 201 |
|
| 202 |
// 创建GLTR文本可视化实例
|
| 203 |
const lmf = new GLTR_Text_Box(d3.select("#results"), eventHandler);
|
client/src/pages/attribution/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
|
|
| 11 |
import { PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION } from '../../shared/cross/panelSplitStorage';
|
| 12 |
import { TextInputController } from '../../shared/controllers/textInputController';
|
| 13 |
import { initializeCommonApp } from '../../shared/bootstrap';
|
|
|
|
| 14 |
import { showAlertDialog } from '../../shared/ui/dialog';
|
| 15 |
import URLHandler from '../../shared/core/URLHandler';
|
| 16 |
import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
|
|
@@ -165,6 +166,8 @@ function setAnalyzeLoading(loading: boolean): void {
|
|
| 165 |
syncAnalyzeButtonState();
|
| 166 |
}
|
| 167 |
|
|
|
|
|
|
|
| 168 |
// input 事件同步按钮状态
|
| 169 |
[contextField, targetField].forEach((field) => {
|
| 170 |
(field.node() as HTMLTextAreaElement | null)?.addEventListener('input', syncAnalyzeButtonState);
|
|
|
|
| 11 |
import { PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION } from '../../shared/cross/panelSplitStorage';
|
| 12 |
import { TextInputController } from '../../shared/controllers/textInputController';
|
| 13 |
import { initializeCommonApp } from '../../shared/bootstrap';
|
| 14 |
+
import { registerPageBusy } from '../../shared/core/activitySession';
|
| 15 |
import { showAlertDialog } from '../../shared/ui/dialog';
|
| 16 |
import URLHandler from '../../shared/core/URLHandler';
|
| 17 |
import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
|
|
|
|
| 166 |
syncAnalyzeButtonState();
|
| 167 |
}
|
| 168 |
|
| 169 |
+
registerPageBusy(() => analyzeInFlight);
|
| 170 |
+
|
| 171 |
// input 事件同步按钮状态
|
| 172 |
[contextField, targetField].forEach((field) => {
|
| 173 |
(field.node() as HTMLTextAreaElement | null)?.addEventListener('input', syncAnalyzeButtonState);
|
client/src/pages/causal_flow/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
|
|
| 11 |
import { PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE } from '../../shared/cross/panelSplitStorage';
|
| 12 |
import { TextInputController } from '../../shared/controllers/textInputController';
|
| 13 |
import { initializeCommonApp } from '../../shared/bootstrap';
|
|
|
|
| 14 |
import { setPageOptsGetter } from '../../shared/core/clientActivityPing';
|
| 15 |
import { showAlertDialog } from '../../shared/ui/dialog';
|
| 16 |
import URLHandler from '../../shared/core/URLHandler';
|
|
@@ -1658,6 +1659,8 @@ function setGenLoading(loading: boolean): void {
|
|
| 1658 |
syncSubmitButtonState();
|
| 1659 |
}
|
| 1660 |
|
|
|
|
|
|
|
| 1661 |
/** 当前输入是否满足可以发起一次生成(不含 inFlight 判断)。 */
|
| 1662 |
function isInputReadyForRun(): boolean {
|
| 1663 |
const prompt = getActivePromptValue();
|
|
|
|
| 11 |
import { PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE } from '../../shared/cross/panelSplitStorage';
|
| 12 |
import { TextInputController } from '../../shared/controllers/textInputController';
|
| 13 |
import { initializeCommonApp } from '../../shared/bootstrap';
|
| 14 |
+
import { registerPageBusy } from '../../shared/core/activitySession';
|
| 15 |
import { setPageOptsGetter } from '../../shared/core/clientActivityPing';
|
| 16 |
import { showAlertDialog } from '../../shared/ui/dialog';
|
| 17 |
import URLHandler from '../../shared/core/URLHandler';
|
|
|
|
| 1659 |
syncSubmitButtonState();
|
| 1660 |
}
|
| 1661 |
|
| 1662 |
+
registerPageBusy(() => inFlight);
|
| 1663 |
+
|
| 1664 |
/** 当前输入是否满足可以发起一次生成(不含 inFlight 判断)。 */
|
| 1665 |
function isInputReadyForRun(): boolean {
|
| 1666 |
const prompt = getActivePromptValue();
|
client/src/pages/chat/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
|
|
| 12 |
import { PANEL_SPLIT_STORAGE_KEY_CHAT } from '../../shared/cross/panelSplitStorage';
|
| 13 |
import { TextInputController } from '../../shared/controllers/textInputController';
|
| 14 |
import { initializeCommonApp } from '../../shared/bootstrap';
|
|
|
|
| 15 |
import { showAlertDialog } from '../../shared/ui/dialog';
|
| 16 |
import URLHandler from '../../shared/core/URLHandler';
|
| 17 |
import { ToolTip } from '../../shared/vis/ToolTip';
|
|
@@ -489,6 +490,8 @@ const setAskLoading = (loading: boolean): void => {
|
|
| 489 |
syncAskButtonState();
|
| 490 |
};
|
| 491 |
|
|
|
|
|
|
|
| 492 |
const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
|
| 493 |
const prompt = getActivePromptValue();
|
| 494 |
if (askInFlight || prompt.length === 0) return;
|
|
|
|
| 12 |
import { PANEL_SPLIT_STORAGE_KEY_CHAT } from '../../shared/cross/panelSplitStorage';
|
| 13 |
import { TextInputController } from '../../shared/controllers/textInputController';
|
| 14 |
import { initializeCommonApp } from '../../shared/bootstrap';
|
| 15 |
+
import { registerPageBusy } from '../../shared/core/activitySession';
|
| 16 |
import { showAlertDialog } from '../../shared/ui/dialog';
|
| 17 |
import URLHandler from '../../shared/core/URLHandler';
|
| 18 |
import { ToolTip } from '../../shared/vis/ToolTip';
|
|
|
|
| 490 |
syncAskButtonState();
|
| 491 |
};
|
| 492 |
|
| 493 |
+
registerPageBusy(() => askInFlight);
|
| 494 |
+
|
| 495 |
const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
|
| 496 |
const prompt = getActivePromptValue();
|
| 497 |
if (askInFlight || prompt.length === 0) return;
|
client/src/partials/settings-toolbar-controls.html
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<span class="online-count" aria-label="Online users">
|
| 2 |
+
<span class="online-count-label">Online:</span>
|
| 3 |
+
<span id="online_count_value" class="online-count-value" aria-live="polite">—</span>
|
| 4 |
+
</span>
|
| 5 |
+
<button id="settings_btn" class="settings-btn" title="Settings" data-i18n="title">
|
| 6 |
+
<span class="settings-icon">⚙️</span>
|
| 7 |
+
</button>
|
client/src/shared/api/GLTR_API.ts
CHANGED
|
@@ -235,9 +235,6 @@ export class TextAnalysisAPI {
|
|
| 235 |
});
|
| 236 |
}
|
| 237 |
|
| 238 |
-
/**
|
| 239 |
-
* 获取访问统计(管理员)
|
| 240 |
-
*/
|
| 241 |
public getVisitStats(): Promise<{
|
| 242 |
success: boolean,
|
| 243 |
totals: { page_loads: number, active_visits: number },
|
|
@@ -264,6 +261,8 @@ export class TextAnalysisAPI {
|
|
| 264 |
gen_attr_opt_sec?: Record<string, number>,
|
| 265 |
},
|
| 266 |
reset_at?: string | null,
|
|
|
|
|
|
|
| 267 |
}> {
|
| 268 |
return d3.json(this.baseURL + '/api/visit_stats', {
|
| 269 |
headers: this.getHeaders()
|
|
|
|
| 235 |
});
|
| 236 |
}
|
| 237 |
|
|
|
|
|
|
|
|
|
|
| 238 |
public getVisitStats(): Promise<{
|
| 239 |
success: boolean,
|
| 240 |
totals: { page_loads: number, active_visits: number },
|
|
|
|
| 261 |
gen_attr_opt_sec?: Record<string, number>,
|
| 262 |
},
|
| 263 |
reset_at?: string | null,
|
| 264 |
+
online_now?: number,
|
| 265 |
+
online_window_sec?: number,
|
| 266 |
}> {
|
| 267 |
return d3.json(this.baseURL + '/api/visit_stats', {
|
| 268 |
headers: this.getHeaders()
|
client/src/shared/bootstrap.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { TextAnalysisAPI } from './api/GLTR_API';
|
|
| 9 |
import { initForceNarrowFromStorage } from './core/responsive';
|
| 10 |
import { getTokenSurprisalColor, getByteSurprisalColor, HISTOGRAM_MIN_ALPHA } from './cross/SurprisalColorConfig';
|
| 11 |
import { initClientActivityPing } from './core/clientActivityPing';
|
|
|
|
| 12 |
|
| 13 |
/**
|
| 14 |
* 公共初始化返回对象
|
|
@@ -29,15 +30,18 @@ export interface CommonAppContext {
|
|
| 29 |
*/
|
| 30 |
export function initializeCommonApp(apiPrefix: string = '', element?: Element): CommonAppContext {
|
| 31 |
initForceNarrowFromStorage();
|
|
|
|
| 32 |
initClientActivityPing(apiPrefix);
|
| 33 |
|
|
|
|
|
|
|
| 34 |
// 使用传入的元素或默认 body 元素
|
| 35 |
const targetElement = element || document.body;
|
| 36 |
|
| 37 |
const format = d3.format('.2f');
|
| 38 |
return {
|
| 39 |
eventHandler: new SimpleEventHandler(targetElement),
|
| 40 |
-
api
|
| 41 |
tokenSurprisalColorScale: (v) => getTokenSurprisalColor(v, HISTOGRAM_MIN_ALPHA),
|
| 42 |
byteSurprisalColorScale: (v) => getByteSurprisalColor(v, 1, HISTOGRAM_MIN_ALPHA),
|
| 43 |
totalSurprisalFormat: (n: number | null) => n !== null && Number.isFinite(n) ? format(n) : String(n)
|
|
|
|
| 9 |
import { initForceNarrowFromStorage } from './core/responsive';
|
| 10 |
import { getTokenSurprisalColor, getByteSurprisalColor, HISTOGRAM_MIN_ALPHA } from './cross/SurprisalColorConfig';
|
| 11 |
import { initClientActivityPing } from './core/clientActivityPing';
|
| 12 |
+
import { initOnlineCountDisplay } from './cross/onlineCountDisplay';
|
| 13 |
|
| 14 |
/**
|
| 15 |
* 公共初始化返回对象
|
|
|
|
| 30 |
*/
|
| 31 |
export function initializeCommonApp(apiPrefix: string = '', element?: Element): CommonAppContext {
|
| 32 |
initForceNarrowFromStorage();
|
| 33 |
+
initOnlineCountDisplay();
|
| 34 |
initClientActivityPing(apiPrefix);
|
| 35 |
|
| 36 |
+
const api = new TextAnalysisAPI(apiPrefix);
|
| 37 |
+
|
| 38 |
// 使用传入的元素或默认 body 元素
|
| 39 |
const targetElement = element || document.body;
|
| 40 |
|
| 41 |
const format = d3.format('.2f');
|
| 42 |
return {
|
| 43 |
eventHandler: new SimpleEventHandler(targetElement),
|
| 44 |
+
api,
|
| 45 |
tokenSurprisalColorScale: (v) => getTokenSurprisalColor(v, HISTOGRAM_MIN_ALPHA),
|
| 46 |
byteSurprisalColorScale: (v) => getByteSurprisalColor(v, 1, HISTOGRAM_MIN_ALPHA),
|
| 47 |
totalSurprisalFormat: (n: number | null) => n !== null && Number.isFinite(n) ? format(n) : String(n)
|
client/src/shared/core/activitySession.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 各页入口注册 isBusy;心跳 tick 用 isSessionActive() = 可见 || getter()。
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
let pageBusyGetter: (() => boolean) | undefined;
|
| 6 |
+
|
| 7 |
+
export function registerPageBusy(getter: () => boolean): void {
|
| 8 |
+
pageBusyGetter = getter;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function isSessionActive(): boolean {
|
| 12 |
+
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
| 13 |
+
return true;
|
| 14 |
+
}
|
| 15 |
+
return pageBusyGetter?.() ?? false;
|
| 16 |
+
}
|
client/src/shared/core/clientActivityPing.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
| 1 |
import URLHandler from './URLHandler';
|
| 2 |
import { AdminManager } from '../cross/adminManager';
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
let _pageOptsGetter: (() => Record<string, boolean>) | undefined;
|
| 7 |
/** gen_attribute.html 等页面注册当前选项状态,供心跳上报时附带。 */
|
|
@@ -11,7 +17,7 @@ export function setPageOptsGetter(fn: () => Record<string, boolean>): void {
|
|
| 11 |
|
| 12 |
export type ReportedClientOs = 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown';
|
| 13 |
|
| 14 |
-
/** UA 粗略归类;仅在首轮心跳
|
| 15 |
function detectInitialClientOs(): ReportedClientOs {
|
| 16 |
const ua = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
|
| 17 |
if (/iPad|iPhone|iPod/i.test(ua)) return 'ios';
|
|
@@ -27,37 +33,64 @@ function detectInitialClientOs(): ReportedClientOs {
|
|
| 27 |
return 'unknown';
|
| 28 |
}
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
export function initClientActivityPing(apiPrefix: string | null | undefined): void {
|
| 31 |
if (typeof window === 'undefined') return;
|
| 32 |
-
|
| 33 |
-
const
|
| 34 |
-
let
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
if (n !== FIRST && n % S !== 0) return;
|
| 40 |
-
const cum = n;
|
| 41 |
-
const delta_active_sec = Math.max(cum - reportedTotal, 0);
|
| 42 |
const payload: Record<string, unknown> = {
|
| 43 |
page_path: location.pathname + location.search,
|
| 44 |
-
total_active_sec:
|
| 45 |
-
delta_active_sec,
|
| 46 |
};
|
| 47 |
-
if (
|
| 48 |
if (_pageOptsGetter) {
|
| 49 |
const active = Object.fromEntries(Object.entries(_pageOptsGetter()).filter(([, v]) => v));
|
| 50 |
if (Object.keys(active).length > 0) payload.page_opts = active;
|
| 51 |
}
|
| 52 |
-
const
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
.catch(() => {});
|
| 56 |
};
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 60 |
};
|
| 61 |
-
|
| 62 |
-
|
| 63 |
}
|
|
|
|
| 1 |
import URLHandler from './URLHandler';
|
| 2 |
import { AdminManager } from '../cross/adminManager';
|
| 3 |
+
import { applyOnlineCount } from '../cross/onlineCountDisplay';
|
| 4 |
+
import { isSessionActive } from './activitySession';
|
| 5 |
|
| 6 |
+
/** 活跃秒采样间隔 */
|
| 7 |
+
const TICK_MS = 1000;
|
| 8 |
+
/** 上报档位:累计活跃秒达到这些值时 POST */
|
| 9 |
+
const FIRST_REPORT_SEC = 2;
|
| 10 |
+
const REPORT_INTERVAL_SEC = 10;
|
| 11 |
|
| 12 |
let _pageOptsGetter: (() => Record<string, boolean>) | undefined;
|
| 13 |
/** gen_attribute.html 等页面注册当前选项状态,供心跳上报时附带。 */
|
|
|
|
| 17 |
|
| 18 |
export type ReportedClientOs = 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown';
|
| 19 |
|
| 20 |
+
/** UA 粗略归类;仅在首轮心跳顺带上报一次。 */
|
| 21 |
function detectInitialClientOs(): ReportedClientOs {
|
| 22 |
const ua = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
|
| 23 |
if (/iPad|iPhone|iPod/i.test(ua)) return 'ios';
|
|
|
|
| 33 |
return 'unknown';
|
| 34 |
}
|
| 35 |
|
| 36 |
+
function applyOnlineFromActivityResponse(r: Response): void {
|
| 37 |
+
const ct = r.headers.get('content-type') || '';
|
| 38 |
+
if (!ct.includes('application/json')) return;
|
| 39 |
+
void r
|
| 40 |
+
.clone()
|
| 41 |
+
.json()
|
| 42 |
+
.then((data: { online_now?: unknown }) => {
|
| 43 |
+
if (data && 'online_now' in data) applyOnlineCount(data.online_now);
|
| 44 |
+
})
|
| 45 |
+
.catch(() => {});
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/** 累计活跃秒为 2、10、20… 时上报 */
|
| 49 |
+
function shouldReportAtSec(totalActiveSec: number): boolean {
|
| 50 |
+
return totalActiveSec === FIRST_REPORT_SEC || totalActiveSec % REPORT_INTERVAL_SEC === 0;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
export function initClientActivityPing(apiPrefix: string | null | undefined): void {
|
| 54 |
if (typeof window === 'undefined') return;
|
| 55 |
+
const admin = AdminManager.getInstance();
|
| 56 |
+
const endpoint = `${apiPrefix || URLHandler.basicURL()}/api/client-activity`;
|
| 57 |
+
let totalActiveSec = 0;
|
| 58 |
+
let lastReportedSec = 0;
|
| 59 |
+
|
| 60 |
+
const postActivity = (includeClientOs: boolean) => {
|
| 61 |
+
const deltaActiveSec = Math.max(totalActiveSec - lastReportedSec, 0);
|
|
|
|
|
|
|
|
|
|
| 62 |
const payload: Record<string, unknown> = {
|
| 63 |
page_path: location.pathname + location.search,
|
| 64 |
+
total_active_sec: totalActiveSec,
|
| 65 |
+
delta_active_sec: deltaActiveSec,
|
| 66 |
};
|
| 67 |
+
if (includeClientOs) payload.client_os = detectInitialClientOs();
|
| 68 |
if (_pageOptsGetter) {
|
| 69 |
const active = Object.fromEntries(Object.entries(_pageOptsGetter()).filter(([, v]) => v));
|
| 70 |
if (Object.keys(active).length > 0) payload.page_opts = active;
|
| 71 |
}
|
| 72 |
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
| 73 |
+
const adminToken = admin.isInAdminMode() ? admin.getAdminToken() : null;
|
| 74 |
+
if (adminToken) headers['X-Admin-Token'] = adminToken;
|
| 75 |
+
void fetch(endpoint, {
|
| 76 |
+
method: 'POST',
|
| 77 |
+
headers,
|
| 78 |
+
body: JSON.stringify(payload),
|
| 79 |
+
keepalive: true,
|
| 80 |
+
})
|
| 81 |
+
.then((r) => {
|
| 82 |
+
applyOnlineFromActivityResponse(r);
|
| 83 |
+
if (r.ok) lastReportedSec = totalActiveSec;
|
| 84 |
+
})
|
| 85 |
.catch(() => {});
|
| 86 |
};
|
| 87 |
+
|
| 88 |
+
const tick = () => {
|
| 89 |
+
if (!isSessionActive()) return;
|
| 90 |
+
totalActiveSec += 1;
|
| 91 |
+
if (!shouldReportAtSec(totalActiveSec)) return;
|
| 92 |
+
postActivity(totalActiveSec === FIRST_REPORT_SEC);
|
| 93 |
};
|
| 94 |
+
|
| 95 |
+
window.setInterval(tick, TICK_MS);
|
| 96 |
}
|
client/src/shared/cross/onlineCountDisplay.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 页头在线人数(由 client-activity 响应更新 #online_count_value)
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
const VALUE_ID = 'online_count_value';
|
| 6 |
+
|
| 7 |
+
export function initOnlineCountDisplay(): void {
|
| 8 |
+
const el = document.getElementById(VALUE_ID);
|
| 9 |
+
if (el && !el.textContent?.trim()) el.textContent = '—';
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function applyOnlineCount(n: unknown): void {
|
| 13 |
+
const el = document.getElementById(VALUE_ID);
|
| 14 |
+
if (!el) return;
|
| 15 |
+
el.textContent =
|
| 16 |
+
typeof n === 'number' && Number.isFinite(n) ? String(n) : '—';
|
| 17 |
+
}
|
client/src/shared/cross/visitStatsDialog.ts
CHANGED
|
@@ -130,6 +130,7 @@ function visitStatsHtml(data: VisitStatsRow): string {
|
|
| 130 |
`[All-time (${g('+ delta since reset')})]`,
|
| 131 |
`Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
|
| 132 |
`Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
|
|
|
|
| 133 |
'',
|
| 134 |
'[OS]',
|
| 135 |
...linesJoined(orderedKeysGt0(OS_ORDER, os), os, sb.os ?? {}),
|
|
|
|
| 130 |
`[All-time (${g('+ delta since reset')})]`,
|
| 131 |
`Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
|
| 132 |
`Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
|
| 133 |
+
`Online: ${data.online_now ?? 'unknown'}`,
|
| 134 |
'',
|
| 135 |
'[OS]',
|
| 136 |
...linesJoined(orderedKeysGt0(OS_ORDER, os), os, sb.os ?? {}),
|
server.yaml
CHANGED
|
@@ -397,14 +397,20 @@ paths:
|
|
| 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:
|
|
@@ -863,6 +869,13 @@ paths:
|
|
| 863 |
type: string
|
| 864 |
description: |
|
| 865 |
最近一次手动 reset 的 UTC 时间;从未 reset 时为 null。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
|
| 867 |
/visit_stats/reset:
|
| 868 |
post:
|
|
|
|
| 397 |
client_os:
|
| 398 |
type: string
|
| 399 |
description: 首轮心跳附带;ios / android / windows / macos / linux / unknown
|
| 400 |
+
description: |
|
| 401 |
+
有效心跳计入访问统计与在线估计。请求头带有效 X-Admin-Token 时仅返回 online_now,不计入统计。
|
| 402 |
responses:
|
| 403 |
200:
|
| 404 |
+
description: acknowledged; includes estimated online_now after valid heartbeat
|
| 405 |
schema:
|
| 406 |
type: object
|
| 407 |
properties:
|
| 408 |
ok:
|
| 409 |
type: boolean
|
| 410 |
+
online_now:
|
| 411 |
+
type: integer
|
| 412 |
+
online_window_sec:
|
| 413 |
+
type: integer
|
| 414 |
|
| 415 |
/check_admin:
|
| 416 |
post:
|
|
|
|
| 869 |
type: string
|
| 870 |
description: |
|
| 871 |
最近一次手动 reset 的 UTC 时间;从未 reset 时为 null。
|
| 872 |
+
online_now:
|
| 873 |
+
type: integer
|
| 874 |
+
description: |
|
| 875 |
+
最近 online_window_sec 内 client-activity 心跳数按每 10s 一次折算的当前在线估计(ceil);进程内存,重启清零。
|
| 876 |
+
online_window_sec:
|
| 877 |
+
type: integer
|
| 878 |
+
description: 在线估计滑动窗口秒数(当前为 20)。
|
| 879 |
|
| 880 |
/visit_stats/reset:
|
| 881 |
post:
|