feat(dashboard): 最近请求缓存命中列 + 中文模式人民币计价 + 价格解析记忆化

- 最近请求表新增缓存命中率列(缓存读取/总输入)
- useCurrency:中文界面费用按最新 USD→CNY 汇率显示 ¥,英文保持 $(open.er-api.com,缓存 12h,失败兜底 7.2)
- match_price 记忆化缓存:O(记录数×5910) → O(模型种类×5910),扫描从秒级降到百毫秒级
- 仪表盘右上角显示加载耗时/数据量(get_summary 往返埋点);Rust 侧分段计时
This commit is contained in:
KimiSwitch Dev committed 2026-08-02 00:18:19 +08:00
1 parent de132ffb3a
commit 38a11666ac
9 files changed
+173 -28

No files matched your search

+30
View File
@@ -610,7 +610,26 @@ fn strip_context_suffix(model_name: &str) -> Option<String> {
})
}
/// 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<Mutex<HashMap<String, (String, f64, f64, f64, bool)>>> =
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<String>, range: Option<String>, refresh: Option<bool>) -> 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<String>, range: Option<String>, 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<String>, range: Option<String>, 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),
+4 -2
View File
@@ -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<DailyRow | null>(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"),
@@ -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 */}
<div className="grid grid-cols-2 gap-2 px-5 py-3 sm:grid-cols-4">
<SummaryChip label={t("totalTokens")} value={fmtTokens(day.totalTokens)} tone="default" />
<SummaryChip label={t("cost")} value={fmtUsd(day.costUsd)} tone="orange" />
<SummaryChip label={t("cost")} value={money(day.costUsd)} tone="orange" />
<SummaryChip label={t("requests")} value={Math.round(day.requests).toLocaleString()} tone="blue" />
<SummaryChip label={t("cacheHitRate")} value={fmtPct(day.cacheHitRate)} tone="green" />
</div>
+38 -21
View File
@@ -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 (
<div className="h-full overflow-auto p-4 space-y-4">
{/* Range tabs + refresh */}
<div className="flex items-center justify-between">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => changeRange(r)}
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{RANGE_LABELS[r]}
</button>
))}
<div className="flex items-center gap-3">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => changeRange(r)}
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{RANGE_LABELS[r]}
</button>
))}
</div>
{loadStats && !loading && (
<span className="hidden text-[10px] text-content-muted sm:inline">
{t("loadStats", { ms: loadStats.ms, kb: loadStats.kb })}
</span>
)}
</div>
<button
type="button"
@@ -175,7 +190,7 @@ export function DashboardPage() {
{fmtTokens(tot.totalTokens)}
</div>
<div className="mt-1 text-[11px] text-content-muted">
{fmtInt(tot.requests)} {t("unitTimes")} · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
{fmtInt(tot.requests)} {t("unitTimes")} · {money(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
</div>
</button>
);
@@ -305,7 +320,7 @@ export function DashboardPage() {
<td className="py-2 pr-3 text-right text-content-muted">{fmtInt(m.requests)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtTokens(m.totalTokens)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtPct(m.cacheHitRate)}</td>
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{fmtUsd(m.costUsd)}</td>
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{money(m.costUsd)}</td>
</tr>
))}
</tbody>
@@ -346,6 +361,7 @@ export function DashboardPage() {
<th className="pb-2 pr-4 font-normal text-right">{t("colInput")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colOutput")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheRead")}</th>
<th className="pb-2 pr-4 font-normal text-right">{t("colCacheHit")}</th>
<th className="pb-2 font-normal text-right">{t("cost")}</th>
</tr>
</thead>
@@ -359,7 +375,8 @@ export function DashboardPage() {
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{fmtUsd(r.costUsd)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtPct(cacheHitOf(r))}</td>
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{money(r.costUsd)}</td>
</tr>
))}
</tbody>
+4 -2
View File
@@ -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 }) {
>
<div className="font-medium text-content-primary">{cell.date}</div>
<div className="text-content-muted">
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
{fmtTokens(cell.totalTokens)} · {money(cell.costUsd)} ·{" "}
{fmtInt(cell.requests)} {t("requests")}
</div>
<div className="text-content-muted">
+83
View File
@@ -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<number | null>(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 };
}
+8 -1
View File
@@ -22,16 +22,23 @@ export function useDashboard() {
const [data, setData] = useState<SummaryResult | null>(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 refresh = useCallback(async (force = false) => {
setLoading(true);
setError(null);
const start = performance.now();
try {
const result = await invoke<SummaryResult>("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 };
}
+1
View File
@@ -197,6 +197,7 @@ export const enTranslations: Record<TranslationKey, string> = {
scanning: "Scanning…",
refresh: "Refresh",
refreshing: "Refreshing…",
loadStats: "Loaded in {ms} ms · {kb} KB payload",
refreshed: "Refreshed",
cancel: "Cancel",
confirmDeleteTitle: "Delete session forever?",
+1
View File
@@ -194,6 +194,7 @@ export const zhTranslations = {
scanning: "扫描中…",
refresh: "刷新",
refreshing: "刷新中…",
loadStats: "加载 {ms} ms · 数据 {kb} KB",
refreshed: "已刷新",
cancel: "取消",
confirmDeleteTitle: "永久删除会话?",