diff --git a/wsai/dashboard.py b/wsai/dashboard.py index 038b86a..59c816e 100644 --- a/wsai/dashboard.py +++ b/wsai/dashboard.py @@ -726,7 +726,8 @@ PAGE = r"""
0대화 수
0오류
-
0클로드 토큰
+
0오늘 토큰
+
0주간 토큰
0초가동시간
·연결
@@ -822,10 +823,14 @@ function renderStatus(s){ $('s-turns').textContent = s.turns_total ?? 0; $('s-errors').textContent = s.errors_total ?? 0; $('s-up').textContent = fmtUptime(s.uptime_s); - const ci=s.claude_input_tokens||0, co=s.claude_output_tokens||0, cr=s.claude_requests||0; - const cel=$('s-claude'); - if(cel){ cel.textContent = fmtNum(ci+co); - cel.parentElement.title = '클로드 사용량 (서버 시작 후) — 입력 '+ci.toLocaleString()+' · 출력 '+co.toLocaleString()+' 토큰 · 요청 '+cr+'회'; } + const cu = s.claude_usage || {today:{input:0,output:0,requests:0}, week:{input:0,output:0,requests:0}}; + const td=cu.today||{}, wk=cu.week||{}; + const te=$('s-claude-today'); + if(te){ te.textContent = fmtNum((td.input||0)+(td.output||0)); + te.parentElement.title = '오늘 클로드 — 입력 '+(td.input||0).toLocaleString()+' · 출력 '+(td.output||0).toLocaleString()+' 토큰 · 요청 '+(td.requests||0)+'회'; } + const we=$('s-claude-week'); + if(we){ we.textContent = fmtNum((wk.input||0)+(wk.output||0)); + we.parentElement.title = '최근 7일 클로드 — 입력 '+(wk.input||0).toLocaleString()+' · 출력 '+(wk.output||0).toLocaleString()+' 토큰 · 요청 '+(wk.requests||0)+'회'; } const listening = s.listening; $('dot').className = 'dot ' + (listening ? 'live' : 'off'); $('listen').textContent = listening ? '듣는 중' : (s.running ? '실행 중 (대기)' : '중지됨'); diff --git a/wsai/monitor.py b/wsai/monitor.py index 23cac2d..8eeebdb 100644 --- a/wsai/monitor.py +++ b/wsai/monitor.py @@ -194,10 +194,15 @@ class Monitor: with self._lock: s = dict(self._status) s["uptime_s"] = round(_now_wall() - s["started_wall"], 1) + from . import usage_store + s["claude_usage"] = usage_store.summary() # 오늘 / 최근 7일 토큰 return s def add_claude_usage(self, input_tokens: int, output_tokens: int) -> None: - """Accumulate one Claude call's token usage for the dashboard.""" + """Record one Claude call's token usage (session counters + persisted + per-day store for the dashboard's 하루/일주일 사용량).""" + from . import usage_store + usage_store.record(input_tokens, output_tokens) with self._lock: self._status["claude_requests"] += 1 self._status["claude_input_tokens"] += int(input_tokens or 0) diff --git a/wsai/usage_store.py b/wsai/usage_store.py new file mode 100644 index 0000000..7eec622 --- /dev/null +++ b/wsai/usage_store.py @@ -0,0 +1,79 @@ +"""Persisted per-day Claude token usage, for the dashboard's 하루/일주일 사용량. + +Each brain call records its token usage into a date bucket +(``{ "2026-08-22": {"req": n, "in": n, "out": n}, ... }``) persisted to disk so +the totals survive a restart. ``summary()`` returns today's and the last-7-days +aggregates. Kept in memory; the file is read once and rewritten on each record. + +Path: ``$WSAI_USAGE_PATH`` or ``~/.config/wsai/usage.json`` (disk, never tmpfs). +""" + +from __future__ import annotations + +import json +import os +import threading +from datetime import date, timedelta +from pathlib import Path + +_LOCK = threading.Lock() +_DATA: dict[str, dict[str, int]] | None = None +_KEEP_DAYS = 90 + + +def _path() -> Path: + override = os.environ.get("WSAI_USAGE_PATH") + return Path(override) if override else (Path.home() / ".config" / "wsai" / "usage.json") + + +def _load() -> dict[str, dict[str, int]]: + global _DATA + if _DATA is not None: + return _DATA + try: + _DATA = json.loads(_path().read_text(encoding="utf-8")) + assert isinstance(_DATA, dict) + except (OSError, ValueError, AssertionError): + _DATA = {} + return _DATA + + +def _write() -> None: + p = _path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(_DATA, ensure_ascii=False), encoding="utf-8") + + +def record(input_tokens: int, output_tokens: int) -> None: + """Add one call's usage to today's bucket and persist.""" + today = date.today().isoformat() + with _LOCK: + data = _load() + b = data.setdefault(today, {"req": 0, "in": 0, "out": 0}) + b["req"] += 1 + b["in"] += int(input_tokens or 0) + b["out"] += int(output_tokens or 0) + # Prune buckets older than _KEEP_DAYS so the file can't grow forever. + cutoff = (date.today() - timedelta(days=_KEEP_DAYS)).isoformat() + for d in [d for d in data if d < cutoff]: + del data[d] + _write() + + +def summary() -> dict: + """Today's and the last-7-days token totals.""" + with _LOCK: + data = _load() + today = date.today().isoformat() + week_start = (date.today() - timedelta(days=6)).isoformat() + t = data.get(today, {"req": 0, "in": 0, "out": 0}) + wk = {"req": 0, "in": 0, "out": 0} + for d, b in data.items(): + if d >= week_start: + wk["req"] += b.get("req", 0) + wk["in"] += b.get("in", 0) + wk["out"] += b.get("out", 0) + return { + "today": {"requests": t.get("req", 0), "input": t.get("in", 0), "output": t.get("out", 0)}, + "week": {"requests": wk["req"], "input": wk["in"], "output": wk["out"]}, + }