新增访问统计重置功能,UI和样式优化。
Browse files- backend/api/visit_stats_api.py +8 -1
- backend/visit_stats.py +49 -0
- client/src/css/chat.scss +2 -2
- client/src/css/gen_attribute.scss +17 -4
- client/src/css/start.scss +2 -0
- client/src/gen_attribute.html +8 -12
- client/src/ts/api/GLTR_API.ts +15 -0
- client/src/ts/utils/settingsMenuManager.ts +29 -4
- client/src/ts/utils/textMetricsUpdater.ts +8 -11
- server.py +1 -1
- server.yaml +32 -0
backend/api/visit_stats_api.py
CHANGED
|
@@ -1,8 +1,15 @@
|
|
| 1 |
"""访问统计 API(仅管理员可用)"""
|
| 2 |
-
from backend.visit_stats import get_stats_snapshot
|
| 3 |
from backend.api.utils import require_admin
|
| 4 |
|
| 5 |
|
| 6 |
@require_admin
|
| 7 |
def get_visit_stats():
|
| 8 |
return get_stats_snapshot(), 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""访问统计 API(仅管理员可用)"""
|
| 2 |
+
from backend.visit_stats import get_stats_snapshot, reset_delta_base
|
| 3 |
from backend.api.utils import require_admin
|
| 4 |
|
| 5 |
|
| 6 |
@require_admin
|
| 7 |
def get_visit_stats():
|
| 8 |
return get_stats_snapshot(), 200
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@require_admin
|
| 12 |
+
def post_visit_stats_reset():
|
| 13 |
+
if reset_delta_base():
|
| 14 |
+
return {"success": True}, 200
|
| 15 |
+
return {"success": False, "error": "Failed to persist reset base"}, 500
|
backend/visit_stats.py
CHANGED
|
@@ -47,11 +47,16 @@ _base: dict = {}
|
|
| 47 |
_startup_base: dict = {}
|
| 48 |
_process_start_at: str | None = None
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
_cached_server_platform: str | None = None
|
| 51 |
|
| 52 |
_HF_REPO = "dqy08/info-lens-stats"
|
| 53 |
_HF_TOKEN = os.environ.get("HF_TOKEN_stats_write")
|
| 54 |
_HF_TOTAL_FILE = "stats_total.json"
|
|
|
|
| 55 |
_HF_DELTA_DIR = "stats_delta"
|
| 56 |
|
| 57 |
|
|
@@ -226,6 +231,47 @@ def _load_base():
|
|
| 226 |
print(f"[访问统计] 历史已加载 page_loads={pl} active_visits={av}", flush=True)
|
| 227 |
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
def _persist_tick():
|
| 230 |
"""先读 stats_total 再写:delta 与 total 为同一 record 形状;两次上传均成功后提交 _base,并减去本周期对应会话快照。"""
|
| 231 |
global _base
|
|
@@ -381,12 +427,15 @@ def get_stats_snapshot() -> dict:
|
|
| 381 |
public["startup_base"] = _startup_base
|
| 382 |
if _process_start_at is not None:
|
| 383 |
public["process_start_at"] = _process_start_at
|
|
|
|
|
|
|
| 384 |
return public
|
| 385 |
|
| 386 |
|
| 387 |
def _daemon_persist_hourly():
|
| 388 |
global _startup_base, _process_start_at
|
| 389 |
_load_base()
|
|
|
|
| 390 |
_startup_base = copy.deepcopy(_base)
|
| 391 |
_process_start_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 392 |
_report_restart_event()
|
|
|
|
| 47 |
_startup_base: dict = {}
|
| 48 |
_process_start_at: str | None = None
|
| 49 |
|
| 50 |
+
# 手动 reset 后的快照基线与时间,持久化到 HF,重启后保留。
|
| 51 |
+
_reset_base: dict = {}
|
| 52 |
+
_reset_at: str | None = None
|
| 53 |
+
|
| 54 |
_cached_server_platform: str | None = None
|
| 55 |
|
| 56 |
_HF_REPO = "dqy08/info-lens-stats"
|
| 57 |
_HF_TOKEN = os.environ.get("HF_TOKEN_stats_write")
|
| 58 |
_HF_TOTAL_FILE = "stats_total.json"
|
| 59 |
+
_HF_RESET_BASE_FILE = "stats_reset_base.json"
|
| 60 |
_HF_DELTA_DIR = "stats_delta"
|
| 61 |
|
| 62 |
|
|
|
|
| 231 |
print(f"[访问统计] 历史已加载 page_loads={pl} active_visits={av}", flush=True)
|
| 232 |
|
| 233 |
|
| 234 |
+
def _load_reset_base():
|
| 235 |
+
global _reset_base, _reset_at
|
| 236 |
+
if not _HF_TOKEN:
|
| 237 |
+
return
|
| 238 |
+
try:
|
| 239 |
+
from huggingface_hub import hf_hub_download
|
| 240 |
+
path = hf_hub_download(
|
| 241 |
+
repo_id=_HF_REPO,
|
| 242 |
+
filename=_HF_RESET_BASE_FILE,
|
| 243 |
+
repo_type="dataset",
|
| 244 |
+
token=_HF_TOKEN,
|
| 245 |
+
force_download=True,
|
| 246 |
+
)
|
| 247 |
+
with open(path, encoding="utf-8") as f:
|
| 248 |
+
data = json.load(f)
|
| 249 |
+
with _LOCK:
|
| 250 |
+
_reset_base = copy.deepcopy(data)
|
| 251 |
+
_reset_at = data.get("reset_at")
|
| 252 |
+
print(f"[访问统计] delta reset base 已加载 reset_at={_reset_at}", flush=True)
|
| 253 |
+
except Exception as e:
|
| 254 |
+
print(f"[访问统计] 读取 {_HF_RESET_BASE_FILE} 失败(首次或未设置): {e}", flush=True)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def reset_delta_base() -> bool:
|
| 258 |
+
"""先 persist 当前增量,再将落盘后的累计快照保存为 delta reset base。"""
|
| 259 |
+
global _reset_base, _reset_at
|
| 260 |
+
_persist_tick()
|
| 261 |
+
sample = _sample_locked_counters()
|
| 262 |
+
_, stats_body, _ = _merge_from_sample(sample)
|
| 263 |
+
reset_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 264 |
+
reset_rec = {"reset_at": reset_at, **stats_body}
|
| 265 |
+
if _HF_TOKEN and not _upload_dataset_record(_HF_RESET_BASE_FILE, reset_rec):
|
| 266 |
+
print("[访问统计] reset base 持久化失败。", flush=True)
|
| 267 |
+
return False
|
| 268 |
+
with _LOCK:
|
| 269 |
+
_reset_base = copy.deepcopy(reset_rec)
|
| 270 |
+
_reset_at = reset_at
|
| 271 |
+
print(f"[访问统计] delta reset base 已更新 reset_at={reset_at}", flush=True)
|
| 272 |
+
return True
|
| 273 |
+
|
| 274 |
+
|
| 275 |
def _persist_tick():
|
| 276 |
"""先读 stats_total 再写:delta 与 total 为同一 record 形状;两次上传均成功后提交 _base,并减去本周期对应会话快照。"""
|
| 277 |
global _base
|
|
|
|
| 427 |
public["startup_base"] = _startup_base
|
| 428 |
if _process_start_at is not None:
|
| 429 |
public["process_start_at"] = _process_start_at
|
| 430 |
+
public["reset_base"] = _reset_base
|
| 431 |
+
public["reset_at"] = _reset_at
|
| 432 |
return public
|
| 433 |
|
| 434 |
|
| 435 |
def _daemon_persist_hourly():
|
| 436 |
global _startup_base, _process_start_at
|
| 437 |
_load_base()
|
| 438 |
+
_load_reset_base()
|
| 439 |
_startup_base = copy.deepcopy(_base)
|
| 440 |
_process_start_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 441 |
_report_restart_event()
|
client/src/css/chat.scss
CHANGED
|
@@ -5,9 +5,9 @@
|
|
| 5 |
@use "lmf-readout" as lmf;
|
| 6 |
@use "query-history-dropdown" as qh;
|
| 7 |
|
| 8 |
-
//
|
| 9 |
.input-section > .chat-raw-prompt-mode-row {
|
| 10 |
-
margin-bottom:
|
| 11 |
}
|
| 12 |
|
| 13 |
// 与 Raw prompt 行同结构:Ask 行上方单独配置(max new tokens)
|
|
|
|
| 5 |
@use "lmf-readout" as lmf;
|
| 6 |
@use "query-history-dropdown" as qh;
|
| 7 |
|
| 8 |
+
// 与下方面板的间距由 .input-header { padding-top } 承担,避免与本行 margin-bottom 叠成双倍
|
| 9 |
.input-section > .chat-raw-prompt-mode-row {
|
| 10 |
+
margin-bottom: 0;
|
| 11 |
}
|
| 12 |
|
| 13 |
// 与 Raw prompt 行同结构:Ask 行上方单独配置(max new tokens)
|
client/src/css/gen_attribute.scss
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 3 |
@use "generation-status";
|
| 4 |
@use "lmf-readout" as lmf;
|
| 5 |
|
|
|
|
|
|
|
| 6 |
// 单行右对齐:Cached history 最右、下拉相对整行 bar 全宽;Cached demos 靠其左侧、下拉随按钮宽(同 query-history 默认 left/right 于窄 wrapper)
|
| 7 |
.chat-cached-history-bar.chat-cached-history-bar--dual {
|
| 8 |
position: relative;
|
|
@@ -354,6 +356,10 @@ body.gen-attribute-page .input-section {
|
|
| 354 |
color: var(--text-primary);
|
| 355 |
}
|
| 356 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
}
|
| 358 |
|
| 359 |
// 与 Attribution 页 Exclude prompt patterns 同形;generated 仅本页有 UI(持久化键见 attributionExclude*PatternsStorage)
|
|
@@ -363,9 +369,9 @@ body.gen-attribute-page .input-section {
|
|
| 363 |
flex-direction: column;
|
| 364 |
gap: 4px;
|
| 365 |
|
| 366 |
-
//
|
| 367 |
&:has(.gen-attr-teacher-forcing-toggle-row) {
|
| 368 |
-
gap:
|
| 369 |
}
|
| 370 |
}
|
| 371 |
|
|
@@ -424,11 +430,18 @@ body.gen-attribute-page .input-section {
|
|
| 424 |
.gen-attr-dag-measure-width-row {
|
| 425 |
margin-top: 8px;
|
| 426 |
flex-wrap: wrap;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
}
|
| 428 |
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
| 431 |
|
|
|
|
| 432 |
.semantic-submode-label,
|
| 433 |
.gen-attr-dag-layout-mode-select {
|
| 434 |
font-weight: 700;
|
|
|
|
| 3 |
@use "generation-status";
|
| 4 |
@use "lmf-readout" as lmf;
|
| 5 |
|
| 6 |
+
$gen-attr-option-row-gap: 12px;
|
| 7 |
+
|
| 8 |
// 单行右对齐:Cached history 最右、下拉相对整行 bar 全宽;Cached demos 靠其左侧、下拉随按钮宽(同 query-history 默认 left/right 于窄 wrapper)
|
| 9 |
.chat-cached-history-bar.chat-cached-history-bar--dual {
|
| 10 |
position: relative;
|
|
|
|
| 356 |
color: var(--text-primary);
|
| 357 |
}
|
| 358 |
}
|
| 359 |
+
|
| 360 |
+
.textarea-wrapper.chat-prompt-actions-row > .chat-completion-options-row.semantic-submode-row {
|
| 361 |
+
gap: $gen-attr-option-row-gap;
|
| 362 |
+
}
|
| 363 |
}
|
| 364 |
|
| 365 |
// 与 Attribution 页 Exclude prompt patterns 同形;generated 仅本页有 UI(持久化键见 attributionExclude*PatternsStorage)
|
|
|
|
| 369 |
flex-direction: column;
|
| 370 |
gap: 4px;
|
| 371 |
|
| 372 |
+
// 勾选行 ↔ Forced continuation:间距仅来自 .input-header padding-top(避免与本行 gap 叠成双份)
|
| 373 |
&:has(.gen-attr-teacher-forcing-toggle-row) {
|
| 374 |
+
gap: 0;
|
| 375 |
}
|
| 376 |
}
|
| 377 |
|
|
|
|
| 430 |
.gen-attr-dag-measure-width-row {
|
| 431 |
margin-top: 8px;
|
| 432 |
flex-wrap: wrap;
|
| 433 |
+
|
| 434 |
+
&.semantic-submode-row {
|
| 435 |
+
gap: $gen-attr-option-row-gap;
|
| 436 |
+
}
|
| 437 |
}
|
| 438 |
|
| 439 |
+
// 高 surprisal 两项勾选项:固定同一行,避免与其它 DAG 行一致的 wrap 把两项拆成两行
|
| 440 |
+
.gen-attr-dag-measure-width-row.gen-attr-dag-surprisal-toggles-row {
|
| 441 |
+
flex-wrap: nowrap;
|
| 442 |
+
}
|
| 443 |
|
| 444 |
+
.gen-attr-dag-measure-width-row .gen-attr-dag-layout-mode-group {
|
| 445 |
.semantic-submode-label,
|
| 446 |
.gen-attr-dag-layout-mode-select {
|
| 447 |
font-weight: 700;
|
client/src/css/start.scss
CHANGED
|
@@ -62,6 +62,7 @@
|
|
| 62 |
--primary-color: #2196F3; // 主题色(链接、当前模型标题等)
|
| 63 |
--text-disabled: #999; // 禁用态文本
|
| 64 |
--text-area-bg: #fff; // 文本显示区域背景色
|
|
|
|
| 65 |
--minimap-width: 8px; // minimap 宽度
|
| 66 |
--tooltip-text-normal: #333; // tooltip普通文本颜色
|
| 67 |
--tooltip-text-selected: #933; // tooltip选中文本颜色
|
|
@@ -205,6 +206,7 @@ textarea{
|
|
| 205 |
justify-content: space-between; // Analyze在左,Save在右
|
| 206 |
align-items: center;
|
| 207 |
width: 100%;
|
|
|
|
| 208 |
margin-top: 5px;
|
| 209 |
gap: 10px;
|
| 210 |
box-sizing: border-box; // 确保宽度计算正确
|
|
|
|
| 62 |
--primary-color: #2196F3; // 主题色(链接、当前模型标题等)
|
| 63 |
--text-disabled: #999; // 禁用态文本
|
| 64 |
--text-area-bg: #fff; // 文本显示区域背景色
|
| 65 |
+
--textarea-actions-button-row-min-height: 3.5rem; // textarea 下「按钮 + stats」行最小高度(analysis / chat / attribution / gen_attribute)
|
| 66 |
--minimap-width: 8px; // minimap 宽度
|
| 67 |
--tooltip-text-normal: #333; // tooltip普通文本颜色
|
| 68 |
--tooltip-text-selected: #933; // tooltip选中文本颜色
|
|
|
|
| 206 |
justify-content: space-between; // Analyze在左,Save在右
|
| 207 |
align-items: center;
|
| 208 |
width: 100%;
|
| 209 |
+
min-height: var(--textarea-actions-button-row-min-height);
|
| 210 |
margin-top: 5px;
|
| 211 |
gap: 10px;
|
| 212 |
box-sizing: border-box; // 确保宽度计算正确
|
client/src/gen_attribute.html
CHANGED
|
@@ -225,7 +225,7 @@
|
|
| 225 |
<span class="semantic-submode-label">px</span>
|
| 226 |
</span>
|
| 227 |
</div>
|
| 228 |
-
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
| 229 |
<span class="semantic-submode-group">
|
| 230 |
<label class="semantic-submode-label">
|
| 231 |
<input type="checkbox" id="gen_attr_dag_node_ci_visual_scale" checked
|
|
@@ -234,8 +234,6 @@
|
|
| 234 |
Enlarge high-surprisal nodes
|
| 235 |
</label>
|
| 236 |
</span>
|
| 237 |
-
</div>
|
| 238 |
-
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
| 239 |
<span class="semantic-submode-group">
|
| 240 |
<label class="semantic-submode-label">
|
| 241 |
<input type="checkbox" id="gen_attr_dag_edge_weaken_high_surprisal" checked
|
|
@@ -246,6 +244,13 @@
|
|
| 246 |
</span>
|
| 247 |
</div>
|
| 248 |
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
<span class="semantic-submode-group">
|
| 250 |
<label class="semantic-submode-label">
|
| 251 |
<input type="checkbox" id="gen_attr_dag_hide_inactive_edges"
|
|
@@ -255,15 +260,6 @@
|
|
| 255 |
</label>
|
| 256 |
</span>
|
| 257 |
</div>
|
| 258 |
-
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
| 259 |
-
<span class="semantic-submode-group">
|
| 260 |
-
<label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Edge top-p coverage</label>
|
| 261 |
-
<input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
|
| 262 |
-
value="0.7" min="0.05" max="1" step="0.05"
|
| 263 |
-
title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
|
| 264 |
-
data-i18n="title">
|
| 265 |
-
</span>
|
| 266 |
-
</div>
|
| 267 |
<div class="attribution-exclude-prompt-patterns-row">
|
| 268 |
<div class="semantic-submode-row attribution-exclude-prompt-patterns-header">
|
| 269 |
<span class="semantic-submode-group">
|
|
|
|
| 225 |
<span class="semantic-submode-label">px</span>
|
| 226 |
</span>
|
| 227 |
</div>
|
| 228 |
+
<div class="gen-attr-dag-measure-width-row gen-attr-dag-surprisal-toggles-row semantic-submode-row">
|
| 229 |
<span class="semantic-submode-group">
|
| 230 |
<label class="semantic-submode-label">
|
| 231 |
<input type="checkbox" id="gen_attr_dag_node_ci_visual_scale" checked
|
|
|
|
| 234 |
Enlarge high-surprisal nodes
|
| 235 |
</label>
|
| 236 |
</span>
|
|
|
|
|
|
|
| 237 |
<span class="semantic-submode-group">
|
| 238 |
<label class="semantic-submode-label">
|
| 239 |
<input type="checkbox" id="gen_attr_dag_edge_weaken_high_surprisal" checked
|
|
|
|
| 244 |
</span>
|
| 245 |
</div>
|
| 246 |
<div class="gen-attr-dag-measure-width-row semantic-submode-row">
|
| 247 |
+
<span class="semantic-submode-group">
|
| 248 |
+
<label class="semantic-submode-label" for="gen_attr_dag_edge_top_p_coverage" data-i18n>Edge top-p coverage</label>
|
| 249 |
+
<input type="number" id="gen_attr_dag_edge_top_p_coverage" class="gen-attr-dag-measure-width-input"
|
| 250 |
+
value="0.7" min="0.05" max="1" step="0.05"
|
| 251 |
+
title="Coverage is the cumulative mass share within each generation step's Top-N candidate pool (after sorting candidates into the pool and normalizing mass inside that pool). Higher values keep more incoming edges. The denominator is this pool only, not every token-attribution entry returned for the step."
|
| 252 |
+
data-i18n="title">
|
| 253 |
+
</span>
|
| 254 |
<span class="semantic-submode-group">
|
| 255 |
<label class="semantic-submode-label">
|
| 256 |
<input type="checkbox" id="gen_attr_dag_hide_inactive_edges"
|
|
|
|
| 260 |
</label>
|
| 261 |
</span>
|
| 262 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
<div class="attribution-exclude-prompt-patterns-row">
|
| 264 |
<div class="semantic-submode-row attribution-exclude-prompt-patterns-header">
|
| 265 |
<span class="semantic-submode-group">
|
client/src/ts/api/GLTR_API.ts
CHANGED
|
@@ -253,12 +253,27 @@ export class TextAnalysisAPI {
|
|
| 253 |
api?: Record<string, number>,
|
| 254 |
os?: Record<string, number>,
|
| 255 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
}> {
|
| 257 |
return d3.json(this.baseURL + '/api/visit_stats', {
|
| 258 |
headers: this.getHeaders()
|
| 259 |
});
|
| 260 |
}
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
/**
|
| 263 |
* 获取可用模型列表
|
| 264 |
*/
|
|
|
|
| 253 |
api?: Record<string, number>,
|
| 254 |
os?: Record<string, number>,
|
| 255 |
},
|
| 256 |
+
reset_base?: {
|
| 257 |
+
page_loads?: number,
|
| 258 |
+
active_visits?: number,
|
| 259 |
+
page_sec?: Record<string, number>,
|
| 260 |
+
api?: Record<string, number>,
|
| 261 |
+
os?: Record<string, number>,
|
| 262 |
+
},
|
| 263 |
+
reset_at?: string | null,
|
| 264 |
}> {
|
| 265 |
return d3.json(this.baseURL + '/api/visit_stats', {
|
| 266 |
headers: this.getHeaders()
|
| 267 |
});
|
| 268 |
}
|
| 269 |
|
| 270 |
+
public resetVisitStats(): Promise<{ success: boolean, error?: string }> {
|
| 271 |
+
return d3.json(this.baseURL + '/api/visit_stats/reset', {
|
| 272 |
+
method: 'POST',
|
| 273 |
+
headers: this.getHeaders(),
|
| 274 |
+
});
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
/**
|
| 278 |
* 获取可用模型列表
|
| 279 |
*/
|
client/src/ts/utils/settingsMenuManager.ts
CHANGED
|
@@ -408,7 +408,8 @@ export class SettingsMenuManager {
|
|
| 408 |
const GREEN = '#22c55e';
|
| 409 |
const g = (s: string) => `<span style="color:${GREEN}">${s}</span>`;
|
| 410 |
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| 411 |
-
|
|
|
|
| 412 |
|
| 413 |
const deltaSuffix = (d: number) => d !== 0 ? ` ${g(`(${d > 0 ? '+' : ''}${d})`)}` : '';
|
| 414 |
const t = data.totals;
|
|
@@ -426,10 +427,10 @@ export class SettingsMenuManager {
|
|
| 426 |
};
|
| 427 |
|
| 428 |
return [
|
| 429 |
-
`
|
| 430 |
`Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`,
|
| 431 |
'',
|
| 432 |
-
`[All-time (${g('+ delta since
|
| 433 |
`Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
|
| 434 |
`Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
|
| 435 |
'',
|
|
@@ -472,8 +473,32 @@ export class SettingsMenuManager {
|
|
| 472 |
title: 'Visit Stats',
|
| 473 |
content: (dialog) => {
|
| 474 |
const wrap = dialog.append('div').attr('class', 'dialog-form-container');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
const body = wrap.append('div');
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
.attr('type', 'button')
|
| 478 |
.attr('class', 'refresh-btn')
|
| 479 |
.attr('title', 'Refresh')
|
|
|
|
| 408 |
const GREEN = '#22c55e';
|
| 409 |
const g = (s: string) => `<span style="color:${GREEN}">${s}</span>`;
|
| 410 |
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| 411 |
+
// 优先用 reset_base,否则回退到 startup_base
|
| 412 |
+
const sb = (Object.keys(data.reset_base ?? {}).length > 0 ? data.reset_base : data.startup_base) ?? {};
|
| 413 |
|
| 414 |
const deltaSuffix = (d: number) => d !== 0 ? ` ${g(`(${d > 0 ? '+' : ''}${d})`)}` : '';
|
| 415 |
const t = data.totals;
|
|
|
|
| 427 |
};
|
| 428 |
|
| 429 |
return [
|
| 430 |
+
`Last delta reset: ${esc(data.reset_at ? new Date(data.reset_at).toLocaleString() : 'unknown')}`,
|
| 431 |
`Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`,
|
| 432 |
'',
|
| 433 |
+
`[All-time (${g('+ delta since reset')})]`,
|
| 434 |
`Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
|
| 435 |
`Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
|
| 436 |
'',
|
|
|
|
| 473 |
title: 'Visit Stats',
|
| 474 |
content: (dialog) => {
|
| 475 |
const wrap = dialog.append('div').attr('class', 'dialog-form-container');
|
| 476 |
+
const headerRow = wrap.append('div')
|
| 477 |
+
.style('display', 'flex')
|
| 478 |
+
.style('justify-content', 'flex-end')
|
| 479 |
+
.style('align-items', 'center')
|
| 480 |
+
.style('gap', '6px')
|
| 481 |
+
.style('margin-bottom', '6px');
|
| 482 |
const body = wrap.append('div');
|
| 483 |
+
headerRow.append('button')
|
| 484 |
+
.attr('type', 'button')
|
| 485 |
+
.attr('class', 'refresh-btn')
|
| 486 |
+
.style('font-size', '13px')
|
| 487 |
+
.attr('title', 'Persist current increments then reset delta base')
|
| 488 |
+
.text('Persist and reset delta')
|
| 489 |
+
.on('click', async function () {
|
| 490 |
+
const btn = d3.select(this);
|
| 491 |
+
btn.property('disabled', true).text('…');
|
| 492 |
+
try {
|
| 493 |
+
const res = await this.api.resetVisitStats();
|
| 494 |
+
if (!res?.success) throw new Error(res?.error ?? 'failed');
|
| 495 |
+
await fetchAndRender(body);
|
| 496 |
+
} catch (e) {
|
| 497 |
+
alert(`Reset failed: ${e}`);
|
| 498 |
+
}
|
| 499 |
+
btn.property('disabled', false).text('Persist and reset delta');
|
| 500 |
+
}.bind(this));
|
| 501 |
+
headerRow.append('button')
|
| 502 |
.attr('type', 'button')
|
| 503 |
.attr('class', 'refresh-btn')
|
| 504 |
.attr('title', 'Refresh')
|
client/src/ts/utils/textMetricsUpdater.ts
CHANGED
|
@@ -9,19 +9,16 @@ export type ApiTokenUsage = {
|
|
| 9 |
total_tokens?: number;
|
| 10 |
};
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
function formatApiUsageLine(usage: ApiTokenUsage | null | undefined): string | null {
|
| 13 |
if (!usage) return null;
|
| 14 |
-
const
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
}
|
| 18 |
-
if (typeof usage.completion_tokens === 'number' && Number.isFinite(usage.completion_tokens)) {
|
| 19 |
-
parts.push(`completion ${usage.completion_tokens} tokens`);
|
| 20 |
-
}
|
| 21 |
-
if (typeof usage.total_tokens === 'number' && Number.isFinite(usage.total_tokens)) {
|
| 22 |
-
parts.push(`total ${usage.total_tokens} tokens`);
|
| 23 |
-
}
|
| 24 |
-
return parts.length > 0 ? parts.join('<br/>') : null;
|
| 25 |
}
|
| 26 |
|
| 27 |
/** 仅展示后端返回的 usage(如 Chat 页,无 bytes/chars/tokens/surprisal) */
|
|
|
|
| 9 |
total_tokens?: number;
|
| 10 |
};
|
| 11 |
|
| 12 |
+
function usageTokenLabel(n: unknown): string {
|
| 13 |
+
return typeof n === 'number' && Number.isFinite(n) ? String(n) : 'unknown';
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
function formatApiUsageLine(usage: ApiTokenUsage | null | undefined): string | null {
|
| 17 |
if (!usage) return null;
|
| 18 |
+
const total = usageTokenLabel(usage.total_tokens);
|
| 19 |
+
const p = usageTokenLabel(usage.prompt_tokens);
|
| 20 |
+
const c = usageTokenLabel(usage.completion_tokens);
|
| 21 |
+
return `${total} tokens<br/>prompt | completion = ${p} | ${c} tokens`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
/** 仅展示后端返回的 usage(如 Chat 页,无 bytes/chars/tokens/surprisal) */
|
server.py
CHANGED
|
@@ -44,7 +44,7 @@ from backend.api.model_switch import ( # noqa: F401
|
|
| 44 |
get_current_model,
|
| 45 |
switch_model,
|
| 46 |
)
|
| 47 |
-
from backend.api.visit_stats_api import get_visit_stats # noqa: F401
|
| 48 |
from backend.api.openai_completions import ( # noqa: F401
|
| 49 |
completions,
|
| 50 |
completions_prompt,
|
|
|
|
| 44 |
get_current_model,
|
| 45 |
switch_model,
|
| 46 |
)
|
| 47 |
+
from backend.api.visit_stats_api import get_visit_stats, post_visit_stats_reset # noqa: F401
|
| 48 |
from backend.api.openai_completions import ( # noqa: F401
|
| 49 |
completions,
|
| 50 |
completions_prompt,
|
server.yaml
CHANGED
|
@@ -855,6 +855,38 @@ paths:
|
|
| 855 |
type: string
|
| 856 |
description: |
|
| 857 |
统计线程完成 _load_base 并拍下 startup_base 时的 UTC 时间(与 saved_at 相同格式)。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
|
| 859 |
definitions:
|
| 860 |
# 与 client generatedSchemas TokenWithOffset 一致;信息密度 analyze 与 completions info_radar 共用
|
|
|
|
| 855 |
type: string
|
| 856 |
description: |
|
| 857 |
统计线程完成 _load_base 并拍下 startup_base 时的 UTC 时间(与 saved_at 相同格式)。
|
| 858 |
+
reset_base:
|
| 859 |
+
type: object
|
| 860 |
+
description: |
|
| 861 |
+
最近一次手动 reset 时的全量快照;前端用 totals − reset_base 计算自 reset 以来的增量。
|
| 862 |
+
reset_at:
|
| 863 |
+
type: string
|
| 864 |
+
description: |
|
| 865 |
+
最近一次手动 reset 的 UTC 时间;从未 reset 时为 null。
|
| 866 |
+
|
| 867 |
+
/visit_stats/reset:
|
| 868 |
+
post:
|
| 869 |
+
tags:
|
| 870 |
+
- all
|
| 871 |
+
summary: reset delta base (admin only)
|
| 872 |
+
operationId: server.post_visit_stats_reset
|
| 873 |
+
responses:
|
| 874 |
+
200:
|
| 875 |
+
description: reset base saved
|
| 876 |
+
schema:
|
| 877 |
+
type: object
|
| 878 |
+
properties:
|
| 879 |
+
success:
|
| 880 |
+
type: boolean
|
| 881 |
+
500:
|
| 882 |
+
description: persist failed
|
| 883 |
+
schema:
|
| 884 |
+
type: object
|
| 885 |
+
properties:
|
| 886 |
+
success:
|
| 887 |
+
type: boolean
|
| 888 |
+
error:
|
| 889 |
+
type: string
|
| 890 |
|
| 891 |
definitions:
|
| 892 |
# 与 client generatedSchemas TokenWithOffset 一致;信息密度 analyze 与 completions info_radar 共用
|