diff --git a/src-tauri/src/agents/antigravity.rs b/src-tauri/src/agents/antigravity.rs index 673ad39..9c35019 100644 --- a/src-tauri/src/agents/antigravity.rs +++ b/src-tauri/src/agents/antigravity.rs @@ -22,7 +22,7 @@ pub(crate) fn find_antigravity_cli(home: &Path) -> Option { .join("agy/bin/agy.exe"); #[cfg(not(target_os = "windows"))] let native = home.join(".local/bin/agy"); - if native.is_file() { + if agent_cli_executable(&native) { Some(native) } else { find_named_agent_executable(home, &["agy"]) diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index f5cd1f6..5c21d30 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1121,12 +1121,9 @@ pub(crate) fn agent_installation_detected( executable_found: bool, app_installed: bool, ) -> bool { - version.is_some() - || (matches!( - client, - AgentClient::ClaudeDesktop | AgentClient::OpenCode | AgentClient::ZCode | AgentClient::WorkBuddy - ) && executable_found) + executable_found || app_installed + || (client == AgentClient::ClaudeDesktop && version.is_some()) } pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool { @@ -2476,12 +2473,11 @@ pub(crate) fn find_opencode_managed_executable(home: &Path) -> Option { home.join("Applications/OpenCode.app/Contents/MacOS/opencode-cli"), ] .into_iter() - .find(|path| path.is_file()) + .find(|path| agent_cli_executable(path)) } #[cfg(not(target_os = "macos"))] { - let path = home.join(".opencode/bin/opencode"); - path.is_file().then_some(path) + find_named_agent_executable_in_directories(&[home.join(".opencode/bin")], &["opencode"]) } } @@ -2493,7 +2489,7 @@ pub(crate) fn find_kimi_code_executable(home: &Path) -> Option { let managed = [directory.join("kimi")]; managed .into_iter() - .find(|path| path.is_file()) + .find(|path| agent_cli_executable(path)) .or_else(|| find_named_agent_executable(home, &["kimi"])) } @@ -2505,7 +2501,7 @@ pub(crate) fn find_grok_build_executable(home: &Path) -> Option { let managed = [directory.join("grok")]; managed .into_iter() - .find(|path| path.is_file()) + .find(|path| agent_cli_executable(path)) .or_else(|| find_named_agent_executable(home, &["grok"])) } @@ -2514,18 +2510,54 @@ pub(crate) fn find_pi_executable(home: &Path) -> Option { } pub(crate) fn find_named_agent_executable(home: &Path, names: &[&str]) -> Option { - for directory in agent_executable_directories(home) { + find_named_agent_executable_in_directories(&agent_executable_directories(home), names) +} + +pub(crate) fn agent_cli_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(target_os = "windows")] + { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + ["exe", "cmd", "bat", "com"] + .iter() + .any(|expected| extension.eq_ignore_ascii_case(expected)) + }) + } + #[cfg(not(any(unix, target_os = "windows")))] + true +} + +pub(crate) fn find_named_agent_executable_in_directories( + directories: &[PathBuf], + names: &[&str], +) -> Option { + for directory in directories { for name in names { #[cfg(target_os = "windows")] let candidates = [ directory.join(format!("{name}.exe")), directory.join(format!("{name}.cmd")), directory.join(format!("{name}.bat")), - directory.join(name), + directory.join(format!("{name}.com")), ]; #[cfg(not(target_os = "windows"))] let candidates = [directory.join(name)]; - if let Some(candidate) = candidates.into_iter().find(|path| path.is_file()) { + if let Some(candidate) = candidates + .into_iter() + .find(|path| agent_cli_executable(path)) + { return Some(candidate); } } @@ -2534,6 +2566,18 @@ pub(crate) fn find_named_agent_executable(home: &Path, names: &[&str]) -> Option } pub(crate) fn agent_executable_directories(home: &Path) -> Vec { + #[cfg(target_os = "windows")] + let registered = windows_registered_path_directories(); + #[cfg(not(target_os = "windows"))] + let registered = Vec::new(); + agent_executable_directories_from_environment(home, |name| env::var_os(name), ®istered) +} + +pub(crate) fn agent_executable_directories_from_environment( + home: &Path, + environment: impl Fn(&str) -> Option, + registered: &[PathBuf], +) -> Vec { let mut directories = Vec::new(); let mut push = |path: PathBuf| { if !path.as_os_str().is_empty() && !directories.iter().any(|item| item == &path) { @@ -2541,28 +2585,46 @@ pub(crate) fn agent_executable_directories(home: &Path) -> Vec { } }; - if let Some(path) = env::var_os("PATH") { + if let Some(path) = environment("PATH") { env::split_paths(&path).for_each(&mut push); } + registered.iter().cloned().for_each(&mut push); [ home.join(".local/bin"), home.join(".npm-global/bin"), home.join(".bun/bin"), home.join(".cargo/bin"), + home.join(".volta/bin"), + home.join(".local/share/pnpm"), + home.join(".pyenv/shims"), home.join("bin"), ] .into_iter() .for_each(&mut push); - for variable in ["PNPM_HOME", "BUN_INSTALL", "NPM_CONFIG_PREFIX"] { - if let Some(path) = env::var_os(variable) { + for variable in [ + "PNPM_HOME", + "BUN_INSTALL", + "NPM_CONFIG_PREFIX", + "npm_config_prefix", + "VOLTA_HOME", + "UV_TOOL_BIN_DIR", + "NVM_SYMLINK", + ] { + if let Some(path) = environment(variable).filter(|path| !path.is_empty()) { let path = PathBuf::from(path); - push( - if variable == "BUN_INSTALL" || variable == "NPM_CONFIG_PREFIX" { + if matches!(variable, "NPM_CONFIG_PREFIX" | "npm_config_prefix") { + push(if cfg!(target_os = "windows") { + path + } else { + path.join("bin") + }); + } else { + push(if matches!(variable, "BUN_INSTALL" | "VOLTA_HOME") { path.join("bin") } else { path - }, - ); + }); + } } } for root in [ @@ -2570,8 +2632,14 @@ pub(crate) fn agent_executable_directories(home: &Path) -> Vec { home.join(".local/state/fnm_multishells"), ] { if let Ok(entries) = fs::read_dir(root) { - for entry in entries.flatten() { - push(entry.path().join("bin")); + let mut entries = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect::>(); + entries.sort(); + for entry in entries.into_iter().rev() { + push(entry.join("bin")); } } } @@ -2586,11 +2654,31 @@ pub(crate) fn agent_executable_directories(home: &Path) -> Vec { #[cfg(target_os = "windows")] { - if let Some(app_data) = env::var_os("APPDATA") { - push(PathBuf::from(app_data).join("npm")); - } - if let Some(local_app_data) = env::var_os("LOCALAPPDATA") { - push(PathBuf::from(local_app_data).join("Microsoft/WindowsApps")); + let app_data = environment("APPDATA") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Roaming")); + let local = environment("LOCALAPPDATA") + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData/Local")); + push(app_data.join("npm")); + push(home.join(".npm-global")); + push(local.join("Microsoft/WindowsApps")); + push(local.join("pnpm")); + push(local.join("Volta/bin")); + for root in [local.join("Programs/Python"), local.join("Python")] { + if let Ok(entries) = fs::read_dir(root) { + let mut entries = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect::>(); + entries.sort(); + for entry in entries.into_iter().rev() { + push(entry.join("Scripts")); + } + } } } diff --git a/src-tauri/src/agents/windows_probe.rs b/src-tauri/src/agents/windows_probe.rs index 69ead02..0418f7c 100644 --- a/src-tauri/src/agents/windows_probe.rs +++ b/src-tauri/src/agents/windows_probe.rs @@ -212,6 +212,23 @@ fn current_version_roots() -> [RegistryRoot; 4] { ] } +pub(crate) fn windows_registered_path_directories() -> Vec { + let mut paths = Vec::new(); + for (hive, key) in [ + (HKEY_CURRENT_USER, r"Environment"), + ( + HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + ), + ] { + if let Some(value) = RegistryKey::open(hive, key).and_then(|key| key.string("Path")) { + let expanded = expand_environment_strings(&value).unwrap_or(value); + paths.extend(env::split_paths(&expanded).filter(|path| path.is_absolute())); + } + } + paths +} + fn join_registry_path(prefix: &str, suffix: &str) -> String { format!("{prefix}\\{suffix}") } diff --git a/src-tauri/src/tests/agent_paths.rs b/src-tauri/src/tests/agent_paths.rs index e2490c6..492aaef 100644 --- a/src-tauri/src/tests/agent_paths.rs +++ b/src-tauri/src/tests/agent_paths.rs @@ -1,6 +1,128 @@ use super::support::*; use super::*; +#[test] +fn installation_detection_separates_executables_from_version_metadata() { + for client in [ + AgentClient::ClaudeCode, + AgentClient::Codex, + AgentClient::OpenCode, + AgentClient::OpenClaw, + AgentClient::Hermes, + AgentClient::DeepSeekHarness, + AgentClient::KimiCode, + AgentClient::GrokBuild, + ] { + assert!(agent_installation_detected(client, None, true, false)); + assert!(!agent_installation_detected(client, None, false, false)); + assert!(!agent_installation_detected( + client, + Some("1.0.0"), + false, + false + )); + } + assert!(agent_installation_detected( + AgentClient::Codex, + None, + false, + true + )); + assert!(agent_installation_detected( + AgentClient::ClaudeDesktop, + Some("1.0.0"), + false, + false, + )); +} + +#[test] +fn executable_directories_preserve_path_precedence_and_package_manager_locations() { + let home = agent_test_home("agent-search-environment"); + let inherited = home.join("inherited"); + let registered = home.join("registered"); + let npm = home.join("custom-npm"); + let uv = home.join("custom-uv"); + let volta = home.join("custom-volta"); + let directories = agent_executable_directories_from_environment( + &home, + |name| match name { + "PATH" => Some(env::join_paths([&inherited, &inherited]).unwrap()), + "NPM_CONFIG_PREFIX" => Some(npm.clone().into_os_string()), + "UV_TOOL_BIN_DIR" => Some(uv.clone().into_os_string()), + "VOLTA_HOME" => Some(volta.clone().into_os_string()), + "BUN_INSTALL" => Some(std::ffi::OsString::new()), + _ => None, + }, + &[registered.clone(), inherited.clone()], + ); + assert_eq!(&directories[..2], &[inherited.clone(), registered]); + assert_eq!( + directories + .iter() + .filter(|path| **path == inherited) + .count(), + 1 + ); + let npm_bin = if cfg!(target_os = "windows") { + npm + } else { + npm.join("bin") + }; + assert!(directories.contains(&npm_bin)); + assert!(directories.contains(&uv)); + assert!(directories.contains(&volta.join("bin"))); + assert!(directories.iter().all(|path| path.is_absolute())); + fs::remove_dir_all(home).unwrap(); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_cli_search_skips_shell_stubs_and_finds_runnable_shims() { + let home = agent_test_home("agent-search-shims"); + let first = home.join("first"); + let second = home.join("second"); + fs::create_dir_all(first.join("agent.exe")).unwrap(); + fs::create_dir_all(&second).unwrap(); + fs::write(first.join("agent"), "shell stub").unwrap(); + fs::write(first.join("agent.ps1"), "powershell stub").unwrap(); + let executable = second.join("agent.cmd"); + fs::write(&executable, "@exit /b 1\r\n").unwrap(); + assert_eq!( + find_named_agent_executable_in_directories(&[first, second], &["agent"]), + Some(executable.clone()), + ); + let version = read_agent_version(&executable, &home); + assert_eq!(version, None); + assert!(agent_installation_detected( + AgentClient::ClaudeCode, + version.as_deref(), + agent_cli_executable(&executable), + false, + )); + fs::remove_dir_all(home).unwrap(); +} + +#[cfg(unix)] +#[test] +fn unix_cli_search_requires_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + let home = agent_test_home("agent-search-permissions"); + let executable = home.join("agent"); + fs::write(&executable, "exit 1\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!( + find_named_agent_executable_in_directories(&[home.clone()], &["agent"]), + None + ); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + find_named_agent_executable_in_directories(&[home.clone()], &["agent"]), + Some(executable) + ); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn configuration_paths_ignore_inherited_environment() { let home = agent_test_home("isolated-paths"); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index dce9402..13f81b4 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -224,8 +224,11 @@ pub(crate) struct UsageRecord { api_group_key: String, #[serde(default)] model: String, - #[serde(default)] + #[serde(default, alias = "responseModel")] response_model: String, + // Derived from the active price catalog when reading a page; never trust imported estimates. + #[serde(default, skip_deserializing)] + cost: Option, #[serde(default)] alias: String, #[serde(default, skip_serializing)] @@ -264,6 +267,12 @@ pub(crate) struct UsageRecord { tokens: UsageTokenStats, } +#[derive(Clone, Serialize)] +struct UsageRecordCost { + total: f64, + pricing_model: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LegacyUsageHourFile { @@ -2765,9 +2774,16 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result 0 - && matches!(tier.as_str(), "priority" | "fast") + let multiplier = match tier.as_str() { + "flex" | "batch" => 0.5, + "priority" | "fast" => service_tier_multiplier(model), + _ => 1.0, + }; + // A grouped total can contain both context lengths. Long-context overrides + // must not suppress the service-tier multiplier on its short requests. + let long_multiplier = if matches!(tier.as_str(), "priority" | "fast") && !is_model_family(model, "gpt-6") { 1.0 } else { - match tier.as_str() { - "flex" | "batch" => 0.5, - "priority" | "fast" => service_tier_multiplier(model), - _ => 1.0, - } + multiplier }; - (short_cost + long_cost) * multiplier + short_cost * multiplier + long_cost * long_multiplier +} + +fn usage_record_cost(record: &UsageRecord, prices: &HashMap) -> Option { + let (model, price) = resolve_model_price(&record.model, &record.alias, prices)?; + let input = record.tokens.input_tokens; + // Keep the same context buckets and tier selection as the aggregate pricing query. + let long = input > LONG_CONTEXT_INPUT_TOKEN_THRESHOLD + || (input > GROK_47_LONG_CONTEXT_INPUT_TOKEN_THRESHOLD && is_model_family(model, "grok-4.7")); + let tokens = CostTokens { + input, + output: record.tokens.output_tokens, + cache_read: record.tokens.cache_read_tokens, + cache_creation: record.tokens.cache_creation_tokens, + long_input: if long { input } else { 0 }, + long_output: if long { record.tokens.output_tokens } else { 0 }, + long_cache_read: if long { record.tokens.cache_read_tokens } else { 0 }, + long_cache_creation: if long { record.tokens.cache_creation_tokens } else { 0 }, + }; + let identity = format!("{} {} {}", record.executor_type, record.provider, record.auth_type).to_ascii_lowercase(); + let tier = if identity.contains("codex") || record.response_service_tier.trim().is_empty() { + &record.service_tier + } else { + &record.response_service_tier + }; + Some(UsageRecordCost { + total: cost_for_price(model, tier, &tokens, &price), + pricing_model: price.model, + }) } fn cost_for_token_segment( @@ -4243,8 +4289,10 @@ fn load_usage_events( .map_err(|error| format!("Failed to query SQLite usage events: {error}"))? .collect::, _>>() .map_err(|error| format!("Failed to read SQLite usage events: {error}"))?; + let prices = load_model_prices(connection)?; for item in &mut items { item.source_display = usage_source_display(config, &item.provider, &item.source); + item.cost = usage_record_cost(item, &prices); } Ok(UsageEventPage { items, @@ -4273,6 +4321,7 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result { api_group_key: row.get(20)?, model: row.get(8)?, response_model: row.get(37)?, + cost: None, alias: row.get(9)?, client_ip: row.get(21)?, x_forwarded_for: row.get(22)?, @@ -5044,6 +5093,7 @@ mod tests { api_group_key: "hash".to_string(), model: model.to_string(), response_model: String::new(), + cost: None, alias: String::new(), client_ip: None, x_forwarded_for: None, @@ -6586,6 +6636,60 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn response_model_accepts_manager_plus_field_names() { + let record = normalize_usage_record(serde_json::json!({ + "request_id": "compatible-models", "requestedModel": "client-alias", + "resolvedModel": "gpt-6-sol", "model": "display-model", + "responseModel": " reported-model " + }), &GuiConfigFile::default()).unwrap(); + assert_eq!(record.alias, "client-alias"); + assert_eq!(record.model, "gpt-6-sol"); + assert_eq!(record.response_model, "reported-model"); + } + + #[test] + fn request_cost_matches_aggregate_and_recalculates_current_prices() { + let mut connection = Connection::open_in_memory().unwrap(); + initialize_usage_schema(&connection).unwrap(); + let mut records = Vec::new(); + for (index, model) in ["gpt-6-sol", "gpt-5.6-terra", "xai/grok-4.7"].iter().enumerate() { + for input in [200_000, 250_000, 300_000] { + let mut record = sample_record(&format!("{index}-{input}"), "2026-10-04T00:00:00Z", model); + record.provider = if index < 2 { "codex" } else { "openai" }.into(); + record.service_tier = "fast".into(); + record.response_service_tier = "flex".into(); + record.tokens.input_tokens = input; + record.tokens.output_tokens = 500; + record.tokens.reasoning_tokens = 100; + record.tokens.cache_read_tokens = 100_000; + record.tokens.cache_creation_tokens = 1_000; + record.tokens.total_tokens = input + 500; + record.response_model = "unpriced-response-must-not-change-billing".into(); + records.push(record); + } + } + records.push(sample_record("unknown-price", "2026-10-04T00:00:00Z", "unpriced-model")); + insert_usage_records(&mut connection, &records).unwrap(); + let query = UsageQuery::default(); + let page = load_usage_events(&connection, &query, &GuiConfigFile::default()).unwrap(); + let (aggregate, priced) = load_estimated_cost(&connection, &build_usage_filter(&query)).unwrap(); + let total: f64 = page.items.iter().filter_map(|r| r.cost.as_ref().map(|c| c.total)).sum(); + assert!((aggregate - total).abs() < 1e-9); + assert_eq!(priced, 9); + assert!(page.items.iter().find(|r| r.id == "unknown-price").unwrap().cost.is_none()); + + let free_price = ModelPrice { model: "unpriced-model".into(), prompt_configured: true, + completion_configured: true, cache_read_configured: true, cache_creation_configured: true, + source: "manual".into(), ..ModelPrice::default() }; + upsert_model_price(&connection, &free_price).unwrap(); + let updated = load_usage_events(&connection, &query, &GuiConfigFile::default()).unwrap(); + let free = updated.items.iter().find(|r| r.id == "unknown-price").unwrap(); + assert_eq!(free.cost.as_ref().unwrap().total, 0.0); + let imported: UsageRecord = serde_json::from_value(serde_json::to_value(free).unwrap()).unwrap(); + assert!(imported.cost.is_none(), "Imported estimates must be recomputed"); + } + #[test] fn estimates_gpt56_cost_with_cache_and_service_tier_rules() { let terra = official_model_price("openai/gpt-5.6-terra").unwrap(); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index c8793e7..223f6a5 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -731,8 +731,16 @@ export const jaOverrides = { 'usage.column.time': '日時', 'usage.column.model': 'モデル', 'usage.model.request': 'リクエストモデル', - 'usage.model.upstream': '上流モデル', - 'usage.model.response': '上流レスポンス', + 'usage.model.upstream': 'ルーティングモデル', + 'usage.model.response': '応答モデル', + 'usage.column.cost': '今回の費用', + 'usage.cost.estimate': '推定 · USD', + 'usage.cost.unpriced': '価格未設定', + 'usage.cost.hint': '現在の価格設定による USD 推定額。料金モデル:{model}。入力、出力、キャッシュ読み書き、サービス階層と長文ルールを含みます。実際の請求額ではありません。', + 'usage.model.mismatch': 'モデル不一致', + 'usage.model.responseHint': '使用量 API の response_model(上流が申告したモデル名であり、モデルの実体を検証したものではありません)。', + 'usage.provider.hint': '使用量 API の provider と auth_type に基づく接続先と認証方式です。モデル名から提供元を推測しません。', + 'usage.provider.unknownAccess': '接続方式の記録なし', 'usage.column.effort': '推論レベル', 'usage.column.request': 'リクエスト', 'usage.column.tokens': 'Token', @@ -1089,7 +1097,7 @@ export const jaOverrides = { 'agents.clients.page': '{page} / {total} ページ', 'agents.clients.listed': 'クライアント {count} 件', 'agents.clients.manage': 'クライアントを管理', - 'agents.clients.description': '一覧に残すクライアントを選択して保存します。順序は固定で、収まらない場合はページに分けて表示します。クライアントをクリックすると設定が開きます。', + 'agents.clients.description': '一覧に残すクライアントを選択して保存します。インストール済みを先に表示し、各グループ内の順序は固定です。収まらない場合はページに分けて表示します。クリックすると設定が開きます。', 'agents.clients.search': 'クライアントを検索', 'agents.clients.selected': '{count} 件のクライアントを選択中', 'agents.clients.automatic': '自動表示に戻す', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 71c9b89..d65aaa8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -719,8 +719,16 @@ export const en: Record = { 'usage.column.time': 'Time', 'usage.column.model': 'Model', 'usage.model.request': 'Requested model', - 'usage.model.upstream': 'Upstream model', - 'usage.model.response': 'Upstream response', + 'usage.model.upstream': 'Routed model', + 'usage.model.response': 'Response model', + 'usage.column.cost': 'Request cost', + 'usage.cost.estimate': 'Estimated · USD', + 'usage.cost.unpriced': 'Unpriced', + 'usage.cost.hint': 'Estimated USD using current pricing. Pricing model: {model}; includes input, output, cache reads and writes, service tier and long-context rules. Not an invoice.', + 'usage.model.mismatch': 'Model mismatch', + 'usage.model.responseHint': 'From usage API response_model: the model name declared upstream, not a verified model identity.', + 'usage.provider.hint': 'From usage API provider and auth_type: the route and authentication method, not a vendor inferred from the model name.', + 'usage.provider.unknownAccess': 'Access type unrecorded', 'usage.column.effort': 'Reasoning Effort', 'usage.column.request': 'Request', 'usage.column.tokens': 'Token', @@ -1089,7 +1097,7 @@ export const en: Record = { 'agents.clients.page': 'Page {page} / {total}', 'agents.clients.listed': '{count} clients', 'agents.clients.manage': 'Manage Clients', - 'agents.clients.description': 'Choose the clients to keep in the list, then save. Clients stay in a fixed order across pages. Click a client to open its settings.', + 'agents.clients.description': 'Choose the clients to keep in the list, then save. Installed clients come first, with a fixed order within each group across pages. Click a client to open its settings.', 'agents.clients.search': 'Search clients', 'agents.clients.selected': '{count} clients selected', 'agents.clients.automatic': 'Restore automatic display', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index e072c92..ee9a660 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -718,8 +718,16 @@ export const zhCN = { 'usage.column.time': '时间', 'usage.column.model': '模型', 'usage.model.request': '请求模型', - 'usage.model.upstream': '上游模型', - 'usage.model.response': '上游响应', + 'usage.model.upstream': '路由模型', + 'usage.model.response': '响应模型', + 'usage.column.cost': '本次费用', + 'usage.cost.estimate': '估算 · USD', + 'usage.cost.unpriced': '未定价', + 'usage.cost.hint': '按当前价格统计配置估算(USD)。计价模型:{model};包含输入、输出、缓存读取及写入,应用服务层级与长上下文规则,非实际账单。', + 'usage.model.mismatch': '模型不一致', + 'usage.model.responseHint': '来自用量 API 的 response_model(上游声明的模型名,不代表已验证模型身份)。', + 'usage.provider.hint': '来自用量 API 的 provider 与 auth_type,表示接入通道和认证方式,不按模型名推断厂商。', + 'usage.provider.unknownAccess': '未记录接入方式', 'usage.column.effort': '推理强度', 'usage.column.request': '请求', 'usage.column.tokens': 'Token', @@ -1088,7 +1096,7 @@ export const zhCN = { 'agents.clients.page': '第 {page} / {total} 页', 'agents.clients.listed': '{count} 个客户端', 'agents.clients.manage': '管理客户端', - 'agents.clients.description': '勾选要保留在列表中的客户端,保存后生效。列表顺序固定,放不下时分页显示;点击客户端切换配置。', + 'agents.clients.description': '勾选要保留在列表中的客户端,保存后生效。已安装的客户端优先,同组内顺序固定,放不下时分页显示;点击客户端切换配置。', 'agents.clients.search': '搜索客户端', 'agents.clients.selected': '已选择 {count} 个客户端', 'agents.clients.automatic': '恢复自动显示', diff --git a/src/pages/AgentClientList.tsx b/src/pages/AgentClientList.tsx index 4da06d3..3f62367 100644 --- a/src/pages/AgentClientList.tsx +++ b/src/pages/AgentClientList.tsx @@ -47,10 +47,12 @@ export function AgentClientList({ const close = () => setManaging(false); const [draftIds, setDraftIds] = useState(null); const [query, setQuery] = useState(''); + // Stable sorting keeps the existing order within each installation group. + const orderedClients = [...clients].sort((left, right) => Number(right.installed) - Number(left.installed)); // Keep the current client reachable even when detection fails or it was uninstalled. - const automaticIds = clients.filter((client) => client.detected || client.id === selected) + const automaticIds = orderedClients.filter((client) => client.detected || client.id === selected) .map((client) => client.id); - const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id)); + const visibleClients = orderedClients.filter((client) => (visibleIds ?? automaticIds).includes(client.id)); const pageCount = Math.max(1, Math.ceil(visibleClients.length / capacity)); const currentPage = Math.min(page, pageCount - 1); const pageClients = visibleClients.slice(currentPage * capacity, (currentPage + 1) * capacity); @@ -101,13 +103,13 @@ export function AgentClientList({ } setVisibleIds(draftIds); if (!draftSelection.includes(selected)) { - const first = clients.find((client) => draftSelection.includes(client.id)); + const first = orderedClients.find((client) => draftSelection.includes(client.id)); if (first) onSelect(first.id); } setManaging(false); }; - const matches = clients.filter((client) => client.name.toLowerCase().includes(query.trim().toLowerCase())); + const matches = orderedClients.filter((client) => client.name.toLowerCase().includes(query.trim().toLowerCase())); return <>