diff --git a/README.md b/README.md index cf3d538..bd7f077 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,10 @@ A: 代码不依赖 Windows 专属 API,但 `tauri.conf.json` 的 bundle 目标 - [Tauri](https://tauri.app) — 桌面应用框架 - 设计文档见 `docs/superpowers/specs/`,实施计划见 `docs/superpowers/plans/` +## 致谢 + +用量仪表盘与会话管理功能基于 [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard)(MIT 许可证,© JochenYang)移植。原始项目实现了 Kimi Code CLI 的本地 Token 用量统计与会话管理,本项目的 Rust 后端(`src-tauri/src/dashboard.rs`)、前端仪表盘(`src/components/dashboard/`)以及会话管理页(`src/components/sessions/`)均源自该项目,感谢原作者的开源贡献。 + --- 许可证:MIT,详见 [LICENSE](./LICENSE)。Copyright (c) 2026 CodingPlan.site \ No newline at end of file diff --git a/README_EN.md b/README_EN.md index 336f718..38a1820 100644 --- a/README_EN.md +++ b/README_EN.md @@ -326,6 +326,10 @@ A: The code doesn't depend on Windows-only APIs, but `tauri.conf.json` only targ - [Tauri](https://tauri.app) — the desktop app framework - Design specs in `docs/superpowers/specs/`, implementation plans in `docs/superpowers/plans/` +## Credits + +The usage dashboard and session manager are ported from [kimicode-dashboard](https://github.com/JochenYang/kimicode-dashboard) (MIT License, © JochenYang). The original project implements local token usage analytics and session management for Kimi Code CLI. The Rust backend (`src-tauri/src/dashboard.rs`), dashboard UI (`src/components/dashboard/`), and sessions page (`src/components/sessions/`) in this project are derived from that work. Many thanks to the original author. + --- License: MIT, see [LICENSE](./LICENSE). Copyright (c) 2026 CodingPlan.site \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 50a07ef..cbe7660 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1091,6 +1091,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1957,7 +1963,9 @@ dependencies = [ "anyhow", "chrono", "dirs 5.0.1", + "fs_extra", "indexmap 2.14.0", + "regex", "reqwest 0.12.28", "rusqlite", "serde", @@ -1967,6 +1975,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "toml 0.8.23", + "walkdir", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ebb51b8..085d767 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,8 +15,11 @@ tauri-plugin-single-instance = "2.0.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" indexmap = { version = "2.2", features = ["serde"] } -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } anyhow = "1.0" +regex = "1" +walkdir = "2" +fs_extra = "1" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } dirs = "5.0" toml = "0.8" diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs new file mode 100644 index 0000000..90c20cd --- /dev/null +++ b/src-tauri/src/dashboard.rs @@ -0,0 +1,1342 @@ +use chrono::{DateTime, Datelike, Local, TimeZone}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use walkdir::WalkDir; + +// --------------------------------------------------------------------------- +// Types – match the Node API JSON shape exactly +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PathsResult { + pub current: String, + pub valid: bool, + pub candidates: Vec, + pub env: EnvInfo, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PathCandidate { + pub path: String, + pub valid: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EnvInfo { + #[serde(rename = "KIMI_CODE_HOME")] + pub kimi_code_home: Option, + #[serde(rename = "KIMI_MODEL_NAME")] + pub kimi_model_name: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PriceRow { + pub id: String, + pub cache_hit: f64, + pub input: f64, + pub output: f64, + pub context: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PricesResult { + pub prices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SummaryResult { + pub home: String, + pub valid: bool, + pub scanned_at: u64, + pub meta: ScanMeta, + pub model_map: ModelMapInfo, + pub range: String, + pub stats: RangeStats, + pub heatmap: HeatmapData, + pub all_models: Vec, + pub all_model_count: usize, + pub range_totals: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ScanMeta { + pub files_scanned: usize, + pub lines_seen: usize, + pub record_count: usize, + pub home: String, + pub sessions_root: String, + pub errors: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ModelMapInfo { + pub default_model: Option, + pub env_model: Option, + pub alias_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EnvModelInfo { + pub name: String, + pub provider: Option, + pub model: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RangeStats { + pub range: String, + pub totals: TotalsRow, + pub daily: Vec, + pub models: Vec, + pub recent: Vec, + pub recent_total: usize, + pub recent_limit: usize, +} + +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TotalsRow { + pub requests: usize, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub total_tokens: u64, + pub cache_hit_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DailyRow { + pub date: String, + pub requests: usize, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub total_tokens: u64, + pub cache_hit_rate: f64, + /// Per-model token breakdown for stacked-bar rendering + pub by_model: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ModelRow { + pub model: String, + pub model_display: String, + pub model_resolved: String, + pub price_id: String, + pub cost_estimated: bool, + pub requests: usize, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub total_tokens: u64, + pub cache_hit_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecentRow { + pub time: u64, + pub model: String, + pub model_display: String, + pub model_resolved: String, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub total_tokens: u64, + pub cost_usd: f64, + pub cost_estimated: bool, + pub price_id: String, + pub from_env: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AllModelRow { + pub model: String, + pub model_display: String, + pub requests: usize, + pub total_tokens: u64, + pub cost_usd: f64, + pub cost_estimated: bool, + pub cache_hit_rate: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HeatmapData { + pub weeks: usize, + pub start: String, + pub end: String, + pub max_tokens: u64, + pub cells: Vec, + pub month_labels: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HeatmapCell { + pub date: String, + pub dow: usize, + pub week_index: usize, + pub requests: usize, + pub total_tokens: u64, + pub cost_usd: f64, + pub cache_hit_rate: f64, + pub level: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MonthLabel { + pub week_index: usize, + pub label: String, +} + +// Sessions types +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionsResult { + pub home: String, + pub archive_root: String, + pub workspaces: Vec, + pub sessions: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceRow { + pub id: String, + pub name: String, + pub root: Option, + pub created_at: Option, + pub last_opened_at: Option, + pub active_count: usize, + pub archived_count: usize, + pub empty: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionRow { + pub id: String, + pub workspace_id: String, + pub status: String, + pub title: Option, + pub work_dir: Option, + pub created_at: Option, + pub updated_at: Option, + pub bytes: u64, + pub files: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ActionResponse { + pub ok: bool, + pub workspace_id: String, + pub session_id: String, + pub status: Option, + pub path: Option, + pub deleted: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PreviewResult { + pub workspace_id: String, + pub session_id: String, + pub status: String, + pub title: Option, + pub work_dir: Option, + pub created_at: Option, + pub updated_at: Option, + pub message_count: usize, + pub truncated: bool, + pub messages: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PreviewMessage { + pub role: String, + pub time: Option, + pub text: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceDeleteBody { + pub workspace_id: String, + pub confirm: Option, + pub force: Option, +} + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- +pub struct AppState { + pub scan_cache: Mutex, +} + +#[derive(Clone)] +pub struct ScanCache { + pub home: String, + pub scanned_at: u64, + pub records: Vec, + pub meta: ScanMeta, + pub model_map: ModelMapInfo, +} + +#[derive(Debug, Clone)] +pub struct UsageRecord { + pub time: u64, + pub model: String, + pub input_other: u64, + pub output: u64, + pub input_cache_read: u64, + pub input_cache_creation: u64, + pub cost_usd: f64, + pub cost_estimated: bool, + pub price_id: String, + pub model_resolved: String, + pub model_display: String, + pub provider: Option, + pub from_env: bool, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn resolve_kimi_home(override_path: Option) -> PathBuf { + if let Some(p) = override_path { + if !p.trim().is_empty() { + return PathBuf::from(p); + } + } + if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") { + if !env_home.trim().is_empty() { + return PathBuf::from(env_home); + } + } + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".kimi-code") +} + +fn is_kimi_home(dir: &Path) -> bool { + if !dir.exists() || !dir.is_dir() { + return false; + } + dir.join("config.toml").exists() || dir.join("sessions").exists() +} + +fn sessions_root(home: &Path) -> PathBuf { + home.join("sessions") +} + +fn workspace_re() -> Regex { + Regex::new(r"^wd_[A-Za-z0-9._-]+$").unwrap() +} + +fn session_re() -> Regex { + Regex::new(r"^session_[0-9a-fA-F-]{8,}$").unwrap() +} + +fn safe_read_dir(path: &Path) -> Vec { + let mut entries = Vec::new(); + if let Ok(rd) = fs::read_dir(path) { + for e in rd.flatten() { + entries.push(e); + } + } + entries +} + +fn file_size_approx(path: &Path) -> (u64, usize) { + let mut total_bytes = 0u64; + let mut total_files = 0usize; + for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) { + if entry.file_type().is_file() { + if let Ok(meta) = entry.metadata() { + total_bytes += meta.len(); + total_files += 1; + } + } + } + (total_bytes, total_files) +} + +fn day_key(ts_ms: u64) -> String { + let secs = if ts_ms > 1e12 as u64 { ts_ms / 1000 } else { ts_ms }; + // Bucket by LOCAL calendar date (not UTC) so records around midnight stay on the correct day. + let local = Local + .timestamp_opt(secs as i64, 0) + .single() + .unwrap_or_else(|| DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().with_timezone(&Local)); + format!("{:04}-{:02}-{:02}", local.year(), local.month(), local.day()) +} + +// --------------------------------------------------------------------------- +// Pricing +// --------------------------------------------------------------------------- + +fn list_prices() -> Vec { + vec![ + PriceRow { id: "kimi-k3".into(), cache_hit: 0.30, input: 3.00, output: 15.00, context: 1_048_576 }, + PriceRow { id: "kimi-k2.7-code".into(), cache_hit: 0.19, input: 0.95, output: 4.00, context: 262_144 }, + PriceRow { id: "kimi-k2.6".into(), cache_hit: 0.16, input: 0.95, output: 4.00, context: 262_144 }, + PriceRow { id: "kimi-k2.5".into(), cache_hit: 0.10, input: 0.60, output: 3.00, context: 262_144 }, + PriceRow { id: "kimi-k2-turbo".into(), cache_hit: 0.15, input: 1.15, output: 8.00, context: 262_144 }, + PriceRow { id: "kimi-k2".into(), cache_hit: 0.15, input: 0.60, output: 2.50, context: 262_144 }, + ] +} + +fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) { + let bare = match model_name.rsplit_once('/') { + Some((_, b)) => b, + None => model_name, + }; + let bare_l = bare.to_ascii_lowercase(); + for p in &list_prices() { + let id_l = p.id.to_ascii_lowercase(); + if bare_l == id_l || bare_l.contains(&id_l) || id_l.contains(&bare_l) { + return (p.id.clone(), p.cache_hit, p.input, p.output, false); + } + } + ("kimi-k2.6".into(), 0.16, 0.95, 4.00, true) +} + +fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create: u64, model: &str) -> (f64, bool) { + let (_price_id, cache_hit, input_price, output_price, est) = match_price(model); + let cost = (input_other as f64 / 1e6) * input_price + + (cache_read as f64 / 1e6) * cache_hit + + (cache_create as f64 / 1e6) * input_price + + (output as f64 / 1e6) * output_price; + (cost, est) +} + +// --------------------------------------------------------------------------- +// Scanner +// --------------------------------------------------------------------------- + +fn scan_usage(home: &Path) -> (Vec, ScanMeta) { + let root = sessions_root(home); + let mut records = Vec::new(); + let mut files_scanned = 0; + let mut lines_seen = 0; + let errors = Vec::new(); + + if !root.exists() { + return (records, ScanMeta { + files_scanned: 0, lines_seen: 0, record_count: 0, + home: home.to_string_lossy().to_string(), + sessions_root: root.to_string_lossy().to_string(), + errors: vec!["sessions directory not found".into()], + }); + } + + for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) { + if entry.file_name() != "wire.jsonl" { continue; } + if !entry.file_type().is_file() { continue; } + // Skip blob/task directories + let p = entry.path(); + if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") { + continue; + } + files_scanned += 1; + let content = match fs::read_to_string(entry.path()) { + 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(); + 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, &model_raw); + let (pid, _ch, _ip, _op, _) = match_price(&model_raw); + + records.push(UsageRecord { + time, + model: model_raw.clone(), + model_resolved: bare.to_string(), + model_display: model_raw.clone(), + provider: model_raw.rsplit_once('/').map(|x| x.0.to_string()), + from_env: model_raw == "__kimi_env_model__", + input_other, + output, + input_cache_read: cache_read, + input_cache_creation: cache_create, + cost_usd: cost, + cost_estimated: est, + price_id: pid, + }); + } + } + + 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 +// --------------------------------------------------------------------------- + +fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats { + let filtered: Vec<&UsageRecord> = filter_by_range(records, range, now_ms); + let range_label = range.to_string(); + + if filtered.is_empty() { + return RangeStats { + range: range_label, + totals: TotalsRow::default(), + daily: vec![], + models: vec![], + recent: vec![], + recent_total: 0, + recent_limit: 500, + }; + } + + let mut totals = TotalsRow::default(); + let mut by_day: HashMap)> = HashMap::new(); + let mut by_model: HashMap = HashMap::new(); + + for r in &filtered { + totals.add(r); + let dk = day_key(r.time); + let (day_totals, day_models) = by_day.entry(dk.clone()).or_default(); + day_totals.add(r); + *day_models.entry(r.model.clone()).or_insert(0) += + r.input_other + r.output + r.input_cache_read + r.input_cache_creation; + + let mk = r.model.clone(); + let entry = by_model.entry(mk.clone()).or_insert_with(|| { + let m = ModelRow { + model: mk.clone(), + model_display: r.model_display.clone(), + model_resolved: r.model_resolved.clone(), + price_id: r.price_id.clone(), + cost_estimated: r.cost_estimated, + requests: 0, input_other: 0, output: 0, + input_cache_read: 0, input_cache_creation: 0, + cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0, + }; + (m, TotalsRow::default()) + }); + entry.1.add(r); + entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated; + } + + let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation; + totals.cache_hit_rate = if total_input > 0 { totals.input_cache_read as f64 / total_input as f64 } else { 0.0 }; + + let mut daily: Vec = by_day.into_iter() + .map(|(date, (t, by_model))| { + let ti = t.input_other + t.input_cache_read + t.input_cache_creation; + let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 }; + DailyRow { + date, requests: t.requests, + input_other: t.input_other, output: t.output, + input_cache_read: t.input_cache_read, input_cache_creation: t.input_cache_creation, + cost_usd: t.cost_usd, total_tokens: t.total_tokens, cache_hit_rate: ch, + by_model, + } + }) + .collect(); + daily.sort_by(|a, b| a.date.cmp(&b.date)); + + let mut models: Vec = by_model.into_iter().map(|(_, (mut m, t))| { + let ti = t.input_other + t.input_cache_read + t.input_cache_creation; + m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 }; + m.requests = t.requests; + m.input_other = t.input_other; m.output = t.output; + m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation; + m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens; + m + }).collect(); + models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); + + let recent_total = filtered.len(); + let recent_limit = 500; + let recent: Vec = filtered.iter().take(recent_limit).map(|r| RecentRow { + time: r.time, model: r.model.clone(), model_display: r.model_display.clone(), + model_resolved: r.model_resolved.clone(), + input_other: r.input_other, output: r.output, + input_cache_read: r.input_cache_read, input_cache_creation: r.input_cache_creation, + total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation, + cost_usd: r.cost_usd, cost_estimated: r.cost_estimated, + price_id: r.price_id.clone(), from_env: r.from_env, + }).collect(); + + RangeStats { + range: range_label, + totals, + daily, + models, + recent, + recent_total, + recent_limit, + } +} + +impl TotalsRow { + fn default() -> Self { + TotalsRow { + requests: 0, input_other: 0, output: 0, + input_cache_read: 0, input_cache_creation: 0, + cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0, + } + } + fn add(&mut self, r: &UsageRecord) { + self.requests += 1; + self.input_other += r.input_other; + self.output += r.output; + self.input_cache_read += r.input_cache_read; + self.input_cache_creation += r.input_cache_creation; + self.cost_usd += r.cost_usd; + self.total_tokens = self.input_other + self.output + self.input_cache_read + self.input_cache_creation; + } +} + +fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> { + if range == "all" { return records.iter().collect(); } + let start = range_start(range, now_ms); + records.iter().filter(|r| r.time >= start).collect() +} + +fn range_start(range: &str, now_ms: u64) -> u64 { + match range { + "today" => { + // LOCAL midnight today — not UTC midnight. Without this, users east of UTC see + // "today" begin at the wrong hour (e.g. UTC+8 users get cutoff at local 08:00). + let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default(); + let now_local = now_utc.with_timezone(&Local); + let today_date = now_local.date_naive(); + let today_start = today_date.and_hms_opt(0, 0, 0).unwrap_or_default(); + Local + .from_local_datetime(&today_start) + .single() + .map(|dt| dt.timestamp() as u64 * 1000) + .unwrap_or_else(|| today_start.and_utc().timestamp() as u64 * 1000) + } + "7d" => now_ms - 7 * 24 * 3600 * 1000, + "30d" => now_ms - 30 * 24 * 3600 * 1000, + _ => now_ms - 30 * 24 * 3600 * 1000, + } +} + +fn build_heatmap(records: &[UsageRecord], now_ms: u64) -> HeatmapData { + let weeks = 53; + // Use LOCAL today for the heatmap's right edge so the current day's cell lights up correctly. + let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default(); + let now_local = now_utc.with_timezone(&Local); + let end_date = now_local.date_naive(); + let start_date = end_date - chrono::Duration::days(weeks * 7 - 1); + let start_date = start_date - chrono::Duration::days(start_date.weekday().num_days_from_sunday() as i64); + + let mut by_day: HashMap = HashMap::new(); + for r in records { + let dk = day_key(r.time); + let t = by_day.entry(dk).or_default(); + t.add(r); + } + + let mut cursor = start_date; + let mut cells = Vec::new(); + let mut max_tokens = 0u64; + + while cursor <= end_date { + let key = format!("{:04}-{:02}-{:02}", cursor.year(), cursor.month(), cursor.day()); + let t = by_day.get(&key).cloned().unwrap_or_else(TotalsRow::default); + let tok = t.total_tokens; + if tok > max_tokens { max_tokens = tok; } + let dow = cursor.weekday().num_days_from_sunday() as usize; + let week_idx = ((cursor - start_date).num_days() / 7) as usize; + let ti = t.input_other + t.input_cache_read + t.input_cache_creation; + let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 }; + cells.push(HeatmapCell { + date: key, dow, week_index: week_idx, + requests: t.requests, total_tokens: tok, cost_usd: t.cost_usd, + cache_hit_rate: ch, level: 0, + }); + cursor += chrono::Duration::days(1); + if cells.len() > (weeks * 7 + 7) as usize { break; } + } + for c in &mut cells { + c.level = if max_tokens == 0 || c.total_tokens == 0 { + 0 + } else { + let r = c.total_tokens as f64 / max_tokens as f64; + if r > 0.75 { 4 } else if r > 0.5 { 3 } else if r > 0.25 { 2 } else { 1 } + }; + } + + let mut month_labels = Vec::new(); + let mut last_month = String::new(); + for c in &cells { + let m = c.date[..7].to_string(); + if m != last_month { + month_labels.push(MonthLabel { week_index: c.week_index, label: c.date[5..7].to_string() }); + last_month = m; + } + } + + HeatmapData { + weeks: weeks as usize, + start: start_date.format("%Y-%m-%d").to_string(), + end: end_date.format("%Y-%m-%d").to_string(), + max_tokens, + cells, + month_labels, + } +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +fn list_workspace_dirs(home: &Path) -> Vec { + let root = sessions_root(home); + if !root.exists() { return vec![]; } + let mut out = Vec::new(); + let re = workspace_re(); + for entry in safe_read_dir(&root) { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } + let name = entry.file_name().to_string_lossy().to_string(); + if name == ".kcd-archive" { continue; } + if !re.is_match(&name) { continue; } + out.push(name); + } + out.sort(); + out +} + +fn read_state_safe(session_dir: &Path) -> (Option, Option, Option, Option) { + let sp = session_dir.join("state.json"); + if !sp.exists() { return (None, None, None, None); } + if let Ok(content) = fs::read_to_string(&sp) { + if let Ok(obj) = serde_json::from_str::(&content) { + let title = obj.get("title").and_then(|v| v.as_str().map(|s| s.to_string())); + let work_dir = obj.get("workDir").and_then(|v| v.as_str().map(|s| s.to_string())); + let created_at = obj.get("createdAt").and_then(|v| v.as_str().map(|s| s.to_string())); + let updated_at = obj.get("updatedAt").and_then(|v| v.as_str().map(|s| s.to_string())); + return (title, work_dir, created_at, updated_at); + } + } + (None, None, None, None) +} + +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) { + caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_else(|| id.to_string()) + } else { + id.to_string() + } +} + +fn list_sessions_in_dir(dir: &Path, workspace_id: &str, status: &str) -> Vec { + let mut sessions = Vec::new(); + if !dir.exists() { return sessions; } + let re = session_re(); + for entry in safe_read_dir(dir) { + if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } + let name = entry.file_name().to_string_lossy().to_string(); + if !re.is_match(&name) { continue; } + let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path()); + let (bytes, files) = file_size_approx(&entry.path()); + let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok()) + .map(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)).flatten(); + sessions.push(SessionRow { + id: name, workspace_id: workspace_id.to_string(), status: status.to_string(), + title, work_dir, created_at, + updated_at: updated_at.or_else(|| mtime.map(|m| { + let secs = if m > 1e12 as u64 { m / 1000 } else { m }; + let dt = DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().naive_utc(); + dt.to_string() + })), + bytes, files, + }); + } + sessions +} + +fn list_sessions_cmd(home: &Path, status: &str, workspace_filter: Option) -> SessionsResult { + let root = sessions_root(home); + let archive_root = root.join(".kcd-archive"); + let mut ws_ids: Vec = list_workspace_dirs(home); + // Also include archive-only workspaces + if archive_root.exists() { + for entry in safe_read_dir(&archive_root) { + let name = entry.file_name().to_string_lossy().to_string(); + if workspace_re().is_match(&name) && !ws_ids.contains(&name) { + ws_ids.push(name); + } + } + } + ws_ids.sort(); + + // Load workspaces.json + let wp = home.join("workspaces.json"); + let mut ws_meta: HashMap = HashMap::new(); + if let Ok(content) = fs::read_to_string(&wp) { + if let Ok(obj) = serde_json::from_str::(&content) { + if let Some(ws) = obj.get("workspaces").and_then(|v| v.as_object()) { + for (k, v) in ws { + ws_meta.insert(k.clone(), v.clone()); + } + } + } + } + + let mut workspaces = Vec::new(); + let mut all_sessions = Vec::new(); + + for wid in &ws_ids { + if let Some(ref filter) = workspace_filter { + if wid != filter { continue; } + } + let meta = ws_meta.get(wid); + let name = meta.and_then(|m| m.get("name").and_then(|v| v.as_str())) + .map(|s| s.to_string()).unwrap_or_else(|| humanize_workspace(wid)); + let root_path = meta.and_then(|m| m.get("root").and_then(|v| v.as_str()).map(|s| s.to_string())); + + let active_dir = root.join(wid); + let arch_dir = archive_root.join(wid); + let active_all = list_sessions_in_dir(&active_dir, wid, "active"); + let arch_all = list_sessions_in_dir(&arch_dir, wid, "archived"); + let active_list = if status == "archived" { vec![] } else { active_all.clone() }; + let arch_list = if status == "active" { vec![] } else { arch_all.clone() }; + + let empty = active_all.is_empty() && arch_all.is_empty(); + workspaces.push(WorkspaceRow { + id: wid.clone(), name, root: root_path, + created_at: None, last_opened_at: None, + active_count: active_all.len(), archived_count: arch_all.len(), empty, + }); + all_sessions.extend(active_list); + all_sessions.extend(arch_list); + } + + all_sessions.sort_by(|a, b| { + let ta = a.updated_at.as_deref().or(a.created_at.as_deref()).unwrap_or("").to_string(); + let tb = b.updated_at.as_deref().or(b.created_at.as_deref()).unwrap_or("").to_string(); + tb.cmp(&ta) + }); + + SessionsResult { + home: home.to_string_lossy().to_string(), + archive_root: ".kcd-archive".into(), + workspaces, + sessions: all_sessions, + } +} + +fn assert_safe_path(home: &Path, workspace_id: &str, session_id: &str) -> Result { + if !workspace_re().is_match(workspace_id) { + return Err("invalid workspace id".into()); + } + if !session_re().is_match(session_id) { + return Err("invalid session id".into()); + } + let root = sessions_root(home).canonicalize().unwrap_or_else(|_| sessions_root(home)); + let candidate = root.join(workspace_id).join(session_id).canonicalize().unwrap_or_else(|_| root.join(workspace_id).join(session_id)); + if !candidate.starts_with(&root) { + return Err("path escape blocked".into()); + } + Ok(candidate) +} + +fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result { + let src = assert_safe_path(home, workspace_id, session_id)?; + let dest = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); + if !src.exists() { return Err("session not found".into()); } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; + } + fs::rename(&src, &dest).or_else(|_| { + // cross-device fallback + fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok(); + fs::remove_dir_all(&src).ok(); + Ok::<(), String>(()) + }).map_err(|e: String| format!("move: {}", e))?; + scrub_session_index(home, session_id); + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), status: Some("archived".into()), + path: Some(dest.to_string_lossy().to_string()), deleted: None, + }) +} + +fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result { + let src = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); + let dest = assert_safe_path(home, workspace_id, session_id)?; + if !src.exists() { return Err("archived session not found".into()); } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; + } + fs::rename(&src, &dest).or_else(|_| { + fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok(); + fs::remove_dir_all(&src).ok(); + Ok::<(), String>(()) + }).map_err(|e: String| format!("move: {}", e))?; + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), status: Some("active".into()), + path: Some(dest.to_string_lossy().to_string()), deleted: None, + }) +} + +fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result { + let active = assert_safe_path(home, workspace_id, session_id)?; + let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); + let target = match status_hint { + Some("archived") if archived.exists() => archived, + Some("active") if active.exists() => active, + _ => { + if active.exists() { active } else if archived.exists() { archived } + else { return Err("session not found".into()); } + } + }; + fs::remove_dir_all(&target).map_err(|e| format!("delete: {}", e))?; + scrub_session_index(home, session_id); + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), status: None, + path: Some(target.to_string_lossy().to_string()), deleted: Some(true), + }) +} + +fn scrub_session_index(home: &Path, session_id: &str) { + let ip = home.join("session_index.jsonl"); + if !ip.exists() { return; } + if let Ok(content) = fs::read_to_string(&ip) { + let lines: Vec<&str> = content.lines().filter(|l| { + let sid = format!("\"sessionId\":\"{}\"", session_id); + let sid2 = format!("\"sessionId\": \"{}\"", session_id); + let path_match = format!("/{}\"", session_id); + !l.contains(&sid) && !l.contains(&sid2) && !l.contains(&path_match) + }).collect(); + if lines.len() < content.lines().count() { + let _ = fs::write(&ip, lines.join("\n") + "\n"); + } + } +} + +fn delete_workspace_cmd(home: &Path, workspace_id: &str, _confirm: bool, _force: bool) -> Result { + if !workspace_re().is_match(workspace_id) { + return Err("invalid workspace id".into()); + } + let root = sessions_root(home); + let active_dir = root.join(workspace_id); + let arch_dir = root.join(".kcd-archive").join(workspace_id); + + if active_dir.exists() { + let active_list = list_sessions_in_dir(&active_dir, workspace_id, "active"); + if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } + } + if arch_dir.exists() { + let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, "archived"); + if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } + } + + if active_dir.exists() { fs::remove_dir_all(&active_dir).ok(); } + if arch_dir.exists() { fs::remove_dir_all(&arch_dir).ok(); } + + // Update workspaces.json + let wp = home.join("workspaces.json"); + if let Ok(content) = fs::read_to_string(&wp) { + if let Ok(mut obj) = serde_json::from_str::(&content) { + if let Some(ws) = obj.get_mut("workspaces").and_then(|v| v.as_object_mut()) { + ws.remove(workspace_id); + } + let deleted = obj.get_mut("deleted_workspace_ids") + .and_then(|v| v.as_array_mut()); + if let Some(arr) = deleted { + if !arr.iter().any(|v| v.as_str() == Some(workspace_id)) { + arr.push(serde_json::Value::String(workspace_id.to_string())); + } + } + let _ = fs::write(&wp, serde_json::to_string_pretty(&obj).unwrap() + "\n"); + } + } + + Ok(ActionResponse { + ok: true, workspace_id: workspace_id.to_string(), + session_id: String::new(), status: None, path: None, deleted: Some(true), + }) +} + +// --------------------------------------------------------------------------- +// Preview +// --------------------------------------------------------------------------- + +fn clip_text(s: &str, max: usize) -> String { + // Only walk the first `max` bytes — earlier version filtered the whole string, + // which was fine for short text but blew up when previewing big turns. + let (end, trunc) = if s.len() <= max { + (s.len(), false) + } else { + let mut e = max; + while e < s.len() && !s.is_char_boundary(e) { e += 1; } + (e, true) + }; + let head = &s[..end]; + let cleaned: String = head.chars().filter(|&c| c != '\0').collect(); + if trunc { format!("{}…", cleaned) } else { cleaned } +} + +fn extract_text_parts(content: &serde_json::Value) -> String { + if let Some(s) = content.as_str() { return s.to_string(); } + if let Some(arr) = content.as_array() { + let mut parts = Vec::new(); + for p in arr { + if let Some(obj) = p.as_object() { + if let Some(t) = obj.get("text").and_then(|v| v.as_str()) { + parts.push(t.to_string()); + } + } + } + return parts.join("\n"); + } + String::new() +} + +fn push_user_msg(msgs: &mut Vec, role: &str, text: &str, time: u64) { + let norm: String = text.split_whitespace().collect::>().join(" "); + if norm.is_empty() { return; } + if let Some(last) = msgs.last() { + if last.role == role { + let last_norm: String = last.text.split_whitespace().collect::>().join(" "); + if last_norm == norm { return; } + } + } + let text = if looks_like_secret(text) { "[redacted: possible secret content]".into() } else { clip_text(text, 2500) }; + msgs.push(PreviewMessage { role: role.into(), time: Some(time), text }); +} + +fn flush_assistant_msg(msgs: &mut Vec, bucket: &Option<(Vec, u64)>) { + if let Some((texts, _)) = bucket { + let combined = texts.join(""); + if !combined.trim().is_empty() { + let text = if looks_like_secret(&combined) { "[redacted: possible secret content]".into() } else { clip_text(&combined, 2500) }; + if let Some(last) = msgs.last() { + if last.role == "assistant" { + let last_norm: String = last.text.split_whitespace().collect::>().join(" "); + let this_norm: String = text.split_whitespace().collect::>().join(" "); + if last_norm == this_norm { return; } + } + } + msgs.push(PreviewMessage { role: "assistant".into(), time: None, text }); + } + } +} + +fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result { + let root = sessions_root(home); + let active = assert_safe_path(home, workspace_id, session_id)?; + let archived = root.join(".kcd-archive").join(workspace_id).join(session_id); + let session_dir = match status_hint { + Some("archived") if archived.exists() => archived, + _ => if active.exists() { active } else if archived.exists() { archived } + else { return Err("session not found".into()); } + }; + + let (title, work_dir, created_at, updated_at) = read_state_safe(&session_dir); + let api_dir = session_dir.join("agents").join("main"); + let mut wire_path = api_dir.join("wire.jsonl"); + if !wire_path.exists() { + wire_path = session_dir.join("wire.jsonl"); + } + if !wire_path.exists() { + // try first agent wire + let agents_dir = session_dir.join("agents"); + if agents_dir.exists() { + for entry in safe_read_dir(&agents_dir) { + let w = entry.path().join("wire.jsonl"); + if w.exists() { wire_path = w; break; } + } + } + } + + let mut messages: Vec = Vec::new(); + let mut truncated = false; + let max_msgs = 80; + let _max_chars = 2500usize; + // Hard byte cap: refuse to stream more than this from a single wire.jsonl. + // Typical heavy sessions are 5–30 MB; anything larger risks OOM in the WebView. + const MAX_WIRE_BYTES: usize = 20 * 1024 * 1024; // 20 MB + + if wire_path.exists() { + let file = match fs::File::open(&wire_path) { + Ok(f) => f, + Err(e) => return Err(format!("open wire: {e}")), + }; + let reader = BufReader::new(file); + let mut bytes_read = 0usize; + let mut line_count = 0usize; + let mut current_assistant: Option<(Vec, u64)> = None; + let mut current_step_key: Option = None; + + for line_res in reader.lines() { + let line = match line_res { + Ok(l) => l, + Err(_) => break, + }; + bytes_read += line.len() + 1; // +1 for the stripped newline + line_count += 1; + if bytes_read > MAX_WIRE_BYTES { truncated = true; break; } + if messages.len() >= max_msgs { truncated = true; break; } + if !line.contains("\"context.append_message\"") && !line.contains("\"turn.steer\"") + && !line.contains("\"turn.prompt\"") && !line.contains("\"content.part\"") + && !line.contains("\"step.end\"") { continue; } + let obj: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, Err(_) => continue, + }; + let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + if typ == "context.append_message" { + flush_assistant_msg(&mut messages, ¤t_assistant); + current_assistant = None; + current_step_key = None; + let msg = &obj["message"]; + let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown"); + if role == "tool" { continue; } + let raw = extract_text_parts(&msg["content"]); + if raw.trim().is_empty() { continue; } + push_user_msg(&mut messages, role, &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)); + continue; + } + + if typ == "turn.steer" || typ == "turn.prompt" { + let input = &obj["input"]; + let raw = if let Some(s) = input.as_str() { s.to_string() } + else if let Some(arr) = input.as_array() { + arr.iter().filter_map(|x| x.get("text").and_then(|v| v.as_str())).collect::>().join("\n") + } else { String::new() }; + if raw.trim().is_empty() { continue; } + push_user_msg(&mut messages, "user", &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)); + continue; + } + + if typ == "context.append_loop_event" { + let ev = &obj["event"]; + let ev_type = ev.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if ev_type == "content.part" { + let part = &ev["part"]; + if part.get("type").and_then(|v| v.as_str()) == Some("text") { + let text = part.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if !text.is_empty() { + let step_key = ev.get("stepUuid").and_then(|v| v.as_str()) + .map(|s| s.to_string()).unwrap_or_else(|| format!("{}:{}", ev["turnId"], ev["step"])); + // flush if step changed + if current_step_key.as_deref() != Some(&step_key) { + flush_assistant_msg(&mut messages, ¤t_assistant); + current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0))); + current_step_key = Some(step_key.clone()); + } else if current_assistant.is_none() { + current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0))); + } + current_assistant.as_mut().unwrap().0.push(text.to_string()); + } + } + continue; + } + if ev_type == "step.end" { + flush_assistant_msg(&mut messages, ¤t_assistant); + current_assistant = None; + current_step_key = None; + } + } + } + flush_assistant_msg(&mut messages, ¤t_assistant); + if line_count > 8000 { truncated = true; } + if messages.len() >= max_msgs { truncated = true; } + if bytes_read > MAX_WIRE_BYTES { truncated = true; } + } + + Ok(PreviewResult { + workspace_id: workspace_id.to_string(), + session_id: session_id.to_string(), + status: if session_dir.to_string_lossy().contains(".kcd-archive") { "archived".into() } else { "active".into() }, + title, work_dir, created_at, updated_at, + message_count: messages.len(), + truncated, + messages, + }) +} + +fn looks_like_secret(s: &str) -> bool { + let re = regex::Regex::new(r"(?i)api[_-]?key|sk-[a-zA-Z0-9]{12,}|BEGIN (RSA |OPENSSH )?PRIVATE KEY").unwrap(); + re.is_match(s) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub fn get_paths() -> PathsResult { + let home = resolve_kimi_home(None); + let valid = is_kimi_home(&home); + let mut candidates = Vec::new(); + if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") { + let p = PathBuf::from(&env_home); + candidates.push(PathCandidate { path: env_home, valid: is_kimi_home(&p) }); + } + if let Some(hd) = dirs::home_dir() { + let p = hd.join(".kimi-code"); + candidates.push(PathCandidate { path: p.to_string_lossy().to_string(), valid: is_kimi_home(&p) }); + if let Ok(up) = std::env::var("USERPROFILE") { + let p2 = PathBuf::from(&up).join(".kimi-code"); + if !candidates.iter().any(|c| c.path == p2.to_string_lossy()) { + candidates.push(PathCandidate { path: p2.to_string_lossy().to_string(), valid: is_kimi_home(&p2) }); + } + } + } + PathsResult { + current: home.to_string_lossy().to_string(), + valid, + candidates, + env: EnvInfo { + kimi_code_home: std::env::var("KIMI_CODE_HOME").ok(), + kimi_model_name: std::env::var("KIMI_MODEL_NAME").ok(), + }, + } +} + +#[tauri::command] +pub fn get_prices() -> PricesResult { + PricesResult { prices: list_prices() } +} + +#[tauri::command] +pub fn get_summary(home_override: Option, range: Option, refresh: Option) -> SummaryResult { + let _refresh = refresh.unwrap_or(false); + let home = resolve_kimi_home(home_override); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + let r = range.unwrap_or_else(|| "30d".into()); + + let (records, meta) = scan_usage(&home); + let stats = aggregate(&records, &r, now_ms); + let all_stats = aggregate(&records, "all", now_ms); + let heatmap = build_heatmap(&records, now_ms); + + let all_models: Vec = all_stats.models.into_iter().map(|m| { + AllModelRow { + model: m.model, model_display: m.model_display, requests: m.requests, + total_tokens: m.total_tokens, cost_usd: m.cost_usd, cost_estimated: m.cost_estimated, + cache_hit_rate: m.cache_hit_rate, + } + }).collect(); + + let all_model_count = all_models.len(); + let mut range_totals = HashMap::new(); + for r_k in ["today", "7d", "30d", "all"] { + let s = aggregate(&records, r_k, now_ms); + range_totals.insert(r_k.to_string(), s.totals); + } + + let default_model = None; + let env_model = std::env::var("KIMI_MODEL_NAME").ok().map(|name| EnvModelInfo { + name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(), + }); + + SummaryResult { + home: home.to_string_lossy().to_string(), + valid: is_kimi_home(&home), + scanned_at: now_ms, + meta, + model_map: ModelMapInfo { default_model, env_model, alias_count: 0 }, + range: r, + stats, + heatmap, + all_models, + all_model_count, + range_totals, + } +} + +#[tauri::command] +pub fn list_sessions(home_override: Option, status: Option, workspace: Option) -> SessionsResult { + let home = resolve_kimi_home(home_override); + list_sessions_cmd(&home, &status.unwrap_or_else(|| "active".into()), workspace) +} + +#[tauri::command] +pub fn archive_session(home_override: Option, workspace_id: String, session_id: String) -> Result { + let home = resolve_kimi_home(home_override); + archive_session_cmd(&home, &workspace_id, &session_id) +} + +#[tauri::command] +pub fn unarchive_session(home_override: Option, workspace_id: String, session_id: String) -> Result { + let home = resolve_kimi_home(home_override); + unarchive_session_cmd(&home, &workspace_id, &session_id) +} + +#[tauri::command] +pub fn delete_session(home_override: Option, workspace_id: String, session_id: String, status: Option) -> Result { + let home = resolve_kimi_home(home_override); + delete_session_cmd(&home, &workspace_id, &session_id, status.as_deref()) +} + +#[tauri::command] +pub fn delete_workspace(home_override: Option, workspace_id: String, confirm: bool, force: Option) -> Result { + if !confirm { return Err("confirm_required: Pass confirm:true to delete an empty workspace".into()); } + let home = resolve_kimi_home(home_override); + delete_workspace_cmd(&home, &workspace_id, confirm, force.unwrap_or(false)) +} + +#[tauri::command] +pub fn get_session_preview(home_override: Option, workspace_id: String, session_id: String, status: Option) -> Result { + let home = resolve_kimi_home(home_override); + get_session_preview_cmd(&home, &workspace_id, &session_id, status.as_deref()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5ad9d7..45f62b2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod config_io; +pub mod dashboard; pub mod db; pub mod kimi_code_io; pub mod models; @@ -99,6 +100,15 @@ pub fn run() { commands::get_app_version, commands::list_provider_models, commands::debug_log, + dashboard::get_paths, + dashboard::get_prices, + dashboard::get_summary, + dashboard::list_sessions, + dashboard::archive_session, + dashboard::unarchive_session, + dashboard::delete_session, + dashboard::delete_workspace, + dashboard::get_session_preview, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 8aacfe2..910898e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,8 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { useConfig } from "./hooks/useConfig"; import { ProviderList } from "./components/ProviderList"; import { ProviderEdit } from "./components/ProviderEdit"; +import { DashboardPage } from "./components/dashboard/DashboardPage"; +import { SessionsPage } from "./components/sessions/SessionsPage"; import { useTranslation } from "./i18n"; import { getDefaultMaxContextSize } from "./lib/model-defaults"; import { getModelRef } from "./lib/models-dev"; @@ -35,24 +37,19 @@ function findFirstModelForProvider(models: Record, providerName: return null; } -const AGENTS: { key: Agent; label: string }[] = [ - { key: "kimi_code", label: "Kimi Code" }, - { key: "pi", label: "Pi" }, -]; - function getInitialAgent(): Agent { try { const stored = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null; - if (stored === "kimi_code" || stored === "pi") return stored; + if (stored === "kimi_code") return stored; } catch { // ignore } - return "pi"; + return "kimi_code"; } export default function App() { const { t, lang, setLang } = useTranslation(); - const [agent, setAgentState] = useState(getInitialAgent); + const [agent] = useState(getInitialAgent); const { config, dirty, @@ -63,21 +60,11 @@ export default function App() { updateConfig, } = useConfig(agent); - const [view, setView] = useState<"list" | "edit">("list"); + const [view, setView] = useState<"list" | "edit" | "dashboard" | "sessions">("list"); const [editingProvider, setEditingProvider] = useState(""); const [loadTimeout, setLoadTimeout] = useState(false); const [switchMessage, setSwitchMessage] = useState(null); - const setAgent = (next: Agent) => { - setAgentState(next); - try { - localStorage.setItem(AGENT_STORAGE_KEY, next); - } catch { - // ignore - } - setView("list"); - setEditingProvider(""); - }; useEffect(() => { if (!loading) { @@ -377,20 +364,39 @@ export default function App() {
- {AGENTS.map(({ key, label }) => ( - - ))} + + +
setWorkspace(e.target.value)} + className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none max-w-[200px]" + > + + {workspaces.map((w) => ( + + ))} + +
+
+ +
+
+
+ {workspace === "all" + ? t("allWorkspaces") + : workspaceMap.get(workspace)?.name || workspace} +
+
+ {t("sessionsInView", { n: String(sessions.length) })} + {workspace !== "all" ? ` · ${t("isolatedToWorkspace")}` : ""} +
+
+
+ + + + + + + + + + + + + + {sessions.length === 0 ? ( + + + + ) : ( + sessions.map((row) => { + const key = rowKey(row); + const ws = workspaceMap.get(row.workspaceId); + const busy = busyId === key; + return ( + + + + + + + + + + ); + }) + )} + +
+ { + if (el) el.indeterminate = someSelected; + }} + type="checkbox" + className="h-4 w-4 accent-blue-600" + checked={allSelected} + disabled={!sessions.length} + onChange={toggleSelectAll} + aria-label={t("selectAll")} + /> + {t("sessionTitle")}{t("workspace")}{t("status")}{t("size")}{t("updated")}{t("actions")}
+ {loading ? t("scanning") : t("noSessions")} +
+ toggleSelect(row)} + /> + +
+ + + {row.id} + + {row.workDir && ( + + {row.workDir} + + )} +
+
+ + {ws?.name || row.workspaceId} + + + {row.status === "archived" ? ( + + {t("statusArchived")} + + ) : ( + + {t("statusActive")} + + )} + + {fmtBytes(row.bytes)} + + {fmtTime(parseTime(row.updatedAt), locale)} + +
+ {row.status === "active" ? ( + + ) : ( + + )} + +
+
+
+
+ + + + {/* Attribution */} +
+ {data?.home && <>{data.home}} +
+ 会话管理功能基于{" "} + + kimicode-dashboard + {" "} + (MIT,© JochenYang)移植 +
+ + {/* Confirm dialog */} + {confirm && ( +
+
+
+

+ {confirm.mode === "bulk" + ? t("confirmBulkTitle") + : confirm.mode === "workspace" + ? t("confirmDeleteWorkspaceTitle") + : t("confirmDeleteTitle")} +

+

+ {confirm.mode === "bulk" + ? t("confirmBulkDelete", { n: String(confirm.rows.length) }) + : confirm.mode === "workspace" + ? t("confirmDeleteWorkspace") + : t("confirmDeleteSession")} +

+ {confirm.mode === "one" && ( +
+
+ {confirm.row.title || confirm.row.id} +
+
+ {confirm.row.id} +
+
+ )} + {confirm.mode === "bulk" && ( +
+ {t("selectedCount", { n: String(confirm.rows.length) })} +
+ )} + {confirm.mode === "workspace" && ( +
+
+ {confirm.workspace.name || confirm.workspace.id} +
+ {confirm.workspace.root && ( +
+ {confirm.workspace.root} +
+ )} +
+ {confirm.workspace.id} +
+
+ )} +
+
+ + +
+
+
+ )} + + {/* Preview dialog */} + {preview && ( +
+
+
+

{t("previewTitle")}

+

{t("previewHint")}

+
+
+ {preview.row.title || preview.row.id} +
+
{preview.row.id}
+
+
+
+ {preview.loading && ( +
+
+ {t("scanning")} +
+ )} + {preview.error && ( +
+ {preview.error} +
+ )} + {!preview.loading && !preview.error && !preview.messages.length && ( +
{t("previewEmpty")}
+ )} + {preview.messages.map((m, i) => { + const roleLabel = + m.role === "user" + ? t("roleUser") + : m.role === "assistant" + ? t("roleAssistant") + : m.role === "system" + ? t("roleSystem") + : m.role; + return ( + + ); + })} + {preview.truncated && ( +
{t("previewTruncated")}
+ )} +
+
+ +
+
+
+ )} +
+ ); +} + +// Per-message card with 500-char collapse + expand toggle. +// Renders long texts lazily — prevents huge
 blocks from bloating the DOM.
