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 强制重扫
This commit is contained in:
1 parent
9fd22ef55a
commit
1fecb161a9
7 files changed
+64105
-16
No files matched your search
+63986
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -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]
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+50
-8
@@ -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.
|
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
|
||||||
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
|
// "minimax/MiniMax-M3"), while provider APIs return bare model ids
|
||||||
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
|
// (e.g. "kimi-k2.5"). Index lowercase for tolerant matching.
|
||||||
const byLowerKey: Record<string, ModelRef> = {};
|
const lower: Record<string, ModelRef> = {};
|
||||||
for (const [key, value] of Object.entries(data)) {
|
for (const [key, value] of Object.entries(data)) {
|
||||||
byLowerKey[key.toLowerCase()] = value;
|
lower[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function modelsDevReady(): Promise<void> {
|
||||||
|
if (!loadPromise) {
|
||||||
|
loadPromise = fetch(`${import.meta.env.BASE_URL}models-dev.json`)
|
||||||
|
.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}`;
|
||||||
|
|||||||
Reference in new issue
Block a user