Compare commits

...
8 Commits
Author SHA1 Message Date
KimiSwitch Dev 317562781c feat(website): 官网更新至 v0.8.2(下载版本号 v0.8.2、模型库 8,467·225、更新日志补 v0.8.2 性能专项条目、JSON-LD softwareVersion/dateModified 同步) 2026-10-09 20:00:13 +08:00
KimiSwitch Dev 6a8331d28b chore(release): bump to v0.8.2 — 用量统计性能专项 + models.dev 快照同步
Release / Version consistency (push) Waiting to run
2026-10-09 16:44:10 +08:00
KimiSwitch Dev c08de0ebb0 chore(data): 同步 models-dev 2026-10-09(8,467 模型 / 225 供应商) 2026-10-09 16:44:09 +08:00
KimiSwitch Dev ded166c4c8 perf(dashboard): 用量统计从秒级降到感知不到 — SQLite 连接单例、配置读取缓存、聚合结果缓存、快照持久化 + 后台校验
功能与数据口径零变化(用量数字、字段、IPC 签名、导出格式均与 v0.8.1 一致)。

- db.rs:新增进程级连接单例(含迁移只跑一次),卡片墙场景由 200+ 次「开连接 + 7 条 DDL」降为 1 次;
  新增写路径代际计数器供各处读缓存判失效
- commands.rs:load_agent_config_command 结果缓存(db 代际 + config.toml 文件戳双重校验),
  N 张供应商卡片由 N 次全量加载(config.toml 解析 + SQLite 全量读 + 逐供应商 settings 读)降为 1 次
- dashboard.rs:
  * build_all_models 单遍轻量聚合,替换原先为取一张模型表而白算日/明细/供应商三份数据的整表遍历
  * SUMMARY_CACHE 按记录集 Arc 身份 + 日期 + 分钟桶缓存热力图/模型总览/各范围汇总,切范围来回近乎零成本
  * 归档会话列表与合成记录按代际 + Arc 身份缓存(合并后的 Arc 保持稳定,否则上层缓存永不命中)
  * 解析结果持久化到 ~/.kimi-switch/scan-cache-<hash>.json:重启后首次 get_summary 直读快照
    (实测 6.4 万条记录 / 1.03 GB:22~27s → 0.85s),随后后台线程全量校验,
    有变化时经 dashboard://records-updated 事件推送,前端无感刷新(先快后准);
    快照损坏/指纹不符静默降级为全量扫描,refresh=true 语义不变
- useDashboard.ts:stale-while-revalidate,切范围/重进页面先呈现旧数据再静默刷新,不再闪加载态;
  订阅上述事件做无感更新,保留原世代号防竞态

