From 911a20a91a5b45f1489b6f815ec780a2b1c98f91 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 18 Sep 2026 16:45:30 +0800 Subject: [PATCH] feat(usage): add configurable database size limit --- src-tauri/src/main.rs | 2 + src-tauri/src/usage.rs | 430 +++++++++++++++++++++++++++++---- src/i18n/ja.ts | 13 +- src/i18n/locales/en.ts | 13 +- src/i18n/locales/zh-CN.ts | 13 +- src/pages/UsageRecordsPage.tsx | 111 ++++++++- src/styles.css | 91 +++++-- 7 files changed, 608 insertions(+), 65 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0006e0f..e841707 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2648,7 +2648,9 @@ fn main() { usage::get_usage_analysis, usage::get_usage_events, usage::get_usage_pricing, + usage::get_usage_storage_settings, usage::repair_usage_cache_records, + usage::save_usage_storage_settings, usage::save_usage_model_price, usage::delete_usage_model_price, usage::sync_usage_model_prices, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 6c00335..6fc2125 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -44,6 +44,7 @@ const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1"; const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3"; const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4"; const USAGE_EVENT_KEY_MIGRATION_KEY: &str = "event_key_v5"; +const USAGE_DATABASE_MAX_MB_KEY: &str = "max_database_size_mb"; const USAGE_UPDATED_EVENT: &str = "usage-records-updated"; const USAGE_SCHEMA_VERSION: u8 = 1; const USAGE_DATABASE_SCHEMA_VERSION: i64 = 5; @@ -56,6 +57,7 @@ const USAGE_QUEUE_KEY: &str = "usage"; const LEGACY_USAGE_QUEUE_KEY: &str = "queue"; const HTTP_USAGE_QUEUE_SOURCE: &str = "http_pull:usage_queue"; const SQLITE_BUSY_TIMEOUT_SECONDS: u64 = 5; +const BYTES_PER_MB: u64 = 1024 * 1024; const TOKENS_PER_PRICE_UNIT: f64 = 1_000_000.0; const LONG_CONTEXT_INPUT_TOKEN_THRESHOLD: u64 = 272_000; const BUNDLED_MODEL_PRICE_CATALOG: &str = include_str!("../resources/model_prices.json"); @@ -105,6 +107,19 @@ struct UsageInboxRow { attempt_count: i64, } +#[derive(Clone, Copy, Default)] +struct UsagePersistOutcome { + inserted: usize, + deleted: u64, +} + +impl UsagePersistOutcome { + fn add(&mut self, other: Self) { + self.inserted = self.inserted.saturating_add(other.inserted); + self.deleted = self.deleted.saturating_add(other.deleted); + } +} + #[derive(Debug, Eq, PartialEq)] struct UsageMigrationSnapshot { records: i64, @@ -305,6 +320,15 @@ pub(crate) struct UsageRepairResult { backup_path: Option, } +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UsageStorageSettings { + max_database_size_mb: u64, + database_size_bytes: u64, + total_records: u64, + deleted_records: u64, +} + #[derive(Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ModelPrice { @@ -487,9 +511,13 @@ impl UsageCollectorState { } } - fn increment_total_records(&self, added: usize) { + fn apply_record_changes(&self, inserted: usize, deleted: u64) { if let Ok(mut inner) = self.inner.lock() { - inner.status.total_records = inner.status.total_records.saturating_add(added as u64); + inner.status.total_records = inner + .status + .total_records + .saturating_add(inserted as u64) + .saturating_sub(deleted); } } @@ -579,7 +607,8 @@ fn initialize_usage_storage_at(root: &Path) -> Result<(), String> { .map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?; migrate_usage_database(&mut connection, root)?; migrate_legacy_json_storage(&mut connection, root)?; - cleanup_usage_inbox(&connection, Local::now()) + cleanup_usage_inbox(&connection, Local::now())?; + enforce_usage_database_limit(&connection).map(|_| ()) } #[tauri::command] @@ -587,6 +616,37 @@ pub(crate) async fn repair_usage_cache_records() -> Result Result { + run_usage_task(|| { + let root = usage_root_dir()?; + let connection = open_usage_database_at(&root)?; + load_usage_storage_settings(&connection, &root, 0) + }) + .await +} + +#[tauri::command] +pub(crate) async fn save_usage_storage_settings( + app: tauri::AppHandle, + max_database_size_mb: u64, +) -> Result { + let settings = run_usage_task(move || { + let root = usage_root_dir()?; + let connection = open_usage_database_at(&root)?; + save_usage_database_limit(&connection, max_database_size_mb)?; + let deleted = enforce_usage_database_limit(&connection)?; + load_usage_storage_settings(&connection, &root, deleted) + }) + .await?; + app.state::() + .set_total_records(settings.total_records); + if settings.deleted_records > 0 { + let _ = app.emit(USAGE_UPDATED_EVENT, Local::now().to_rfc3339()); + } + Ok(settings) +} + fn repair_usage_cache_records_at(root: &Path) -> Result { let mut connection = open_usage_database_at(root)?; let candidate_count: i64 = connection @@ -1329,6 +1389,198 @@ fn open_usage_database_at(root: &Path) -> Result { Ok(connection) } +fn load_usage_database_limit(connection: &Connection) -> Result { + let value = connection + .query_row( + "SELECT value FROM usage_metadata WHERE key = ?1", + params![USAGE_DATABASE_MAX_MB_KEY], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("读取使用记录数据库大小限制失败: {error}"))?; + value + .map(|value| { + value + .parse::() + .map_err(|error| format!("使用记录数据库大小限制无效: {error}")) + }) + .transpose() + .map(|value| value.unwrap_or(0)) +} + +fn save_usage_database_limit( + connection: &Connection, + max_database_size_mb: u64, +) -> Result<(), String> { + max_database_size_mb + .checked_mul(BYTES_PER_MB) + .ok_or_else(|| "使用记录数据库大小限制过大".to_string())?; + connection + .execute( + r#"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value"#, + params![USAGE_DATABASE_MAX_MB_KEY, max_database_size_mb.to_string()], + ) + .map(|_| ()) + .map_err(|error| format!("保存使用记录数据库大小限制失败: {error}")) +} + +fn usage_database_active_bytes(connection: &Connection) -> Result { + let page_size = connection + .query_row("PRAGMA page_size", [], |row| row.get::<_, u64>(0)) + .map_err(|error| format!("读取使用记录数据库页大小失败: {error}"))?; + let page_count = connection + .query_row("PRAGMA page_count", [], |row| row.get::<_, u64>(0)) + .map_err(|error| format!("读取使用记录数据库页数失败: {error}"))?; + let free_pages = connection + .query_row("PRAGMA freelist_count", [], |row| row.get::<_, u64>(0)) + .map_err(|error| format!("读取使用记录数据库空闲页数失败: {error}"))?; + page_count + .saturating_sub(free_pages) + .checked_mul(page_size) + .ok_or_else(|| "使用记录数据库大小溢出".to_string()) +} + +fn usage_database_allocated_bytes(connection: &Connection) -> Result { + let page_size = connection + .query_row("PRAGMA page_size", [], |row| row.get::<_, u64>(0)) + .map_err(|error| format!("读取使用记录数据库页大小失败: {error}"))?; + let page_count = connection + .query_row("PRAGMA page_count", [], |row| row.get::<_, u64>(0)) + .map_err(|error| format!("读取使用记录数据库页数失败: {error}"))?; + page_count + .checked_mul(page_size) + .ok_or_else(|| "使用记录数据库大小溢出".to_string()) +} + +fn usage_database_file_bytes(database_path: &Path) -> Result { + let mut wal_path = database_path.as_os_str().to_os_string(); + wal_path.push("-wal"); + let paths = [database_path.to_path_buf(), PathBuf::from(wal_path)]; + let mut total = 0_u64; + for path in paths { + match fs::metadata(&path) { + Ok(metadata) => total = total.saturating_add(metadata.len()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取使用记录数据库文件大小失败 {}: {error}", + path.display() + )); + } + } + } + Ok(total) +} + +fn usage_database_disk_bytes(root: &Path) -> Result { + usage_database_file_bytes(&root.join(USAGE_DATABASE_FILE)) +} + +fn usage_database_connection_disk_bytes(connection: &Connection) -> Result { + let path = connection + .path() + .ok_or_else(|| "无法读取使用记录数据库文件路径".to_string())?; + usage_database_file_bytes(Path::new(path)) +} + +fn truncate_usage_database_wal(connection: &Connection) { + let _ = connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }); +} + +fn load_usage_storage_settings( + connection: &Connection, + root: &Path, + deleted_records: u64, +) -> Result { + let total_records = connection + .query_row("SELECT COUNT(*) FROM usage_events", [], |row| { + row.get::<_, u64>(0) + }) + .map_err(|error| format!("统计使用记录数量失败: {error}"))?; + Ok(UsageStorageSettings { + max_database_size_mb: load_usage_database_limit(connection)?, + database_size_bytes: usage_database_disk_bytes(root)?, + total_records, + deleted_records, + }) +} + +fn enforce_usage_database_limit(connection: &Connection) -> Result { + let max_database_size_mb = load_usage_database_limit(connection)?; + if max_database_size_mb == 0 { + return Ok(0); + } + let max_bytes = max_database_size_mb + .checked_mul(BYTES_PER_MB) + .ok_or_else(|| "使用记录数据库大小限制过大".to_string())?; + + if usage_database_connection_disk_bytes(connection)? <= max_bytes { + return Ok(0); + } + truncate_usage_database_wal(connection); + let allocated_bytes = usage_database_allocated_bytes(connection)?; + if allocated_bytes <= max_bytes { + return Ok(0); + } + + let inbox_deleted = connection + .execute( + "DELETE FROM usage_inbox WHERE status IN ('processed', 'decode_failed', 'discarded')", + [], + ) + .map_err(|error| format!("清理使用记录临时数据失败: {error}"))?; + let mut active_bytes = usage_database_active_bytes(connection)?; + let mut deleted_records = 0_u64; + + while active_bytes > max_bytes { + let total_records = connection + .query_row("SELECT COUNT(*) FROM usage_events", [], |row| { + row.get::<_, u64>(0) + }) + .map_err(|error| format!("统计待清理使用记录失败: {error}"))?; + if total_records == 0 { + break; + } + let excess = active_bytes.saturating_sub(max_bytes); + let estimated = ((total_records as u128 * excess as u128) + .saturating_add(active_bytes.saturating_sub(1) as u128) + / active_bytes.max(1) as u128) as u64; + let batch = estimated.max(1).min(total_records).min(i64::MAX as u64) as i64; + let deleted = connection + .execute( + r#"DELETE FROM usage_events + WHERE id IN ( + SELECT id FROM usage_events + ORDER BY timestamp_ms ASC, id ASC + LIMIT ?1 + )"#, + params![batch], + ) + .map_err(|error| format!("删除最旧使用记录失败: {error}"))? + as u64; + if deleted == 0 { + break; + } + deleted_records = deleted_records.saturating_add(deleted); + active_bytes = usage_database_active_bytes(connection)?; + } + + if allocated_bytes > max_bytes || inbox_deleted > 0 || deleted_records > 0 { + connection + .execute_batch("VACUUM;") + .map_err(|error| format!("回收使用记录数据库空间失败: {error}"))?; + truncate_usage_database_wal(connection); + } + Ok(deleted_records) +} + fn migrate_legacy_json_storage(connection: &mut Connection, root: &Path) -> Result<(), String> { let migrated = connection .query_row( @@ -1536,10 +1788,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { let recovered = open_usage_database_at(&root) .and_then(|mut connection| process_usage_inbox(&mut connection, &config)); match recovered { - Ok(saved) if saved > 0 => { + Ok(outcome) if outcome.inserted > 0 || outcome.deleted > 0 => { + let saved = outcome.inserted; let collected_at = Local::now().to_rfc3339(); app.state::() - .increment_total_records(saved); + .apply_record_changes(outcome.inserted, outcome.deleted); set_collector_status( &app, "collecting", @@ -1586,10 +1839,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { subscription_config = Some((config.port, config.management_secret_key.clone())); set_collector_status(&app, "collecting", "已连接 CPA usage 实时订阅", None); match backfill_usage_queue(&root, &config, &mut redis_queue).await { - Ok(saved) => { + Ok(outcome) => { + let saved = outcome.inserted; publish_collected_records( &app, - saved, + outcome, &format!("实时订阅已连接,补录 {saved} 条队列记录"), ); retry_seconds = 1; @@ -1634,10 +1888,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { payload, &config, ) { - Ok(saved) => { + Ok(outcome) => { + let saved = outcome.inserted; publish_collected_records( &app, - saved, + outcome, &format!("实时订阅已保存 {saved} 条新记录"), ); retry_seconds = 1; @@ -1692,10 +1947,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) { batch.messages, &config, ) { - Ok(saved) => { + Ok(outcome) => { + let saved = outcome.inserted; publish_collected_records( &app, - saved, + outcome, &format!("{source_label}已保存 {saved} 条新记录"), ); retry_seconds = 1; @@ -1720,27 +1976,27 @@ async fn backfill_usage_queue( root: &Path, config: &GuiConfigFile, redis_queue: &mut RedisUsageQueueSource, -) -> Result { - let mut saved_total = 0_usize; +) -> Result { + let mut outcome = UsagePersistOutcome::default(); loop { match redis_queue.pull(config).await { Ok(batch) => { let fetched = batch.messages.len(); if fetched == 0 { - return Ok(saved_total); + return Ok(outcome); } - saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source( + outcome.add(persist_raw_usage_messages_from_source( root, &batch.source, batch.messages, config, )?); if fetched < USAGE_QUEUE_BATCH_SIZE { - return Ok(saved_total); + return Ok(outcome); } } Err(redis_error) => { - return backfill_http_usage_queue(root, config, saved_total) + return backfill_http_usage_queue(root, config, outcome) .await .map_err(|http_error| { format!("补录队列失败(Redis: {redis_error}; HTTP: {http_error})") @@ -1753,31 +2009,32 @@ async fn backfill_usage_queue( async fn backfill_http_usage_queue( root: &Path, config: &GuiConfigFile, - mut saved_total: usize, -) -> Result { + mut outcome: UsagePersistOutcome, +) -> Result { loop { let messages = fetch_usage_queue_raw(config).await?; let fetched = messages.len(); if fetched == 0 { - return Ok(saved_total); + return Ok(outcome); } - saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source( + outcome.add(persist_raw_usage_messages_from_source( root, HTTP_USAGE_QUEUE_SOURCE, messages, config, )?); if fetched < USAGE_QUEUE_BATCH_SIZE { - return Ok(saved_total); + return Ok(outcome); } } } -fn publish_collected_records(app: &tauri::AppHandle, saved: usize, message: &str) { +fn publish_collected_records(app: &tauri::AppHandle, outcome: UsagePersistOutcome, message: &str) { + let saved = outcome.inserted; let collected_at = Local::now().to_rfc3339(); - if saved > 0 { + if saved > 0 || outcome.deleted > 0 { app.state::() - .increment_total_records(saved); + .apply_record_changes(saved, outcome.deleted); } set_collector_status( app, @@ -1805,9 +2062,9 @@ pub(crate) fn persist_local_usage_event( } let config = app.state::().snapshot()?; let root = usage_root_dir()?; - let saved = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?; - publish_collected_records(app, saved, "已保存桌面健康检测使用记录"); - Ok(saved) + let outcome = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?; + publish_collected_records(app, outcome, "已保存桌面健康检测使用记录"); + Ok(outcome.inserted) } async fn wait_or_cancel(token: &CancellationToken, seconds: u64) { @@ -1909,7 +2166,7 @@ fn persist_queue_items_from_source( source: &str, items: Vec, config: &GuiConfigFile, -) -> Result { +) -> Result { let mut connection = open_usage_database_at(root)?; enqueue_usage_queue_items(&mut connection, source, items)?; process_usage_inbox(&mut connection, config) @@ -2009,7 +2266,7 @@ fn persist_raw_usage_message_from_source( source: &str, raw_message: String, config: &GuiConfigFile, -) -> Result { +) -> Result { persist_raw_usage_messages_from_source(root, source, vec![raw_message], config) } @@ -2018,7 +2275,7 @@ fn persist_raw_usage_messages_from_source( source: &str, raw_messages: Vec, config: &GuiConfigFile, -) -> Result { +) -> Result { let mut connection = open_usage_database_at(root)?; enqueue_usage_raw_messages(&mut connection, source, raw_messages)?; process_usage_inbox(&mut connection, config) @@ -2027,10 +2284,10 @@ fn persist_raw_usage_messages_from_source( fn process_usage_inbox( connection: &mut Connection, config: &GuiConfigFile, -) -> Result { +) -> Result { let rows = list_processable_usage_inbox(connection, USAGE_INBOX_PROCESS_LIMIT)?; if rows.is_empty() { - return Ok(0); + return Ok(UsagePersistOutcome::default()); } let mut valid_rows = Vec::with_capacity(rows.len()); let mut records = Vec::with_capacity(rows.len()); @@ -2048,7 +2305,10 @@ fn process_usage_inbox( } } if records.is_empty() { - return Ok(0); + return Ok(UsagePersistOutcome { + inserted: 0, + deleted: enforce_usage_database_limit(connection)?, + }); } let persist_result = (|| -> Result { @@ -2077,7 +2337,10 @@ fn process_usage_inbox( Ok(inserted) })(); match persist_result { - Ok(inserted) => Ok(inserted), + Ok(inserted) => Ok(UsagePersistOutcome { + inserted, + deleted: enforce_usage_database_limit(connection)?, + }), Err(error) => { mark_usage_inbox_process_failed(connection, &valid_rows, &error)?; Err(error) @@ -4067,12 +4330,15 @@ mod tests { } #[test] - fn collector_total_records_are_cached_and_incremented() { + fn collector_total_records_apply_insertions_and_retention_deletions() { let state = UsageCollectorState::default(); state.set_total_records(40); - state.increment_total_records(2); + state.apply_record_changes(4, 2); assert_eq!(state.status().unwrap().total_records, 42); + + state.apply_record_changes(0, 100); + assert_eq!(state.status().unwrap().total_records, 0); } fn test_root(name: &str) -> PathBuf { @@ -4117,6 +4383,86 @@ mod tests { open_usage_database_at(root).unwrap() } + #[test] + fn usage_database_limit_defaults_to_unlimited_and_persists() { + let root = test_root("database-limit-persistence"); + let connection = open_test_database(&root); + + assert_eq!(load_usage_database_limit(&connection).unwrap(), 0); + save_usage_database_limit(&connection, 128).unwrap(); + drop(connection); + + let connection = open_usage_database_at(&root).unwrap(); + assert_eq!(load_usage_database_limit(&connection).unwrap(), 128); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn usage_database_limit_removes_oldest_records_after_collection() { + let root = test_root("database-limit-retention"); + let mut connection = open_test_database(&root); + save_usage_database_limit(&connection, 1).unwrap(); + + let payload = "x".repeat(8 * 1024); + let transaction = connection.transaction().unwrap(); + for timestamp_ms in 0_i64..512 { + transaction + .execute( + r#"INSERT INTO usage_events ( + event_key, timestamp, timestamp_ms, local_hour, model, + failure_body, created_at + ) VALUES (?1, ?2, ?3, '2026-01-01T00', 'retention-test', ?4, ?2)"#, + params![ + format!("retention-{timestamp_ms}"), + format!("2026-01-01T00:00:{timestamp_ms:03}Z"), + timestamp_ms, + payload, + ], + ) + .unwrap(); + } + transaction.commit().unwrap(); + + enqueue_usage_queue_items( + &mut connection, + "retention-test", + vec![serde_json::json!({ + "timestamp": "2026-09-18T12:00:00+08:00", + "request_id": "newest-retained-record", + "model": "retention-test", + "tokens": { "input_tokens": 1, "output_tokens": 1 } + })], + ) + .unwrap(); + let outcome = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap(); + + assert_eq!(outcome.inserted, 1); + assert!(outcome.deleted > 0); + let (remaining, oldest_timestamp, newest_record): (u64, i64, u64) = connection + .query_row( + r#"SELECT COUNT(*), MIN(timestamp_ms), + SUM(CASE WHEN request_id = 'newest-retained-record' THEN 1 ELSE 0 END) + FROM usage_events"#, + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert!(remaining > 0); + assert!(remaining < 513); + assert!(oldest_timestamp > 0); + assert_eq!(newest_record, 1); + assert!(usage_database_active_bytes(&connection).unwrap() <= BYTES_PER_MB); + assert!(usage_database_disk_bytes(&root).unwrap() <= BYTES_PER_MB); + + let settings = load_usage_storage_settings(&connection, &root, outcome.deleted).unwrap(); + assert_eq!(settings.max_database_size_mb, 1); + assert_eq!(settings.total_records, remaining); + assert_eq!(settings.deleted_records, outcome.deleted); + drop(connection); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn repairs_legacy_claude_input_tokens_on_demand() { let root = test_root("claude-input-migration"); @@ -4782,7 +5128,7 @@ mod tests { &config, ) .unwrap(); - assert_eq!(inserted, 1); + assert_eq!(inserted.inserted, 1); let connection = open_usage_database_at(&root).unwrap(); let inbox_count = connection .query_row("SELECT COUNT(*) FROM usage_inbox", [], |row| { @@ -4825,7 +5171,7 @@ mod tests { row.get::<_, String>(0) }) .unwrap(); - assert_eq!(inserted, 0); + assert_eq!(inserted.inserted, 0); assert_eq!(event_count, 0); assert_eq!(status, "decode_failed"); drop(connection); @@ -4883,7 +5229,7 @@ mod tests { ) .unwrap(); - assert_eq!(inserted, 2); + assert_eq!(inserted.inserted, 2); assert_eq!(events.total, 2); assert!(events.items.iter().all(|event| !event.failed)); assert!(events @@ -5225,7 +5571,7 @@ mod tests { ) .unwrap(); - assert_eq!(inserted, 1); + assert_eq!(inserted.inserted, 1); assert_eq!(event_count, 1); assert_eq!(processed_count, 1); assert_eq!(failed_count, 1); @@ -5259,7 +5605,7 @@ mod tests { .unwrap(); let inserted = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap(); - assert_eq!(inserted, 2); + assert_eq!(inserted.inserted, 2); let event_count = connection .query_row("SELECT COUNT(*) FROM usage_events", [], |row| { row.get::<_, i64>(0) diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index ef2070b..34dfee5 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -448,9 +448,18 @@ export const jaOverrides = { 'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました', 'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?', 'usage.dataManagement.title': '履歴データ管理', - 'usage.dataManagement.description': 'キャッシュ Token の不整合がある使用履歴をスキャンして修復します。', + 'usage.dataManagement.description': '使用履歴の保存容量を管理し、既知の履歴データ異常を修復します。', 'usage.dataManagement.manualBadge': '手動操作', - 'usage.dataManagement.notice': '修復前にデータベースをバックアップします。判定可能な Claude / Anthropic の記録のみ処理します。', + 'usage.dataManagement.storageTitle': 'データベース容量上限', + 'usage.dataManagement.storageDescription': '0 にすると無制限です。上限に達すると古い使用履歴から自動的に削除します。', + 'usage.dataManagement.storageCurrent': '現在の使用量 {size} · {records} 件', + 'usage.dataManagement.storageInput': 'データベース容量上限(MB)', + 'usage.dataManagement.storageUnit': 'MB', + 'usage.dataManagement.storageSave': '上限を保存', + 'usage.dataManagement.storageSaving': '保存中…', + 'usage.dataManagement.storageInvalid': 'データベース容量上限には 0 または正の整数を入力してください。', + 'usage.dataManagement.storageSaved': 'データベース容量上限を保存しました。', + 'usage.dataManagement.storageSavedWithCleanup': 'データベース容量上限を保存し、古い記録を {deleted} 件削除しました。', 'usage.dataManagement.actionTitle': 'キャッシュ記録を修復', 'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。', 'usage.dataManagement.run': 'スキャンして修復', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 6fbb7b1..ef39dd0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -435,9 +435,18 @@ export const en: Record = { 'usage.pricing.deleted': 'Model price deleted; overview cost updated', 'usage.pricing.deleteConfirm': 'Delete the price for {model}?', 'usage.dataManagement.title': 'Historical Data Management', - 'usage.dataManagement.description': 'Scan and repair known anomalies in historical usage records.', + 'usage.dataManagement.description': 'Manage usage record storage and repair known historical data anomalies.', 'usage.dataManagement.manualBadge': 'Manual action', - 'usage.dataManagement.notice': 'A database backup is created first. Only cache anomalies and unknown records are processed.', + 'usage.dataManagement.storageTitle': 'Database size limit', + 'usage.dataManagement.storageDescription': 'Set to 0 for no limit. When the limit is reached, the oldest usage records are deleted automatically.', + 'usage.dataManagement.storageCurrent': 'Currently using {size} · {records} records', + 'usage.dataManagement.storageInput': 'Database size limit (MB)', + 'usage.dataManagement.storageUnit': 'MB', + 'usage.dataManagement.storageSave': 'Save limit', + 'usage.dataManagement.storageSaving': 'Saving…', + 'usage.dataManagement.storageInvalid': 'Enter 0 or a positive integer for the database size limit.', + 'usage.dataManagement.storageSaved': 'Database size limit saved.', + 'usage.dataManagement.storageSavedWithCleanup': 'Database size limit saved and {deleted} oldest records deleted.', 'usage.dataManagement.actionTitle': 'Repair historical anomalies', 'usage.dataManagement.actionDescription': 'Correct Claude cache token accounting and delete records recorded as unknown usage.', 'usage.dataManagement.run': 'Scan and repair', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 45613d2..571207a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -434,9 +434,18 @@ export const zhCN = { 'usage.pricing.deleted': '模型价格已删除,总览成本已更新', 'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?', 'usage.dataManagement.title': '历史数据管理', - 'usage.dataManagement.description': '扫描并修复历史使用记录中的已知数据异常。', + 'usage.dataManagement.description': '管理使用记录存储并修复历史数据中的已知异常。', 'usage.dataManagement.manualBadge': '手动操作', - 'usage.dataManagement.notice': '修复前会自动备份数据库,仅处理缓存口径异常和 unknown 记录。', + 'usage.dataManagement.storageTitle': '数据库大小限制', + 'usage.dataManagement.storageDescription': '设为 0 时不限制;达到上限后自动删除最旧的使用记录。', + 'usage.dataManagement.storageCurrent': '当前占用 {size} · {records} 条记录', + 'usage.dataManagement.storageInput': '数据库大小上限(MB)', + 'usage.dataManagement.storageUnit': 'MB', + 'usage.dataManagement.storageSave': '保存限制', + 'usage.dataManagement.storageSaving': '保存中…', + 'usage.dataManagement.storageInvalid': '请输入 0 或正整数作为数据库大小上限。', + 'usage.dataManagement.storageSaved': '数据库大小限制已保存。', + 'usage.dataManagement.storageSavedWithCleanup': '数据库大小限制已保存,并删除了最旧的 {deleted} 条记录。', 'usage.dataManagement.actionTitle': '修复历史异常记录', 'usage.dataManagement.actionDescription': '修正错误缓存信息,并删除被误记为 unknown 的记录。', 'usage.dataManagement.run': '扫描并修复', diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index 13fca02..50b2e09 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -170,6 +170,13 @@ type UsageRepairResult = { backupPath: string | null; }; +type UsageStorageSettings = { + maxDatabaseSizeMb: number; + databaseSizeBytes: number; + totalRecords: number; + deletedRecords: number; +}; + type ModelPriceSyncResult = { imported: number; skipped: number; @@ -240,6 +247,13 @@ const rangeQuery = (range: UsageRange, customStart: string, customEnd: string): const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale()); +const formatStorageBytes = (value: number) => { + const bytes = Number.isFinite(value) ? Math.max(0, value) : 0; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; +}; + const formatUsd = (amount: number) => { if (!Number.isFinite(amount) || amount <= 0) return '$0.00'; const maximumFractionDigits = @@ -715,6 +729,57 @@ function UsageDataManagementView() { const [running, setRunning] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(''); + const [storage, setStorage] = useState(null); + const [limitDraft, setLimitDraft] = useState('0'); + const [loadingLimit, setLoadingLimit] = useState(true); + const [savingLimit, setSavingLimit] = useState(false); + const [storageNotice, setStorageNotice] = useState(''); + + useEffect(() => { + let disposed = false; + invoke('get_usage_storage_settings') + .then((next) => { + if (disposed) return; + setStorage(next); + setLimitDraft(String(next.maxDatabaseSizeMb)); + }) + .catch((requestError) => { + if (!disposed) setError(String(requestError)); + }) + .finally(() => { + if (!disposed) setLoadingLimit(false); + }); + return () => { + disposed = true; + }; + }, []); + + const saveStorageLimit = async () => { + const normalized = limitDraft.trim(); + const maxDatabaseSizeMb = Number(normalized); + if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(maxDatabaseSizeMb)) { + setError(t('usage.dataManagement.storageInvalid')); + return; + } + setSavingLimit(true); + setError(''); + setStorageNotice(''); + try { + const next = await invoke('save_usage_storage_settings', { maxDatabaseSizeMb }); + setStorage(next); + setLimitDraft(String(next.maxDatabaseSizeMb)); + setStorageNotice(t( + next.deletedRecords > 0 + ? 'usage.dataManagement.storageSavedWithCleanup' + : 'usage.dataManagement.storageSaved', + { deleted: next.deletedRecords.toLocaleString() }, + )); + } catch (requestError) { + setError(String(requestError)); + } finally { + setSavingLimit(false); + } + }; const repair = async () => { if (!await askConfirmation({ title: t('usage.dataManagement.title'), message: t('usage.dataManagement.confirm') })) return; @@ -724,6 +789,8 @@ function UsageDataManagementView() { try { const next = await invoke('repair_usage_cache_records'); setResult(next); + const nextStorage = await invoke('get_usage_storage_settings'); + setStorage(nextStorage); } catch (requestError) { setError(String(requestError)); } finally { @@ -745,11 +812,49 @@ function UsageDataManagementView() { {t('usage.dataManagement.manualBadge')} -
-