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

- 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:
KimiSwitch Dev committed 2026-09-14 01:52:21 +08:00
1 parent 00cf194b5c
commit 0f421b4e44
15 files changed
+6264 -943

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.7.17",
"version": "0.7.18",
"type": "module",
"scripts": {
"dev": "vite",
+14
View File
@@ -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)
## 备注
- 归档仍是元数据语义(文件原地保留),删除文件才会触发快照合并;用量配置、账单查询等其余功能不受影响
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.17"
version = "0.7.18"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -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"
+607 -63
View File
@@ -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,67 +863,8 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
Ok(c) => c,
Err(_) => continue,
};
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,
Err(_) => continue,
};
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
if scope != "turn" { continue; }
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
// Kimi Code writes the internal marker `__secondary__` as the model
// key for usage records emitted by subagents bound to the
// configured secondary model. The record stays keyed on that
// stable marker so historical records keep their semantics if the
// secondary config changes later; cost billing follows the
// currently configured secondary model, and the record is flagged
// is_secondary so the UI shows it as its own "subagent model"
// entry.
let is_secondary = model_raw == "__secondary__";
let from_env = model_raw == "__kimi_env_model__";
let price_model = if is_secondary {
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
} else {
model_raw.clone()
};
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
let usage = &obj["usage"];
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
// Resolve model display
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
// Subagent records are inherently estimates: the `__secondary__`
// marker carries no model identity, so the cost is flagged as
// estimated even when the current secondary config resolves to a
// priced listing.
let est = est || is_secondary;
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
records.push(UsageRecord {
time,
model: model_raw.clone(),
model_resolved: bare.to_string(),
model_display: model_raw.clone(),
provider: resolve_provider(&price_model, &alias2prov),
from_env,
is_secondary,
input_other,
output,
input_cache_read: cache_read,
input_cache_creation: cache_create,
cost_usd: cost,
cost_estimated: est,
price_id: pid,
});
}
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));
@@ -926,6 +877,180 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
})
}
/// 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() {
if !line.contains("\"usage.record\"") { continue; }
let obj: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
if scope != "turn" { continue; }
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
// Kimi Code writes the internal marker `__secondary__` as the model
// key for usage records emitted by subagents bound to the
// configured secondary model. The record stays keyed on that
// stable marker so historical records keep their semantics if the
// secondary config changes later; cost billing follows the
// currently configured secondary model, and the record is flagged
// is_secondary so the UI shows it as its own "subagent model"
// entry.
let is_secondary = model_raw == "__secondary__";
let from_env = model_raw == "__kimi_env_model__";
let price_model = if is_secondary {
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
} else {
model_raw.clone()
};
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
let usage = &obj["usage"];
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
// Resolve model display
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
// Subagent records are inherently estimates: the `__secondary__`
// marker carries no model identity, so the cost is flagged as
// estimated even when the current secondary config resolves to a
// priced listing.
let est = est || is_secondary;
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
records.push(UsageRecord {
time,
model: model_raw.clone(),
model_resolved: bare.to_string(),
model_display: model_raw.clone(),
provider: resolve_provider(&price_model, &alias2prov),
from_env,
is_secondary,
input_other,
output,
input_cache_read: cache_read,
input_cache_creation: cache_create,
cost_usd: cost,
cost_estimated: est,
price_id: pid,
});
}
records
}
/// 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)
}
// ---------------------------------------------------------------------------
// Scan cache
// ---------------------------------------------------------------------------
@@ -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));
}
}
+128
View File
@@ -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)
}
+1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.17",
"version": "0.7.18",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+189 -2
View File
@@ -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">
+13
View File
@@ -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,
+13
View File
@@ -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",
+12
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+2407 -382
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -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;