Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ TokenMeter is a lightweight Windows desktop monitor for token usage, API costs,

| Light theme | Dark theme |
| --- | --- |
| ![TokenMeter light theme](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter dark theme](docs/images/token-spider-ui-v3-dark.png) |
| ![TokenMeter light theme](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter dark theme](docs/images/tokenmeter-v1.11.2-dark.png) |

## Requirements

Expand Down Expand Up @@ -137,7 +137,7 @@ TokenMeter/

## Version and releases

Current version: `1.11.1`. See [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) for change notes and checksums.
Current version: `1.11.2`. See [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) for change notes and checksums.

## License

Expand Down
4 changes: 2 additions & 2 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ TokenMeter は、DeepSeek と Xiaomi MiMo の Token 消費量、API コスト、

| ライトテーマ | ダークテーマ |
| --- | --- |
| ![TokenMeter ライトテーマ](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter ダークテーマ](docs/images/token-spider-ui-v3-dark.png) |
| ![TokenMeter ライトテーマ](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter ダークテーマ](docs/images/tokenmeter-v1.11.2-dark.png) |

## 動作要件

Expand Down Expand Up @@ -129,7 +129,7 @@ TokenMeter/

## バージョンと Release

現在のバージョン:`1.11.1`。変更履歴とチェックサムは [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) を参照してください。
現在のバージョン:`1.11.2`。変更履歴とチェックサムは [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases) を参照してください。

## License

Expand Down
4 changes: 2 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ TokenMeter는 DeepSeek와 Xiaomi MiMo의 Token 소비량, API 비용, 계정 잔

| 라이트 테마 | 다크 테마 |
| --- | --- |
| ![TokenMeter 라이트 테마](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 다크 테마](docs/images/token-spider-ui-v3-dark.png) |
| ![TokenMeter 라이트 테마](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 다크 테마](docs/images/tokenmeter-v1.11.2-dark.png) |

## 시스템 요구 사항

Expand Down Expand Up @@ -129,7 +129,7 @@ TokenMeter/

## 버전 및 Release

현재 버전: `1.11.1`. 변경 사항과 체크섬은 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)에서 확인하세요.
현재 버전: `1.11.2`. 변경 사항과 체크섬은 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)에서 확인하세요.

## License

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,16 @@ TokenMeter 是一款轻量级 Windows 桌面 AI 用量监控工具。它以类
- Codex 按接口返回的窗口时长展示当前周额度与重置时间,右侧显示近 7 天 Token 使用量;同时保留年度活动、累计/峰值 Token、按单个任务计算的最长聊天和连续使用天数。
- Codex 默认读取本机 CLI 目录;非默认位置通过只读目录选择器设置,并兼容旧版已保存的 `auth.json` 文件路径。
- DeepSeek 支持峰谷计价提示;MiMo Cookie 可通过专用 Chrome 会话获取和续期。
- 网络异常时保留最近成功数据;历史数据缓存在本地 SQLite。
- 网络异常时保留最近一次成功额度,并继续更新本地 Token 活动;本地统计不会覆盖远程额度更新时间。
- 历史数据缓存在本地 SQLite;自动更新前备份 `usage.db`,分时数据按设置天数的 2 倍保护期清理并记录清理明细。
- API Key、Bearer Token 和 Cookie 保存到 Windows 凭据管理器。
- 支持迁移应用数据目录、自动更新及单实例运行。

## 界面截图

| 浅色主题 | 深色主题 |
| --- | --- |
| ![TokenMeter 浅色主题](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主题](docs/images/token-spider-ui-v3-dark.png) |
| ![TokenMeter 浅色主题](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主题](docs/images/tokenmeter-v1.11.2-dark.png) |

## 系统要求

Expand Down Expand Up @@ -151,7 +152,7 @@ TokenMeter/

## 版本与 Release

当前版本:`1.11.1`。更新记录及校验文件见 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。
当前版本:`1.11.2`。更新记录及校验文件见 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。

2.0 的 Provider 能力边界、分批计划和已执行基础见 [TokenMeter 2.0 路线图](docs/V2_ROADMAP.md)。

