diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index f678796..b11f14e 100644 --- a/src-tauri/src/dashboard.rs +++ b/src-tauri/src/dashboard.rs @@ -610,7 +610,26 @@ fn strip_context_suffix(model_name: &str) -> Option { }) } +/// Memoized price resolution. `match_price` is called once per usage record +/// during the scan, and the models.dev suffix scan is O(5910) per miss — with +/// thousands of records sharing a handful of model names, memoizing the result +/// turns the whole scan from O(records × 5910) into O(unique_models × 5910). +static MATCH_PRICE_MEMO: OnceLock>> = + OnceLock::new(); + +/// Resolve a model's price, caching per-model results (hits and misses). fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) { + let key = model_name.to_ascii_lowercase(); + let memo = MATCH_PRICE_MEMO.get_or_init(|| Mutex::new(HashMap::new())); + if let Some(hit) = memo.lock().unwrap().get(&key) { + return hit.clone(); + } + let result = match_price_inner(model_name); + memo.lock().unwrap().insert(key, result.clone()); + result +} + +fn match_price_inner(model_name: &str) -> (String, f64, f64, f64, bool) { let bare = match model_name.rsplit_once('/') { Some((_, b)) => b, None => model_name, @@ -1537,6 +1556,7 @@ pub fn get_prices() -> PricesResult { #[tauri::command] pub fn get_summary(home_override: Option, range: Option, refresh: Option) -> SummaryResult { + let t0 = std::time::Instant::now(); let refresh = refresh.unwrap_or(false); let home = resolve_kimi_home(home_override); let now_ms = std::time::SystemTime::now() @@ -1544,6 +1564,7 @@ pub fn get_summary(home_override: Option, range: Option, refresh let r = range.unwrap_or_else(|| "30d".into()); let (records, meta) = scan_usage_cached(&home, refresh); + let t1 = std::time::Instant::now(); let stats = aggregate(&records, &r, now_ms); let all_stats = aggregate(&records, "all", now_ms); let heatmap = build_heatmap(&records, now_ms); @@ -1568,6 +1589,15 @@ pub fn get_summary(home_override: Option, range: Option, refresh name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(), }); + let t2 = std::time::Instant::now(); + let scan_ms = t1.duration_since(t0).as_secs_f64() * 1000.0; + let aggregate_ms = t2.duration_since(t1).as_secs_f64() * 1000.0; + let total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0; + eprintln!( + "[dashboard] get_summary range={} records={} scan={:.0}ms aggregate={:.0}ms total={:.0}ms", + r, records.len(), scan_ms, aggregate_ms, total_ms + ); + SummaryResult { home: home.to_string_lossy().to_string(), valid: is_kimi_home(&home), diff --git a/src/components/dashboard/DailyBars.tsx b/src/components/dashboard/DailyBars.tsx index bded9e5..de3cc0d 100644 --- a/src/components/dashboard/DailyBars.tsx +++ b/src/components/dashboard/DailyBars.tsx @@ -1,7 +1,8 @@ import { useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "../../i18n"; -import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format"; +import { useCurrency } from "../../hooks/useCurrency"; +import { fmtPct, fmtTokens } from "../../lib/dashboard-format"; import type { DailyRow } from "../../types/dashboard"; import { DailyDetailModal } from "./DailyDetailModal"; @@ -43,6 +44,7 @@ function shortModel(model: string): string { export function DailyBars({ daily, dimension, names, unknownProviderLabel }: DailyBarsProps) { const { t } = useTranslation(); + const { money } = useCurrency(); const [selected, setSelected] = useState(null); const colorMap = useMemo(() => { @@ -103,7 +105,7 @@ export function DailyBars({ daily, dimension, names, unknownProviderLabel }: Dai const tooltipLines = [ d.date, - `${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${fmtUsd(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`, + `${t("totalTokens")}: ${fmtTokens(d.totalTokens)} · ${money(d.costUsd)} · ${t("colCacheHit")} ${fmtPct(d.cacheHitRate)}`, ...segments.map((s) => `${displayName(s.key)}: ${fmtTokens(s.tokens)}`), "", t("doubleClickHint"), diff --git a/src/components/dashboard/DailyDetailModal.tsx b/src/components/dashboard/DailyDetailModal.tsx index c90ef1e..517e0f7 100644 --- a/src/components/dashboard/DailyDetailModal.tsx +++ b/src/components/dashboard/DailyDetailModal.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useTranslation } from "../../i18n"; -import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format"; +import { useCurrency } from "../../hooks/useCurrency"; +import { fmtPct, fmtTokens } from "../../lib/dashboard-format"; import type { DailyRow } from "../../types/dashboard"; import { modelColor } from "./DailyBars"; @@ -22,6 +23,7 @@ function shortModel(model: string): string { export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabel, onClose }: DailyDetailModalProps) { const { t } = useTranslation(); + const { money } = useCurrency(); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -106,7 +108,7 @@ export function DailyDetailModal({ day, dimension, colorMap, unknownProviderLabe {/* Day summary chips */}
- +
diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index acc6636..2bb629f 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -1,13 +1,20 @@ import { useState, type ReactNode } from "react"; import { useDashboard, type DashboardRange } from "../../hooks/useDashboard"; +import { useCurrency } from "../../hooks/useCurrency"; import { useTranslation } from "../../i18n"; -import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format"; +import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format"; import { DailyBars } from "./DailyBars"; import { Heatmap } from "./Heatmap"; import { TrendLineChart } from "./TrendLineChart"; const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"]; +/** Per-request cache hit rate: cached tokens / total input tokens. */ +function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number { + const denom = r.inputOther + r.inputCacheRead; + return denom > 0 ? r.inputCacheRead / denom : 0; +} + function Card({ title, subtitle, @@ -44,7 +51,8 @@ function KpiCard({ label, value, sub }: { label: string; value: string; sub?: st export function DashboardPage() { const { t } = useTranslation(); - const { range, changeRange, data, loading, error, refresh } = useDashboard(); + const { money } = useCurrency(); + const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard(); const [showAllModels, setShowAllModels] = useState(false); const [recentPage, setRecentPage] = useState(1); const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily"); @@ -120,28 +128,35 @@ export function DashboardPage() { { label: t("kpiCacheCreation"), value: fmtTokens(totals.inputCacheCreation), sub: fmtInt(totals.inputCacheCreation) }, { label: t("kpiCacheHit"), value: fmtPct(totals.cacheHitRate) }, { label: t("totalTokens"), value: fmtTokens(totals.totalTokens), sub: fmtInt(totals.totalTokens) }, - { label: t("kpiCost"), value: fmtUsd(totals.costUsd) }, + { label: t("kpiCost"), value: money(totals.costUsd) }, ]; return (
{/* Range tabs + refresh */}
-
- {RANGES.map((r) => ( - - ))} +
+
+ {RANGES.map((r) => ( + + ))} +
+ {loadStats && !loading && ( + + {t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })} + + )}
); @@ -305,7 +320,7 @@ export function DashboardPage() { {fmtInt(m.requests)} {fmtTokens(m.totalTokens)} {fmtPct(m.cacheHitRate)} - {fmtUsd(m.costUsd)} + {money(m.costUsd)} ))} @@ -346,6 +361,7 @@ export function DashboardPage() { {t("colInput")} {t("colOutput")} {t("colCacheRead")} + {t("colCacheHit")} {t("cost")} @@ -359,7 +375,8 @@ export function DashboardPage() { {fmtTokens(r.inputOther)} {fmtTokens(r.output)} {fmtTokens(r.inputCacheRead)} - {fmtUsd(r.costUsd)} + {fmtPct(cacheHitOf(r))} + {money(r.costUsd)} ))} diff --git a/src/components/dashboard/Heatmap.tsx b/src/components/dashboard/Heatmap.tsx index 2c14d53..0092e2b 100644 --- a/src/components/dashboard/Heatmap.tsx +++ b/src/components/dashboard/Heatmap.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "../../i18n"; -import { fmtPct, fmtTokens, fmtUsd } from "../../lib/dashboard-format"; +import { useCurrency } from "../../hooks/useCurrency"; +import { fmtPct, fmtTokens } from "../../lib/dashboard-format"; import type { HeatmapCell, HeatmapData } from "../../types/dashboard"; interface HeatmapProps { @@ -220,6 +221,7 @@ export function Heatmap({ heatmap }: HeatmapProps) { function HeatmapTooltip({ state }: { state: HoverState }) { const { t } = useTranslation(); + const { money } = useCurrency(); const { cell, x, y } = state; // Position above the cursor with a small offset; flip below if too close to top. const offset = 14; @@ -239,7 +241,7 @@ function HeatmapTooltip({ state }: { state: HoverState }) { >
{cell.date}
- {fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "} + {fmtTokens(cell.totalTokens)} · {money(cell.costUsd)} ·{" "} {fmtInt(cell.requests)} {t("requests")}
diff --git a/src/hooks/useCurrency.ts b/src/hooks/useCurrency.ts new file mode 100644 index 0000000..f1f01a4 --- /dev/null +++ b/src/hooks/useCurrency.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "../i18n"; +import { fmtUsd } from "../lib/dashboard-format"; + +/** + * USD → CNY exchange rate for the dashboard. + * Chinese mode shows prices in CNY (latest available rate), English stays in + * USD. The rate is fetched once and cached in localStorage for 12h; any + * failure falls back to a conservative fixed rate so the UI never blocks. + */ + +const FALLBACK_RATE = 7.2; +const CACHE_KEY = "kimi-switch-cny-rate"; +const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours + +function readCachedRate(): number | null { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return null; + const { ts, rate } = JSON.parse(raw) as { ts: number; rate: number }; + if (Date.now() - ts > CACHE_TTL || !(rate > 1)) return null; + return rate; + } catch { + return null; + } +} + +function writeCachedRate(rate: number) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), rate })); + } catch { + /* ignore */ + } +} + +export function useCurrency() { + const { lang } = useTranslation(); + const [rate, setRate] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const cached = readCachedRate(); + if (cached) { + setRate(cached); + return; + } + try { + const res = await fetch("https://open.er-api.com/v6/latest/USD"); + if (res.ok) { + const data = (await res.json()) as { rates?: { CNY?: number } }; + const cny = Number(data.rates?.CNY); + if (cny > 1) { + if (!cancelled) setRate(cny); + writeCachedRate(cny); + return; + } + } + } catch { + /* network blocked → fallback */ + } + if (!cancelled) setRate(FALLBACK_RATE); + })(); + return () => { + cancelled = true; + }; + }, []); + + /** Format a USD cost for display: CNY in Chinese mode, USD otherwise. */ + const money = useCallback( + (usd: number): string => { + if (lang !== "zh") return fmtUsd(usd); + const v = (Number(usd) || 0) * (rate ?? FALLBACK_RATE); + if (v === 0) return "¥0.00"; + if (v < 0.1) return "¥" + v.toFixed(3); + if (v < 1) return "¥" + v.toFixed(2); + return "¥" + v.toFixed(2); + }, + [lang, rate], + ); + + return { money, cnyRate: rate ?? FALLBACK_RATE }; +} diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts index e8a9d03..545a2b2 100644 --- a/src/hooks/useDashboard.ts +++ b/src/hooks/useDashboard.ts @@ -22,16 +22,23 @@ export function useDashboard() { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + /** Round-trip timing for the last get_summary call (user-perceived lag). */ + const [loadStats, setLoadStats] = useState<{ ms: number; kb: number } | null>(null); const refresh = useCallback(async (force = false) => { setLoading(true); setError(null); + const start = performance.now(); try { const result = await invoke("get_summary", { range, refresh: force, }); setData(result); + setLoadStats({ + ms: Math.round(performance.now() - start), + kb: Math.round(JSON.stringify(result).length / 1024), + }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setError(msg); @@ -53,5 +60,5 @@ export function useDashboard() { } }, []); - return { range, changeRange, data, loading, error, refresh }; + return { range, changeRange, data, loading, error, refresh, loadStats }; } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 84bac24..e9d5e31 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -197,6 +197,7 @@ export const enTranslations: Record = { scanning: "Scanning…", refresh: "Refresh", refreshing: "Refreshing…", + loadStats: "Loaded in {ms} ms · {kb} KB payload", refreshed: "Refreshed", cancel: "Cancel", confirmDeleteTitle: "Delete session forever?", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index b3e9ac1..ef4a855 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -194,6 +194,7 @@ export const zhTranslations = { scanning: "扫描中…", refresh: "刷新", refreshing: "刷新中…", + loadStats: "加载 {ms} ms · 数据 {kb} KB", refreshed: "已刷新", cancel: "取消", confirmDeleteTitle: "永久删除会话?",