diff --git a/Cargo.lock b/Cargo.lock index f22764d..6be9a11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,11 +1172,12 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "cursor-byok-desktop" -version = "0.1.7" +version = "1.0.0" 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/package-lock.json b/apps/desktop/package-lock.json index 0ce8b9d..bf0aa2d 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "cursor-byok-desktop", - "version": "0.1.7", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cursor-byok-desktop", - "version": "0.1.7", + "version": "1.0.0", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.8.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 72b7056..1951880 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "cursor-byok-desktop", - "version": "0.1.7", + "version": "1.0.0", "description": "Cursor BYOK desktop management application", "type": "module", "scripts": { 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-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1cd30c3..ecb4d9c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cursor-byok-desktop" -version = "0.1.7" +version = "1.0.0" edition = "2021" publish = false @@ -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/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/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; 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(); - } -} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 846b1d3..052b201 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Cursor BYOK", - "version": "0.1.7", + "version": "1.0.0", "identifier": "dev.cursorbyok.desktop", "build": { "beforeDevCommand": "npm run dev", 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/CommitSettingsCard.tsx b/apps/desktop/src/features/settings/CommitSettingsCard.tsx index e52d3a0..29bc3b2 100644 --- a/apps/desktop/src/features/settings/CommitSettingsCard.tsx +++ b/apps/desktop/src/features/settings/CommitSettingsCard.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { api, pluginText, type CommitSettingsView } from "../../shared/api"; +import { commitPromptLocale } from "../../i18n/runtime"; import { useI18n } from "../../i18n/store"; import { useAppStore } from "../../shared/store/appStore"; import { Button } from "../../shared/ui/Button"; @@ -33,11 +34,12 @@ export function CommitSettingsCard() { void (async () => { try { let loaded = await api.commitSettings(locale); - if (!loaded.prompt.trim() && loaded.prompt_locale !== locale) { + const promptLocale = commitPromptLocale(locale); + if (!loaded.prompt.trim() && loaded.prompt_locale !== promptLocale) { loaded = await api.setCommitSettings({ model_id: loaded.model_id, prompt: "", - prompt_locale: locale, + prompt_locale: promptLocale, }); } if (active) { @@ -97,7 +99,7 @@ export function CommitSettingsCard() { return api.setCommitSettings({ model_id: modelId, prompt: normalizedPrompt, - prompt_locale: locale, + prompt_locale: commitPromptLocale(locale), }); }, [view, locale], 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" })}