+const PREVIEW_TEXT_LIMIT = 500;
+
+function PreviewMessageCard({
+  role,
+  roleLabel,
+  time,
+  text,
+  locale,
+}: {
+  role: string;
+  roleLabel: string;
+  time: number | null;
+  text: string;
+  locale: string;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const isLong = text.length > PREVIEW_TEXT_LIMIT;
+  const visible = expanded || !isLong ? text : text.slice(0, PREVIEW_TEXT_LIMIT);
+
+  return (
+    
+
+ {roleLabel} + {time ? ( + {fmtTime(time, locale)} + ) : null} +
+
+        {visible}
+        {!expanded && isLong && (
+           …
+        )}
+      
+ {isLong && ( + + )} +
+ ); +} diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts new file mode 100644 index 0000000..1c52dae --- /dev/null +++ b/src/hooks/useDashboard.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import type { SummaryResult } from "../types/dashboard"; + +export type DashboardRange = "today" | "7d" | "30d" | "all"; + +const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range"; + +export function useDashboard() { + const [range, setRange] = useState(() => { + try { + const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null; + if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") { + return stored; + } + } catch { + // ignore + } + return "30d"; + }); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await invoke("get_summary", { range }); + setData(result); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + } finally { + setLoading(false); + } + }, [range]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const changeRange = useCallback((next: DashboardRange) => { + setRange(next); + try { + localStorage.setItem(RANGE_STORAGE_KEY, next); + } catch { + // ignore + } + }, []); + + return { range, changeRange, data, loading, error, refresh }; +} diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts new file mode 100644 index 0000000..d4c803c --- /dev/null +++ b/src/hooks/useSessions.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import type { + ActionResponse, + PreviewResult, + SessionStatusFilter, + SessionsResult, +} from "../types/sessions"; + +export function useSessions() { + const [status, setStatus] = useState("active"); + const [workspace, setWorkspace] = useState("all"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await invoke("list_sessions", { + status, + workspace: workspace === "all" ? null : workspace, + }); + setData(result); + } catch (err) { + setData(null); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [status, workspace]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const archiveSession = useCallback( + async (workspaceId: string, sessionId: string) => { + await invoke("archive_session", { workspaceId, sessionId }); + await refresh(); + }, + [refresh] + ); + + const unarchiveSession = useCallback( + async (workspaceId: string, sessionId: string) => { + await invoke("unarchive_session", { + workspaceId, + sessionId, + }); + await refresh(); + }, + [refresh] + ); + + const deleteSession = useCallback( + async (workspaceId: string, sessionId: string, sessionStatus?: string) => { + await invoke("delete_session", { + workspaceId, + sessionId, + status: sessionStatus ?? null, + }); + await refresh(); + }, + [refresh] + ); + + const deleteWorkspace = useCallback( + async (workspaceId: string) => { + await invoke("delete_workspace", { + workspaceId, + confirm: true, + }); + await refresh(); + }, + [refresh] + ); + + const getPreview = useCallback( + async (workspaceId: string, sessionId: string, sessionStatus?: string) => { + return invoke("get_session_preview", { + workspaceId, + sessionId, + status: sessionStatus ?? null, + }); + }, + [] + ); + + return { + status, + setStatus, + workspace, + setWorkspace, + data, + loading, + error, + setError, + refresh, + archiveSession, + unarchiveSession, + deleteSession, + deleteWorkspace, + getPreview, + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2e2607f..ab730ee 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -20,6 +20,8 @@ export const enTranslations: Record = { // Provider list providers: "Providers", + dashboard: "Dashboard", + sessions: "Sessions", addProvider: "Add Provider", noProviders: "No providers yet", addFirstProvider: "Add first provider", @@ -131,6 +133,71 @@ export const enTranslations: Record = { capToolUse: "Tool Use", customCapabilities: "Other capabilities (comma-separated)", + // Sessions + sessionsTitle: "Sessions", + sessionsSubtitle: + "Browse, preview, archive, or delete Kimi Code sessions by workspace. Archiving keeps data on disk.", + workspaces: "Workspaces", + workspaceIsolatedHint: "Sessions are isolated per workspace", + allWorkspaces: "All workspaces", + emptyWorkspace: "Empty", + deleteWorkspace: "Delete workspace", + noSessions: "No sessions", + statusActive: "Active", + statusArchived: "Archived", + statusAll: "All", + selectAll: "Select all", + deselectAll: "Deselect all", + selectedCount: "{n} selected", + archive: "Archive", + unarchive: "Unarchive", + sessionTitle: "Session", + workspace: "Workspace", + status: "Status", + size: "Size", + updated: "Updated", + actions: "Actions", + sessionsInView: "{n} sessions", + isolatedToWorkspace: "this workspace only", + openPreview: "Open preview", + scanning: "Scanning…", + refresh: "Refresh", + refreshing: "Refreshing…", + refreshed: "Refreshed", + cancel: "Cancel", + confirmDeleteTitle: "Delete session forever?", + confirmDeleteSession: + "This cannot be undone. Prefer archive if you only want it out of the way.", + confirmBulkTitle: "Delete selected forever?", + confirmBulkDelete: "Permanently delete {n} sessions. This cannot be undone.", + confirmDeleteWorkspaceTitle: "Delete empty workspace?", + confirmDeleteWorkspace: "Only workspaces with no sessions can be removed.", + deleteForever: "Delete forever", + previewTitle: "Session preview", + previewHint: + "Roles and truncated text only — secrets and raw tool dumps are omitted.", + previewEmpty: "No previewable messages", + previewTruncated: "Preview truncated", + roleUser: "User", + roleAssistant: "Assistant", + roleSystem: "System", + + // Heatmap + noData: "No data", + heatLess: "Less", + heatMore: "More", + requests: "Requests", + cacheHitRate: "Cache hit rate", + + // Daily detail modal + dayDetail: "Daily usage detail", + close: "Close", + totalTokens: "Total tokens", + cost: "Cost", + modelDistribution: "Model distribution", + modalEscHint: "Press Esc or click outside to close", + doubleClickHint: "Double-click for details", + // Languages language: "Language", zh: "中文", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 6e1dda3..94f290a 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -18,6 +18,8 @@ export const zhTranslations = { // Provider list providers: "供应商", + dashboard: "用量仪表盘", + sessions: "会话管理", addProvider: "添加供应商", noProviders: "暂无供应商", addFirstProvider: "添加第一个供应商", @@ -129,6 +131,68 @@ export const zhTranslations = { capToolUse: "工具调用", customCapabilities: "其他能力(逗号分隔)", + // Sessions + sessionsTitle: "会话管理", + sessionsSubtitle: "按工作区查看、预览、归档或删除 Kimi Code 会话。归档不会删除数据。", + workspaces: "工作区", + workspaceIsolatedHint: "会话按工作区隔离存储", + allWorkspaces: "全部工作区", + emptyWorkspace: "空", + deleteWorkspace: "删除工作区", + noSessions: "暂无会话", + statusActive: "活跃", + statusArchived: "已归档", + statusAll: "全部", + selectAll: "全选", + deselectAll: "取消全选", + selectedCount: "已选 {n} 项", + archive: "归档", + unarchive: "取消归档", + sessionTitle: "会话", + workspace: "工作区", + status: "状态", + size: "大小", + updated: "更新时间", + actions: "操作", + sessionsInView: "共 {n} 个会话", + isolatedToWorkspace: "当前工作区", + openPreview: "打开预览", + scanning: "扫描中…", + refresh: "刷新", + refreshing: "刷新中…", + refreshed: "已刷新", + cancel: "取消", + confirmDeleteTitle: "永久删除会话?", + confirmDeleteSession: "删除后无法恢复。若只是暂时不用,请改用归档。", + confirmBulkTitle: "批量永久删除?", + confirmBulkDelete: "将永久删除 {n} 个会话,此操作无法撤销。", + confirmDeleteWorkspaceTitle: "删除空工作区?", + confirmDeleteWorkspace: "仅允许删除没有任何会话的工作区。", + deleteForever: "永久删除", + previewTitle: "会话预览", + previewHint: "仅展示角色与截断后的文本,不会暴露密钥或工具原始输出。", + previewEmpty: "没有可预览的消息", + previewTruncated: "预览已截断", + roleUser: "用户", + roleAssistant: "助手", + roleSystem: "系统", + + // Heatmap + noData: "暂无数据", + heatLess: "少", + heatMore: "多", + requests: "请求", + cacheHitRate: "缓存命中率", + + // Daily detail modal + dayDetail: "每日用量详情", + close: "关闭", + totalTokens: "总 Token", + cost: "费用", + modelDistribution: "模型用量分布", + modalEscHint: "按 Esc 或点击外部关闭", + doubleClickHint: "双击查看详情", + // Languages language: "语言", zh: "中文", diff --git a/src/lib/dashboard-format.ts b/src/lib/dashboard-format.ts new file mode 100644 index 0000000..909c6e1 --- /dev/null +++ b/src/lib/dashboard-format.ts @@ -0,0 +1,38 @@ +// Formatting helpers for the dashboard — ported from kimicode-dashboard/web/src/format.js + +export function fmtInt(n: number, locale = "en"): string { + return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format( + Math.round(Number(n) || 0) + ); +} + +export function fmtTokens(n: number): string { + const v = Number(n) || 0; + if (v >= 1e9) return (v / 1e9).toFixed(2) + "B"; + if (v >= 1e6) return (v / 1e6).toFixed(2) + "M"; + if (v >= 1e3) return (v / 1e3).toFixed(1) + "K"; + return String(Math.round(v)); +} + +export function fmtUsd(n: number): string { + const v = Number(n) || 0; + if (v === 0) return "$0.00"; + if (v < 0.01) return "$" + v.toFixed(4); + if (v < 1) return "$" + v.toFixed(3); + return "$" + v.toFixed(2); +} + +export function fmtPct(n: number): string { + return ((Number(n) || 0) * 100).toFixed(1) + "%"; +} + +export function fmtTime(ms: number, locale = "en"): string { + if (!ms) return "—"; + return new Date(ms).toLocaleString(locale === "zh" ? "zh-CN" : "en-US", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts new file mode 100644 index 0000000..72f9cf5 --- /dev/null +++ b/src/types/dashboard.ts @@ -0,0 +1,108 @@ +// Dashboard types — match the Rust structs in src-tauri/src/dashboard.rs (camelCase serde) + +export interface TotalsRow { + requests: number; + inputOther: number; + output: number; + inputCacheRead: number; + inputCacheCreation: number; + costUsd: number; + totalTokens: number; + cacheHitRate: number; +} + +export interface DailyRow extends TotalsRow { + date: string; + byModel: Record; +} + +export interface ModelRow extends TotalsRow { + model: string; + modelDisplay: string; + modelResolved: string; + priceId: string; + costEstimated: boolean; +} + +export interface RecentRow { + time: number; + model: string; + modelDisplay: string; + modelResolved: string; + inputOther: number; + output: number; + inputCacheRead: number; + inputCacheCreation: number; + totalTokens: number; + costUsd: number; + costEstimated: boolean; + priceId: string; + fromEnv: boolean; +} + +export interface RangeStats { + range: string; + totals: TotalsRow; + daily: DailyRow[]; + models: ModelRow[]; + recent: RecentRow[]; + recentTotal: number; + recentLimit: number; +} + +export interface HeatmapCell { + date: string; + dow: number; + weekIndex: number; + requests: number; + totalTokens: number; + costUsd: number; + cacheHitRate: number; + level: number; +} + +export interface MonthLabel { + weekIndex: number; + label: string; +} + +export interface HeatmapData { + weeks: number; + start: string; + end: string; + maxTokens: number; + cells: HeatmapCell[]; + monthLabels: MonthLabel[]; +} + +export interface ScanMeta { + filesScanned: number; + linesSeen: number; + recordCount: number; + home: string; + sessionsRoot: string; + errors: string[]; +} + +export interface AllModelRow { + model: string; + modelDisplay: string; + requests: number; + totalTokens: number; + costUsd: number; + costEstimated: boolean; + cacheHitRate: number; +} + +export interface SummaryResult { + home: string; + valid: boolean; + scannedAt: number; + meta: ScanMeta; + range: string; + stats: RangeStats; + heatmap: HeatmapData; + allModels: AllModelRow[]; + allModelCount: number; + rangeTotals: Record; +} diff --git a/src/types/sessions.ts b/src/types/sessions.ts new file mode 100644 index 0000000..2d3c8ce --- /dev/null +++ b/src/types/sessions.ts @@ -0,0 +1,61 @@ +// Session management types — match Rust structs in dashboard.rs (camelCase serde) + +export interface WorkspaceRow { + id: string; + name: string; + root: string | null; + createdAt: string | null; + lastOpenedAt: string | null; + activeCount: number; + archivedCount: number; + empty: boolean; +} + +export interface SessionRow { + id: string; + workspaceId: string; + status: "active" | "archived" | string; + title: string | null; + workDir: string | null; + createdAt: string | null; + updatedAt: string | null; + bytes: number; + files: number; +} + +export interface SessionsResult { + home: string; + archiveRoot: string; + workspaces: WorkspaceRow[]; + sessions: SessionRow[]; +} + +export interface ActionResponse { + ok: boolean; + workspaceId: string; + sessionId: string; + status?: string | null; + path?: string | null; + deleted?: boolean | null; +} + +export interface PreviewMessage { + role: string; + time: number | null; + text: string; +} + +export interface PreviewResult { + workspaceId: string; + sessionId: string; + status: string; + title: string | null; + workDir: string | null; + createdAt: string | null; + updatedAt: string | null; + messageCount: number; + truncated: boolean; + messages: PreviewMessage[]; +} + +export type SessionStatusFilter = "active" | "archived" | "all";