From ec0afa0430095cd7a807d5dd61b5adb9ce1798b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E4=BD=9C=E9=A2=82?= Date: Tue, 21 Apr 2026 11:57:23 +0800 Subject: [PATCH] feat: add usage automation controls --- AGENTS.md | 39 ++ README.md | 13 + src-tauri/src/auth/storage.rs | 112 +++++- src-tauri/src/commands/account.rs | 61 ++- src-tauri/src/lib.rs | 11 +- src-tauri/src/types.rs | 43 ++ src-tauri/src/web.rs | 18 +- src/App.tsx | 643 +++++++++++++++++++++++++++++- src/hooks/useAccounts.ts | 15 + src/types/index.ts | 9 + 10 files changed, 933 insertions(+), 31 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..760723be --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This is a Tauri 2 desktop app with a React 19 + TypeScript frontend and Rust backend. Frontend code lives in `src/`: `components/` for UI, `hooks/` for React state logic, `lib/platform.ts` for Tauri/web backend calls, and `types/` for shared TypeScript shapes. Rust code lives in `src-tauri/src/`, grouped by `auth/`, `api/`, `commands/`, and `web.rs`. Icons and platform assets are under `src-tauri/icons/`; release/version helpers are in `scripts/`. + +## Build, Test, and Development Commands + +- `pnpm install`: install JavaScript dependencies from `pnpm-lock.yaml`. +- `pnpm tauri dev`: run the desktop app in development mode. +- `pnpm build`: run TypeScript checks and build the Vite frontend. +- `pnpm tauri build`: build the production desktop bundles under `src-tauri/target/release/bundle/`. +- `pnpm lan`: build the frontend and run the Rust web dashboard on `0.0.0.0:3210`. +- `cargo test --manifest-path src-tauri/Cargo.toml`: run Rust tests when backend tests are added. + +## Coding Style & Naming Conventions + +Use TypeScript strict mode as enforced by `tsconfig.json`: no unused locals or parameters, no implicit type looseness, and JSX via `react-jsx`. Match existing formatting: two-space indentation, double-quoted imports, semicolons, and named exports for components. Name React components in `PascalCase`, hooks as `useSomething`, and shared types/interfaces descriptively. For Rust, follow `rustfmt`, use `snake_case` for modules/functions, and keep Tauri command handlers in `src-tauri/src/commands/`. + +## Testing Guidelines + +There is no dedicated frontend test runner configured yet, so treat `pnpm build` as required validation for TypeScript and Vite changes. For backend logic, add focused Rust unit tests near the module under test or integration tests under `src-tauri/tests/`. Name tests by behavior, for example `refreshes_expired_token`. Manually verify UI changes with `pnpm tauri dev`; include platform checks when changing Tauri config, updater behavior, process handling, or file dialogs. + +## Commit & Pull Request Guidelines + +Recent history uses short imperative subjects, with Conventional-style prefixes for release chores, for example `chore: release 0.2.2` and `Add release script`. Keep commits focused and describe the user-visible change or maintenance task. Pull requests should include a summary, validation commands, linked issues when applicable, and screenshots or recordings for UI changes. For Tauri/Rust changes, note tested platforms and relevant environment variables such as `CODEX_SWITCHER_WEB_HOST` or `CODEX_SWITCHER_WEB_PORT`. + +## Security & Configuration Tips + +This app manages Codex account data. Do not commit `auth.json`, decrypted exports, local backups, tokens, or machine-specific secrets. Prefer encrypted full exports for backups, and document new configuration in `README.md`. + + +## Language +Use **Chinese** for: +- Task execution results and error messages +- Confirmations and clarifications with the user +- Solution descriptions and to-do items +- Commit info for git + diff --git a/README.md b/README.md index 9b84f490..22630687 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,21 @@ - **Multi-Account Management** – Add and manage multiple Codex accounts in one place - **Quick Switching** – Switch between accounts with a single click - **Usage Monitoring** – View real-time usage for both 5-hour and weekly limits +- **Usage Automation** – Configure low-usage warnings, automatic switching, and manual account priority - **Dual Login Mode** – OAuth authentication or import existing `auth.json` files +## Usage Automation + +Codex Switcher can warn you when the active account is running low on 5-hour quota and can optionally switch to another account automatically. + +- Warning threshold defaults to `10%` remaining. +- Auto-switch threshold defaults to `5%` remaining. +- Auto-switch is disabled by default. +- When auto-switch is enabled, choose either **Usage first** or **Reset first**. +- When auto-switch is disabled, adjust the manual account priority and switch accounts yourself. + +Automatic switching only uses 5-hour limit data. If Codex is currently running, the app will pause automatic switching and show a reminder instead of changing `~/.codex/auth.json`. + ## Installation ### Prerequisites diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index 966d3e74..cdb00b81 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -1,11 +1,12 @@ //! Account storage module - manages reading and writing accounts.json +use std::collections::HashSet; use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; -use crate::types::{AccountsStore, AuthData, StoredAccount}; +use crate::types::{AccountsStore, AuthData, StoredAccount, UsageAutomationSettings}; /// Get the path to the codex-switcher config directory pub fn get_config_dir() -> Result { @@ -62,6 +63,37 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> { Ok(()) } +/// Return a priority list containing each current account exactly once. +pub fn normalized_priority_account_ids( + accounts: &[StoredAccount], + priority_account_ids: &[String], +) -> Vec { + let account_ids: HashSet<&str> = accounts.iter().map(|account| account.id.as_str()).collect(); + let mut seen = HashSet::new(); + let mut normalized = Vec::with_capacity(accounts.len()); + + for account_id in priority_account_ids { + if account_ids.contains(account_id.as_str()) && seen.insert(account_id.as_str()) { + normalized.push(account_id.clone()); + } + } + + for account in accounts { + if seen.insert(account.id.as_str()) { + normalized.push(account.id.clone()); + } + } + + normalized +} + +fn sync_usage_automation_priority(store: &mut AccountsStore) { + store.usage_automation.priority_account_ids = normalized_priority_account_ids( + &store.accounts, + &store.usage_automation.priority_account_ids, + ); +} + /// Add a new account to the store pub fn add_account(account: StoredAccount) -> Result { let mut store = load_accounts()?; @@ -73,6 +105,7 @@ pub fn add_account(account: StoredAccount) -> Result { let account_clone = account.clone(); store.accounts.push(account); + sync_usage_automation_priority(&mut store); // If this is the first account, make it active if store.accounts.len() == 1 { @@ -98,6 +131,7 @@ pub fn remove_account(account_id: &str) -> Result<()> { if store.active_account_id.as_deref() == Some(account_id) { store.active_account_id = store.accounts.first().map(|a| a.id.clone()); } + sync_usage_automation_priority(&mut store); save_accounts(&store)?; Ok(()) @@ -251,3 +285,79 @@ pub fn set_masked_account_ids(ids: Vec) -> Result<()> { save_accounts(&store)?; Ok(()) } + +/// Get usage warning and automatic switching settings. +pub fn get_usage_automation_settings() -> Result { + let store = load_accounts()?; + let mut settings = store.usage_automation.clone(); + settings.priority_account_ids = + normalized_priority_account_ids(&store.accounts, &settings.priority_account_ids); + Ok(settings) +} + +/// Set usage warning and automatic switching settings. +pub fn set_usage_automation_settings(mut settings: UsageAutomationSettings) -> Result<()> { + if !settings.warning_remaining_percent.is_finite() + || !settings.auto_switch_remaining_percent.is_finite() + { + anyhow::bail!("Usage thresholds must be finite numbers"); + } + + if !(0.0..=100.0).contains(&settings.warning_remaining_percent) + || !(0.0..=100.0).contains(&settings.auto_switch_remaining_percent) + { + anyhow::bail!("Usage thresholds must be between 0 and 100"); + } + + if settings.warning_remaining_percent < settings.auto_switch_remaining_percent { + anyhow::bail!("Warning threshold must be greater than or equal to auto-switch threshold"); + } + + let mut store = load_accounts()?; + settings.priority_account_ids = + normalized_priority_account_ids(&store.accounts, &settings.priority_account_ids); + store.usage_automation = settings; + save_accounts(&store)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::normalized_priority_account_ids; + use crate::types::StoredAccount; + + fn account_with_id(id: &str) -> StoredAccount { + let mut account = StoredAccount::new_api_key(id.to_string(), format!("key-{id}")); + account.id = id.to_string(); + account + } + + #[test] + fn normalized_priority_removes_invalid_and_duplicate_ids() { + let accounts = vec![ + account_with_id("first"), + account_with_id("second"), + account_with_id("third"), + ]; + + let normalized = normalized_priority_account_ids( + &accounts, + &[ + "missing".to_string(), + "second".to_string(), + "second".to_string(), + "first".to_string(), + ], + ); + + assert_eq!(normalized, vec!["second", "first", "third"]); + } + + #[test] + fn normalized_priority_preserves_account_order_when_empty() { + let accounts = vec![account_with_id("first"), account_with_id("second")]; + let normalized = normalized_priority_account_ids(&accounts, &[]); + + assert_eq!(normalized, vec!["first", "second"]); + } +} diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index dc161447..6df8c072 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -2,10 +2,14 @@ use crate::auth::{ add_account, create_chatgpt_account_from_refresh_token, get_active_account, - import_from_auth_json, import_from_auth_json_contents, load_accounts, remove_account, - save_accounts, set_active_account, switch_to_account, touch_account, + import_from_auth_json, import_from_auth_json_contents, load_accounts, + normalized_priority_account_ids, remove_account, save_accounts, set_active_account, + switch_to_account, touch_account, +}; +use crate::types::{ + AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount, + UsageAutomationSettings, }; -use crate::types::{AccountInfo, AccountsStore, AuthData, ImportAccountsSummary, StoredAccount}; use anyhow::Context; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -18,7 +22,7 @@ use futures::{stream, StreamExt}; use pbkdf2::pbkdf2_hmac; use rand::RngCore; use sha2::Sha256; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::{Read, Write}; @@ -71,10 +75,26 @@ struct SlimAccountPayload { pub async fn list_accounts() -> Result, String> { let store = load_accounts().map_err(|e| e.to_string())?; let active_id = store.active_account_id.as_deref(); - - let accounts: Vec = store - .accounts + let priority_account_ids = normalized_priority_account_ids( + &store.accounts, + &store.usage_automation.priority_account_ids, + ); + let priority_index: HashMap<&str, usize> = priority_account_ids .iter() + .enumerate() + .map(|(index, account_id)| (account_id.as_str(), index)) + .collect(); + + let mut accounts = store.accounts.iter().collect::>(); + accounts.sort_by_key(|account| { + priority_index + .get(account.id.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + + let accounts: Vec = accounts + .into_iter() .map(|a| AccountInfo::from_stored(a, active_id)) .collect(); @@ -482,6 +502,10 @@ async fn build_store_from_slim_payload( accounts, active_account_id, masked_account_ids: Vec::new(), + usage_automation: UsageAutomationSettings { + priority_account_ids: Vec::new(), + ..UsageAutomationSettings::default() + }, }) } @@ -678,8 +702,10 @@ fn merge_accounts_store( mut current: AccountsStore, imported: AccountsStore, ) -> (AccountsStore, ImportAccountsSummary) { + let current_was_empty = current.accounts.is_empty(); let imported_version = imported.version; let imported_active_id = imported.active_account_id; + let imported_usage_automation = imported.usage_automation; let total_in_payload = imported.accounts.len(); let mut imported_count = 0usize; let mut existing_ids: HashSet = current.accounts.iter().map(|a| a.id.clone()).collect(); @@ -697,6 +723,13 @@ fn merge_accounts_store( } current.version = current.version.max(imported_version).max(1); + if current_was_empty { + current.usage_automation = imported_usage_automation; + } + current.usage_automation.priority_account_ids = normalized_priority_account_ids( + ¤t.accounts, + ¤t.usage_automation.priority_account_ids, + ); let current_active_is_valid = current .active_account_id @@ -736,3 +769,17 @@ pub async fn get_masked_account_ids() -> Result, String> { pub async fn set_masked_account_ids(ids: Vec) -> Result<(), String> { crate::auth::storage::set_masked_account_ids(ids).map_err(|e| e.to_string()) } + +/// Get usage warning and automatic switching settings +#[tauri::command] +pub async fn get_usage_automation_settings() -> Result { + crate::auth::storage::get_usage_automation_settings().map_err(|e| e.to_string()) +} + +/// Set usage warning and automatic switching settings +#[tauri::command] +pub async fn set_usage_automation_settings( + settings: UsageAutomationSettings, +) -> Result<(), String> { + crate::auth::storage::set_usage_automation_settings(settings).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a8c1f64f..c9c9b0cf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,9 +9,11 @@ pub mod web; use commands::{ add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account, export_accounts_full_encrypted_file, export_accounts_slim_text, get_active_account_info, - get_masked_account_ids, get_usage, import_accounts_full_encrypted_file, - import_accounts_slim_text, list_accounts, refresh_all_accounts_usage, rename_account, - set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts, + get_masked_account_ids, get_usage, get_usage_automation_settings, + import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts, + refresh_all_accounts_usage, rename_account, set_masked_account_ids, + set_usage_automation_settings, start_login, switch_account, warmup_account, + warmup_all_accounts, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -41,6 +43,9 @@ pub fn run() { // Masked accounts get_masked_account_ids, set_masked_account_ids, + // Usage automation + get_usage_automation_settings, + set_usage_automation_settings, // OAuth start_login, complete_login, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index dc3d2cc5..c3499419 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -16,6 +16,9 @@ pub struct AccountsStore { /// Set of account IDs that are masked (hidden) #[serde(default)] pub masked_account_ids: Vec, + /// Usage warning and auto-switch settings + #[serde(default)] + pub usage_automation: UsageAutomationSettings, } impl Default for AccountsStore { @@ -25,6 +28,46 @@ impl Default for AccountsStore { accounts: Vec::new(), active_account_id: None, masked_account_ids: Vec::new(), + usage_automation: UsageAutomationSettings::default(), + } + } +} + +/// Strategy used when choosing an automatic switch target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AutoSwitchStrategy { + /// Choose the account with the most remaining 5h usage. + RemainingDesc, + /// Choose the account whose 5h usage window resets soonest. + ResetTimeAsc, +} + +impl Default for AutoSwitchStrategy { + fn default() -> Self { + Self::RemainingDesc + } +} + +/// Configurable usage warning and automatic switching behavior. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageAutomationSettings { + pub warning_remaining_percent: f64, + pub auto_switch_remaining_percent: f64, + pub auto_switch_enabled: bool, + pub auto_switch_strategy: AutoSwitchStrategy, + #[serde(default)] + pub priority_account_ids: Vec, +} + +impl Default for UsageAutomationSettings { + fn default() -> Self { + Self { + warning_remaining_percent: 10.0, + auto_switch_remaining_percent: 5.0, + auto_switch_enabled: false, + auto_switch_strategy: AutoSwitchStrategy::default(), + priority_account_ids: Vec::new(), } } } diff --git a/src-tauri/src/web.rs b/src-tauri/src/web.rs index d1014bda..8f288109 100644 --- a/src-tauri/src/web.rs +++ b/src-tauri/src/web.rs @@ -13,10 +13,12 @@ use crate::commands::{ add_account_from_auth_json_text, add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account, export_accounts_full_encrypted_bytes, export_accounts_slim_text, get_active_account_info, get_masked_account_ids, get_usage, - import_accounts_full_encrypted_bytes, import_accounts_slim_text, list_accounts, - refresh_all_accounts_usage, rename_account, set_masked_account_ids, start_login, - switch_account, warmup_account, warmup_all_accounts, + get_usage_automation_settings, import_accounts_full_encrypted_bytes, import_accounts_slim_text, + list_accounts, refresh_all_accounts_usage, rename_account, set_masked_account_ids, + set_usage_automation_settings, start_login, switch_account, warmup_account, + warmup_all_accounts, }; +use crate::types::UsageAutomationSettings; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -51,6 +53,11 @@ struct MaskedIdsArgs { ids: Vec, } +#[derive(Debug, Deserialize)] +struct UsageAutomationSettingsArgs { + settings: UsageAutomationSettings, +} + #[derive(Debug, Deserialize)] struct UploadAuthJsonArgs { name: String, @@ -186,6 +193,11 @@ async fn invoke_web_command(command: &str, payload: Value) -> Result to_json(get_usage_automation_settings().await?), + "set_usage_automation_settings" => { + let args: UsageAutomationSettingsArgs = parse_args(payload)?; + to_json(set_usage_automation_settings(args.settings).await?) + } "check_codex_processes" => to_json(check_codex_processes().await?), _ => Err(format!("Unsupported web command: {command}")), } diff --git a/src/App.tsx b/src/App.tsx index a2480b40..bf6267c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,12 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useAccounts } from "./hooks/useAccounts"; import { AccountCard, AddAccountModal, UpdateChecker } from "./components"; -import type { CodexProcessInfo } from "./types"; +import type { + AutoSwitchStrategy, + CodexProcessInfo, + UsageAutomationSettings, + UsageInfo, +} from "./types"; import { exportFullBackupFile, importFullBackupFile, @@ -13,11 +18,38 @@ import "./App.css"; const THEME_STORAGE_KEY = "codex-switcher-theme"; type ThemeMode = "light" | "dark"; +type OtherAccountsSort = + | "priority" + | "deadline_asc" + | "deadline_desc" + | "remaining_desc" + | "remaining_asc"; + +const DEFAULT_USAGE_AUTOMATION_SETTINGS: UsageAutomationSettings = { + warning_remaining_percent: 10, + auto_switch_remaining_percent: 5, + auto_switch_enabled: false, + auto_switch_strategy: "remaining_desc", + priority_account_ids: [], +}; + const appWindow = getCurrentWindow(); const isMacOs = typeof navigator !== "undefined" && /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent); +function getPrimaryRemainingPercent(usage: UsageInfo | undefined): number | null { + if (!usage || usage.error) return null; + if (usage.primary_used_percent === null || usage.primary_used_percent === undefined) { + return null; + } + return Math.max(0, 100 - usage.primary_used_percent); +} + +function formatThreshold(value: number): string { + return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1); +} + function App() { const { accounts, @@ -39,9 +71,12 @@ function App() { cancelOAuthLogin, loadMaskedAccountIds, saveMaskedAccountIds, + loadUsageAutomationSettings, + saveUsageAutomationSettings, } = useAccounts(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [configModalMode, setConfigModalMode] = useState<"slim_export" | "slim_import">( "slim_export" @@ -65,11 +100,18 @@ function App() { isError: boolean; } | null>(null); const [maskedAccounts, setMaskedAccounts] = useState>(new Set()); - const [otherAccountsSort, setOtherAccountsSort] = useState< - "deadline_asc" | "deadline_desc" | "remaining_desc" | "remaining_asc" - >("deadline_asc"); + const [usageAutomationSettings, setUsageAutomationSettings] = + useState(DEFAULT_USAGE_AUTOMATION_SETTINGS); + const [settingsDraft, setSettingsDraft] = + useState(DEFAULT_USAGE_AUTOMATION_SETTINGS); + const [settingsError, setSettingsError] = useState(null); + const [isSavingSettings, setIsSavingSettings] = useState(false); + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [otherAccountsSort, setOtherAccountsSort] = + useState("priority"); const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); const actionsMenuRef = useRef(null); + const lastAutomationActionRef = useRef(null); const [themeMode, setThemeMode] = useState(() => { if (typeof window === "undefined") return "light"; try { @@ -81,6 +123,61 @@ function App() { }); const [isWindowMaximized, setIsWindowMaximized] = useState(false); + const accountIds = useMemo(() => accounts.map((account) => account.id), [accounts]); + + const normalizePriorityAccountIds = useCallback( + (priorityAccountIds: string[]) => { + const validIds = new Set(accountIds); + const seen = new Set(); + const normalized: string[] = []; + + priorityAccountIds.forEach((accountId) => { + if (validIds.has(accountId) && !seen.has(accountId)) { + seen.add(accountId); + normalized.push(accountId); + } + }); + + accountIds.forEach((accountId) => { + if (!seen.has(accountId)) { + seen.add(accountId); + normalized.push(accountId); + } + }); + + return normalized; + }, + [accountIds] + ); + + const effectiveUsageAutomationSettings = useMemo( + () => ({ + ...usageAutomationSettings, + priority_account_ids: normalizePriorityAccountIds( + usageAutomationSettings.priority_account_ids + ), + }), + [normalizePriorityAccountIds, usageAutomationSettings] + ); + + const priorityIndex = useMemo(() => { + return new Map( + effectiveUsageAutomationSettings.priority_account_ids.map((accountId, index) => [ + accountId, + index, + ]) + ); + }, [effectiveUsageAutomationSettings.priority_account_ids]); + + const accountsById = useMemo(() => { + return new Map(accounts.map((account) => [account.id, account])); + }, [accounts]); + + const settingsDraftPriorityAccountIds = useMemo( + () => normalizePriorityAccountIds(settingsDraft.priority_account_ids), + [normalizePriorityAccountIds, settingsDraft.priority_account_ids] + ); + const handleTitlebarDrag = useCallback( (event: React.MouseEvent) => { if (!isTauriRuntime() || event.button !== 0) return; @@ -135,8 +232,10 @@ function App() { } return info; }); + return info; } catch (err) { console.error("Failed to check processes:", err); + return null; } }, []); @@ -156,6 +255,30 @@ function App() { }); }, [loadMaskedAccountIds]); + // Load usage automation settings from storage on mount + useEffect(() => { + let cancelled = false; + + loadUsageAutomationSettings() + .then((settings) => { + if (cancelled) return; + setUsageAutomationSettings(settings); + setSettingsDraft(settings); + }) + .catch((err) => { + console.error("Failed to load usage automation settings:", err); + }) + .finally(() => { + if (!cancelled) { + setSettingsLoaded(true); + } + }); + + return () => { + cancelled = true; + }; + }, [loadUsageAutomationSettings]); + useEffect(() => { if (!isActionsMenuOpen) return; @@ -213,8 +336,8 @@ function App() { const handleSwitch = async (accountId: string) => { // Check processes before switching - await checkProcesses(); - if (processInfo && !processInfo.can_switch) { + const latestProcessInfo = await checkProcesses(); + if (latestProcessInfo && !latestProcessInfo.can_switch) { return; } @@ -255,12 +378,12 @@ function App() { } }; - const showWarmupToast = (message: string, isError = false) => { + const showWarmupToast = useCallback((message: string, isError = false) => { setWarmupToast({ message, isError }); setTimeout(() => setWarmupToast(null), 2500); - }; + }, []); - const formatWarmupError = (err: unknown) => { + const formatWarmupError = useCallback((err: unknown) => { if (!err) return "Unknown error"; if (err instanceof Error && err.message) return err.message; if (typeof err === "string") return err; @@ -269,6 +392,107 @@ function App() { } catch { return "Unknown error"; } + }, []); + + const openSettingsModal = () => { + setSettingsDraft({ + ...effectiveUsageAutomationSettings, + priority_account_ids: normalizePriorityAccountIds( + effectiveUsageAutomationSettings.priority_account_ids + ), + }); + setSettingsError(null); + setIsSettingsModalOpen(true); + }; + + const updateSettingsDraftPercent = ( + key: "warning_remaining_percent" | "auto_switch_remaining_percent", + value: string + ) => { + const parsed = Number(value); + setSettingsDraft((prev) => ({ + ...prev, + [key]: Number.isFinite(parsed) ? parsed : 0, + })); + }; + + const movePriorityAccount = (accountId: string, direction: -1 | 1) => { + setSettingsDraft((prev) => { + const priorityIds = normalizePriorityAccountIds(prev.priority_account_ids); + const currentIndex = priorityIds.indexOf(accountId); + const nextIndex = currentIndex + direction; + + if ( + currentIndex < 0 || + nextIndex < 0 || + nextIndex >= priorityIds.length + ) { + return prev; + } + + const nextPriorityIds = [...priorityIds]; + const [moved] = nextPriorityIds.splice(currentIndex, 1); + nextPriorityIds.splice(nextIndex, 0, moved); + + return { + ...prev, + priority_account_ids: nextPriorityIds, + }; + }); + }; + + const handleSaveSettings = async () => { + const nextSettings: UsageAutomationSettings = { + ...settingsDraft, + warning_remaining_percent: Number(settingsDraft.warning_remaining_percent), + auto_switch_remaining_percent: Number( + settingsDraft.auto_switch_remaining_percent + ), + priority_account_ids: normalizePriorityAccountIds( + settingsDraft.priority_account_ids + ), + }; + + if ( + !Number.isFinite(nextSettings.warning_remaining_percent) || + !Number.isFinite(nextSettings.auto_switch_remaining_percent) + ) { + setSettingsError("Thresholds must be valid numbers."); + return; + } + + if ( + nextSettings.warning_remaining_percent < 0 || + nextSettings.warning_remaining_percent > 100 || + nextSettings.auto_switch_remaining_percent < 0 || + nextSettings.auto_switch_remaining_percent > 100 + ) { + setSettingsError("Thresholds must be between 0 and 100."); + return; + } + + if ( + nextSettings.warning_remaining_percent < + nextSettings.auto_switch_remaining_percent + ) { + setSettingsError("Warning threshold must be greater than or equal to auto-switch threshold."); + return; + } + + try { + setIsSavingSettings(true); + setSettingsError(null); + await saveUsageAutomationSettings(nextSettings); + setUsageAutomationSettings(nextSettings); + setSettingsDraft(nextSettings); + setIsSettingsModalOpen(false); + showWarmupToast("Usage automation settings saved."); + } catch (err) { + console.error("Failed to save usage automation settings:", err); + setSettingsError(formatWarmupError(err)); + } finally { + setIsSavingSettings(false); + } }; const handleWarmupAccount = async (accountId: string, accountName: string) => { @@ -408,6 +632,137 @@ function App() { const activeAccount = accounts.find((a) => a.is_active); const otherAccounts = accounts.filter((a) => !a.is_active); const hasRunningProcesses = processInfo && processInfo.count > 0; + const activeRemainingPercent = getPrimaryRemainingPercent(activeAccount?.usage); + + const usageWarningMessage = + activeAccount && + activeRemainingPercent !== null && + activeRemainingPercent < effectiveUsageAutomationSettings.warning_remaining_percent + ? `${activeAccount.name} has ${formatThreshold(activeRemainingPercent)}% 5h usage remaining.` + : null; + + useEffect(() => { + if (!settingsLoaded || !activeAccount) return; + if (switchingId || accounts.some((account) => account.usageLoading)) return; + + const remainingPercent = getPrimaryRemainingPercent(activeAccount.usage); + if ( + remainingPercent === null || + remainingPercent >= + effectiveUsageAutomationSettings.auto_switch_remaining_percent + ) { + lastAutomationActionRef.current = null; + return; + } + + if (!effectiveUsageAutomationSettings.auto_switch_enabled) return; + + const accountUsageSnapshot = accounts + .map((account) => { + const remaining = getPrimaryRemainingPercent(account.usage); + return [ + account.id, + remaining === null ? "unknown" : formatThreshold(remaining), + account.usage?.primary_resets_at ?? "no-reset", + ].join("/"); + }) + .join("|"); + + const actionKey = [ + activeAccount.id, + formatThreshold(remainingPercent), + effectiveUsageAutomationSettings.auto_switch_remaining_percent, + effectiveUsageAutomationSettings.auto_switch_strategy, + effectiveUsageAutomationSettings.priority_account_ids.join(","), + processInfo?.count ?? "unknown", + accountUsageSnapshot, + ].join(":"); + + if (lastAutomationActionRef.current === actionKey) return; + lastAutomationActionRef.current = actionKey; + + let cancelled = false; + + const runAutoSwitch = async () => { + const latestProcessInfo = await checkProcesses(); + if (cancelled) return; + + if (latestProcessInfo && !latestProcessInfo.can_switch) { + showWarmupToast( + "Auto-switch paused. Close all Codex processes before switching.", + true + ); + return; + } + + const switchThreshold = + effectiveUsageAutomationSettings.auto_switch_remaining_percent; + const priorityRank = (accountId: string) => + priorityIndex.get(accountId) ?? Number.MAX_SAFE_INTEGER; + const candidates = accounts + .filter((account) => { + if (account.id === activeAccount.id) return false; + const accountRemaining = getPrimaryRemainingPercent(account.usage); + return accountRemaining !== null && accountRemaining >= switchThreshold; + }) + .sort((a, b) => { + const aRemaining = getPrimaryRemainingPercent(a.usage) ?? -1; + const bRemaining = getPrimaryRemainingPercent(b.usage) ?? -1; + + if ( + effectiveUsageAutomationSettings.auto_switch_strategy === + "remaining_desc" + ) { + const remainingDiff = bRemaining - aRemaining; + if (remainingDiff !== 0) return remainingDiff; + } else { + const aReset = a.usage?.primary_resets_at ?? Number.POSITIVE_INFINITY; + const bReset = b.usage?.primary_resets_at ?? Number.POSITIVE_INFINITY; + const resetDiff = aReset - bReset; + if (resetDiff !== 0) return resetDiff; + } + + const priorityDiff = priorityRank(a.id) - priorityRank(b.id); + if (priorityDiff !== 0) return priorityDiff; + return a.name.localeCompare(b.name); + }); + + const nextAccount = candidates[0]; + if (!nextAccount) { + showWarmupToast("Auto-switch skipped. No eligible account is available.", true); + return; + } + + try { + setSwitchingId(nextAccount.id); + await switchAccount(nextAccount.id); + showWarmupToast(`Auto-switched to ${nextAccount.name}.`); + } catch (err) { + console.error("Failed to auto-switch account:", err); + showWarmupToast(`Auto-switch failed: ${formatWarmupError(err)}`, true); + } finally { + setSwitchingId(null); + } + }; + + void runAutoSwitch(); + + return () => { + cancelled = true; + }; + }, [ + accounts, + activeAccount, + checkProcesses, + effectiveUsageAutomationSettings, + formatWarmupError, + priorityIndex, + processInfo?.count, + settingsLoaded, + showWarmupToast, + switchingId, + switchAccount, + ]); const sortedOtherAccounts = useMemo(() => { const getResetDeadline = (resetAt: number | null | undefined) => @@ -421,6 +776,14 @@ function App() { }; return [...otherAccounts].sort((a, b) => { + if (otherAccountsSort === "priority") { + const priorityDiff = + (priorityIndex.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (priorityIndex.get(b.id) ?? Number.MAX_SAFE_INTEGER); + if (priorityDiff !== 0) return priorityDiff; + return a.name.localeCompare(b.name); + } + if (otherAccountsSort === "deadline_asc" || otherAccountsSort === "deadline_desc") { const deadlineDiff = getResetDeadline(a.usage?.primary_resets_at) - @@ -450,7 +813,7 @@ function App() { if (deadlineDiff !== 0) return deadlineDiff; return a.name.localeCompare(b.name); }); - }, [otherAccounts, otherAccountsSort]); + }, [otherAccounts, otherAccountsSort, priorityIndex]); return (
@@ -581,6 +944,24 @@ function App() { > + +
+ +
+
+ + + +
+ +
+
+
+ Auto-switch +
+
+ {settingsDraft.auto_switch_enabled ? "Enabled" : "Disabled"} +
+
+ +
+ + {settingsDraft.auto_switch_enabled ? ( +
+
+ Auto-switch strategy +
+
+ {( + [ + ["remaining_desc", "Usage first"], + ["reset_time_asc", "Reset first"], + ] as Array<[AutoSwitchStrategy, string]> + ).map(([strategy, label]) => ( + + ))} +
+
+ ) : ( +
+
+ Manual priority +
+
+ {settingsDraftPriorityAccountIds.length === 0 ? ( +
+ No accounts +
+ ) : ( + settingsDraftPriorityAccountIds.map((accountId, index) => { + const account = accountsById.get(accountId); + if (!account) return null; + + return ( +
+
+ {index + 1} +
+
+
+ {account.name} +
+ {account.email && ( +
+ {account.email} +
+ )} +
+
+ + +
+
+ ); + }) + )} +
+
+ )} + + {settingsError && ( +
+ {settingsError} +
+ )} +
+ +
+ + +
+ + + )} + {/* Import/Export Config Modal */} {isConfigModalOpen && (
diff --git a/src/hooks/useAccounts.ts b/src/hooks/useAccounts.ts index d9ef0176..840866bf 100644 --- a/src/hooks/useAccounts.ts +++ b/src/hooks/useAccounts.ts @@ -5,6 +5,7 @@ import type { AccountWithUsage, WarmupSummary, ImportAccountsSummary, + UsageAutomationSettings, } from "../types"; import { invokeBackend, type FileSource } from "../lib/platform"; @@ -353,6 +354,18 @@ export function useAccounts() { } }, []); + const loadUsageAutomationSettings = useCallback(async () => { + return await invokeBackend("get_usage_automation_settings"); + }, []); + + const saveUsageAutomationSettings = useCallback( + async (settings: UsageAutomationSettings) => { + await invokeBackend("set_usage_automation_settings", { settings }); + await loadAccounts(true); + }, + [loadAccounts] + ); + useEffect(() => { loadAccounts().then((accountList) => refreshUsage(accountList)); @@ -386,5 +399,7 @@ export function useAccounts() { cancelOAuthLogin, loadMaskedAccountIds, saveMaskedAccountIds, + loadUsageAutomationSettings, + saveUsageAutomationSettings, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 6b2110be..20ce9df4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,7 @@ // Types matching the Rust backend export type AuthMode = "api_key" | "chat_gpt"; +export type AutoSwitchStrategy = "remaining_desc" | "reset_time_asc"; export interface AccountInfo { id: string; @@ -38,6 +39,14 @@ export interface AccountWithUsage extends AccountInfo { usageLoading?: boolean; } +export interface UsageAutomationSettings { + warning_remaining_percent: number; + auto_switch_remaining_percent: number; + auto_switch_enabled: boolean; + auto_switch_strategy: AutoSwitchStrategy; + priority_account_ids: string[]; +} + export interface CodexProcessInfo { count: number; background_count: number;