feat: v0.5.2 - daily trend refactor + provider/model two-level drill-down

- Dashboard: 用量趋势卡片改为外层 tab 容器(下划线 tab),移除卡片标题与内部 pill 切换器
- 用量趋势: 新增 供应商模型用量趋势 tab,与 模型用量趋势 并列
- 供应商 tab 弹窗: 每日用量详情改为两级(供应商 → 模型)分组
- 后端: DailyRow 新增 by_provider_model 嵌套聚合
- 前端: DailyBars 通用化(dimension/colorMap),DailyDetailModal 按 tab 维度渲染
- i18n: 新增 providerDistribution(中/英)
- 版本: 0.5.1 → 0.5.2(tauri.conf / Cargo.toml / package.json / README)
This commit is contained in:
KimiSwitch Dev committed 2026-07-29 13:41:04 +08:00
1 parent 3dd1fd93fe
commit a1fdc6c93d
16 files changed
+1190 -506

No files matched your search

+81 -4
View File
@@ -134,6 +134,11 @@ pub struct DailyRow {
pub cache_hit_rate: f64,
/// Per-model token breakdown for stacked-bar rendering
pub by_model: HashMap<String, u64>,
/// Per-provider token breakdown for stacked-bar rendering
pub by_provider: HashMap<String, u64>,
/// Per-provider, per-model nested token breakdown (provider → model → tokens),
/// used by the provider-tab drill-down detail.
pub by_provider_model: HashMap<String, HashMap<String, u64>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -353,6 +358,66 @@ fn is_kimi_home(dir: &Path) -> bool {
dir.join("config.toml").exists() || dir.join("sessions").exists()
}
/// Build a legacy alias→provider map by merging the current config.toml with every
/// `config.toml.bak.*` snapshot (home root + backups/). Historical usage records may
/// reference model aliases that no longer exist in the current config (old bare or
/// `-N`-suffixed aliases); their provider is recovered from these snapshots.
fn build_alias_provider_map(home: &Path) -> HashMap<String, String> {
let mut map: HashMap<String, String> = HashMap::new();
let mut candidates: Vec<PathBuf> = Vec::new();
let current = home.join("config.toml");
if current.is_file() {
candidates.push(current);
}
for dir in [home.to_path_buf(), home.join("backups")] {
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with("config.toml.bak.") {
candidates.push(e.path());
}
}
}
}
for path in candidates {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let parsed: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(models) = parsed.get("models").and_then(|m| m.as_table()) {
for (alias, m) in models {
if let Some(p) = m.get("provider").and_then(|p| p.as_str()) {
// Current config is parsed first and wins on conflict.
map.entry(alias.clone()).or_insert_with(|| p.to_string());
}
}
}
}
map
}
/// Resolve the provider for a usage record's raw model key: prefer the `provider/`
/// prefix, then fall back to the legacy alias→provider map (config + backups).
fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> {
let p = model_raw
.rsplit_once('/')
.map(|(prov, _)| prov.to_string())
.or_else(|| map.get(model_raw).cloned());
p.map(|x| normalize_provider(&x))
}
/// Normalize historical provider-name variants to the canonical name.
fn normalize_provider(p: &str) -> String {
match p {
"CodingPlanSite" => "CodingPlan.site".to_string(),
other => other.to_string(),
}
}
fn sessions_root(home: &Path) -> PathBuf {
home.join("sessions")
}
@@ -444,6 +509,7 @@ fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create:
fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
let root = sessions_root(home);
let alias2prov = build_alias_provider_map(home);
let mut records = Vec::new();
let mut files_scanned = 0;
let mut lines_seen = 0;
@@ -500,7 +566,7 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
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()),
provider: resolve_provider(&model_raw, &alias2prov),
from_env: model_raw == "__kimi_env_model__",
input_other,
output,
@@ -544,16 +610,22 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
}
let mut totals = TotalsRow::default();
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, u64>)> = HashMap::new();
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, u64>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = 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();
let (day_totals, day_models, day_prov_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 provider_key = r.provider.clone().unwrap_or_else(|| "unknown".to_string());
*day_prov_models
.entry(provider_key)
.or_default()
.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(|| {
@@ -577,7 +649,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
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<DailyRow> = by_day.into_iter()
.map(|(date, (t, by_model))| {
.map(|(date, (t, by_model, by_provider_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 {
@@ -586,6 +658,11 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
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,
by_provider: by_provider_model
.iter()
.map(|(p, m)| (p.clone(), m.values().sum::<u64>()))
.collect(),
by_provider_model,
}
})
.collect();