feat(sessions): 一键归档 + 归档用量快照落库 — bump to v0.7.18
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
- archive_sessions_before:按 updatedAt 截止点批量元数据归档(1月/半月/周/指定日期) - 归档瞬间聚合 wire.jsonl 用量按天×模型快照进 SQLite archived_sessions 表 - 统计合并:文件已删除的归档会话由快照合成 UsageRecord 注入 get_summary/日明细,趋势/热力图/模型统计全兼容且绝不双计 - models-dev 快照同步 2026-09-14(7784 模型);pricing 测试跟随 deepseek-v4-flash 调价(0.15/0.60) - cargo test 129 通过(含端到端归档→删目录→统计保留用例)、tsc 零错误
This commit is contained in:
1 parent
00cf194b5c
commit
0f421b4e44
15 files changed
+6212
-891
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.7.17",
|
||||
"version": "0.7.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# KimiSwitch v0.7.18
|
||||
|
||||
## 新增
|
||||
|
||||
- **会话管理「一键归档」**:支持按 1 个月前 / 半个月前 / 1 星期前 / 指定日期,一键归档所有最后活跃时间早于所选时间点的会话(一次批量操作,替代逐个选择);结果反馈已归档 / 跳过数量
|
||||
- **归档用量快照落库**:归档瞬间把每个会话的 token 用量与费用按「天 × 模型」粒度快照进 SQLite(`archived_sessions` 表)。之后删除已归档会话(释放磁盘)不再损失历史统计——仪表盘的趋势图、热力图、模型统计、区间合计均会自动合并快照数据,且与现存文件实时统计严格去重(只合并文件已删除的归档会话,绝不双计)
|
||||
|
||||
## 修复
|
||||
|
||||
- models-dev 快照同步至 2026-09-14(7784 模型 / 213 供应商),`public/` 前端静态目录同步;pricing 测试价格断言跟随 DeepSeek 官方调价(deepseek-v4-flash input 0.14→0.15 / output 0.28→0.60)
|
||||
|
||||
## 备注
|
||||
|
||||
- 归档仍是元数据语义(文件原地保留),删除文件才会触发快照合并;用量配置、账单查询等其余功能不受影响
|
||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.17"
|
||||
version = "0.7.18"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.17"
|
||||
version = "0.7.18"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
+555
-11
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Datelike, Local, TimeZone};
|
||||
use crate::db::{ArchivedSessionSnapshot, DayModelStat, DayStat};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -277,6 +278,15 @@ pub struct ActionResponse {
|
||||
pub deleted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Outcome of the bulk "archive everything older than X" command.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BulkArchiveResult {
|
||||
pub archived: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreviewResult {
|
||||
@@ -853,8 +863,30 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
lines_seen += content.lines().count();
|
||||
records.extend(parse_wire_usage_records(&content, &secondary_alias, &alias2prov));
|
||||
}
|
||||
|
||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
let count = records.len();
|
||||
(records, ScanMeta {
|
||||
files_scanned, lines_seen, record_count: count,
|
||||
home: home.to_string_lossy().to_string(),
|
||||
sessions_root: root.to_string_lossy().to_string(),
|
||||
errors: errors.into_iter().take(20).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one `wire.jsonl` body into usage records. Extracted from
|
||||
/// `scan_usage` so a single session can be snapshotted without walking the
|
||||
/// whole sessions tree; the record filter chain is unchanged.
|
||||
fn parse_wire_usage_records(
|
||||
content: &str,
|
||||
secondary_alias: &Option<String>,
|
||||
alias2prov: &HashMap<String, String>,
|
||||
) -> Vec<UsageRecord> {
|
||||
let mut records = Vec::new();
|
||||
for line in content.lines() {
|
||||
lines_seen += 1;
|
||||
if !line.contains("\"usage.record\"") { continue; }
|
||||
let obj: serde_json::Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
@@ -914,16 +946,109 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
|
||||
price_id: pid,
|
||||
});
|
||||
}
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
let count = records.len();
|
||||
(records, ScanMeta {
|
||||
files_scanned, lines_seen, record_count: count,
|
||||
home: home.to_string_lossy().to_string(),
|
||||
sessions_root: root.to_string_lossy().to_string(),
|
||||
errors: errors.into_iter().take(20).collect(),
|
||||
/// Aggregate raw usage records into the per-day, per-model snapshot stored
|
||||
/// for an archived session.
|
||||
fn snapshot_from_records(records: &[UsageRecord]) -> Vec<DayStat> {
|
||||
let mut by_day: HashMap<String, (u64, HashMap<String, DayModelStat>)> = HashMap::new();
|
||||
for r in records {
|
||||
let entry = by_day
|
||||
.entry(day_key(r.time))
|
||||
.or_insert_with(|| (local_midnight_ms(r.time, 0), HashMap::new()));
|
||||
let m = entry.1.entry(r.model.clone()).or_insert_with(|| DayModelStat {
|
||||
model: r.model.clone(),
|
||||
requests: 0,
|
||||
input_other: 0,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 0.0,
|
||||
});
|
||||
m.requests += 1;
|
||||
m.input_other += r.input_other;
|
||||
m.output += r.output;
|
||||
m.input_cache_read += r.input_cache_read;
|
||||
m.input_cache_creation += r.input_cache_creation;
|
||||
m.cost_usd += r.cost_usd;
|
||||
}
|
||||
let mut days: Vec<DayStat> = by_day
|
||||
.into_iter()
|
||||
.map(|(day, (day_start_ms, models))| {
|
||||
let mut models: Vec<DayModelStat> = models.into_values().collect();
|
||||
models.sort_by(|a, b| a.model.cmp(&b.model));
|
||||
DayStat { day, day_start_ms, models }
|
||||
})
|
||||
.collect();
|
||||
days.sort_by(|a, b| a.day.cmp(&b.day));
|
||||
days
|
||||
}
|
||||
|
||||
/// Synthesize usage records from stored archive snapshots. Only snapshots
|
||||
/// whose session directory is gone are synthesized: sessions still on disk
|
||||
/// are covered by the live wire.jsonl scan, and emitting both would double
|
||||
/// count their usage.
|
||||
fn synthesize_snapshot_records(rows: &[ArchivedSessionSnapshot], home: &Path) -> Vec<UsageRecord> {
|
||||
let root = sessions_root(home);
|
||||
let legacy_root = root.join(".kcd-archive");
|
||||
let alias2prov = build_alias_provider_map(home);
|
||||
let secondary_alias = read_secondary_model_alias(home);
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
if root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
|
||||
if legacy_root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
|
||||
for day in &row.day_stats {
|
||||
for m in &day.models {
|
||||
let is_secondary = m.model == "__secondary__";
|
||||
let price_model = if is_secondary {
|
||||
secondary_alias.clone().unwrap_or_else(|| m.model.clone())
|
||||
} else {
|
||||
m.model.clone()
|
||||
};
|
||||
let bare = m.model.rsplit_once('/').map(|x| x.1).unwrap_or(&m.model).to_string();
|
||||
out.push(UsageRecord {
|
||||
time: day.day_start_ms,
|
||||
model: m.model.clone(),
|
||||
model_resolved: bare,
|
||||
model_display: m.model.clone(),
|
||||
provider: resolve_provider(&price_model, &alias2prov),
|
||||
from_env: m.model == "__kimi_env_model__",
|
||||
is_secondary,
|
||||
input_other: m.input_other,
|
||||
output: m.output,
|
||||
input_cache_read: m.input_cache_read,
|
||||
input_cache_creation: m.input_cache_creation,
|
||||
cost_usd: m.cost_usd,
|
||||
cost_estimated: true,
|
||||
price_id: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Append the synthesized records for `rows` to a live scan result and
|
||||
/// restore the newest-first ordering `scan_usage` produces.
|
||||
fn merge_snapshot_records(
|
||||
mut records: Vec<UsageRecord>,
|
||||
rows: &[ArchivedSessionSnapshot],
|
||||
home: &Path,
|
||||
) -> Vec<UsageRecord> {
|
||||
// Nothing archived yet: keep the scan result untouched (and skip the
|
||||
// config reads the synthesis would do).
|
||||
if rows.is_empty() { return records; }
|
||||
records.extend(synthesize_snapshot_records(rows, home));
|
||||
records.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
records
|
||||
}
|
||||
|
||||
/// Live scan records plus the stored usage of archived sessions whose files
|
||||
/// have been deleted, so dashboard stats survive session deletion.
|
||||
fn records_with_archive_merge(home: &Path, records: Vec<UsageRecord>) -> Vec<UsageRecord> {
|
||||
let rows = crate::db::list_archived_sessions().unwrap_or_default();
|
||||
merge_snapshot_records(records, &rows, home)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1332,6 +1457,147 @@ fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), S
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a `state.json` timestamp into epoch ms. kimi-code writes epoch-ms
|
||||
/// numbers, other builds write ISO-8601 strings — accept both, plus numeric
|
||||
/// strings.
|
||||
fn parse_state_time_ms(value: &serde_json::Value) -> Option<u64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
|
||||
serde_json::Value::String(s) => parse_time_string_ms(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_time_string_ms(s: &str) -> Option<u64> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() { return None; }
|
||||
if let Ok(n) = t.parse::<u64>() {
|
||||
// Numeric string: epoch ms when it is already millisecond-sized.
|
||||
return Some(if n >= 1e12 as u64 { n } else { n * 1000 });
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(t) {
|
||||
return Some(dt.timestamp_millis().max(0) as u64);
|
||||
}
|
||||
for fmt in [
|
||||
"%Y-%m-%dT%H:%M:%S%.f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S%.f",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
] {
|
||||
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(t, fmt) {
|
||||
return Some(naive.and_utc().timestamp_millis().max(0) as u64);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a session's `state.json` as a JSON object (None when missing or
|
||||
/// unparseable).
|
||||
fn read_state_obj(session_dir: &Path) -> Option<serde_json::Value> {
|
||||
let content = fs::read_to_string(session_dir.join("state.json")).ok()?;
|
||||
let obj: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
if obj.is_object() { Some(obj) } else { None }
|
||||
}
|
||||
|
||||
/// Session directory mtime in epoch ms — the fallback when `state.json` has
|
||||
/// no parseable `updatedAt`.
|
||||
fn dir_mtime_ms(dir: &Path) -> Option<u64> {
|
||||
dir.metadata().ok().and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// Usage records of one session: every `wire.jsonl` below its directory
|
||||
/// (agent subdirectories included), with the same blob/task filtering the
|
||||
/// full-tree scan applies.
|
||||
fn parse_session_wire_usage(
|
||||
dir: &Path,
|
||||
secondary_alias: &Option<String>,
|
||||
alias2prov: &HashMap<String, String>,
|
||||
) -> Vec<UsageRecord> {
|
||||
let mut records = Vec::new();
|
||||
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
|
||||
if entry.file_name() != "wire.jsonl" || !entry.file_type().is_file() { continue; }
|
||||
let p = entry.path();
|
||||
if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") { continue; }
|
||||
let Ok(content) = fs::read_to_string(p) else { continue };
|
||||
records.extend(parse_wire_usage_records(&content, secondary_alias, alias2prov));
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
|
||||
/// (epoch ms) and store a usage snapshot per session in SQLite, so dashboard
|
||||
/// stats keep counting their history after the directories are deleted.
|
||||
/// Sessions already archived, in `.kcd-archive`, or without a readable
|
||||
/// `state.json` are left alone.
|
||||
fn archive_sessions_before_cmd(home: &Path, cutoff_ms: u64) -> BulkArchiveResult {
|
||||
let root = sessions_root(home);
|
||||
let alias2prov = build_alias_provider_map(home);
|
||||
let secondary_alias = read_secondary_model_alias(home);
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
|
||||
let mut result = BulkArchiveResult { archived: 0, skipped: 0, errors: Vec::new() };
|
||||
|
||||
for wid in list_workspace_dirs(home) {
|
||||
for entry in safe_read_dir(&root.join(&wid)) {
|
||||
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
|
||||
let sid = entry.file_name().to_string_lossy().to_string();
|
||||
if !session_re().is_match(&sid) { continue; }
|
||||
let dir = entry.path();
|
||||
let Some(state) = read_state_obj(&dir) else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if state.get("archived").and_then(|v| v.as_bool()).unwrap_or(false) { continue; }
|
||||
let Some(updated_ms) = state
|
||||
.get("updatedAt")
|
||||
.and_then(parse_state_time_ms)
|
||||
.or_else(|| dir_mtime_ms(&dir))
|
||||
else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if updated_ms >= cutoff_ms {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = set_session_archived_meta(&dir, true) {
|
||||
result.errors.push(format!("{wid}/{sid}: {e}"));
|
||||
continue;
|
||||
}
|
||||
let day_stats = snapshot_from_records(&parse_session_wire_usage(
|
||||
&dir, &secondary_alias, &alias2prov,
|
||||
));
|
||||
let mut total_tokens = 0u64;
|
||||
let mut total_cost_usd = 0.0f64;
|
||||
for day in &day_stats {
|
||||
for m in &day.models {
|
||||
total_tokens += m.input_other + m.output + m.input_cache_read + m.input_cache_creation;
|
||||
total_cost_usd += m.cost_usd;
|
||||
}
|
||||
}
|
||||
let snapshot = ArchivedSessionSnapshot {
|
||||
session_id: sid.clone(),
|
||||
workspace_id: wid.clone(),
|
||||
title: state.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
archived_at_ms: now_ms,
|
||||
updated_at_ms: Some(updated_ms),
|
||||
created_at_ms: state.get("createdAt").and_then(parse_state_time_ms),
|
||||
total_tokens,
|
||||
total_cost_usd,
|
||||
day_stats,
|
||||
};
|
||||
result.archived += 1;
|
||||
if let Err(e) = crate::db::upsert_archived_session(&snapshot) {
|
||||
result.errors.push(format!("{wid}/{sid}: snapshot: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn humanize_workspace(id: &str) -> String {
|
||||
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
|
||||
if let Some(caps) = re.captures(id) {
|
||||
@@ -1876,6 +2142,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
||||
let r = range.unwrap_or_else(|| "30d".into());
|
||||
|
||||
let (records, meta) = scan_usage_cached(&home, refresh);
|
||||
let records = records_with_archive_merge(&home, records);
|
||||
let t1 = std::time::Instant::now();
|
||||
let stats = aggregate(&records, &r, now_ms);
|
||||
let all_stats = aggregate(&records, "all", now_ms);
|
||||
@@ -1983,6 +2250,7 @@ fn build_day_detail(date: &str, records: &[UsageRecord]) -> Option<DailyRow> {
|
||||
pub fn get_day_detail(home_override: Option<String>, date: String) -> Option<DailyRow> {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
let (records, _) = scan_usage_cached(&home, false);
|
||||
let records = records_with_archive_merge(&home, records);
|
||||
build_day_detail(&date, &records)
|
||||
}
|
||||
|
||||
@@ -1998,6 +2266,14 @@ pub fn archive_session(home_override: Option<String>, workspace_id: String, sess
|
||||
archive_session_cmd(&home, &workspace_id, &session_id)
|
||||
}
|
||||
|
||||
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
|
||||
/// (epoch ms) across all workspaces, storing a usage snapshot per session.
|
||||
#[tauri::command]
|
||||
pub fn archive_sessions_before(home_override: Option<String>, cutoff_ms: u64) -> BulkArchiveResult {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
archive_sessions_before_cmd(&home, cutoff_ms)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
|
||||
let home = resolve_kimi_home(home_override);
|
||||
@@ -2073,8 +2349,8 @@ mod pricing_tests {
|
||||
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
|
||||
assert!(!est, "resolved via models.dev, not estimated");
|
||||
assert_eq!(id, "deepseek/deepseek-v4-flash");
|
||||
assert_eq!(input, 0.14);
|
||||
assert_eq!(output, 0.28);
|
||||
assert_eq!(input, 0.15);
|
||||
assert_eq!(output, 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2432,3 +2708,271 @@ mod range_and_model_totals_tests {
|
||||
assert_eq!(total, 3 * 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod archive_snapshot_tests {
|
||||
use super::*;
|
||||
|
||||
const WID: &str = "wd_proj_a1b2c3d4e5f6";
|
||||
const LIVE_SID: &str = "session_11111111-2222-3333-4444-555555555555";
|
||||
const GONE_SID: &str = "session_99999999-8888-7777-6666-555555555555";
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
}
|
||||
|
||||
fn rec(time: u64, model: &str, tokens: (u64, u64, u64, u64), cost: f64) -> UsageRecord {
|
||||
UsageRecord {
|
||||
time,
|
||||
model: model.to_string(),
|
||||
input_other: tokens.0,
|
||||
output: tokens.1,
|
||||
input_cache_read: tokens.2,
|
||||
input_cache_creation: tokens.3,
|
||||
cost_usd: cost,
|
||||
cost_estimated: false,
|
||||
price_id: String::new(),
|
||||
model_resolved: model.rsplit_once('/').map(|x| x.1).unwrap_or(model).to_string(),
|
||||
model_display: model.to_string(),
|
||||
provider: None,
|
||||
from_env: false,
|
||||
is_secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_row(
|
||||
sid: &str,
|
||||
day_start_ms: u64,
|
||||
model: &str,
|
||||
tokens: u64,
|
||||
) -> ArchivedSessionSnapshot {
|
||||
ArchivedSessionSnapshot {
|
||||
session_id: sid.to_string(),
|
||||
workspace_id: WID.to_string(),
|
||||
title: None,
|
||||
archived_at_ms: 0,
|
||||
updated_at_ms: None,
|
||||
created_at_ms: None,
|
||||
total_tokens: tokens,
|
||||
total_cost_usd: 0.0,
|
||||
day_stats: vec![DayStat {
|
||||
day: day_key(day_start_ms),
|
||||
day_start_ms,
|
||||
models: vec![DayModelStat {
|
||||
model: model.to_string(),
|
||||
requests: 2,
|
||||
input_other: tokens,
|
||||
output: 0,
|
||||
input_cache_read: 0,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 1.5,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn write_state(dir: &Path, updated_ms: u64, archived: bool) {
|
||||
let state = serde_json::json!({
|
||||
"id": dir.file_name().unwrap().to_string_lossy(),
|
||||
"createdAt": updated_ms,
|
||||
"updatedAt": updated_ms,
|
||||
"archived": archived,
|
||||
"title": "archived candidate",
|
||||
});
|
||||
fs::write(dir.join("state.json"), state.to_string()).unwrap();
|
||||
}
|
||||
|
||||
fn read_state(dir: &Path) -> serde_json::Value {
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("state.json")).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_groups_records_by_local_day_and_model() {
|
||||
let now = now_ms();
|
||||
let d1 = local_midnight_ms(now, 2);
|
||||
let d2 = local_midnight_ms(now, 1);
|
||||
let records = vec![
|
||||
rec(d1 + 3_600_000, "openai/gpt-x", (100, 10, 5, 1), 0.5),
|
||||
rec(d1 + 7_200_000, "openai/gpt-x", (200, 20, 0, 0), 0.25),
|
||||
rec(d1 + 60_000, "moonshotai/kimi-k3", (1, 2, 3, 4), 0.01),
|
||||
rec(d2 + 1_000, "openai/gpt-x", (7, 7, 7, 7), 0.07),
|
||||
];
|
||||
let days = snapshot_from_records(&records);
|
||||
assert_eq!(days.len(), 2, "one bucket per local day");
|
||||
|
||||
let first = &days[0];
|
||||
assert_eq!(first.day, day_key(d1));
|
||||
assert_eq!(first.day_start_ms, d1, "day bucket starts at local midnight");
|
||||
assert_eq!(first.models.len(), 2);
|
||||
let gpt = first.models.iter().find(|m| m.model == "openai/gpt-x").unwrap();
|
||||
assert_eq!(gpt.requests, 2);
|
||||
assert_eq!(gpt.input_other, 300);
|
||||
assert_eq!(gpt.output, 30);
|
||||
assert_eq!(gpt.input_cache_read, 5);
|
||||
assert_eq!(gpt.input_cache_creation, 1);
|
||||
assert_eq!(gpt.cost_usd, 0.75);
|
||||
// Deterministic ordering: models sorted by raw model key.
|
||||
assert_eq!(first.models[0].model, "moonshotai/kimi-k3");
|
||||
|
||||
assert_eq!(days[1].day, day_key(d2));
|
||||
assert_eq!(days[1].day_start_ms, d2);
|
||||
assert_eq!(days[1].models.len(), 1);
|
||||
assert_eq!(days[1].models[0].input_other, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesize_only_covers_sessions_whose_files_are_gone() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
|
||||
|
||||
let day_start = 1_700_000_000_000u64;
|
||||
let rows = vec![
|
||||
snapshot_row(LIVE_SID, day_start, "openai/gpt-x", 100),
|
||||
snapshot_row(GONE_SID, day_start, "openai/gpt-x", 200),
|
||||
];
|
||||
let out = synthesize_snapshot_records(&rows, &home);
|
||||
assert_eq!(out.len(), 1, "live session dirs stay with the live scan");
|
||||
|
||||
let r = &out[0];
|
||||
assert_eq!(r.time, day_start);
|
||||
assert_eq!(r.model, "openai/gpt-x");
|
||||
assert_eq!(r.model_resolved, "gpt-x");
|
||||
assert_eq!(r.model_display, "openai/gpt-x");
|
||||
assert_eq!(r.provider.as_deref(), Some("openai"));
|
||||
assert_eq!(r.input_other, 200);
|
||||
assert_eq!(r.cost_usd, 1.5);
|
||||
assert!(r.cost_estimated, "snapshot costs are estimates");
|
||||
assert_eq!(r.price_id, "");
|
||||
assert!(!r.is_secondary);
|
||||
assert!(!r.from_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_appends_missing_sessions_newest_first_without_double_counting() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
|
||||
|
||||
let now = now_ms();
|
||||
let old_day = local_midnight_ms(now, 5);
|
||||
let new_day = local_midnight_ms(now, 1);
|
||||
let gone_old = "session_22222222-2222-3333-4444-555555555555";
|
||||
let gone_new = "session_33333333-2222-3333-4444-555555555555";
|
||||
let rows = vec![
|
||||
snapshot_row(LIVE_SID, new_day, "openai/gpt-x", 999),
|
||||
snapshot_row(gone_old, old_day, "openai/gpt-x", 10),
|
||||
snapshot_row(gone_new, new_day, "openai/gpt-x", 20),
|
||||
];
|
||||
let live = vec![rec(new_day + 5_000, "openai/gpt-x", (1, 1, 1, 1), 0.01)];
|
||||
let merged = merge_snapshot_records(live, &rows, &home);
|
||||
|
||||
assert_eq!(merged.len(), 3, "live record plus the two missing sessions");
|
||||
assert_eq!(merged[0].time, new_day + 5_000, "newest record first");
|
||||
assert_eq!(merged[1].time, new_day);
|
||||
assert_eq!(merged[2].time, old_day);
|
||||
assert_eq!(merged[1].input_other, 20);
|
||||
assert_eq!(merged[2].input_other, 10);
|
||||
assert!(
|
||||
merged.iter().all(|r| r.input_other != 999),
|
||||
"a live session's stored snapshot must never be synthesized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_times_parse_epoch_numbers_and_iso_strings() {
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!(1_700_000_000_000u64)),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("1700000000000")),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05.000Z")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_state_time_ms(&serde_json::json!("2025-01-02 03:04:05")),
|
||||
Some(1_735_787_045_000)
|
||||
);
|
||||
assert_eq!(parse_state_time_ms(&serde_json::json!(null)), None);
|
||||
assert_eq!(parse_state_time_ms(&serde_json::json!("not-a-date")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_archive_flags_old_sessions_and_snapshots_their_usage() {
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
let home = td.path().join("home");
|
||||
// Redirect the SQLite store so the test never touches the user's DB.
|
||||
std::env::set_var("KIMI_SWITCH_DB_PATH", td.path().join("test.db"));
|
||||
|
||||
let ws = home.join("sessions").join(WID);
|
||||
let now = now_ms();
|
||||
let old_ms = now - 60 * 24 * 3600 * 1000;
|
||||
|
||||
let old_dir = ws.join(LIVE_SID);
|
||||
fs::create_dir_all(&old_dir).unwrap();
|
||||
write_state(&old_dir, old_ms, false);
|
||||
let wire = serde_json::json!({
|
||||
"type": "usage.record",
|
||||
"usageScope": "turn",
|
||||
"time": old_ms,
|
||||
"model": "openai/gpt-x",
|
||||
"usage": {
|
||||
"inputOther": 100,
|
||||
"output": 50,
|
||||
"inputCacheRead": 10,
|
||||
"inputCacheCreation": 5,
|
||||
},
|
||||
});
|
||||
fs::write(old_dir.join("wire.jsonl"), format!("{wire}\n")).unwrap();
|
||||
|
||||
let new_sid = "session_44444444-2222-3333-4444-555555555555";
|
||||
let new_dir = ws.join(new_sid);
|
||||
fs::create_dir_all(&new_dir).unwrap();
|
||||
write_state(&new_dir, now - 3_600_000, false);
|
||||
|
||||
let cutoff = now - 30 * 24 * 3600 * 1000;
|
||||
let res = archive_sessions_before_cmd(&home, cutoff);
|
||||
assert_eq!(res.archived, 1, "only the stale session is archived");
|
||||
assert_eq!(res.skipped, 1, "the recent session is reported as skipped");
|
||||
assert!(res.errors.is_empty(), "{:?}", res.errors);
|
||||
|
||||
// Metadata archive happened in place, the recent session is untouched.
|
||||
assert_eq!(read_state(&old_dir)["archived"], true);
|
||||
assert!(read_state(&old_dir)["archivedAt"].as_u64().unwrap() > 0);
|
||||
assert_eq!(read_state(&new_dir)["archived"], false);
|
||||
|
||||
// Usage snapshot landed in SQLite with the session's totals.
|
||||
let rows = crate::db::list_archived_sessions().unwrap();
|
||||
let row = rows.iter().find(|r| r.session_id == LIVE_SID).expect("snapshot row");
|
||||
assert_eq!(row.workspace_id, WID);
|
||||
assert_eq!(row.title.as_deref(), Some("archived candidate"));
|
||||
assert_eq!(row.updated_at_ms, Some(old_ms));
|
||||
assert_eq!(row.total_tokens, 165);
|
||||
assert!(row.total_cost_usd > 0.0);
|
||||
assert_eq!(row.day_stats.len(), 1);
|
||||
assert_eq!(row.day_stats[0].day, day_key(old_ms));
|
||||
assert_eq!(row.day_stats[0].models[0].requests, 1);
|
||||
assert_eq!(row.day_stats[0].models[0].input_other, 100);
|
||||
|
||||
// While the files are still there, the live scan owns the usage.
|
||||
let merged = records_with_archive_merge(&home, vec![]);
|
||||
assert!(merged.is_empty(), "no synthesized records while the dir exists");
|
||||
|
||||
// Once the archived directory is deleted, stats come from the snapshot.
|
||||
fs::remove_dir_all(&old_dir).unwrap();
|
||||
let merged = records_with_archive_merge(&home, vec![]);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].input_other, 100);
|
||||
assert_eq!(merged[0].output, 50);
|
||||
assert_eq!(day_key(merged[0].time), day_key(old_ms));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
||||
use anyhow::Context;
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
||||
@@ -23,6 +24,13 @@ pub fn kimi_switch_data_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn db_path() -> PathBuf {
|
||||
// Tests (and power users) can point the store elsewhere; production always
|
||||
// resolves to the default location under the home directory.
|
||||
if let Some(p) = std::env::var_os("KIMI_SWITCH_DB_PATH") {
|
||||
if !p.is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
kimi_switch_data_dir().join("kimi-switch.db")
|
||||
}
|
||||
|
||||
@@ -31,6 +39,10 @@ pub fn db_path() -> PathBuf {
|
||||
/// configuration. Safe to call on every startup — it is a no-op once the new
|
||||
/// directory exists.
|
||||
fn migrate_legacy_data_dir() {
|
||||
// A redirected store (tests) must never touch the real home directory.
|
||||
if std::env::var_os("KIMI_SWITCH_DB_PATH").is_some() {
|
||||
return;
|
||||
}
|
||||
let new_dir = kimi_switch_data_dir();
|
||||
if new_dir.exists() {
|
||||
return;
|
||||
@@ -106,6 +118,24 @@ pub fn init_db() -> DbResult<Connection> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Usage snapshots taken when sessions are bulk-archived: keep the session's
|
||||
// token/cost history queryable after its directory (and wire.jsonl) is
|
||||
// deleted from disk.
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS archived_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
archived_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER,
|
||||
created_at_ms INTEGER,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd REAL NOT NULL DEFAULT 0,
|
||||
day_stats TEXT NOT NULL DEFAULT '[]'
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Add icon columns for provider icon picker (introduced in v0.3.1).
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors.
|
||||
for stmt in [
|
||||
@@ -330,6 +360,104 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Archived-session usage snapshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-model usage totals for one calendar day of an archived session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DayModelStat {
|
||||
/// Raw model key as written to wire.jsonl (`__secondary__` kept verbatim).
|
||||
pub model: String,
|
||||
pub requests: u64,
|
||||
pub input_other: u64,
|
||||
pub output: u64,
|
||||
pub input_cache_read: u64,
|
||||
pub input_cache_creation: u64,
|
||||
pub cost_usd: f64,
|
||||
}
|
||||
|
||||
/// One day's per-model usage, stored as JSON in `archived_sessions.day_stats`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DayStat {
|
||||
/// Local calendar day, `YYYY-MM-DD`.
|
||||
pub day: String,
|
||||
/// Local midnight of `day`, epoch ms — used as the synthesized record time.
|
||||
pub day_start_ms: u64,
|
||||
pub models: Vec<DayModelStat>,
|
||||
}
|
||||
|
||||
/// Usage snapshot of one session, taken at the moment it is archived.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArchivedSessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub workspace_id: String,
|
||||
pub title: Option<String>,
|
||||
pub archived_at_ms: u64,
|
||||
pub updated_at_ms: Option<u64>,
|
||||
pub created_at_ms: Option<u64>,
|
||||
pub total_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
pub day_stats: Vec<DayStat>,
|
||||
}
|
||||
|
||||
/// Insert or replace one archived-session snapshot (keyed by session id, which
|
||||
/// is a UUID and never reused).
|
||||
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
|
||||
let conn = init_db()?;
|
||||
let day_stats = serde_json::to_string(&row.day_stats)?;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO archived_sessions
|
||||
(session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms, total_tokens, total_cost_usd, day_stats)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
row.session_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.archived_at_ms as i64,
|
||||
row.updated_at_ms.map(|v| v as i64),
|
||||
row.created_at_ms.map(|v| v as i64),
|
||||
row.total_tokens as i64,
|
||||
row.total_cost_usd,
|
||||
day_stats,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
|
||||
/// to an empty breakdown instead of failing the whole list.
|
||||
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
|
||||
let conn = init_db()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
|
||||
total_tokens, total_cost_usd, day_stats
|
||||
FROM archived_sessions",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let day_stats_json: String = row.get(8)?;
|
||||
Ok(ArchivedSessionSnapshot {
|
||||
session_id: row.get(0)?,
|
||||
workspace_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
archived_at_ms: row.get::<_, i64>(3)? as u64,
|
||||
updated_at_ms: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
|
||||
created_at_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
|
||||
total_tokens: row.get::<_, i64>(6)? as u64,
|
||||
total_cost_usd: row.get(7)?,
|
||||
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||
ProviderType::from_kimi_type(s)
|
||||
}
|
||||
@@ -121,6 +121,7 @@ pub fn run() {
|
||||
dashboard::get_day_detail,
|
||||
dashboard::list_sessions,
|
||||
dashboard::archive_session,
|
||||
dashboard::archive_sessions_before,
|
||||
dashboard::unarchive_session,
|
||||
dashboard::delete_session,
|
||||
dashboard::delete_workspace,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.17",
|
||||
"version": "0.7.18",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSessions } from "../../hooks/useSessions";
|
||||
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
|
||||
import { useTranslation } from "../../i18n";
|
||||
import type { SessionRow, WorkspaceRow } from "../../types/sessions";
|
||||
import type { BulkArchiveResult, SessionRow, WorkspaceRow } from "../../types/sessions";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
const v = Number(n) || 0;
|
||||
@@ -23,6 +23,44 @@ function rowKey(row: SessionRow): string {
|
||||
return `${row.workspaceId}/${row.id}`;
|
||||
}
|
||||
|
||||
type ArchiveAllMode = "month" | "half" | "week" | "custom";
|
||||
|
||||
type ArchiveAllState = {
|
||||
mode: ArchiveAllMode;
|
||||
/** YYYY-MM-DD, used when mode is "custom". */
|
||||
date: string;
|
||||
busy: boolean;
|
||||
result: BulkArchiveResult | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function localDateInput(d: Date): string {
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Today minus one month — the default value of the custom-date picker. */
|
||||
function defaultCustomDate(): string {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
return localDateInput(d);
|
||||
}
|
||||
|
||||
/** Cutoff epoch ms for the selected option (custom dates start at local midnight). */
|
||||
function cutoffFor(state: ArchiveAllState): number {
|
||||
const now = new Date();
|
||||
if (state.mode === "month" || state.mode === "half" || state.mode === "week") {
|
||||
const d = new Date(now);
|
||||
if (state.mode === "month") d.setMonth(d.getMonth() - 1);
|
||||
if (state.mode === "half") d.setDate(d.getDate() - 15);
|
||||
if (state.mode === "week") d.setDate(d.getDate() - 7);
|
||||
return d.getTime();
|
||||
}
|
||||
const [y, m, day] = state.date.split("-").map(Number);
|
||||
return new Date(y || now.getFullYear(), (m || 1) - 1, day || 1, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
type ConfirmState =
|
||||
| { mode: "one"; row: SessionRow }
|
||||
| { mode: "bulk"; rows: SessionRow[] }
|
||||
@@ -52,6 +90,7 @@ export function SessionsPage() {
|
||||
refresh,
|
||||
archiveSession,
|
||||
unarchiveSession,
|
||||
archiveBefore,
|
||||
deleteSession,
|
||||
deleteWorkspace,
|
||||
getPreview,
|
||||
@@ -62,6 +101,7 @@ export function SessionsPage() {
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<PreviewState>(null);
|
||||
const [archiveAll, setArchiveAll] = useState<ArchiveAllState | null>(null);
|
||||
|
||||
const workspaces = data?.workspaces ?? [];
|
||||
const sessions = data?.sessions ?? [];
|
||||
@@ -199,6 +239,30 @@ export function SessionsPage() {
|
||||
0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!archiveAll) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setArchiveAll((s) => (s && s.busy ? s : null));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [archiveAll]);
|
||||
|
||||
const runArchiveAll = async () => {
|
||||
if (!archiveAll || archiveAll.busy) return;
|
||||
const cutoffMs = cutoffFor(archiveAll);
|
||||
setArchiveAll((s) => (s ? { ...s, busy: true, result: null, error: null } : s));
|
||||
try {
|
||||
const res = await archiveBefore(cutoffMs);
|
||||
setArchiveAll((s) => (s ? { ...s, busy: false, result: res } : s));
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setArchiveAll((s) =>
|
||||
s ? { ...s, busy: false, error: e instanceof Error ? e.message : String(e) } : s
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
|
||||
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
@@ -376,6 +440,23 @@ export function SessionsPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{status !== "archived" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setArchiveAll({
|
||||
mode: "month",
|
||||
date: defaultCustomDate(),
|
||||
busy: false,
|
||||
result: null,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
|
||||
>
|
||||
{t("sessionsArchiveAll")}
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
value={workspace}
|
||||
onChange={(e) => setWorkspace(e.target.value)}
|
||||
@@ -548,6 +629,112 @@ export function SessionsPage() {
|
||||
{data?.home && <>{data.home}</>}
|
||||
</div>
|
||||
|
||||
{/* Bulk archive dialog */}
|
||||
{archiveAll && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
|
||||
<div className="space-y-3 p-5">
|
||||
<h3 className="text-base font-semibold text-content-primary">
|
||||
{t("archiveAllTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-content-muted">{t("archiveAllDesc")}</p>
|
||||
<div className="space-y-0.5">
|
||||
{(
|
||||
[
|
||||
["month", t("archiveOptionMonth")],
|
||||
["half", t("archiveOptionHalfMonth")],
|
||||
["week", t("archiveOptionWeek")],
|
||||
["custom", t("archiveOptionCustom")],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<label
|
||||
key={mode}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-content-primary hover:bg-input"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="archiveAllMode"
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
checked={archiveAll.mode === mode}
|
||||
disabled={archiveAll.busy}
|
||||
onChange={() =>
|
||||
setArchiveAll((s) =>
|
||||
s ? { ...s, mode, result: null, error: null } : s
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{archiveAll.mode === "custom" && (
|
||||
<input
|
||||
type="date"
|
||||
value={archiveAll.date}
|
||||
disabled={archiveAll.busy}
|
||||
onChange={(e) =>
|
||||
setArchiveAll((s) => (s ? { ...s, date: e.target.value } : s))
|
||||
}
|
||||
className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<div className="rounded-md border border-border bg-input px-3 py-2.5 text-sm text-content-muted">
|
||||
{t("archiveAllConfirm", {
|
||||
date: new Date(cutoffFor(archiveAll)).toLocaleDateString(
|
||||
locale === "zh" ? "zh-CN" : "en-US"
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
{archiveAll.error && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
|
||||
{t("archiveAllFailed")}: {archiveAll.error}
|
||||
</div>
|
||||
)}
|
||||
{archiveAll.result && archiveAll.result.archived === 0 && (
|
||||
<div className="rounded-md border border-border bg-input px-3 py-2 text-sm text-content-muted">
|
||||
{t("archiveAllEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{archiveAll.result && archiveAll.result.archived > 0 && (
|
||||
<div className="rounded-md border border-emerald-500/40 bg-emerald-900/20 px-3 py-2 text-sm text-emerald-300">
|
||||
{t("archiveAllDone", {
|
||||
archived: archiveAll.result.archived,
|
||||
skipped: archiveAll.result.skipped,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{archiveAll.result && archiveAll.result.errors.length > 0 && (
|
||||
<div className="space-y-1 rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-xs text-red-300">
|
||||
{archiveAll.result.errors.slice(0, 3).map((err, i) => (
|
||||
<div key={i} className="break-all">
|
||||
{err}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border bg-footer px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={archiveAll.busy}
|
||||
onClick={() => setArchiveAll(null)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
|
||||
>
|
||||
{archiveAll.result ? t("close") : t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={archiveAll.busy}
|
||||
onClick={runArchiveAll}
|
||||
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{archiveAll.busy ? t("archiveAllRunning") : t("sessionsArchiveAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ActionResponse,
|
||||
BulkArchiveResult,
|
||||
PreviewResult,
|
||||
SessionStatusFilter,
|
||||
SessionsResult,
|
||||
@@ -54,6 +55,17 @@ export function useSessions() {
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const archiveBefore = useCallback(
|
||||
async (cutoffMs: number) => {
|
||||
const result = await invoke<BulkArchiveResult>("archive_sessions_before", {
|
||||
cutoffMs,
|
||||
});
|
||||
await refresh();
|
||||
return result;
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const deleteSession = useCallback(
|
||||
async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
|
||||
await invoke<ActionResponse>("delete_session", {
|
||||
@@ -100,6 +112,7 @@ export function useSessions() {
|
||||
refresh,
|
||||
archiveSession,
|
||||
unarchiveSession,
|
||||
archiveBefore,
|
||||
deleteSession,
|
||||
deleteWorkspace,
|
||||
getPreview,
|
||||
|
||||
@@ -225,6 +225,19 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
roleUser: "User",
|
||||
roleAssistant: "Assistant",
|
||||
roleSystem: "System",
|
||||
sessionsArchiveAll: "Archive old sessions",
|
||||
archiveAllTitle: "Archive old sessions",
|
||||
archiveAllDesc:
|
||||
"Archives every active session whose last activity is older than the selected date. Files are kept and usage stats stay intact.",
|
||||
archiveOptionMonth: "Older than 1 month",
|
||||
archiveOptionHalfMonth: "Older than 15 days",
|
||||
archiveOptionWeek: "Older than 1 week",
|
||||
archiveOptionCustom: "Specific date",
|
||||
archiveAllConfirm: "Archive every active session last active before {date}. Continue?",
|
||||
archiveAllRunning: "Archiving…",
|
||||
archiveAllDone: "Archived {archived} sessions, skipped {skipped}",
|
||||
archiveAllFailed: "Archive failed",
|
||||
archiveAllEmpty: "No sessions match",
|
||||
|
||||
// Heatmap
|
||||
noData: "No data",
|
||||
|
||||
@@ -220,6 +220,18 @@ export const zhTranslations = {
|
||||
roleUser: "用户",
|
||||
roleAssistant: "助手",
|
||||
roleSystem: "系统",
|
||||
sessionsArchiveAll: "一键归档",
|
||||
archiveAllTitle: "一键归档会话",
|
||||
archiveAllDesc: "将归档最后活跃时间早于所选日期的全部活跃会话;文件保留,统计不受影响。",
|
||||
archiveOptionMonth: "1 个月前",
|
||||
archiveOptionHalfMonth: "半个月前",
|
||||
archiveOptionWeek: "1 星期前",
|
||||
archiveOptionCustom: "指定日期",
|
||||
archiveAllConfirm: "将归档最后活跃时间早于 {date} 的全部活跃会话。确认?",
|
||||
archiveAllRunning: "正在归档…",
|
||||
archiveAllDone: "已归档 {archived} 个会话,跳过 {skipped} 个",
|
||||
archiveAllFailed: "归档失败",
|
||||
archiveAllEmpty: "没有符合条件的会话",
|
||||
|
||||
// Heatmap
|
||||
noData: "暂无数据",
|
||||
|
||||
+2870
-492
File diff suppressed because it is too large.
Load diff
+2407
-382
File diff suppressed because it is too large.
Load diff
@@ -39,6 +39,12 @@ export interface ActionResponse {
|
||||
deleted?: boolean | null;
|
||||
}
|
||||
|
||||
export interface BulkArchiveResult {
|
||||
archived: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface PreviewMessage {
|
||||
role: string;
|
||||
time: number | null;
|
||||
|
||||
Reference in new issue
Block a user