dqy08 commited on
Commit
1c34af9
·
1 Parent(s): 21b5186

新增访问统计重置功能,UI和样式优化。

Browse files
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
- // Raw prompt mode 行在输入区上方,与下方面板留出间距
9
  .input-section > .chat-raw-prompt-mode-row {
10
- margin-bottom: 8px;
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
- // Teacher forcing:与同页 Chat 模板的相邻面板间距(12px,见 chat.scss `#chat_input_panel`)对齐;容器默认 gap 仅 4px 会显得挤。
367
  &:has(.gen-attr-teacher-forcing-toggle-row) {
368
- gap: 12px;
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
- .gen-attr-dag-measure-width-row .gen-attr-dag-layout-mode-group {
430
- margin-right: 12px;
 
 
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
411
- const sb = data.startup_base ?? {};
 
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
- `Process start: ${esc(data.process_start_at ? new Date(data.process_start_at).toLocaleString() : 'unknown')}`,
430
  `Last persisted: ${esc(data.saved_at ? new Date(data.saved_at).toLocaleString() : 'unknown')}`,
431
  '',
432
- `[All-time (${g('+ delta since process start')})]`,
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
- wrap.append('button')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 parts: string[] = [];
15
- if (typeof usage.prompt_tokens === 'number' && Number.isFinite(usage.prompt_tokens)) {
16
- parts.push(`prompt ${usage.prompt_tokens} tokens`);
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 共用