diff --git a/apps/desktop/src-tauri/src/resource_limits.rs b/apps/desktop/src-tauri/src/resource_limits.rs index b553f66..9be98b4 100644 --- a/apps/desktop/src-tauri/src/resource_limits.rs +++ b/apps/desktop/src-tauri/src/resource_limits.rs @@ -49,8 +49,8 @@ pub(crate) fn raise_open_file_limit() -> io::Result { } Ok(OpenFileLimit { - previous: previous as u64, - effective: effective.rlim_cur as u64, - hard: effective.rlim_max as u64, + previous, + effective: effective.rlim_cur, + hard: effective.rlim_max, }) } diff --git a/apps/desktop/src-tauri/src/update/mod.rs b/apps/desktop/src-tauri/src/update/mod.rs index a6f0e62..c1566d0 100644 --- a/apps/desktop/src-tauri/src/update/mod.rs +++ b/apps/desktop/src-tauri/src/update/mod.rs @@ -1,8 +1,16 @@ +use std::process::ExitCode; + +#[cfg(target_os = "windows")] use std::{ fs::{self, OpenOptions}, - io::{Cursor, Read, Write}, - path::{Path, PathBuf}, - process::{Command, ExitCode}, + io::Write, + path::PathBuf, + process::Command, +}; +#[cfg(any(target_os = "windows", test))] +use std::{ + io::{Cursor, Read}, + path::Path, }; use serde::Serialize; @@ -14,8 +22,10 @@ use tauri_plugin_updater::UpdaterExt; #[cfg(target_os = "windows")] mod replacement; +#[cfg(target_os = "windows")] const PORTABLE_UPDATE_ENDPOINT: &str = "https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json"; +#[cfg(any(target_os = "windows", test))] const WINDOWS_PAYLOAD_NAME: &str = "cursor-byok-desktop.exe"; #[derive(Serialize)] @@ -176,6 +186,7 @@ async fn wait_for_replacement_start( result } +#[cfg(target_os = "windows")] fn ensure_target_writable(target: &Path) -> std::io::Result<()> { let parent = target .parent() @@ -193,6 +204,7 @@ fn ensure_target_writable(target: &Path) -> std::io::Result<()> { fs::remove_file(probe) } +#[cfg(any(target_os = "windows", test))] fn extract_windows_payload(bytes: &[u8]) -> Result, String> { let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) .map_err(|error| format!("failed to open ZIP: {error}"))?; @@ -222,6 +234,7 @@ fn extract_windows_payload(bytes: &[u8]) -> Result, String> { Ok(payload) } +#[cfg(target_os = "windows")] fn stage_payload(target: &Path, payload: &[u8]) -> std::io::Result { let directory = tempfile::Builder::new() .prefix("cursor-byok-portable-update-") diff --git a/apps/desktop/src/features/home/HomePage.tsx b/apps/desktop/src/features/home/HomePage.tsx index 6a85f85..ae6973f 100644 --- a/apps/desktop/src/features/home/HomePage.tsx +++ b/apps/desktop/src/features/home/HomePage.tsx @@ -5,7 +5,7 @@ import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart"; import { HomeMetrics } from "./metrics/HomeMetrics"; import { PageContent } from "../../shell/layout/PageContent"; import type { VirtualPageSection } from "../../shell/layout/VirtualPage"; -import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/OverviewTimeRangeFilter"; +import { OverviewTimeRangeFilter, type OverviewRangePreset, type QuickPreset } from "./overview/OverviewTimeRangeFilter"; import { PageActions } from "../../shell/PageActions"; import { appStore, useAppStore } from "../../shared/store/appStore"; import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput"; @@ -27,7 +27,9 @@ function presetRange(preset: Exclude, now = new D start.setMonth(start.getMonth() - 1); return { startMs: start.getTime(), endMs }; } - const duration = preset === "ten-minutes" ? 10 * 60_000 : preset === "hour" ? 60 * 60_000 : 7 * 24 * 60 * 60_000; + const duration = preset === "ten-minutes" ? 10 * 60_000 + : preset === "hour" ? 60 * 60_000 + : 7 * 24 * 60 * 60_000; return { startMs: now.getTime() - duration, endMs }; } @@ -35,6 +37,8 @@ export function HomePage() { const { overview, busy, models, plugins } = useAppStore(); const { locale } = useI18n(); const [preset, setPreset] = useState("month"); + const [quick, setQuick] = useState(null); + const [fourHourBucket, setFourHourBucket] = useState(undefined); const [customRange, setCustomRange] = useState(null); const [customOpen, setCustomOpen] = useState(false); const [customStart, setCustomStart] = useState(""); @@ -53,13 +57,14 @@ export function HomePage() { void api.overview({ ...selectedRange, modelHashes: appliedModels, + bucketMs: fourHourBucket, }).then((next) => { if (active) setRangeOverview(next); }).finally(() => { if (active) setRangeBusy(false); }); return () => { active = false; }; - }, [preset, customRange, overview, refreshVersion, appliedModels]); + }, [preset, customRange, overview, refreshVersion, appliedModels, fourHourBucket]); const filteredOverview = rangeOverview ?? overview; const dailyTokenUsage = filteredOverview.token_usage_series.map((bucket) => ({ @@ -96,9 +101,26 @@ export function HomePage() { if (startMs === null || endMs === null || startMs >= endMs) return; setCustomRange({ startMs, endMs }); setAppliedModels(selectedModels); + setQuick(null); + setFourHourBucket(undefined); setPreset("custom"); setCustomOpen(false); }; + const selectQuick = (durationMs: number, bucketMs?: number) => { + const endMs = Date.now(); + setCustomRange({ startMs: endMs - durationMs, endMs }); + setAppliedModels(selectedModels); + setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours"); + setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined); + setPreset("custom"); + setCustomOpen(false); + }; + const selectPreset = (value: Exclude) => { + setPreset(value); + setQuick(null); + setFourHourBucket(undefined); + setCustomOpen(false); + }; const refresh = async () => { await appStore.refresh(); setRefreshVersion((version) => version + 1); @@ -146,13 +168,16 @@ export function HomePage() { return <> { setPreset(value); setCustomOpen(false); }} + onSelect={selectPreset} + onQuickSelect={selectQuick} onCustomOpenChange={openCustom} onCustomStartChange={setCustomStart} onCustomEndChange={setCustomEnd} diff --git a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss index 6278333..13a3b45 100644 --- a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss +++ b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.module.scss @@ -76,3 +76,50 @@ justify-content: flex-end; gap: 6px; } + +.quickPresets { + display: flex; + gap: 6px; + + button { + height: 26px; + padding: 0 10px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-button-secondaryBackground, var(--vscode-sideBar-background)); + border: 1px solid var(--vscode-sideBar-border); + border-radius: 4px; + font-size: type.$font-size-xs; + white-space: nowrap; + + &:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); } + &[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); border-color: transparent; } + } +} + +.granularityMenu { + position: fixed; + z-index: 15000; + min-width: 96px; + padding: 4px; + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 6px; + box-shadow: var(--oa-dropdown-shadow); + + button { + width: 100%; + min-height: 28px; + display: block; + padding: 4px 8px; + color: var(--vscode-dropdown-foreground); + text-align: left; + background: transparent; + border: 0; + border-radius: 4px; + font-size: type.$font-size-xs; + white-space: nowrap; + + &:hover { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } + &[aria-pressed="true"] { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } + } +} diff --git a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx index ed45d1f..f76c761 100644 --- a/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx +++ b/apps/desktop/src/features/home/overview/OverviewTimeRangeFilter.tsx @@ -10,9 +10,12 @@ import { refreshIcon } from "../../../shared/ui/icons"; import styles from "./OverviewTimeRangeFilter.module.scss"; export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom"; +export type QuickPreset = "four-hours" | "twenty-four-hours"; -export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: { +export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOpen, customStart, customEnd, modelOptions, selectedModels, busy, onSelect, onQuickSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onCustomApply, onRefresh }: { value: OverviewRangePreset; + quick: QuickPreset | null; + fourHourBucket: number | undefined; customOpen: boolean; customStart: string; customEnd: string; @@ -20,6 +23,7 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom selectedModels: string[]; busy: boolean; onSelect: (value: Exclude) => void; + onQuickSelect: (durationMs: number, bucketMs?: number) => void; onCustomOpenChange: (open: boolean) => void; onCustomStartChange: (value: string) => void; onCustomEndChange: (value: string) => void; @@ -34,10 +38,25 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom { value: "week", label: t("近一周") }, { value: "month", label: t("近一个月") }, ]; + const quickPresets: Array<{ value: QuickPreset; label: string }> = [ + { value: "four-hours", label: t("近4小时") }, + { value: "twenty-four-hours", label: t("近24小时") }, + ]; + const granularityOptions = [ + { bucketMs: 60_000, label: t("1分钟") }, + { bucketMs: 15 * 60_000, label: t("15分钟") }, + { bucketMs: 30 * 60_000, label: t("30分钟") }, + { bucketMs: 60 * 60_000, label: t("1小时") }, + ]; const customButton = useRef(null); const popover = useRef(null); + const fourHourButton = useRef(null); + const granularityMenu = useRef(null); const popoverId = useId(); + const granularityMenuId = useId(); const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 }); + const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0 }); + const [granularityOpen, setGranularityOpen] = useState(false); useLayoutEffect(() => { if (!customOpen || !customButton.current || !popover.current) return; @@ -53,11 +72,28 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom }).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y })))); }, [customOpen]); + useLayoutEffect(() => { + if (!granularityOpen || !fourHourButton.current || !granularityMenu.current) return; + return autoUpdate(fourHourButton.current, granularityMenu.current, () => + void computePosition(fourHourButton.current!, granularityMenu.current!, { + placement: "right-start", + middleware: [offset(4), flip({ padding: 10 }), shift({ padding: 10 })], + }).then(({ x, y }) => setMenuPosition({ left: x, top: y }))); + }, [granularityOpen]); + + useEffect(() => { + if (!customOpen) setGranularityOpen(false); + }, [customOpen]); + useEffect(() => { if (!customOpen) return; const closeOutside = (event: PointerEvent) => { const target = event.target as Node; - if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false); + if ( + !customButton.current?.contains(target) + && !popover.current?.contains(target) + && !granularityMenu.current?.contains(target) + ) onCustomOpenChange(false); }; document.addEventListener("pointerdown", closeOutside); return () => document.removeEventListener("pointerdown", closeOutside); @@ -66,6 +102,10 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom const parsedStart = parseTimeInput(customStart); const parsedEnd = parseTimeInput(customEnd); const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd; + const closeGranularity = () => { + setGranularityOpen(false); + fourHourButton.current?.focus(); + }; return
@@ -98,11 +138,37 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); + if (granularityOpen) { + closeGranularity(); + return; + } onCustomOpenChange(false); customButton.current?.focus(); } }} > +
+ {quickPresets.map((preset) => preset.value === "four-hours" + ? + : )} +
@@ -111,5 +177,31 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
, document.body)} + {granularityOpen && customOpen && createPortal(, document.body)} ; } diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 0dae2e0..5aaa7dd 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -428,6 +428,18 @@ } ] }, + "100fad4a0b3ab781": { + "source": "近24小时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 43, + "column": 42 + } + ] + }, "105a9082c346f958": { "source": "测试中…", "kind": "text", @@ -1015,7 +1027,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 110, + "line": 176, "column": 105 }, { @@ -1615,6 +1627,18 @@ } ] }, + "4256064de039de89": { + "source": "显示粒度", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 185, + "column": 19 + } + ] + }, "42655ed8e4108ae2": { "source": "输入(非缓存)", "kind": "text", @@ -1944,7 +1968,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 86, + "line": 126, "column": 9 }, { @@ -1969,6 +1993,18 @@ } ] }, + "4f0f45b0312ca4ba": { + "source": "1小时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 49, + "column": 37 + } + ] + }, "51d04bc3d286f018": { "source": "近1自然日", "kind": "text", @@ -1976,7 +2012,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 32, + "line": 36, "column": 30 } ] @@ -2002,7 +2038,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 107, + "line": 173, "column": 70 } ] @@ -2324,6 +2360,18 @@ } ] }, + "609640f72d422b57": { + "source": "快捷时间范围", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 150, + "column": 56 + } + ] + }, "61a4c7bac12dc125": { "source": "异常调用:{count}", "kind": "template", @@ -2388,7 +2436,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 111, + "line": 177, "column": 109 } ] @@ -2422,7 +2470,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 486, + "line": 487, "column": 43 } ] @@ -2573,7 +2621,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 106, + "line": 172, "column": 21 } ] @@ -2749,7 +2797,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 481, + "line": 482, "column": 43 } ] @@ -3008,6 +3056,18 @@ } ] }, + "859757ea792a73da": { + "source": "30分钟", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 48, + "column": 37 + } + ] + }, "864597982c308d72": { "source": "已开启静默启动", "kind": "text", @@ -3506,7 +3566,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 106, + "line": 172, "column": 70 } ] @@ -3587,7 +3647,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 34, + "line": 38, "column": 29 } ] @@ -4019,12 +4079,12 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 88, + "line": 128, "column": 28 }, { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 88, + "line": 128, "column": 89 }, { @@ -4425,7 +4485,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 70, + "line": 110, "column": 51 } ] @@ -4559,7 +4619,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 108, + "line": 174, "column": 77 } ] @@ -4682,7 +4742,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 35, + "line": 39, "column": 30 } ] @@ -4937,7 +4997,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 31, + "line": 35, "column": 29 } ] @@ -4976,6 +5036,18 @@ } ] }, + "dbaa1c565cb1f108": { + "source": "15分钟", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 47, + "column": 37 + } + ] + }, "dbd3596e4a86f3c2": { "source": "配置模型", "kind": "text", @@ -5283,7 +5355,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 33, + "line": 37, "column": 36 } ] @@ -5375,6 +5447,18 @@ } ] }, + "e85373502c372776": { + "source": "1分钟", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 46, + "column": 32 + } + ] + }, "e8b1268c1e3610f2": { "source": "已存在", "kind": "text", @@ -5558,7 +5642,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 107, + "line": 173, "column": 21 } ] @@ -5618,6 +5702,18 @@ } ] }, + "f784b165bd3fcb0e": { + "source": "近4小时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/home/overview/OverviewTimeRangeFilter.tsx", + "line": 42, + "column": 35 + } + ] + }, "f85537d1fd2ef6f6": { "source": "自定义概览筛选", "kind": "text", @@ -5625,7 +5721,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 96, + "line": 136, "column": 19 } ] @@ -5832,7 +5928,7 @@ "refs": [ { "file": "features/home/HomePage.tsx", - "line": 163, + "line": 188, "column": 25 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 19d6a01..28d11b2 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -27,6 +27,7 @@ "0e41f8e3d59ec47b": "Storage management", "0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models", "0ec1e85b0c3cfa65": "Call details", + "100fad4a0b3ab781": "Last 24 hours", "105a9082c346f958": "Testing…", "124be3f86f197802": "Token usage", "12ae77e6202d063e": "Custom Headers", @@ -107,6 +108,7 @@ "402495402ce333b1": "Reinitialize plugins", "40a08e7cf320ae07": "Clear detailed records?", "4125fc7ba333524c": "Default light", + "4256064de039de89": "Display granularity", "42655ed8e4108ae2": "Input (non-cached)", "42a1d9e5b037c210": "Bytes", "42aa8e01e98c0d8c": "Total duration", @@ -130,6 +132,7 @@ "4d99c976beb8827e": "Ready", "4e30d7c9ed2b0eee": "Not set", "4eafa9e925b30bcd": "Custom", + "4f0f45b0312ca4ba": "1 hour", "51d04bc3d286f018": "Last calendar day", "51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models", "5228358a6db59fe7": "For example: now, 2026-08-23 18:00", @@ -156,6 +159,7 @@ "5f9acfb945229062": "Are you sure you no longer want to see this ad?", "5fd2ec5a6e9b654c": "Total: {cost}", "6078a681a306930d": "Cache write", + "609640f72d422b57": "Quick time ranges", "61a4c7bac12dc125": "Failed calls: {count}", "61c7d1f758b647d5": "Call information", "621f63a5f08384ac": "Cache read: {tokens}", @@ -205,6 +209,7 @@ "842b9f11cdd96bda": "Launch at login", "843ac7e15a5047a7": "Confirm legacy model configuration import", "844b8cc8dff7c1d8": "Default", + "859757ea792a73da": "30 minutes", "864597982c308d72": "Silent start enabled", "86de7c4ee8fa7689": "Sync models", "8716e1344b0daddb": "Cursor official", @@ -342,6 +347,7 @@ "da7ae985487c38e6": "Last hour", "daede9881787abe7": "Notes", "db340a9896306d08": "Test cancelled", + "dbaa1c565cb1f108": "15 minutes", "dbd3596e4a86f3c2": "Configured models", "ddde16f8839da3ce": "Total requests", "de8184da1ef88d03": "Configured", @@ -372,6 +378,7 @@ "e77e3d58b0dcffaa": "Duration", "e825a2a42c22380e": "Model type", "e828bd3a0151edc2": "The local CA must be trusted by the system", + "e85373502c372776": "1 minute", "e8b1268c1e3610f2": "Existing", "ea26b760e930a7ca": "Call observability", "eb11e2df1d8ae387": "Provider URL", @@ -391,6 +398,7 @@ "f6dc1b1641600dd0": "Importing…", "f78265089144369a": "Plugins", "f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}", + "f784b165bd3fcb0e": "Last 4 hours", "f85537d1fd2ef6f6": "Custom overview filters", "f95ea7f4c063eea7": "Disabled", "f9615d05d8e18595": "Clear {label} filter", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index d16c2b8..f866b2f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -27,6 +27,7 @@ "0e41f8e3d59ec47b": "存储管理", "0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型", "0ec1e85b0c3cfa65": "调用详情", + "100fad4a0b3ab781": "近24小时", "105a9082c346f958": "测试中…", "124be3f86f197802": "Token 消耗", "12ae77e6202d063e": "自定义 Headers", @@ -107,6 +108,7 @@ "402495402ce333b1": "重新初始化插件", "40a08e7cf320ae07": "确定要清理详细记录吗?", "4125fc7ba333524c": "默认亮色", + "4256064de039de89": "显示粒度", "42655ed8e4108ae2": "输入(非缓存)", "42a1d9e5b037c210": "字节数", "42aa8e01e98c0d8c": "总耗时", @@ -130,6 +132,7 @@ "4d99c976beb8827e": "可用", "4e30d7c9ed2b0eee": "不设置", "4eafa9e925b30bcd": "自定义", + "4f0f45b0312ca4ba": "1小时", "51d04bc3d286f018": "近1自然日", "51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功", "5228358a6db59fe7": "如:现在、2026-08-23 18:00", @@ -156,6 +159,7 @@ "5f9acfb945229062": "你确认不想再看到此广告吗?", "5fd2ec5a6e9b654c": "合计:{cost}", "6078a681a306930d": "缓存写入", + "609640f72d422b57": "快捷时间范围", "61a4c7bac12dc125": "异常调用:{count}", "61c7d1f758b647d5": "调用信息", "621f63a5f08384ac": "缓存读取:{tokens}", @@ -205,6 +209,7 @@ "842b9f11cdd96bda": "开机启动", "843ac7e15a5047a7": "确认导入旧版模型配置", "844b8cc8dff7c1d8": "默认", + "859757ea792a73da": "30分钟", "864597982c308d72": "已开启静默启动", "86de7c4ee8fa7689": "同步模型", "8716e1344b0daddb": "Cursor 官方", @@ -342,6 +347,7 @@ "da7ae985487c38e6": "近1小时", "daede9881787abe7": "备注", "db340a9896306d08": "测试已取消", + "dbaa1c565cb1f108": "15分钟", "dbd3596e4a86f3c2": "配置模型", "ddde16f8839da3ce": "总请求", "de8184da1ef88d03": "已配置", @@ -372,6 +378,7 @@ "e77e3d58b0dcffaa": "耗时", "e825a2a42c22380e": "模型类型", "e828bd3a0151edc2": "需要在系统中信任本地 CA", + "e85373502c372776": "1分钟", "e8b1268c1e3610f2": "已存在", "ea26b760e930a7ca": "调用观测", "eb11e2df1d8ae387": "上游地址", @@ -391,6 +398,7 @@ "f6dc1b1641600dd0": "正在导入…", "f78265089144369a": "插件配置", "f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", + "f784b165bd3fcb0e": "近4小时", "f85537d1fd2ef6f6": "自定义概览筛选", "f95ea7f4c063eea7": "未启用", "f9615d05d8e18595": "清除{label}筛选", diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 3a83411..c11c11c 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -453,12 +453,13 @@ export const api = { deleteModel: (hash: string) => request(`/models/${hash}`, { method: "DELETE" }), testModel: (hash: string, testId: string, signal?: AbortSignal) => request(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }), cancelModelTest: (hash: string, testId: string) => request(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }), - overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => { + overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; bucketMs?: number }) => { const params = new URLSearchParams(); if (filter) { params.set("start_ms", String(filter.startMs)); params.set("end_ms", String(filter.endMs)); if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes)); + if (filter.bucketMs) params.set("bucket_ms", String(filter.bucketMs)); } const query = params.toString(); return request(`/overview${query ? `?${query}` : ""}`); diff --git a/apps/desktop/src/shell/AppLayout.module.scss b/apps/desktop/src/shell/AppLayout.module.scss index d42d268..1f5ae75 100644 --- a/apps/desktop/src/shell/AppLayout.module.scss +++ b/apps/desktop/src/shell/AppLayout.module.scss @@ -169,7 +169,11 @@ -ms-user-select: none; -webkit-touch-callout: none; -webkit-user-drag: none; - -webkit-app-region: drag; + // Page actions (e.g. the overview time-range bar and the floating refresh + // button) must not act as a window drag handle; the dedicated drag layer in + // AppHeader owns window dragging. Otherwise these controls get dragged and + // double-click maximizes the window. + -webkit-app-region: no-drag; } .actions { diff --git a/server/src/control/overview.rs b/server/src/control/overview.rs index 3b0329c..77c1f5f 100644 --- a/server/src/control/overview.rs +++ b/server/src/control/overview.rs @@ -16,6 +16,7 @@ pub struct OverviewRange { start_ms: Option, end_ms: Option, model_hashes: Option, + bucket_ms: Option, } pub async fn get( @@ -24,7 +25,12 @@ pub async fn get( ) -> Result> { Ok(Json( service - .overview(range.start_ms, range.end_ms, range.model_hashes.as_deref()) + .overview( + range.start_ms, + range.end_ms, + range.model_hashes.as_deref(), + range.bucket_ms, + ) .await?, )) } diff --git a/server/src/control/service.rs b/server/src/control/service.rs index a0aaab0..d6a7163 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -329,8 +329,11 @@ impl ControlService { start_ms: Option, end_ms: Option, model_hashes: Option<&str>, + bucket_ms: Option, ) -> Result { - self.store.overview(start_ms, end_ms, model_hashes).await + self.store + .overview(start_ms, end_ms, model_hashes, bucket_ms) + .await } pub async fn create_models(&self, models: &[ModelConfigInput]) -> Result> { diff --git a/server/src/cursor/conversation/runtime.rs b/server/src/cursor/conversation/runtime.rs index 3a1edec..dae5cf5 100644 --- a/server/src/cursor/conversation/runtime.rs +++ b/server/src/cursor/conversation/runtime.rs @@ -172,9 +172,9 @@ impl ConversationRuntime { break; } TransportCommand::RunFinished { generation, finish } => { - if !current + if current .as_ref() - .is_some_and(|current| current.id == generation) + .is_none_or(|current| current.id != generation) { continue; } diff --git a/server/src/cursor/services/commit_message.rs b/server/src/cursor/services/commit_message.rs index b7943aa..f37043b 100644 --- a/server/src/cursor/services/commit_message.rs +++ b/server/src/cursor/services/commit_message.rs @@ -88,11 +88,7 @@ async fn generate_local( } let model_id = settings.model_id.trim(); ensure_configured_model(registry, model_id).await?; - let invocation = build_invocation( - &settings, - model_id, - build_user_content(&request, &diffs), - ); + let invocation = build_invocation(&settings, model_id, build_user_content(&request, &diffs)); let provider = registry.conversations().dependencies().provider.clone(); let generated = generate( provider, diff --git a/server/src/store/llm_calls.rs b/server/src/store/llm_calls.rs index b9db421..7efcc25 100644 --- a/server/src/store/llm_calls.rs +++ b/server/src/store/llm_calls.rs @@ -452,7 +452,7 @@ mod tests { .await .unwrap(); let overview = store - .overview(None, None, Some(&format!("[\"{plugin_model}\"]"))) + .overview(None, None, Some(&format!("[\"{plugin_model}\"]")), None) .await .unwrap(); assert_eq!(overview.metrics.llm_calls, 1); diff --git a/server/src/store/overview.rs b/server/src/store/overview.rs index 397f8a7..6f088f6 100644 --- a/server/src/store/overview.rs +++ b/server/src/store/overview.rs @@ -15,6 +15,7 @@ use super::Store; const OVERVIEW_DAYS: u64 = 365; const MAX_RANGE_BUCKETS: i64 = 60; +const MAX_EXPLICIT_BUCKETS: i64 = 1440; const MINUTE_MS: i64 = 60_000; const HOUR_MS: i64 = 60 * MINUTE_MS; const DAY_MS: i64 = 24 * HOUR_MS; @@ -25,6 +26,7 @@ impl Store { start_ms: Option, end_ms: Option, model_hashes: Option<&str>, + bucket_ms: Option, ) -> Result { let call_row = sqlx::query( "SELECT @@ -84,7 +86,7 @@ impl Store { }; let (token_usage_granularity, bucket_ms, series_start_ms, bucket_count) = - token_usage_buckets(start_ms, end_ms); + token_usage_buckets(start_ms, end_ms, bucket_ms); let rows = sqlx::query(&format!( "SELECT (created_at_ms / {bucket_ms}) * {bucket_ms} AS bucket_start_ms, @@ -146,20 +148,36 @@ impl Store { fn token_usage_buckets( start_ms: Option, end_ms: Option, + requested_bucket_ms: Option, ) -> (TokenUsageGranularity, i64, i64, i64) { if let (Some(start_ms), Some(end_ms)) = (start_ms, end_ms) { let duration_ms = end_ms.saturating_sub(start_ms).max(1); - let (granularity, bucket_ms) = if duration_ms <= HOUR_MS { - (TokenUsageGranularity::Minute, MINUTE_MS) - } else if duration_ms <= MAX_RANGE_BUCKETS * HOUR_MS { - (TokenUsageGranularity::Hour, HOUR_MS) + let explicit_bucket_ms = requested_bucket_ms.filter(|bucket_ms| *bucket_ms >= MINUTE_MS); + let bucket_ms = explicit_bucket_ms.unwrap_or({ + if duration_ms <= HOUR_MS { + MINUTE_MS + } else if duration_ms <= MAX_RANGE_BUCKETS * HOUR_MS { + HOUR_MS + } else { + DAY_MS + } + }); + let granularity = if bucket_ms < HOUR_MS { + TokenUsageGranularity::Minute + } else if bucket_ms < DAY_MS { + TokenUsageGranularity::Hour } else { - (TokenUsageGranularity::Day, DAY_MS) + TokenUsageGranularity::Day + }; + let max_buckets = if explicit_bucket_ms.is_some() { + MAX_EXPLICIT_BUCKETS + } else { + MAX_RANGE_BUCKETS }; let last_bucket_ms = end_ms.saturating_sub(1).div_euclid(bucket_ms) * bucket_ms; let first_bucket_ms = start_ms.div_euclid(bucket_ms) * bucket_ms; - let bucket_count = ((last_bucket_ms - first_bucket_ms).div_euclid(bucket_ms) + 1) - .clamp(1, MAX_RANGE_BUCKETS); + let bucket_count = + ((last_bucket_ms - first_bucket_ms).div_euclid(bucket_ms) + 1).clamp(1, max_buckets); let series_start_ms = last_bucket_ms.saturating_sub((bucket_count - 1).saturating_mul(bucket_ms)); return (granularity, bucket_ms, series_start_ms, bucket_count);