Expand Down
4 changes: 2 additions & 2 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ TokenMeter 是輕量級 Windows 桌面 AI Token 用量監控工具,用於查

| 淺色主題 | 深色主題 |
| --- | --- |
| ![TokenMeter 淺色主題](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主題](docs/images/token-spider-ui-v3-dark.png) |
| ![TokenMeter 淺色主題](docs/images/token-spider-ui-v3-light.png) | ![TokenMeter 深色主題](docs/images/tokenmeter-v1.11.2-dark.png) |

## 系統需求

Expand Down Expand Up @@ -129,7 +129,7 @@ TokenMeter/

## 版本與 Release

目前版本:`1.11.1`。更新說明與校驗檔請見 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。
目前版本:`1.11.2`。更新說明與校驗檔請見 [GitHub Releases](https://github.com/zensoku142/TokenMeter/releases)。

## License

Expand Down
2 changes: 1 addition & 1 deletion app_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

APP_DISPLAY_NAME = "TokenMeter"
APP_STORAGE_NAME = "TokenSpider"
APP_VERSION = "1.11.1"
APP_VERSION = "1.11.2"

# Keep the legacy storage and mutex identities so upgrades retain user data,
# credentials, and single-instance coordination across every public rename.
Expand Down
9 changes: 9 additions & 0 deletions app_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,15 @@ def launch_installer(bundle: DownloadBundle) -> None:
cleanup_paths = [str(cache_dir.relative_to(updates_root))]
except ValueError as exc:
raise UpdateError("更新缓存目录不在允许的清理范围内") from exc
try:
from data import history

backup_path = history.backup_usage_database(bundle.release.version)
except Exception as exc:
config_manager.logger().exception("Pre-update usage database backup failed")
raise UpdateError("更新前备份本地 usage.db 失败,已取消更新") from exc
if backup_path is not None:
config_manager.logger().info("Pre-update usage database backup: %s", backup_path)
config_manager.save_pending_update_cleanup(
{
"version": MANIFEST_VERSION,
Expand Down
130 changes: 95 additions & 35 deletions data/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
from __future__ import annotations

import sqlite3
from contextlib import contextmanager
from contextlib import closing, contextmanager
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any, Iterator

import config_manager

DB_PATH = config_manager.CONFIG_DIR / "usage.db"
MINUTE_USAGE_CLEANUP_GRACE_MULTIPLIER = 2

MINUTE_TOKEN_TYPES = (
"PROMPT_CACHE_HIT_TOKEN",
Expand Down Expand Up @@ -129,6 +131,40 @@ def _connect() -> Iterator[sqlite3.Connection]:
connection.close()


def backup_usage_database(
release_version: str, observed_at: datetime | None = None
) -> Path | None:
"""Create a consistent pre-update SQLite snapshot outside the update cache."""

source_path = DB_PATH.resolve(strict=False)
if not source_path.is_file():
return None
safe_version = "".join(
character if character.isalnum() or character in {".", "-", "_"} else "_"
for character in str(release_version).strip()
) or "unknown"
timestamp = (observed_at or datetime.now()).strftime("%Y%m%d%H%M%S%f")
backup_dir = source_path.parent / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"usage-before-update-v{safe_version}-{timestamp}.db"
temporary_path = backup_path.with_suffix(backup_path.suffix + ".tmp")
source_uri = source_path.as_uri() + "?mode=ro"
try:
with (
closing(sqlite3.connect(source_uri, uri=True, timeout=5)) as source,
closing(sqlite3.connect(temporary_path, timeout=5)) as target,
):
source.backup(target)
result = target.execute("PRAGMA quick_check").fetchone()
if not result or result[0] != "ok":
raise sqlite3.DatabaseError("usage.db backup integrity check failed")
temporary_path.replace(backup_path)
except Exception:
temporary_path.unlink(missing_ok=True)
raise
return backup_path


def needs_initial_sync(provider: str = "deepseek") -> bool:
with _connect() as connection:
row = connection.execute(
Expand Down Expand Up @@ -422,28 +458,63 @@ def _minute_usage_retention_threshold(current_day: date, retention_days: int) ->
return (current_day - timedelta(days=retention_days - 1)).isoformat()


def _delete_expired_minute_usage(
connection: sqlite3.Connection,
provider: str,
current_day: date,
retention_days: int,
) -> tuple[str, dict[str, int]]:
effective_days = retention_days * MINUTE_USAGE_CLEANUP_GRACE_MULTIPLIER
threshold = _minute_usage_retention_threshold(current_day, effective_days)
deleted_rows: dict[str, int] = {}
for table in (
"minute_usage",
"minute_usage_snapshot",
"minute_cost_usage",
"minute_cost_snapshot",
):
cursor = connection.execute(
f"DELETE FROM {table} WHERE provider = ? AND usage_date < ?",
(provider, threshold),
)
deleted_rows[table] = max(0, int(cursor.rowcount))
return threshold, deleted_rows


def _log_minute_usage_cleanup(
provider: str,
retention_days: int,
threshold: str,
deleted_rows: dict[str, int],
) -> None:
total_deleted = sum(deleted_rows.values())
if not total_deleted:
return
details = ", ".join(
f"{table}={count}" for table, count in deleted_rows.items()
)
config_manager.logger().info(
"Minute usage cleanup: provider=%s cutoff=%s configured_days=%s "
"effective_days=%s deleted_rows=%s (%s)",
provider,
threshold,
retention_days,
retention_days * MINUTE_USAGE_CLEANUP_GRACE_MULTIPLIER,
total_deleted,
details,
)


def clear_expired_minute_usage(
provider: str, current_day: date, retention_days: int = 3
) -> None:
"""删除指定提供商超过保留天数的临时分时缓存。"""
threshold = _minute_usage_retention_threshold(current_day, retention_days)
) -> int:
"""超过配置天数的双倍宽限期后,删除指定提供商的分时缓存。"""
with _connect() as connection:
connection.execute(
"DELETE FROM minute_usage WHERE provider = ? AND usage_date < ?",
(provider, threshold),
)
connection.execute(
"DELETE FROM minute_usage_snapshot WHERE provider = ? AND usage_date < ?",
(provider, threshold),
)
connection.execute(
"DELETE FROM minute_cost_usage WHERE provider = ? AND usage_date < ?",
(provider, threshold),
)
connection.execute(
"DELETE FROM minute_cost_snapshot WHERE provider = ? AND usage_date < ?",
(provider, threshold),
threshold, deleted_rows = _delete_expired_minute_usage(
connection, provider, current_day, retention_days
)
_log_minute_usage_cleanup(provider, retention_days, threshold, deleted_rows)
return sum(deleted_rows.values())


def save_estimated_minute_usage(
Expand Down Expand Up @@ -474,22 +545,8 @@ def save_estimated_minute_usage(
if not cost_cny.is_finite():
cost_cny = None
with _connect() as connection:
threshold = _minute_usage_retention_threshold(usage_day, retention_days)
connection.execute(
"DELETE FROM minute_usage WHERE provider = ? AND usage_date < ?",
(provider, threshold),
)
connection.execute(
"DELETE FROM minute_usage_snapshot WHERE provider = ? AND usage_date < ?",
(provider, threshold),
)
connection.execute(
"DELETE FROM minute_cost_usage WHERE provider = ? AND usage_date < ?",
(provider, threshold),
)
connection.execute(
"DELETE FROM minute_cost_snapshot WHERE provider = ? AND usage_date < ?",
(provider, threshold),
cleanup_threshold, deleted_rows = _delete_expired_minute_usage(
connection, provider, usage_day, retention_days
)
previous, previous_at = _snapshot_rows(connection, provider, usage_date)
if previous_at is None:
Expand Down Expand Up @@ -535,6 +592,9 @@ def save_estimated_minute_usage(
_save_estimated_minute_cost_usage(
connection, provider, usage_date, cost_cny, observed_at
)
_log_minute_usage_cleanup(
provider, retention_days, cleanup_threshold, deleted_rows
)
return status


Expand Down
Loading