From dbec16dd8b99b51db5211c2e24348bd8c9e49d30 Mon Sep 17 00:00:00 2001 From: Linanwanttodo Date: Wed, 2 Sep 2026 18:20:20 +0800 Subject: [PATCH 1/8] perf(desktop): create the main window lazily on demand The webview used to be built unconditionally at startup and only hidden on close, so the forwarding gateway kept WebKitWebProcess and WebKitNetworkProcess resident even when the UI was never used. Now the window is created only when opened from the tray, closing the window destroys the webview, and all-windows-closed keeps the process alive in the tray while the forwarding server keeps running. --- apps/desktop/src-tauri/src/desktop.rs | 89 +++++++++++++++------------ apps/desktop/src-tauri/src/tray.rs | 18 ++---- 2 files changed, 56 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index c700bea..eb05110 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -36,6 +36,7 @@ struct DesktopRuntime { shutdown: CancellationToken, server: Mutex>>>, exiting: AtomicBool, + server_addr: std::net::SocketAddr, } #[tauri::command] @@ -141,6 +142,21 @@ fn create_main_window( builder.build() } +/// 按需打开主窗口:webview 仅在需要界面时创建,关闭窗口即销毁释放内存。 +pub(crate) fn open_main_window(app: &AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + return Ok(()); + } + let address = app.state::().server_addr; + let window = create_main_window(app, address)?; + window.show()?; + window.set_focus()?; + Ok(()) +} + pub fn run() -> ExitCode { let diagnostics = match StartupDiagnostics::initialize() { Ok(diagnostics) => diagnostics, @@ -184,7 +200,7 @@ pub fn run() -> ExitCode { ]) .plugin(tauri_plugin_single_instance::init(|app, args, _| { if !args.iter().any(|arg| arg == AUTOSTART_ARG) { - tray::show_main_window(app); + let _ = open_main_window(app); } })) .plugin(tauri_plugin_clipboard_manager::init()) @@ -240,13 +256,12 @@ pub fn run() -> ExitCode { shutdown, server: Mutex::new(Some(task)), exiting: AtomicBool::new(false), + server_addr: address, }); - let window = create_main_window(app.handle(), address)?; if desktop_settings.silent_start && started_by_autostart { - tracing::info!("silent autostart enabled; keeping the main window hidden"); + tracing::info!("silent autostart enabled; starting without the main window"); } else { - window.show()?; - window.set_focus()?; + open_main_window(app.handle())?; } tray::create(app)?; crate::update::signal_ready_if_requested()?; @@ -262,46 +277,42 @@ pub fn run() -> ExitCode { }; app.run(|app, event| match event { - RunEvent::WindowEvent { - label, - event: tauri::WindowEvent::CloseRequested { api, .. }, - .. - } if label == MAIN_WINDOW_LABEL => { - let runtime = app.state::(); - if !runtime.exiting.load(Ordering::Acquire) { - api.prevent_close(); - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - let _ = window.hide(); + // code 为 None 表示所有窗口已被关闭(轻量模式),阻止退出, + // 转发服务继续在托盘后台运行;code 为 Some 时是显式退出请求。 + RunEvent::ExitRequested { code, api, .. } => match code { + None => api.prevent_exit(), + Some(_) => { + let runtime = app.state::(); + if !runtime.exiting.swap(true, Ordering::AcqRel) { + api.prevent_exit(); + runtime.shutdown.cancel(); + let server = runtime.server.lock().expect("server lock poisoned").take(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(server) = server { + match tokio::time::timeout(Duration::from_secs(11), server).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(error))) => { + tracing::error!(%error, "desktop server shutdown failed") + } + Ok(Err(error)) => { + tracing::error!(%error, "desktop server task failed") + } + Err(_) => tracing::warn!("desktop server shutdown timed out"), + } + } + app.exit(0); + }); } } - } - RunEvent::ExitRequested { api, .. } => { - let runtime = app.state::(); - if !runtime.exiting.swap(true, Ordering::AcqRel) { - api.prevent_exit(); - runtime.shutdown.cancel(); - let server = runtime.server.lock().expect("server lock poisoned").take(); - let app = app.clone(); - tauri::async_runtime::spawn(async move { - if let Some(server) = server { - match tokio::time::timeout(Duration::from_secs(11), server).await { - Ok(Ok(Ok(()))) => {} - Ok(Ok(Err(error))) => { - tracing::error!(%error, "desktop server shutdown failed") - } - Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"), - Err(_) => tracing::warn!("desktop server shutdown timed out"), - } - } - app.exit(0); - }); - } - } + }, #[cfg(target_os = "macos")] RunEvent::Reopen { has_visible_windows: false, .. - } => tray::show_main_window(app), + } => { + let _ = open_main_window(app); + } _ => {} }); diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index a5a2d77..1e7c5df 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,13 +1,13 @@ use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::TrayIconBuilder, - App, AppHandle, Manager, + App, }; #[cfg(target_os = "windows")] use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; -use crate::desktop::MAIN_WINDOW_LABEL; +use crate::desktop::open_main_window; const OPEN_MENU_ID: &str = "tray-open"; const QUIT_MENU_ID: &str = "tray-quit"; @@ -24,7 +24,9 @@ pub fn create(app: &mut App) -> tauri::Result<()> { .menu(&menu) .show_menu_on_left_click(cfg!(target_os = "macos")) .on_menu_event(|app, event| match event.id().as_ref() { - OPEN_MENU_ID => show_main_window(app), + OPEN_MENU_ID => { + let _ = open_main_window(app); + } QUIT_MENU_ID => app.exit(0), _ => {} }) @@ -36,7 +38,7 @@ pub fn create(app: &mut App) -> tauri::Result<()> { .. } = event { - show_main_window(tray.app_handle()); + let _ = open_main_window(tray.app_handle()); } #[cfg(not(target_os = "windows"))] @@ -45,11 +47,3 @@ pub fn create(app: &mut App) -> tauri::Result<()> { .build(app)?; Ok(()) } - -pub fn show_main_window(app: &AppHandle) { - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); - } -} From 147d13f04ea296f88c48fa6f58c9f02362f2027e Mon Sep 17 00:00:00 2001 From: Linanwanttodo Date: Wed, 2 Sep 2026 18:20:33 +0800 Subject: [PATCH 2/8] perf(desktop): switch to the mimalloc global allocator glibc keeps freed pages in its arena, so RSS did not fall back after the webview was destroyed. mimalloc returns freed memory to the OS, letting the resident footprint settle right after the UI closes. --- Cargo.lock | 19 +++++++++++++++++++ apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/lib.rs | 5 +++++ 3 files changed, 25 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 2e4661a..e5618ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1177,6 +1177,7 @@ dependencies = [ "axum", "cursor-server", "libc", + "mimalloc", "rfd", "serde", "serde_json", @@ -3417,6 +3418,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.19" @@ -3604,6 +3614,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 10c01dd..44fd06a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] } axum = "0.8" cursor-server = { path = "../../../server" } libc = "0.2" +mimalloc = "0.1" rfd = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index cef6375..1bbe442 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -6,6 +6,11 @@ mod startup; mod tray; mod update; +// mimalloc 在释放时主动向操作系统归还内存,避免 glibc 保留页导致 +// 关闭窗口后 RSS 无法回落到静默启动水平。 +#[global_allocator] +static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; + pub fn run() -> std::process::ExitCode { if let Some(exit_code) = update::run_replacement_if_requested() { return exit_code; From ff9978f74ca195e8205daffc73c0faed91adf975 Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:56:57 -0400 Subject: [PATCH 3/8] fix(search): stop a repeated URL from faking cross-engine agreement `WebSearch::search` fuses the engines with Reciprocal Rank Fusion, which sums one term per ranked list. `merge` added a term for every result instead, so an engine that listed the same canonical URL twice had both of its ranks counted: 1/61 + 1/62 = 0.0325 That is what two independent engines agreeing at rank 1 are worth (2/61 = 0.0328), produced from a single list. Agreement across engines is the only ranking signal this federation has, and a repeat inside one engine forges it. The repeats come from the repository's own canonicalization, not from exotic input. `canonicalize` in `search/engine.rs` drops the fragment, the trailing slash and the `utm_*`, `gclid`, `fbclid` and `mc_*` parameters, and `redirected_target` unwraps the Bing, DuckDuckGo and Google redirector links, so rows that are visibly distinct on one result page collapse onto one URL. Nothing dedupes an engine's own list before `merge` sees it. `merge` already knew the rule: the engine label was guarded with `!existing.engines.contains(&engine)`. Put the score behind the same guard, so each engine contributes its best rank once. Cross-engine merging and the longest-snippet rule are untouched. --- server/src/search/federation.rs | 83 ++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/server/src/search/federation.rs b/server/src/search/federation.rs index e7bdbd3..94b79bc 100644 --- a/server/src/search/federation.rs +++ b/server/src/search/federation.rs @@ -122,8 +122,12 @@ fn merge(merged: &mut HashMap, engine: &'static str, results: let score = 1.0 / (RRF_K + rank as f64 + 1.0); match merged.get_mut(&result.url) { Some(existing) => { - existing.score += score; + // Reciprocal Rank Fusion sums one term per ranked list. One + // engine listing a URL more than once is still one list, so + // only its best rank counts; a later repeat contributes + // nothing but its snippet. if !existing.engines.contains(&engine) { + existing.score += score; existing.engines.push(engine); } if result.chunk.len() > existing.chunk.len() { @@ -137,3 +141,80 @@ fn merge(merged: &mut HashMap, engine: &'static str, results: } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn hit(url: &str, engine: &'static str, chunk: &str) -> SearchHit { + SearchHit::new("title", url, chunk, vec![engine]) + } + + fn rrf(rank: usize) -> f64 { + 1.0 / (RRF_K + rank as f64 + 1.0) + } + + #[test] + fn an_engine_that_lists_one_url_twice_contributes_a_single_rrf_term() { + let mut merged = HashMap::new(); + merge( + &mut merged, + "duckduckgo", + vec![ + hit("https://example.com/p", "duckduckgo", "short"), + hit("https://example.com/p", "duckduckgo", "longer snippet"), + ], + ); + + let entry = &merged["https://example.com/p"]; + assert_eq!(entry.engines, vec!["duckduckgo"]); + assert_eq!(entry.score, rrf(0)); + assert_eq!(entry.chunk, "longer snippet"); + } + + #[test] + fn two_engines_that_agree_on_a_url_still_sum_both_ranks() { + let mut merged = HashMap::new(); + merge( + &mut merged, + "google", + vec![hit("https://example.com/p", "google", "a")], + ); + merge( + &mut merged, + "bing", + vec![ + hit("https://example.com/other", "bing", "b"), + hit("https://example.com/p", "bing", "c"), + ], + ); + + let entry = &merged["https://example.com/p"]; + assert_eq!(entry.engines, vec!["google", "bing"]); + assert_eq!(entry.score, rrf(0) + rrf(1)); + } + + #[test] + fn agreement_between_engines_outranks_one_engine_repeating_itself() { + let mut merged = HashMap::new(); + merge( + &mut merged, + "duckduckgo", + vec![ + hit("https://example.com/repeated", "duckduckgo", "a"), + hit("https://example.com/repeated", "duckduckgo", "b"), + hit("https://example.com/agreed", "duckduckgo", "c"), + ], + ); + merge( + &mut merged, + "brave", + vec![hit("https://example.com/agreed", "brave", "d")], + ); + + assert!( + merged["https://example.com/agreed"].score + > merged["https://example.com/repeated"].score + ); + } +} From 02ecbedf7ddd41628c7d6c87d3905e934e568f3f Mon Sep 17 00:00:00 2001 From: nanoha <20983037+LyricalNanoha@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:36:11 +0800 Subject: [PATCH 4/8] feat(antigravity): add Gemini 3.8 Flash models and fix endpoint/UA Root cause: Google's fetchAvailableModels API returns different model catalogs depending on the endpoint. The daily endpoint returns 33 models including Gemini 3.8 Flash series, while the production endpoint only returns 28 models without 3.8. Additionally, the User-Agent header was using a stale Electron-style format (Antigravity/4.3.0) that does not match the real Antigravity client's UA (antigravity/hub/2.12.2), and unnecessary x-client-name / x-client-version headers were being sent. Changes: - Reorder ANTIGRAVITY_ENDPOINTS to try daily endpoint first (matching real Antigravity client behavior) - Update ANTIGRAVITY_USER_AGENT to match the real Antigravity hub UA - Remove unnecessary x-client-name/x-client-version headers - Add Gemini 3.8 Flash (high/medium/low/tiered) to static model list - Add gemini-3.8-flash passthrough in resolveAntigravityModel --- .../build-in/antigravity-auth/models.ts | 39 ++++++++++++++++--- .../build-in/antigravity-auth/provider.ts | 2 + 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/server/plugins/build-in/antigravity-auth/models.ts b/server/plugins/build-in/antigravity-auth/models.ts index 4bd57c2..018177a 100644 --- a/server/plugins/build-in/antigravity-auth/models.ts +++ b/server/plugins/build-in/antigravity-auth/models.ts @@ -7,23 +7,50 @@ export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; export const ANTIGRAVITY_ENDPOINTS = [ - ANTIGRAVITY_PROD_ENDPOINT, ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_PROD_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT, ]; const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; export const ANTIGRAVITY_USER_AGENT = - "Antigravity/4.3.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3"; + "antigravity/hub/2.12.2 (aidev_client; os_type=darwin; arch=arm64; cl=975423596)"; -export const ANTIGRAVITY_CLIENT_HEADERS: Record = { - "x-client-name": "antigravity", - "x-client-version": "4.3.0", -}; +export const ANTIGRAVITY_CLIENT_HEADERS: Record = {}; const ANTIGRAVITY_DENYLIST = new Set(["chat_20706", "chat_23310"]); export const STATIC_ANTIGRAVITY_MODELS: ModelDefinition[] = [ + // Gemini 3.8 Series + { + id: "gemini-3.8-flash-high", + displayName: "Gemini 3.8 Flash (High)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gemini-3.8-flash-medium", + displayName: "Gemini 3.8 Flash (Medium)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gemini-3.8-flash-low", + displayName: "Gemini 3.8 Flash (Low)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + { + id: "gemini-3.8-flash-tiered", + displayName: "Gemini 3.8 Flash (Tiered)", + capabilities: { images: true }, + maxOutputTokens: 65536, + privateData: { reasoningEfforts: ["low", "medium", "high"] }, + }, + // Gemini 3.7 Series { id: "gemini-3.7-flash", diff --git a/server/plugins/build-in/antigravity-auth/provider.ts b/server/plugins/build-in/antigravity-auth/provider.ts index e38704c..fe56817 100644 --- a/server/plugins/build-in/antigravity-auth/provider.ts +++ b/server/plugins/build-in/antigravity-auth/provider.ts @@ -69,9 +69,11 @@ function resolveAntigravityModel(modelId: string): string { // 1. If explicit tier is already specified in the model ID, pass it directly! if ( + lower.startsWith("gemini-3.8-flash-") || lower.startsWith("gemini-3.7-flash-") || lower.startsWith("gemini-3.6-flash-") || lower.startsWith("gemini-3.1-pro-") || + lower === "gemini-3.8-flash" || lower === "gemini-3.7-flash" || lower === "gemini-3.6-flash" || lower === "gemini-2.5-flash" || From 0ce64e20babb90b5d483c5dcc1d78f409be400df Mon Sep 17 00:00:00 2001 From: Usuario Exemplo Date: Mon, 14 Sep 2026 18:47:23 -0300 Subject: [PATCH 5/8] feat: add Brazilian Portuguese locale and configurable token pricing Enable Portuguese-speaking users with full pt-BR translations and let users set per-million token rates so home cost estimates reflect their actual pricing. Co-authored-by: Cursor --- apps/desktop/plugins/static-i18n-plugin.ts | 2 +- .../src/features/home/metrics/HomeMetrics.tsx | 27 +- .../features/settings/PricingSettingsCard.tsx | 165 ++++++ .../src/features/settings/SettingsPage.tsx | 5 +- apps/desktop/src/i18n/generated/catalog.json | 487 ++++++++++++------ apps/desktop/src/i18n/locales/en-US.json | 14 +- apps/desktop/src/i18n/locales/pt-BR.json | 446 ++++++++++++++++ apps/desktop/src/i18n/locales/zh-CN.json | 14 +- apps/desktop/src/i18n/runtime.ts | 4 +- apps/desktop/src/i18n/store.ts | 3 +- apps/desktop/src/shared/api.ts | 9 + apps/desktop/src/shared/store/appStore.ts | 26 +- server/src/control/mod.rs | 4 + server/src/control/service.rs | 13 +- server/src/control/settings.rs | 14 + server/src/store/settings.rs | 76 ++- 16 files changed, 1133 insertions(+), 176 deletions(-) create mode 100644 apps/desktop/src/features/settings/PricingSettingsCard.tsx create mode 100644 apps/desktop/src/i18n/locales/pt-BR.json diff --git a/apps/desktop/plugins/static-i18n-plugin.ts b/apps/desktop/plugins/static-i18n-plugin.ts index 0623dc5..0fc23f3 100644 --- a/apps/desktop/plugins/static-i18n-plugin.ts +++ b/apps/desktop/plugins/static-i18n-plugin.ts @@ -9,7 +9,7 @@ import { normalizePath, type Plugin } from "vite" const traverse = traverseModule.default ?? traverseModule const SOURCE_LOCALE = "zh-CN" -const SUPPORTED_LOCALES = ["zh-CN", "en-US"] +const SUPPORTED_LOCALES = ["zh-CN", "en-US", "pt-BR"] const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g const AUTO_IMPORT_NAME = "__staticI18nT" diff --git a/apps/desktop/src/features/home/metrics/HomeMetrics.tsx b/apps/desktop/src/features/home/metrics/HomeMetrics.tsx index 995c405..4cf32bd 100644 --- a/apps/desktop/src/features/home/metrics/HomeMetrics.tsx +++ b/apps/desktop/src/features/home/metrics/HomeMetrics.tsx @@ -1,4 +1,5 @@ import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat"; +import { useAppStore } from "../../../shared/store/appStore"; import { Icon } from "../../../shared/ui/Icon"; import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip"; import { informationOutlineIcon } from "../../../shared/ui/icons"; @@ -15,13 +16,6 @@ export type HomeMetricsData = { cacheWriteTokens: number; }; -const TOKEN_PRICE_PER_MILLION = { - input: 5, - output: 25, - cacheRead: 0.5, - cacheWrite: 6.25, -} as const; - function formatMetricValue(value: number) { const full = formatInteger(value); const compact = formatCompactInteger(value); @@ -66,6 +60,7 @@ function InfoTooltip({ content }: { content: string }) { } export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) { + const { pricing } = useAppStore(); const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens); const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens); const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens); @@ -75,10 +70,10 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat ); const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls); const costs = { - input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input), - output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output), - cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead), - cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite), + input: priceTokens(inputTokens, pricing.input_per_million), + output: priceTokens(outputTokens, pricing.output_per_million), + cacheRead: priceTokens(data.cacheReadTokens, pricing.cache_read_per_million), + cacheWrite: priceTokens(data.cacheWriteTokens, pricing.cache_write_per_million), }; const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite; const cacheCost = costs.cacheRead + costs.cacheWrite; @@ -111,27 +106,27 @@ export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsDat t("缓存读写已计入提示词侧统计。"), ].join("\n"); const costTooltip = [ - t("按 Claude Opus 4.7 价格估算。"), + t("按配置的 Token 价格估算。"), t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }), "", t("普通输入:{tokens} × ${price}/1M = {cost}", { tokens: formatMetricValue(inputTokens), - price: TOKEN_PRICE_PER_MILLION.input, + price: pricing.input_per_million, cost: formatUSD(costs.input), }), t("模型输出:{tokens} × ${price}/1M = {cost}", { tokens: formatMetricValue(outputTokens), - price: TOKEN_PRICE_PER_MILLION.output, + price: pricing.output_per_million, cost: formatUSD(costs.output), }), t("缓存读取:{tokens} × ${price}/1M = {cost}", { tokens: formatMetricValue(data.cacheReadTokens), - price: TOKEN_PRICE_PER_MILLION.cacheRead, + price: pricing.cache_read_per_million, cost: formatUSD(costs.cacheRead), }), t("缓存写入:{tokens} × ${price}/1M = {cost}", { tokens: formatMetricValue(data.cacheWriteTokens), - price: TOKEN_PRICE_PER_MILLION.cacheWrite, + price: pricing.cache_write_per_million, cost: formatUSD(costs.cacheWrite), }), "", diff --git a/apps/desktop/src/features/settings/PricingSettingsCard.tsx b/apps/desktop/src/features/settings/PricingSettingsCard.tsx new file mode 100644 index 0000000..2ae5cde --- /dev/null +++ b/apps/desktop/src/features/settings/PricingSettingsCard.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import type { TokenPricingSettings } from "../../shared/api"; +import { appStore, DEFAULT_TOKEN_PRICING, useAppStore } from "../../shared/store/appStore"; +import { Button } from "../../shared/ui/Button"; +import { FormField, TextInput } from "../../shared/ui/FormControls"; +import { TitledCard } from "../../shared/ui/TitledCard"; +import { useMessage } from "../../shared/ui/message"; +import styles from "./ProxySettingsCard.module.scss"; + +type PricingDraft = { + input_per_million: string; + output_per_million: string; + cache_read_per_million: string; + cache_write_per_million: string; +}; + +function toDraft(pricing: TokenPricingSettings): PricingDraft { + return { + input_per_million: String(pricing.input_per_million), + output_per_million: String(pricing.output_per_million), + cache_read_per_million: String(pricing.cache_read_per_million), + cache_write_per_million: String(pricing.cache_write_per_million), + }; +} + +function formatPrice(value: number) { + return `$${value}`; +} + +function parsePrice(value: string, label: string) { + const price = Number(value); + if (!Number.isFinite(price) || price < 0) { + throw new Error(t("{label}必须是非负数", { label })); + } + return price; +} + +function toSettings(draft: PricingDraft): TokenPricingSettings { + return { + input_per_million: parsePrice(draft.input_per_million, t("输入价格")), + output_per_million: parsePrice(draft.output_per_million, t("输出价格")), + cache_read_per_million: parsePrice(draft.cache_read_per_million, t("缓存读取价格")), + cache_write_per_million: parsePrice(draft.cache_write_per_million, t("缓存写入价格")), + }; +} + +export function PricingSettingsCard() { + const { pricing } = useAppStore(); + const message = useMessage(); + const [draft, setDraft] = useState(() => toDraft(pricing)); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!editing) setDraft(toDraft(pricing)); + }, [pricing, editing]); + + const edit = () => { + setDraft(toDraft(pricing)); + setEditing(true); + }; + + const cancel = () => { + setDraft(toDraft(pricing)); + setEditing(false); + }; + + const save = async () => { + try { + setSaving(true); + const next = toSettings(draft); + if (await appStore.updatePricingSettings(next)) { + setEditing(false); + message(t("定价设置已保存")); + } + } catch (cause) { + message(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSaving(false); + } + }; + + const restoreDefault = () => { + setDraft(toDraft(DEFAULT_TOKEN_PRICING)); + }; + + const action = editing ? ( +
+ + + +
+ ) : ( + + ); + + return ( + +
+ {t("用于首页价值估算的 Token 单价,单位:美元 / 百万 Token。")} + {editing ? ( +
+ + setDraft({ ...draft, input_per_million: event.target.value })} + /> + + + setDraft({ ...draft, output_per_million: event.target.value })} + /> + + + setDraft({ ...draft, cache_read_per_million: event.target.value })} + /> + + + setDraft({ ...draft, cache_write_per_million: event.target.value })} + /> + +
+ ) : ( + <> +
+ {t("输入价格($/1M)")} + {formatPrice(pricing.input_per_million)} +
+
+ {t("输出价格($/1M)")} + {formatPrice(pricing.output_per_million)} +
+
+ {t("缓存读取价格($/1M)")} + {formatPrice(pricing.cache_read_per_million)} +
+
+ {t("缓存写入价格($/1M)")} + {formatPrice(pricing.cache_write_per_million)} +
+ + )} +
+
+ ); +} diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 4f05143..700c416 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -4,6 +4,7 @@ import { PageContent } from "../../shell/layout/PageContent"; import { LegacyModelImport } from "../models/LegacyModelImport"; import { AppLifecycleSettingsCard } from "./AppLifecycleSettingsCard"; import { CommitSettingsCard } from "./CommitSettingsCard"; +import { PricingSettingsCard } from "./PricingSettingsCard"; import { ProxySettingsCard } from "./ProxySettingsCard"; import { TabSettingsCard } from "./TabSettingsCard"; import { Button } from "../../shared/ui/Button"; @@ -231,6 +232,7 @@ export function SettingsPage() { void saveProxy()} /> void saveTab()} /> + {({ busy, previewing, open }) =>
@@ -247,7 +249,7 @@ export function SettingsPage() {
{t("界面语言")} - {t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : "English" })} + {t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : locale === "pt-BR" ? "Português (Brasil)" : "English" })}