测试:Rust 193 项(+16)、前端 130 项全绿
2026-10-09 16:44:03 +08:00
KimiSwitch Dev 803c346854 fix(dashboard): useDashboard 加请求世代校验 — 快速连续切换时间范围时并发 get_summary 响应可能乱序返回导致标签与数据错位;参照 useUsageQuery 的 genRef 模式丢弃过期响应(data/loadStats/error/loading 全部受保护),卸载时使世代失效 2026-10-08 00:28:39 +08:00
KimiSwitch Dev f9f9af0fc9 perf(dashboard): 扫描输入缓存 + 归档合并零浪费 + 锁加固 — resolve_scan_inputs 结果按 config.toml/备份文件 (mtime,len) stat 指纹缓存(命中跳过 15 个配置文件 TOML 解析与 SQLite 读写,set_setting_pub 仅在值变化时写);归档合并先合成后克隆,合成结果为空时原 Arc 直接返回(跳过 6.3 万条 clone+sort,已排序输入的稳定排序为恒等变换);SCAN_CACHE/SCAN_INPUTS_CACHE 改 into_inner 防 Mutex 中毒;新增 4 个测试(Arc::ptr_eq 正反例、配置失效链、跨 home 隔离)并修复测试间 KIMI_SWITCH_DB_PATH 全局 env 并行污染;聚合口径零变化 2026-10-08 00:28:22 +08:00
KimiSwitch Dev 9f51ea5e84 perf(dashboard): 加载统计去掉对整个返回值的 JSON.stringify(数 MB 大对象在 webview 主线程序列化造成卡顿),loadStats 仅保留耗时 ms,中英文案同步 2026-10-07 23:35:30 +08:00
KimiSwitch Dev d190a55bf2 perf(dashboard): 用量统计性能优化 — get_summary/get_day_detail 改异步执行不再阻塞 UI;range_totals 单遍统计(每次调用 7 次全量聚合 → 2 次);扫描缓存改 Arc 共享消除整表深拷贝;wire.jsonl 按 (mtime,len) 逐文件增量解析替换 8s TTL 整表缓存(920MB/6.3万条实测:冷扫描 ~17.7s → ~1.0s,任意间隔切换范围 ~1.5s 且 UI 全程可交互);新增单遍等价性与增量行为测试(含混合增删改 vs 强制全量重扫的全字段指纹比对),聚合口径零变化 2026-10-07 23:35:17 +08:00
19 changed files with 9108 additions and 3371 deletions

No files matched your search

