Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8331d28b | ||
|
|
c08de0ebb0 | ||
|
|
ded166c4c8 | ||
|
|
803c346854 | ||
|
|
f9f9af0fc9 | ||
|
|
9f51ea5e84 | ||
|
|
d190a55bf2 |
No files matched your search
@@ -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)
|
## v0.8.1 (2026-10-04)
|
||||||
|
|
||||||
- **全局配置新增两个顶层开关**:自动生成会话标题(`auto_session_title`)与重复调用拦截(`repeat_breaker`),默认开启,关闭显式写 `false`、开启删键跟随上游默认(适配 kimi-code #3962 / #3995)
|
- **全局配置新增两个顶层开关**:自动生成会话标题(`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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "kimiswitch",
|
"name": "kimiswitch",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+2101
-1020
File diff suppressed because it is too large.
Load diff
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kimiswitch"
|
name = "kimiswitch"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
description = "Kimi Switch - model config manager"
|
description = "Kimi Switch - model config manager"
|
||||||
authors = ["codingplan.site"]
|
authors = ["codingplan.site"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use indexmap::IndexMap;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
@@ -79,8 +79,61 @@ fn load_pi_native_config() -> Result<Config, String> {
|
|||||||
Ok(config)
|
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]
|
#[tauri::command]
|
||||||
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
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).
|
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
|
||||||
let db_config = db::load_config(&agent).ok();
|
let db_config = db::load_config(&agent).ok();
|
||||||
|
|
||||||
|
|||||||
+2270
-118
File diff suppressed because it is too large.
Load diff
+125
-59
@@ -6,6 +6,8 @@
|
|||||||
//! a provider.
|
//! a provider.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
@@ -17,6 +19,51 @@ use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
|||||||
|
|
||||||
pub type DbResult<T> = anyhow::Result<T>;
|
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 {
|
pub fn kimi_switch_data_dir() -> PathBuf {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.map(|h| h.join(".kimi-switch"))
|
.map(|h| h.join(".kimi-switch"))
|
||||||
@@ -158,7 +205,10 @@ pub fn init_db() -> DbResult<Connection> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
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 tx = conn.transaction()?;
|
||||||
|
|
||||||
let default_model = get_setting_tx(&tx, &default_model_key(agent))?;
|
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<()> {
|
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()?;
|
let tx = conn.transaction()?;
|
||||||
|
|
||||||
tx.execute("DELETE FROM providers WHERE agent = ?1", params![agent.as_str()])?;
|
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.
|
/// Public helper: read a single setting without an explicit transaction.
|
||||||
pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
|
pub fn get_setting_pub(key: &str) -> DbResult<Option<String>> {
|
||||||
let conn = init_db()?;
|
with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
|
||||||
let mut rows = stmt.query(params![key])?;
|
let mut rows = stmt.query(params![key])?;
|
||||||
if let Some(row) = rows.next()? {
|
if let Some(row) = rows.next()? {
|
||||||
Ok(Some(row.get(0)?))
|
Ok(Some(row.get(0)?))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public helper: write a single setting in its own transaction.
|
/// Public helper: write a single setting in its own transaction.
|
||||||
pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
|
pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
|
||||||
let mut conn = init_db()?;
|
with_conn(|conn| {
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
set_setting_tx(&tx, key, value)?;
|
set_setting_tx(&tx, key, value)?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bump_generation();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public helper: delete a single setting (no-op if the key does not exist).
|
/// Public helper: delete a single setting (no-op if the key does not exist).
|
||||||
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
|
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
|
||||||
let conn = init_db()?;
|
with_conn(|conn| {
|
||||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bump_generation();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,55 +475,59 @@ pub struct ArchivedSessionSnapshot {
|
|||||||
/// Insert or replace one archived-session snapshot (keyed by session id, which
|
/// Insert or replace one archived-session snapshot (keyed by session id, which
|
||||||
/// is a UUID and never reused).
|
/// is a UUID and never reused).
|
||||||
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
|
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
|
||||||
let conn = init_db()?;
|
with_conn(|conn| {
|
||||||
let day_stats = serde_json::to_string(&row.day_stats)?;
|
let day_stats = serde_json::to_string(&row.day_stats)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO archived_sessions
|
"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)
|
(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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
params![
|
params![
|
||||||
row.session_id,
|
row.session_id,
|
||||||
row.workspace_id,
|
row.workspace_id,
|
||||||
row.title,
|
row.title,
|
||||||
row.archived_at_ms as i64,
|
row.archived_at_ms as i64,
|
||||||
row.updated_at_ms.map(|v| v as i64),
|
row.updated_at_ms.map(|v| v as i64),
|
||||||
row.created_at_ms.map(|v| v as i64),
|
row.created_at_ms.map(|v| v as i64),
|
||||||
row.total_tokens as i64,
|
row.total_tokens as i64,
|
||||||
row.total_cost_usd,
|
row.total_cost_usd,
|
||||||
day_stats,
|
day_stats,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
bump_generation();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
|
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
|
||||||
/// to an empty breakdown instead of failing the whole list.
|
/// to an empty breakdown instead of failing the whole list.
|
||||||
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
|
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
|
||||||
let conn = init_db()?;
|
with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
|
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
|
||||||
total_tokens, total_cost_usd, day_stats
|
total_tokens, total_cost_usd, day_stats
|
||||||
FROM archived_sessions",
|
FROM archived_sessions",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
let day_stats_json: String = row.get(8)?;
|
let day_stats_json: String = row.get(8)?;
|
||||||
Ok(ArchivedSessionSnapshot {
|
Ok(ArchivedSessionSnapshot {
|
||||||
session_id: row.get(0)?,
|
session_id: row.get(0)?,
|
||||||
workspace_id: row.get(1)?,
|
workspace_id: row.get(1)?,
|
||||||
title: row.get(2)?,
|
title: row.get(2)?,
|
||||||
archived_at_ms: row.get::<_, i64>(3)? as u64,
|
archived_at_ms: row.get::<_, i64>(3)? as u64,
|
||||||
updated_at_ms: row.get::<_, Option<i64>>(4)?.map(|v| v 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),
|
created_at_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
|
||||||
total_tokens: row.get::<_, i64>(6)? as u64,
|
total_tokens: row.get::<_, i64>(6)? as u64,
|
||||||
total_cost_usd: row.get(7)?,
|
total_cost_usd: row.get(7)?,
|
||||||
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
|
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
out.push(row?);
|
out.push(row?);
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provider_type_for_str(s: &str) -> ProviderType {
|
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Kimi Switch",
|
"productName": "Kimi Switch",
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"identifier": "com.kimiswitch.app",
|
"identifier": "com.kimiswitch.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
{loadStats && !loading && (
|
{loadStats && !loading && (
|
||||||
<span className="hidden text-[10px] text-content-muted sm:inline">
|
<span className="hidden text-[10px] text-content-muted sm:inline">
|
||||||
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
|
{t("loadStats", { ms: loadStats.ms })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+93
-21
@@ -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 { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { SummaryResult } from "../types/dashboard";
|
import type { SummaryResult } from "../types/dashboard";
|
||||||
|
|
||||||
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
|
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
|
||||||
|
|
||||||
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
|
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
|
||||||
|
|
||||||
export function useDashboard() {
|
/**
|
||||||
const [range, setRange] = useState<DashboardRange>(() => {
|
* Emitted by the backend when the background verification walk found records the
|
||||||
try {
|
* fast (disk-cache) path did not have. The payload is empty — the hook only
|
||||||
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
|
* needs to know that a re-fetch is worth making.
|
||||||
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
|
*/
|
||||||
return stored;
|
const RECORDS_UPDATED_EVENT = "dashboard://records-updated";
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return "30d";
|
|
||||||
});
|
|
||||||
|
|
||||||
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Round-trip timing for the last get_summary call (user-perceived lag). */
|
/** 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) => {
|
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);
|
setError(null);
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
try {
|
try {
|
||||||
@@ -34,23 +70,59 @@ export function useDashboard() {
|
|||||||
range,
|
range,
|
||||||
refresh: force,
|
refresh: force,
|
||||||
});
|
});
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
|
summaryCache.set(range, result);
|
||||||
setData(result);
|
setData(result);
|
||||||
setLoadStats({
|
setLoadStats({ ms: Math.round(performance.now() - start) });
|
||||||
ms: Math.round(performance.now() - start),
|
|
||||||
kb: Math.round(JSON.stringify(result).length / 1024),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (genRef.current === gen) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [range]);
|
}, [range]);
|
||||||
|
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, [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) => {
|
const changeRange = useCallback((next: DashboardRange) => {
|
||||||
setRange(next);
|
setRange(next);
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -257,7 +257,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
scanning: "Scanning…",
|
scanning: "Scanning…",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
refreshing: "Refreshing…",
|
refreshing: "Refreshing…",
|
||||||
loadStats: "Loaded in {ms} ms · {kb} KB payload",
|
loadStats: "Loaded in {ms} ms",
|
||||||
refreshed: "Refreshed",
|
refreshed: "Refreshed",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
confirmDeleteTitle: "Delete session forever?",
|
confirmDeleteTitle: "Delete session forever?",
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ export const zhTranslations = {
|
|||||||
scanning: "扫描中…",
|
scanning: "扫描中…",
|
||||||
refresh: "刷新",
|
refresh: "刷新",
|
||||||
refreshing: "刷新中…",
|
refreshing: "刷新中…",
|
||||||
loadStats: "加载 {ms} ms · 数据 {kb} KB",
|
loadStats: "加载 {ms} ms",
|
||||||
refreshed: "已刷新",
|
refreshed: "已刷新",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
confirmDeleteTitle: "永久删除会话?",
|
confirmDeleteTitle: "永久删除会话?",
|
||||||
|
|||||||
+2289
-1109
File diff suppressed because it is too large.
Load diff
+2101
-1020
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user