diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index cbd7be7..db6224e 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -309,6 +309,17 @@ interface OpenClawAPI { }; usage: { getStats(): Promise; + getDetailedStats(params: { + startDate?: string; + endDate?: string; + filter?: string; + }): Promise; + getExchangeRate(): Promise<{ + rate: number; + currency: string; + isFallback: boolean; + fetchedAt: number; + }>; }; } diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index f29267a..29e2514 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -214,6 +214,7 @@ export default { "settings.usageLoading": "Loading usage data…", "settings.retry": "Retry", "settings.totalSpend": "Total Spend", + "settings.currencySymbol": "¥", "settings.budget": "Budget", "settings.tokenUsage30d": "Token Usage (Last 30 Days)", "settings.sessionCount": "Sessions", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 16a6271..7b622d3 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -202,6 +202,7 @@ export default { "settings.usageLoading": "正在加载用量数据…", "settings.retry": "重试", "settings.totalSpend": "总花费", + "settings.currencySymbol": "¥", "settings.budget": "预算", "settings.tokenUsage30d": "Token 用量(近 30 天)", "settings.sessionCount": "会话数", diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index 9b34e32..7245148 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -85,15 +85,16 @@
{{ t("settings.totalSpend") }} - ${{ usageData.totalSpend.toFixed(4) }} + {{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(4) }}
{{ t("settings.budget") }}
${{ usageData.totalSpend.toFixed(2) }} / ${{ - usageData.maxBudget.toFixed(2) - }}{{ t("settings.currencySymbol") }}{{ toCny(usageData.totalSpend).toFixed(2) }} / + {{ t("settings.currencySymbol") }}{{ toCny(usageData.maxBudget).toFixed(2) }}
- ${{ m.spend.toFixed(4) }} + {{ t("settings.currencySymbol") }}{{ toCny(m.spend).toFixed(4) }}
@@ -1482,11 +1485,24 @@ interface UsageStats { cacheWriteTokens?: number; sessionCount?: number; toolCalls?: number; + /** CNY per 1 USD, provided by the main process to convert relayed USD spend. */ + exchangeRate?: number; + /** Display currency code for spend values (e.g. "CNY"). */ + currency?: string; } const usageData = ref(null); const usageLoading = ref(false); const usageError = ref(""); +// The gateway relays spend in USD; convert to CNY for display using the live rate +// from the main process (defaults to the fallback rate until stats load). +// Fallback mirrors USD_TO_CNY_FALLBACK_RATE in the main process (approx. 2026 rate). +const USD_TO_CNY_FALLBACK_RATE = 7.2; +function toCny(usd: number | null | undefined): number { + const rate = usageData.value?.exchangeRate ?? USD_TO_CNY_FALLBACK_RATE; + return (usd ?? 0) * rate; +} + const usageModelList = computed(() => { if (!usageData.value) return []; // Use detailed breakdown if available, otherwise fall back to modelSpend diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index 9a596cd..c41eafe 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -67,6 +67,22 @@ export const WEIXIN_LOGIN_TIMEOUT_MS = 180_000; /** Number of days of usage data to query from the gateway. */ export const USAGE_QUERY_DAYS = 30; +/** + * Free, no-API-key FX endpoint (ECB data via Frankfurter) used to convert the + * USD spend figures relayed from the OpenClaw gateway into CNY for display. + */ +export const EXCHANGE_RATE_API_URL = "https://api.frankfurter.app/latest?from=USD&to=CNY"; + +/** How long a fetched USD→CNY rate stays cached in memory before we refetch. */ +export const EXCHANGE_RATE_CACHE_TTL_MS = 12 * 60 * 60 * 1_000; + +/** + * Fallback USD→CNY rate used when the FX endpoint is unreachable (offline / API + * down) so the usage UI never breaks. Approximate mid-market rate as of 2026 — + * this is only a safety net; the live rate is preferred whenever available. + */ +export const USD_TO_CNY_FALLBACK_RATE = 7.2; + /** Public manifest used by the P0 manual update checker. */ export const UPDATE_MANIFEST_URL = "https://microclaw.microsoftol.com/releases/latest.json"; diff --git a/desktop/src/exchange-rate.test.ts b/desktop/src/exchange-rate.test.ts new file mode 100644 index 0000000..2be2db8 --- /dev/null +++ b/desktop/src/exchange-rate.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getUsdToCnyRate, resetExchangeRateCache } from "./exchange-rate"; +import { USD_TO_CNY_FALLBACK_RATE } from "./constants"; + +afterEach(() => { + resetExchangeRateCache(); +}); + +describe("getUsdToCnyRate", () => { + it("returns the live CNY rate from a valid response", async () => { + const result = await getUsdToCnyRate(async () => ({ + amount: 1, + base: "USD", + date: "2026-07-29", + rates: { CNY: 7.15 }, + })); + + expect(result.rate).toBe(7.15); + expect(result.currency).toBe("CNY"); + expect(result.isFallback).toBe(false); + }); + + it("caches the rate within the TTL and does not refetch", async () => { + const fetchJson = vi.fn(async () => ({ rates: { CNY: 7.11 } })); + + const first = await getUsdToCnyRate(fetchJson, 1_000); + const second = await getUsdToCnyRate(fetchJson, 2_000); + + expect(fetchJson).toHaveBeenCalledTimes(1); + expect(first.rate).toBe(7.11); + expect(second.rate).toBe(7.11); + expect(second.fetchedAt).toBe(1_000); + }); + + it("refetches once the cache TTL has elapsed", async () => { + const fetchJson = vi + .fn() + .mockResolvedValueOnce({ rates: { CNY: 7.0 } }) + .mockResolvedValueOnce({ rates: { CNY: 7.5 } }); + + const first = await getUsdToCnyRate(fetchJson, 0); + // 12h + 1ms later — past the TTL. + const second = await getUsdToCnyRate(fetchJson, 12 * 60 * 60 * 1_000 + 1); + + expect(fetchJson).toHaveBeenCalledTimes(2); + expect(first.rate).toBe(7.0); + expect(second.rate).toBe(7.5); + }); + + it("falls back to the hardcoded rate when the fetch fails", async () => { + const result = await getUsdToCnyRate(async () => { + throw new Error("offline"); + }); + + expect(result.rate).toBe(USD_TO_CNY_FALLBACK_RATE); + expect(result.isFallback).toBe(true); + }); + + it("falls back when the response is missing a valid CNY rate", async () => { + const result = await getUsdToCnyRate(async () => ({ rates: {} })); + + expect(result.rate).toBe(USD_TO_CNY_FALLBACK_RATE); + expect(result.isFallback).toBe(true); + }); + + it("reuses the last good rate when a later fetch fails", async () => { + const fetchJson = vi + .fn() + .mockResolvedValueOnce({ rates: { CNY: 7.2 } }) + .mockRejectedValueOnce(new Error("offline")); + + await getUsdToCnyRate(fetchJson, 0); + const stale = await getUsdToCnyRate(fetchJson, 12 * 60 * 60 * 1_000 + 1); + + expect(stale.rate).toBe(7.2); + expect(stale.isFallback).toBe(false); + }); +}); diff --git a/desktop/src/exchange-rate.ts b/desktop/src/exchange-rate.ts new file mode 100644 index 0000000..0319b90 --- /dev/null +++ b/desktop/src/exchange-rate.ts @@ -0,0 +1,105 @@ +import { + EXCHANGE_RATE_API_URL, + EXCHANGE_RATE_CACHE_TTL_MS, + USD_TO_CNY_FALLBACK_RATE, +} from "./constants"; + +/** + * USD→CNY conversion used to display the spend figures relayed from the OpenClaw + * gateway. The gateway reports cost in USD (OpenClaw's `estimateUsageCost()` is + * priced from a USD model catalog); MicroClaw converts to CNY purely for display. + */ +export interface ExchangeRate { + /** Amount of CNY per 1 USD. */ + rate: number; + /** Target currency code — always "CNY" here. */ + currency: "CNY"; + /** True when the hardcoded fallback rate was used instead of a live fetch. */ + isFallback: boolean; + /** Epoch millis when this rate was obtained. */ + fetchedAt: number; +} + +/** Injectable JSON fetcher so tests can run without real network access. */ +export type FetchJson = (url: string) => Promise; + +interface CacheEntry { + rate: number; + fetchedAt: number; +} + +let cache: CacheEntry | null = null; + +async function fetchRateJson(url: string): Promise { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Exchange rate request failed with HTTP ${response.status}`); + } + return response.json(); +} + +/** + * Parses the Frankfurter response shape `{ rates: { CNY: number } }` and returns + * the CNY rate, or throws if the payload is missing/invalid. + */ +function parseCnyRate(value: unknown): number { + if (!value || typeof value !== "object") { + throw new Error("Exchange rate response must be a JSON object"); + } + const rates = (value as Record).rates; + if (!rates || typeof rates !== "object") { + throw new Error("Exchange rate response is missing rates"); + } + const cny = (rates as Record).CNY; + if (typeof cny !== "number" || !Number.isFinite(cny) || cny <= 0) { + throw new Error("Exchange rate response is missing a valid CNY rate"); + } + return cny; +} + +/** + * Returns the current USD→CNY rate, caching it in memory for + * {@link EXCHANGE_RATE_CACHE_TTL_MS}. Falls back to {@link USD_TO_CNY_FALLBACK_RATE} + * when the endpoint is unreachable so the UI never breaks. + */ +export async function getUsdToCnyRate( + fetchJson: FetchJson = fetchRateJson, + now: number = Date.now(), +): Promise { + if (cache && now - cache.fetchedAt < EXCHANGE_RATE_CACHE_TTL_MS) { + return { + rate: cache.rate, + currency: "CNY", + isFallback: false, + fetchedAt: cache.fetchedAt, + }; + } + + try { + const rate = parseCnyRate(await fetchJson(EXCHANGE_RATE_API_URL)); + cache = { rate, fetchedAt: now }; + return { rate, currency: "CNY", isFallback: false, fetchedAt: now }; + } catch { + // Network/API failure: use the last good cached rate if we have one, + // otherwise the hardcoded fallback so the usage UI keeps working offline. + if (cache) { + return { + rate: cache.rate, + currency: "CNY", + isFallback: false, + fetchedAt: cache.fetchedAt, + }; + } + return { + rate: USD_TO_CNY_FALLBACK_RATE, + currency: "CNY", + isFallback: true, + fetchedAt: now, + }; + } +} + +/** Clears the in-memory rate cache. Intended for tests. */ +export function resetExchangeRateCache(): void { + cache = null; +} diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 5131abe..ca371cb 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -19,6 +19,7 @@ import { shieldIfNeeded, unshieldIfNeeded } from "./sensitive-shield"; import { StudioBackendManager } from "./studio-backend-manager"; import { resolveSupportedLocale, t as mainT } from "./i18n"; import { checkForUpdates } from "./update-checker"; +import { getUsdToCnyRate } from "./exchange-rate"; import { recoverInterruptedOpenClawUpgrade, UpgradeInProgressError, @@ -3434,7 +3435,6 @@ function registerIpcHandlers(): void { // --- Usage (via gateway WebSocket sessions.usage) --- ipcMain.handle("usage:get-stats", async () => { if (!gwClient?.connected) throw new Error("Gateway 未连接"); - const endDate = new Date().toISOString().split("T")[0]; const startDate = new Date(Date.now() - USAGE_QUERY_DAYS * 86_400_000) .toISOString() @@ -3483,6 +3483,10 @@ function registerIpcHandlers(): void { if (d.date) dailySpend[d.date] = d.cost || 0; } + // The gateway reports cost in USD; fetch the live USD→CNY rate so the + // renderer can convert + relabel spend to ¥ (falls back gracefully offline). + const fx = await getUsdToCnyRate(); + return { totalSpend: totals.totalCost || 0, maxBudget: null, @@ -3502,9 +3506,17 @@ function registerIpcHandlers(): void { cacheWriteTokens: totals.cacheWrite || 0, sessionCount: result?.sessions?.length || 0, toolCalls: aggregates.tools?.totalCalls || 0, + // USD→CNY conversion metadata (spend values above remain raw USD) + exchangeRate: fx.rate, + currency: fx.currency, }; }); + // Live USD→CNY exchange rate used to render spend figures in CNY. + ipcMain.handle("usage:get-exchange-rate", async () => { + return getUsdToCnyRate(); + }); + // Detailed usage stats for the full Usage dashboard page ipcMain.handle( "usage:get-detailed-stats", diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 62a0dcc..042ecb0 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -212,6 +212,7 @@ contextBridge.exposeInMainWorld("openclaw", { getStats: () => ipcRenderer.invoke("usage:get-stats"), getDetailedStats: (params: { startDate?: string; endDate?: string; filter?: string }) => ipcRenderer.invoke("usage:get-detailed-stats", params), + getExchangeRate: () => ipcRenderer.invoke("usage:get-exchange-rate"), }, // --- Studio Backend ---