Compare commits

..
3 Commits
Author SHA1 Message Date
KimiSwitch Dev beff12a90e chore: bump version to v0.6.8 2026-08-01 19:37:23 +08:00
KimiSwitch Dev 63761e763c feat(website): 下载页支持三平台真实下载链接
- macOS / Linux 由开发中改为已发布,直连 GitHub Releases 资产(.dmg / .deb / .AppImage / .rpm)
- Windows 保留 MSI + 国内镜像双入口
2026-08-01 19:37:21 +08:00
KimiSwitch Dev 1fecb161a9 perf(dashboard): 仪表盘加载提速
- models-dev 快照移出主 bundle(1.35MB→515KB),改为 /models-dev.json 运行时异步加载,就绪后重渲染
- Rust 启动时后台预热价格索引(warm_price_index),首次 get_summary 不再卡
- get_summary 扫描结果 8s TTL 缓存(按 home),切 tab/改范围复用;刷新按钮 refresh=true 强制重扫
2026-08-01 19:37:20 +08:00
12 changed files with 64156 additions and 57 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.6.7", "version": "0.6.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+63986
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.6.7" version = "0.6.8"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+50 -2
View File
@@ -530,6 +530,15 @@ fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
}) })
} }
/// Warm the compiled-in models.dev price index off the first dashboard open:
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
/// background thread at startup instead of lazily on the first get_summary.
pub fn warm_price_index() {
std::thread::spawn(|| {
let _ = models_dev_cost_index();
});
}
/// Official providers take precedence over resellers when the same model id /// Official providers take precedence over resellers when the same model id
/// ships under multiple providers and no provider prefix disambiguates. /// ships under multiple providers and no provider prefix disambiguates.
const OFFICIAL_PROVIDERS: &[&str] = &[ const OFFICIAL_PROVIDERS: &[&str] = &[
@@ -743,6 +752,45 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
}) })
} }
// ---------------------------------------------------------------------------
// Scan cache
// ---------------------------------------------------------------------------
/// Short-TTL cache for the expensive `scan_usage` walk (reads every
/// wire.jsonl under the sessions root and prices every record). Tab switches
/// call get_summary repeatedly; re-walking the whole tree on every switch is
/// the main dashboard lag. Keyed by home, expires after 8s; `refresh=true`
/// bypasses it.
struct SummaryCacheEntry {
home: String,
scanned_at: std::time::Instant,
records: Vec<UsageRecord>,
meta: ScanMeta,
}
static SUMMARY_CACHE: Mutex<Option<SummaryCacheEntry>> = Mutex::new(None);
const SUMMARY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(8);
fn scan_usage_cached(home: &Path, refresh: bool) -> (Vec<UsageRecord>, ScanMeta) {
let home_s = home.to_string_lossy().to_string();
let mut cache = SUMMARY_CACHE.lock().unwrap();
let hit = cache
.as_ref()
.is_some_and(|e| !refresh && e.home == home_s && e.scanned_at.elapsed() < SUMMARY_CACHE_TTL);
if hit {
let e = cache.as_ref().unwrap();
return (e.records.clone(), e.meta.clone());
}
let (records, meta) = scan_usage(home);
*cache = Some(SummaryCacheEntry {
home: home_s,
scanned_at: std::time::Instant::now(),
records: records.clone(),
meta: meta.clone(),
});
(records, meta)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Aggregate // Aggregate
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1489,13 +1537,13 @@ pub fn get_prices() -> PricesResult {
#[tauri::command] #[tauri::command]
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult { pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
let _refresh = refresh.unwrap_or(false); let refresh = refresh.unwrap_or(false);
let home = resolve_kimi_home(home_override); let home = resolve_kimi_home(home_override);
let now_ms = std::time::SystemTime::now() let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64; .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let r = range.unwrap_or_else(|| "30d".into()); let r = range.unwrap_or_else(|| "30d".into());
let (records, meta) = scan_usage(&home); let (records, meta) = scan_usage_cached(&home, refresh);
let stats = aggregate(&records, &r, now_ms); let stats = aggregate(&records, &r, now_ms);
let all_stats = aggregate(&records, "all", now_ms); let all_stats = aggregate(&records, "all", now_ms);
let heatmap = build_heatmap(&records, now_ms); let heatmap = build_heatmap(&records, now_ms);
+2
View File
@@ -27,6 +27,8 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.setup(|app| { .setup(|app| {
println!("[Tauri] Setup started"); println!("[Tauri] Setup started");
// Build the models.dev price index off the first dashboard query.
dashboard::warm_price_index();
let window = app.get_webview_window("main").unwrap(); let window = app.get_webview_window("main").unwrap();
println!("[Tauri] Window label: {}", window.label()); println!("[Tauri] Window label: {}", window.label());
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.6.7", "version": "0.6.8",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -26,7 +26,10 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["msi", "nsis"], "targets": [
"msi",
"nsis"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+10 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useConfig } from "./hooks/useConfig"; import { useConfig } from "./hooks/useConfig";
@@ -12,7 +12,7 @@ import { UsageConfigModal } from "./components/UsageConfigModal";
import { PresetPickerModal } from "./components/PresetPickerModal"; import { PresetPickerModal } from "./components/PresetPickerModal";
import { useTranslation } from "./i18n"; import { useTranslation } from "./i18n";
import { getDefaultMaxContextSize } from "./lib/model-defaults"; import { getDefaultMaxContextSize } from "./lib/model-defaults";
import { getModelRef } from "./lib/models-dev"; import { getModelRef, modelsDevReady, onModelsDevReady } from "./lib/models-dev";
import { import {
presetToProviderAndModels, presetToProviderAndModels,
type ProviderPreset, type ProviderPreset,
@@ -121,6 +121,14 @@ export default function App() {
}).catch(() => {}); }).catch(() => {});
}, [loading, config, agent]); }, [loading, config, agent]);
// Load the models.dev index in the background (not in the main bundle)
// and re-render once it lands so context/capability columns fill in.
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
useEffect(() => {
onModelsDevReady(forceModelsDevReady);
void modelsDevReady();
}, []);
const providers = useMemo( const providers = useMemo(
() => (config ? Object.values(config.providers) : []), () => (config ? Object.values(config.providers) : []),
[config] [config]
+2 -2
View File
@@ -71,7 +71,7 @@ export function DashboardPage() {
<div className="text-red-400 mb-2">{t("dashLoadFailed")}</div> <div className="text-red-400 mb-2">{t("dashLoadFailed")}</div>
<div className="text-sm text-content-muted">{error}</div> <div className="text-sm text-content-muted">{error}</div>
<button <button
onClick={() => refresh()} onClick={() => refresh(true)}
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm" className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
> >
{t("retry")} {t("retry")}
@@ -145,7 +145,7 @@ export function DashboardPage() {
</div> </div>
<button <button
type="button" type="button"
onClick={() => refresh()} onClick={() => refresh(true)}
disabled={loading} disabled={loading}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50" className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50"
> >
+5 -2
View File
@@ -23,11 +23,14 @@ export function useDashboard() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async (force = false) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const result = await invoke<SummaryResult>("get_summary", { range }); const result = await invoke<SummaryResult>("get_summary", {
range,
refresh: force,
});
setData(result); setData(result);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
+57 -15
View File
@@ -1,9 +1,15 @@
import snapshot from "./models-dev.json";
/** /**
* Compact per-model reference data from models.dev * Compact per-model reference data from models.dev
* (see scripts/fetch-models-dev.mjs). * (see scripts/fetch-models-dev.mjs).
*
* The snapshot (~1.3 MB JSON) is NOT bundled into the main chunk anymore:
* parsing a 1.3 MB JSON literal at startup blocks first paint. It is served
* as a static asset (`/models-dev.json`) and loaded once in the background.
* `getModelRef` stays synchronous and returns undefined until the index is
* ready — callers already fall back to defaults — and `modelsDevReady()`
* lets the app re-render once the index arrives.
*/ */
export interface ModelCost { export interface ModelCost {
input?: number; input?: number;
output?: number; output?: number;
@@ -23,22 +29,58 @@ export interface ModelRef {
cost?: ModelCost; cost?: ModelCost;
} }
const raw = snapshot as Record<string, unknown>; let byLowerKey: Record<string, ModelRef> | null = null;
let readyListeners: Array<() => void> = [];
// Drop the "last_updated" metadata key; the rest is "<provider>/<model>". function buildIndex(raw: Record<string, unknown>) {
const data: Record<string, ModelRef> = {}; const data: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(raw)) { for (const [key, value] of Object.entries(raw)) {
if (key !== "last_updated" && value && typeof value === "object") { if (key !== "last_updated" && value && typeof value === "object") {
data[key] = value as ModelRef; data[key] = value as ModelRef;
}
} }
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
const lower: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(data)) {
lower[key.toLowerCase()] = value;
}
return lower;
} }
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g. let loadPromise: Promise<void> | null = null;
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching. export function modelsDevReady(): Promise<void> {
const byLowerKey: Record<string, ModelRef> = {}; if (!loadPromise) {
for (const [key, value] of Object.entries(data)) { loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
byLowerKey[key.toLowerCase()] = value; .then((r) => {
if (!r.ok) throw new Error(`models-dev.json HTTP ${r.status}`);
return r.json() as Promise<Record<string, unknown>>;
})
.then((raw) => {
byLowerKey = buildIndex(raw);
const listeners = readyListeners;
readyListeners = [];
for (const cb of listeners) cb();
})
.catch((err) => {
// A failed load is permanent for this session: fall back to defaults.
byLowerKey = {};
loadPromise = null; // allow one retry next time
console.warn("models-dev.json load failed:", err);
});
}
return loadPromise;
}
/** Register a callback invoked once the models.dev index is ready. */
export function onModelsDevReady(cb: () => void): void {
if (byLowerKey) {
cb();
return;
}
readyListeners.push(cb);
} }
/** /**
@@ -49,7 +91,7 @@ for (const [key, value] of Object.entries(data)) {
*/ */
export function getModelRef(modelId: string): ModelRef | undefined { export function getModelRef(modelId: string): ModelRef | undefined {
const id = modelId.trim().toLowerCase(); const id = modelId.trim().toLowerCase();
if (!id) return undefined; if (!id || !byLowerKey) return undefined;
const exact = byLowerKey[id]; const exact = byLowerKey[id];
if (exact) return exact; if (exact) return exact;
const suffix = `/${id}`; const suffix = `/${id}`;
+6 -6
View File
@@ -241,9 +241,9 @@ const zh = {
githubBtn: "GitHub 下载", githubBtn: "GitHub 下载",
mirrorBtn: "镜像下载", mirrorBtn: "镜像下载",
items: [ items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi 与 .exe 安装包,系统托盘常驻" }, { id: "windows", name: "Windows", ready: true, note: ".msi 安装包,系统托盘常驻" },
{ id: "macos", name: "macOS", ready: false, note: "GitHub Actions 已能构建 .dmg,待官方适配" }, { id: "macos", name: "macOS", ready: true, note: ".dmg 安装包(Apple Silicon),未签名需右键打开" },
{ id: "linux", name: "Linux", ready: false, note: ".AppImage / .deb / .rpm 构建中" }, { id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm 三格式" },
], ],
}, },
cta: { cta: {
@@ -505,9 +505,9 @@ const en: Dict = {
githubBtn: "Download from GitHub", githubBtn: "Download from GitHub",
mirrorBtn: "China mirror", mirrorBtn: "China mirror",
items: [ items: [
{ id: "windows", name: "Windows", ready: true, note: ".msi and .exe installers, lives in the system tray" }, { id: "windows", name: "Windows", ready: true, note: ".msi installer, lives in the system tray" },
{ id: "macos", name: "macOS", ready: false, note: "GitHub Actions already builds .dmg, official support pending" }, { id: "macos", name: "macOS", ready: true, note: ".dmg installer (Apple Silicon), unsigned - right-click to open" },
{ id: "linux", name: "Linux", ready: false, note: ".AppImage / .deb / .rpm builds in progress" }, { id: "linux", name: "Linux", ready: true, note: ".deb / .AppImage / .rpm" },
], ],
}, },
cta: { cta: {
+31 -24
View File
@@ -8,8 +8,27 @@ const icons: Record<string, Icon> = {
linux: LinuxLogo, linux: LinuxLogo,
}; };
const GITHUB_RELEASES = "https://github.com/billowliu2/KimiSwitch/releases"; const VERSION = "0.6.8";
const GITHUB = "https://github.com/billowliu2/KimiSwitch";
const MIRROR_RELEASES = "https://git.codingplan.site/admin/KimiCodeSwitch/releases"; 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, e.g. Kimi.Switch_0.6.8_x64_en-US.msi). */
const assets: Record<string, { label: string; href: string }[]> = {
windows: [
{ label: "MSI", href: dl("Kimi.Switch_0.6.8_x64_en-US.msi") },
{ label: "镜像", href: MIRROR_RELEASES },
],
macos: [
{ label: "Apple Silicon (.dmg)", href: dl("Kimi.Switch_0.6.8_aarch64.dmg") },
],
linux: [
{ label: ".deb", href: dl("Kimi.Switch_0.6.8_amd64.deb") },
{ label: ".AppImage", href: dl("Kimi.Switch_0.6.8_amd64.AppImage") },
{ label: ".rpm", href: dl("Kimi.Switch-0.6.8-1.x86_64.rpm") },
],
};
export default function Download() { export default function Download() {
const { t } = useLang(); const { t } = useLang();
@@ -22,44 +41,32 @@ export default function Download() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{t.download.items.map((p) => { {t.download.items.map((p) => {
const Icon = icons[p.id] ?? WindowsLogo; const Icon = icons[p.id] ?? WindowsLogo;
const links = assets[p.id] ?? [];
return ( return (
<div key={p.id} className="card flex flex-col"> <div key={p.id} className="card flex flex-col">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-primary/25 bg-primary/10 text-primary"> <div className="flex h-10 w-10 items-center justify-center rounded-xl border border-primary/25 bg-primary/10 text-primary">
<Icon className="h-5 w-5" weight="duotone" /> <Icon className="h-5 w-5" weight="duotone" />
</div> </div>
<span <span className="rounded-full border border-primary/25 bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${ {t.download.ready}
p.ready
? "bg-primary/10 text-primary border border-primary/25"
: "bg-muted text-muted-foreground border border-border"
}`}
>
{p.ready ? t.download.ready : t.download.wip}
</span> </span>
</div> </div>
<h3 className="mb-2 text-lg font-semibold">{p.name}</h3> <h3 className="mb-2 text-lg font-semibold">{p.name}</h3>
<p className="text-sm text-muted-foreground">{p.note}</p> <p className="text-sm text-muted-foreground">{p.note}</p>
{p.ready && ( <div className="mt-4 flex flex-wrap gap-2">
<div className="mt-4 flex flex-wrap gap-2"> {links.map((l) => (
<a <a
href={GITHUB_RELEASES} key={l.label}
href={l.href}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="btn-primary text-xs" className={l.label === "镜像" ? "btn-ghost text-xs" : "btn-primary text-xs"}
> >
{t.download.githubBtn} {l.label}
</a> </a>
<a ))}
href={MIRROR_RELEASES} </div>
target="_blank"
rel="noreferrer"
className="btn-ghost text-xs"
>
{t.download.mirrorBtn}
</a>
</div>
)}
</div> </div>
); );
})} })}