+11
View File
@@ -6,6 +6,17 @@
---
## v0.8.2 (2026-10-09)
性能专项,功能与数据口径不变(用量数字、字段、IPC 接口、导出格式均与 v0.8.1 一致)。
- **仪表盘重启后首次打开:约 22~27 秒 → 约 0.85 秒**(实测 6.4 万条记录 / 1.03 GB)。用量解析结果持久化到 `~/.kimi-switch/scan-cache-*.json`,启动直读快照渲染,后台线程全量校验,有变化时事件推送无感更新(先快后准)
- **切换时间范围几乎瞬时**:热力图 / 模型总览 / 各范围汇总按记录集缓存;并去掉原「全时段」聚合的整表无用遍历
- **界面不再闪加载态**:切范围与重进页面先呈现旧数据再静默刷新(stale-while-revalidate)
- **供应商卡片墙变快**:供应商配置读取缓存(含外部 CLI 改动失效路径),N 张卡片由 N 次全量加载降为 1 次
- **SQLite 连接复用**:连接与迁移改为每库一次,卡片墙场景由 200+ 次降为 1 次
- **models.dev 快照更新**:同步至 2026-10-09(8,467 模型 / 225 供应商)
## v0.8.1 (2026-10-04)
- **全局配置新增两个顶层开关**:自动生成会话标题(`auto_session_title`)与重复调用拦截(`repeat_breaker`),默认开启,关闭显式写 `false`、开启删键跟随上游默认(适配 kimi-code #3962 / #3995)
@@ -0,0 +1,21 @@
# KimiSwitch v0.8.2
本版为**性能专项**:把主要操作的等待时间从「秒级」压到「感知不到」,功能与数据口径完全不变。
## 性能优化
- **仪表盘重启后首次打开:约 22~27 秒 → 约 0.85 秒**(实测 1065 个 `wire.jsonl` / 1.03 GB / 6.4 万条记录)。用量解析结果持久化到本地快照(`~/.kimi-switch/scan-cache-*.json`),启动时直接读快照渲染,后台线程同时做全量校验;校验发现新记录时自动推送事件、无感更新数字(先快后准,无需手动刷新)
- **切换时间范围几乎瞬时**:热力图、模型总览、各范围汇总结果按记录集与日期缓存,来回切换不再重复全量聚合;未命中时也不再做无用的整表遍历(原「全时段」统计会为取一张模型表而白算日/明细/供应商三份数据)
- **界面不再闪加载态**:仪表盘切范围与重新进入页面时先呈现上次数据再后台静默刷新,不再出现数据已经拿过却先显示骨架屏的情况
- **供应商卡片墙显著变快**:供应商配置读取结果加缓存(含敏感字段写入与外部 CLI 改动两条失效路径),N 张卡片从 N 次全量读库 + 配置解析降为 1 次
- **SQLite 连接复用**:库连接与建表迁移从「每次调用各做一遍」改为每库一次,卡片墙场景下由 200+ 次降为 1 次
## 其他
- **models.dev 快照更新**:同步至 2026-10-09(8,467 模型 / 225 供应商),价格与上下文长度随之刷新
## 备注
- 数据口径与功能行为与 v0.8.1 完全一致:用量数字、字段、IPC 接口、导出格式均无变化;「强制刷新」仍会忽略一切缓存重新全量解析
- 测试全量通过:Rust 193 项(新增 16 项,覆盖缓存失效、磁盘快照往返等价、损坏快照降级等)、前端 130 项
- 首次使用本版本时会重建一次磁盘快照(沿用原有全量扫描耗时),此后每次启动均为快速加载
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.8.1",
"version": "0.8.2",
"type": "module",
"scripts": {
"dev": "vite",
+2101 -1020
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.8.1"
version = "0.8.2"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+54 -1
View File
@@ -6,7 +6,7 @@ use indexmap::IndexMap;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, UNIX_EPOCH};
use tauri::Manager;
use tauri_plugin_opener::OpenerExt;
@@ -79,8 +79,61 @@ fn load_pi_native_config() -> Result<Config, String> {
Ok(config)
}
/// Cached `load_agent_config_command` results, keyed by agent. The full load
/// parses config.toml, reads all of SQLite and runs `merge_usage_kinds` (two
/// settings reads per provider); a usage-card wall triggers one load per
/// provider card, so caching turns N loads into one. Entries are validated
/// against the db generation counter (bumped by every write path in db.rs)
/// plus the config.toml file stamp (catches edits made outside the app, e.g.
/// the CLI's /provider command).
static CONFIG_CACHE: Mutex<Option<HashMap<String, (u64, Option<(u64, u64)>, Config)>>> =
Mutex::new(None);
/// (mtime_ms, len) of the authoritative on-disk config file for the agent, or
/// None when the file is absent / the agent has no authoritative file (Pi is
/// SQLite-first, so the db generation alone covers it).
fn config_file_stamp(agent: &Agent) -> Option<(u64, u64)> {
match agent {
Agent::KimiCode => {
let md = std::fs::metadata(crate::kimi_code_io::kimi_code_config_path()).ok()?;
let mtime_ms = md
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_millis() as u64;
Some((mtime_ms, md.len()))
}
Agent::Pi => None,
}
}
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
let stamp = config_file_stamp(&agent);
let gen = db::generation();
let key = agent.as_str().to_string();
{
let guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(map) = guard.as_ref() {
if let Some((cached_gen, cached_stamp, config)) = map.get(&key) {
if *cached_gen == gen && *cached_stamp == stamp {
return Ok(config.clone());
}
}
}
}
let config = load_agent_config_uncached(agent)?;
let mut guard = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
guard
.get_or_insert_with(HashMap::new)
.insert(key, (gen, stamp, config.clone()));
Ok(config)
}
fn load_agent_config_uncached(agent: Agent) -> Result<Config, String> {
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
let db_config = db::load_config(&agent).ok();
+2270 -118
View File
File diff suppressed because it is too large. Load diff
+125 -59
View File
@@ -6,6 +6,8 @@
//! a provider.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use anyhow::Context;
use indexmap::IndexMap;
@@ -17,6 +19,51 @@ use crate::models::{Agent, Config, Model, Provider, ProviderType};
pub type DbResult<T> = anyhow::Result<T>;
/// Process-wide cached connection, keyed by the resolved db path. Opening a
/// SQLite connection plus running the DDL migrations costs several ms; before
/// this cache every public helper paid that cost on every call (a usage-card
/// wall could open 200+ connections per refresh). `Connection` is `Send` but
/// not `Sync`, so a `Mutex` is the correct `static` wrapper — all db access is
/// serialized, which is fine for a single-user desktop app.
static DB_CONN: Mutex<Option<(PathBuf, Connection)>> = Mutex::new(None);
/// Monotonic generation counter, bumped by every write path. Read-side caches
/// (config cache, archived-session cache) key on this to detect staleness.
static DB_GEN: AtomicU64 = AtomicU64::new(0);
pub(crate) fn generation() -> u64 {
DB_GEN.load(Ordering::Relaxed)
}
pub(crate) fn bump_generation() {
DB_GEN.fetch_add(1, Ordering::Relaxed);
}
/// Run `f` against the cached connection for the current `db_path()`, opening
/// (and migrating) it on first use or whenever the path changed (tests point
/// `KIMI_SWITCH_DB_PATH` at per-test temp dirs).
fn with_conn<T>(f: impl FnOnce(&mut Connection) -> DbResult<T>) -> DbResult<T> {
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
let path = db_path();
let needs_open = match guard.as_ref() {
Some((cached_path, _)) => cached_path != &path,
None => true,
};
if needs_open {
*guard = Some((path, init_db()?));
}
let (_, conn) = guard.as_mut().expect("connection just opened");
f(conn)
}
/// Drop the cached connection so tests that switch `KIMI_SWITCH_DB_PATH`
/// between temp dirs never observe cross-test state.
#[cfg(test)]
pub(crate) fn close_cached_conn_for_tests() {
let mut guard = DB_CONN.lock().unwrap_or_else(|e| e.into_inner());
*guard = None;
}
pub fn kimi_switch_data_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".kimi-switch"))
@@ -158,7 +205,10 @@ pub fn init_db() -> DbResult<Connection> {
}
pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut conn = init_db()?;
with_conn(|conn| load_config_inner(conn, agent))
}
fn load_config_inner(conn: &mut Connection, agent: &Agent) -> DbResult<Config> {
let tx = conn.transaction()?;
let default_model = get_setting_tx(&tx, &default_model_key(agent))?;
@@ -249,7 +299,12 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
}
pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
let mut conn = init_db()?;
with_conn(|conn| save_config_inner(conn, agent, config))?;
bump_generation();
Ok(())
}
fn save_config_inner(conn: &mut Connection, agent: &Agent, config: &Config) -> DbResult<()> {
let tx = conn.transaction()?;
tx.execute("DELETE FROM providers WHERE agent = ?1", params![agent.as_str()])?;
@@ -340,29 +395,36 @@ fn set_setting_tx(tx: &rusqlite::Transaction, key: &str, value: &str) -> DbResul
/// Public helper: read a single setting without an explicit transaction.
pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
let conn = init_db()?;
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
Ok(Some(row.get(0)?))
} else {
Ok(None)
}
with_conn(|conn| {
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
Ok(Some(row.get(0)?))
} else {
Ok(None)
}
})
}
/// Public helper: write a single setting in its own transaction.
pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
let mut conn = init_db()?;
let tx = conn.transaction()?;
set_setting_tx(&tx, key, value)?;
tx.commit()?;
with_conn(|conn| {
let tx = conn.transaction()?;
set_setting_tx(&tx, key, value)?;
tx.commit()?;
Ok(())
})?;
bump_generation();
Ok(())
}
/// Public helper: delete a single setting (no-op if the key does not exist).
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
let conn = init_db()?;
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
with_conn(|conn| {
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
Ok(())
})?;
bump_generation();
Ok(())
}
@@ -413,55 +475,59 @@ pub struct ArchivedSessionSnapshot {
/// 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,
],
)?;
with_conn(|conn| {
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(())
})?;
bump_generation();
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)
with_conn(|conn| {
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 {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.8.1",
"version": "0.8.2",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -179,7 +179,7 @@ export function DashboardPage() {
</div>
{loadStats && !loading && (
<span className="hidden text-[10px] text-content-muted sm:inline">
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
{t("loadStats", { ms: loadStats.ms })}
</span>
)}
</div>
+93 -21
View File
@@ -1,32 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { SummaryResult } from "../types/dashboard";
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(() => {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
return stored;
}
} catch {
// ignore
}
return "30d";
});
/**
* Emitted by the backend when the background verification walk found records the
* fast (disk-cache) path did not have. The payload is empty — the hook only
* needs to know that a re-fetch is worth making.
*/
const RECORDS_UPDATED_EVENT = "dashboard://records-updated";
const [data, setData] = useState<SummaryResult | null>(null);
const RANGES: readonly DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
function readStoredRange(): DashboardRange {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored && (RANGES as readonly string[]).includes(stored)) {
return stored;
}
} catch {
// ignore
}
return "30d";
}
/**
* Last successful payload per range, kept across mounts so a range switch (and
* reopening the page) paints instantly from memory and revalidates in the
* background instead of showing a spinner over data that is already known.
* Bounded by the five ranges the dashboard offers.
*/
const summaryCache = new Map<DashboardRange, SummaryResult>();
export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(readStoredRange);
const [data, setData] = useState<SummaryResult | null>(
() => summaryCache.get(readStoredRange()) ?? null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Round-trip timing for the last get_summary call (user-perceived lag). */
const [loadStats, setLoadStats] = useState<{ ms: number; kb: number } | null>(null);
const [loadStats, setLoadStats] = useState<{ ms: number } | null>(null);
// Generation counter: stale responses (superseded range / unmounted) are dropped.
const genRef = useRef(0);
// The event handler must call the *current* refresh, not the one captured when
// the listener was installed, so it reads the callback through a ref.
const refreshRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
const refresh = useCallback(async (force = false) => {
setLoading(true);
const gen = ++genRef.current;
// Stale-while-revalidate: hand the cached payload for this range to the
// view before the request goes out, so a range switch is instant and the
// response only refreshes numbers that are already on screen. `loading` is
// raised only when there is nothing to show (first load of a range) or when
// the user asked for a refresh — the button keeps its busy state, the silent
// revalidation behind a cached range does not.
const cached = summaryCache.get(range);
if (cached) setData(cached);
setLoading(force || !cached);
setError(null);
const start = performance.now();
try {
@@ -34,23 +70,59 @@ export function useDashboard() {
range,
refresh: force,
});
if (genRef.current !== gen) return;
summaryCache.set(range, result);
setData(result);
setLoadStats({
ms: Math.round(performance.now() - start),
kb: Math.round(JSON.stringify(result).length / 1024),
});
setLoadStats({ ms: Math.round(performance.now() - start) });
} catch (err) {
if (genRef.current !== gen) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setLoading(false);
if (genRef.current === gen) setLoading(false);
}
}, [range]);
refreshRef.current = refresh;
useEffect(() => {
refresh();
}, [refresh]);
// The first load of a session answers from the on-disk snapshot, which can be
// behind a session written since the app last ran. The backend verifies in the
// background and signals here when the numbers moved; re-fetching silently
// (never raising `loading`) keeps the already-drawn dashboard on screen and
// just corrects it. A silent call still takes a fresh generation, so it cannot
// be interleaved with a range switch in flight.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
listen(RECORDS_UPDATED_EVENT, () => {
refreshRef.current();
})
.then((fn) => {
// The effect may have been torn down before `listen` resolved.
if (cancelled) fn();
else unlisten = fn;
})
.catch(() => {
// No event bridge (e.g. a browser build): the dashboard still works,
// it just will not be corrected mid-session.
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
// Ignore late responses after unmount.
useEffect(() => {
return () => {
genRef.current += 1;
};
}, []);
const changeRange = useCallback((next: DashboardRange) => {
setRange(next);
try {
+1 -1
View File
@@ -257,7 +257,7 @@ export const enTranslations: Record<TranslationKey, string> = {
scanning: "Scanning…",
refresh: "Refresh",
refreshing: "Refreshing…",
loadStats: "Loaded in {ms} ms · {kb} KB payload",
loadStats: "Loaded in {ms} ms",
refreshed: "Refreshed",
cancel: "Cancel",
confirmDeleteTitle: "Delete session forever?",
+1 -1
View File
@@ -249,7 +249,7 @@ export const zhTranslations = {
scanning: "扫描中…",
refresh: "刷新",
refreshing: "刷新中…",
loadStats: "加载 {ms} ms · 数据 {kb} KB",
loadStats: "加载 {ms} ms",
refreshed: "已刷新",
cancel: "取消",
confirmDeleteTitle: "永久删除会话?",
+2289 -1109
View File
File diff suppressed because it is too large. Load diff
+2101 -1020
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -50,8 +50,8 @@
"url": "https://billowliu2.github.io/KimiSwitch/",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows 10, Windows 11, macOS, Linux",
"softwareVersion": "0.8.1",
"dateModified": "2026-09-03",
"softwareVersion": "0.8.2",
"dateModified": "2026-10-09",
"description": "桌面端 LLM 供应商配置管理器:22 个预设选好即写入 config.toml,用量与账单直读各厂商官方接口。Tauri + Rust,开源 MIT。",
"downloadUrl": "https://github.com/billowliu2/KimiSwitch/releases",
"softwareHelp": "https://github.com/billowliu2/KimiSwitch",
+32 -12
View File
@@ -16,10 +16,10 @@ const zh = {
performance: "性能",
changelog: "更新日志",
download: "下载",
downloadBtn: "下载 v0.8.0",
downloadBtn: "下载 v0.8.2",
},
hero: {
badge: "v0.8.0 · 开源 MIT · Windows / macOS / Linux",
badge: "v0.8.2 · 开源 MIT · Windows / macOS / Linux",
titleBefore: "统一管理你的",
titleAccent: "AI 供应商",
titleAfter: "",
@@ -29,7 +29,7 @@ const zh = {
source: "查看源码",
stats: [
{ value: "22", label: "预设供应商" },
{ value: "8385", label: "模型价格库" },
{ value: "8467", label: "模型价格库" },
{ value: "MIT", label: "开源协议" },
],
},
@@ -76,7 +76,7 @@ const zh = {
{
id: "model-discovery",
title: "模型自动发现",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,385 个模型、226 家供应商)。",
desc: "可用模型直接问供应商 API;显示名 / 上下文 / 能力取自 models.dev 快照(当前 8,467 个模型、225 家供应商)。",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -89,7 +89,7 @@ const zh = {
},
showcase: {
title: "界面演示",
subtitle: "以下截图取自 v0.8.0 实机运行。",
subtitle: "以下截图取自 v0.8.2 实机运行。",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -140,6 +140,16 @@ const zh = {
syncedNote: "数据已同步 GitHub Releases",
fallbackNote: "内置版本记录",
entries: [
{
version: "v0.8.2",
date: "2026-10-09",
items: [
"仪表盘重启后首次打开从约 25 秒降到 1 秒内:用量解析快照持久化到本地,启动直读并在后台校验,有变化自动无感更新(6.4 万条记录实测)",
"切换时间范围几乎瞬时:热力图 / 模型总览 / 各范围汇总按记录集缓存;去除原「全时段」聚合的整表无用遍历",
"界面不再闪加载态:切范围与重新进入页面先呈现上次数据再后台静默刷新;供应商卡片墙由 N 次配置全量加载降为 1 次",
"models.dev 快照同步至 2026-10-09(8,467 模型 / 225 供应商)",
],
},
{
version: "v0.8.1",
date: "2026-10-04",
@@ -348,7 +358,7 @@ const zh = {
},
download: {
title: "下载 Kimi Switch",
subtitle: "当前版本 v0.8.0 · Windows / macOS / Linux 三平台已发布",
subtitle: "当前版本 v0.8.2 · Windows / macOS / Linux 三平台已发布",
autoUpdate: "更新检查跑在启动时和每 8 小时的周期任务里,设置页也可以手动触发。",
ready: "已发布",
wip: "开发中",
@@ -417,10 +427,10 @@ const en: Dict = {
performance: "Performance",
changelog: "Changelog",
download: "Download",
downloadBtn: "Download v0.8.0",
downloadBtn: "Download v0.8.2",
},
hero: {
badge: "v0.8.0 · Open source MIT · Windows / macOS / Linux",
badge: "v0.8.2 · Open source MIT · Windows / macOS / Linux",
titleBefore: "One app for all your ",
titleAccent: "AI providers",
titleAfter: "",
@@ -430,7 +440,7 @@ const en: Dict = {
source: "View source",
stats: [
{ value: "22", label: "Provider presets" },
{ value: "8385", label: "Model price DB" },
{ value: "8467", label: "Model price DB" },
{ value: "MIT", label: "License" },
],
},
@@ -477,7 +487,7 @@ const en: Dict = {
{
id: "model-discovery",
title: "Model discovery",
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (8,385 models, 226 providers).",
desc: "Model lists are fetched from the provider API itself; display names, context sizes and capabilities come from a build-time models.dev snapshot (8,467 models, 225 providers).",
img: "screenshots/kimi-cli-select-model.png",
},
{
@@ -490,7 +500,7 @@ const en: Dict = {
},
showcase: {
title: "Screenshots",
subtitle: "All screenshots below are taken from the running v0.8.0 build.",
subtitle: "All screenshots below are taken from the running v0.8.2 build.",
items: [
{
src: "screenshots/usage-config-page.png",
@@ -541,6 +551,16 @@ const en: Dict = {
syncedNote: "Synced from GitHub Releases",
fallbackNote: "Built-in release notes",
entries: [
{
version: "v0.8.2",
date: "2026-10-09",
items: [
"First dashboard open after a restart drops from ~25s to under 1s: the usage snapshot is persisted locally and read straight at startup, then verified in the background with silent updates when things changed (measured at 64k records)",
"Switching the time range is near-instant: heatmap / model overview / per-range totals are cached per record set, and the wasteful full-table pass behind the 'all time' aggregate is gone",
"No more loading flicker: switching ranges or reopening the page shows the previous data first and revalidates silently; the provider card wall loads config once instead of once per card",
"models.dev snapshot synced to 2026-10-09 (8,467 models / 225 providers)",
],
},
{
version: "v0.8.1",
date: "2026-10-04",
@@ -748,7 +768,7 @@ const en: Dict = {
},
download: {
title: "Download Kimi Switch",
subtitle: "Current version v0.8.0 · Windows, macOS and Linux now released",
subtitle: "Current version v0.8.2 · Windows, macOS and Linux now released",
autoUpdate: "Update checks run at startup and every 8 hours; Settings also has a manual check.",
ready: "Available",
wip: "In development",
+2 -2
View File
@@ -8,13 +8,13 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo,
};
const VERSION = "0.8.1";
const VERSION = "0.8.2";
const GITHUB = "https://github.com/billowliu2/KimiSwitch";
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases";
const dl = (file: string) => `${GITHUB}/releases/download/v${VERSION}/${file}`;
/** Per-platform download assets for the current release (names match the CI
* release workflow exactly — verified against the published v0.7.12 assets:
* release workflow exactly — verified against the published v0.8.2 assets:
* tauri-bundler on CI produces `Kimi.Switch_…` (dots) on every platform,
* except the rpm which uses `Kimi.Switch-<version>-1` (hyphens).
* Filenames interpolate VERSION, so a release bump only changes the