diff --git a/package.json b/package.json index 5a81bab..b9566ea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cpa-gui", "private": true, - "version": "0.2.7", + "version": "0.2.8", "type": "module", "packageManager": "bun@1.3.14", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ee2a3af..16a578e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.7" +version = "0.2.8" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b517c94..c08f0fa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cpa-gui" -version = "0.2.7" +version = "0.2.8" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 23ea007..f88639b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(target_os = "macos")] use std::sync::Arc; use std::{ + collections::HashSet, env, fs, fs::File, io::{self, Read, Seek, SeekFrom, Write}, @@ -436,10 +437,14 @@ struct GuiConfigFile { auth_dir: String, #[serde(deserialize_with = "deserialize_gui_api_keys")] api_keys: Vec, + api_access_remarks: Vec, management_secret_key: String, usage_statistics_enabled: bool, plugins_enabled: bool, routing_strategy: String, + proxy_url: String, + routing_session_affinity: bool, + routing_session_affinity_ttl: String, codex_session_repair_on_launch: bool, } @@ -457,6 +462,30 @@ struct GuiApiKeyEntry { remark: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct GuiApiAccessRemark { + provider_section: String, + api_key_hash: String, + remark: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiAccessRemarkQuery { + provider_section: String, + api_keys: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ApiAccessRemarkUpdate { + provider_section: String, + previous_api_keys: Vec, + api_keys: Vec, + remark: String, +} + #[derive(Deserialize)] #[serde(untagged)] enum GuiApiKeyInput { @@ -497,12 +526,16 @@ impl Default for GuiConfigFile { .map(|path| path_to_string(&path)) .unwrap_or_else(|| OAUTH_DIR_NAME.to_string()), api_keys: vec![default_api_key_entry()], + api_access_remarks: Vec::new(), // Keep plaintext here for management API auth. Core hashes the // value written into config.yaml on startup. management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(), usage_statistics_enabled: true, plugins_enabled: false, routing_strategy: "round-robin".to_string(), + proxy_url: String::new(), + routing_session_affinity: false, + routing_session_affinity_ttl: String::new(), codex_session_repair_on_launch: false, } } @@ -517,11 +550,15 @@ struct GuiConfigPresence { host: Option, auth_dir: Option, api_keys: Option>, + api_access_remarks: Option>, management_secret_key: Option, codex_session_repair_on_launch: Option, usage_statistics_enabled: Option, plugins_enabled: Option, routing_strategy: Option, + proxy_url: Option, + routing_session_affinity: Option, + routing_session_affinity_ttl: Option, } #[derive(Clone, Serialize)] @@ -573,7 +610,7 @@ struct AgentConfigActionResult { conflict_files: Vec, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentModelOption { name: String, @@ -771,6 +808,16 @@ struct GuiNetworkSettings { allow_lan: bool, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GuiNetworkRoutingSettings { + port: u16, + allow_lan: bool, + proxy_url: String, + routing_session_affinity: bool, + routing_session_affinity_ttl: String, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct CoreConfigSettings { @@ -786,6 +833,9 @@ struct CoreConfigSettings { usage_statistics_enabled: bool, plugins_enabled: bool, routing_strategy: String, + proxy_url: String, + routing_session_affinity: bool, + routing_session_affinity_ttl: String, // Kept for internal config migration/tests; never exposed to the WebView. #[allow(dead_code)] #[serde(skip_serializing)] @@ -804,8 +854,13 @@ struct CoreApiKeyView { struct CoreConfigView { api_keys: Vec, management_secret_configured: bool, + port: u16, + allow_lan: bool, plugins_enabled: bool, routing_strategy: String, + proxy_url: String, + routing_session_affinity: bool, + routing_session_affinity_ttl: String, } impl Default for CoreInstallTask { @@ -1157,6 +1212,25 @@ impl GuiConfigState { }) } + fn update_network_routing( + &self, + port: u16, + allow_lan: bool, + proxy_url: &str, + routing_session_affinity: bool, + routing_session_affinity_ttl: &str, + ) -> Result { + self.update(|config| { + config.port = port; + config.allow_lan = allow_lan; + config.host = if allow_lan { "0.0.0.0" } else { "127.0.0.1" }.to_string(); + config.proxy_url = proxy_url.to_string(); + config.routing_session_affinity = routing_session_affinity; + config.routing_session_affinity_ttl = routing_session_affinity_ttl.to_string(); + Ok(()) + }) + } + fn set_run_on_startup(&self, run_on_startup: bool) -> Result { self.update(|config| { config.run_on_startup = run_on_startup; @@ -1223,6 +1297,9 @@ impl GuiConfigState { } config.plugins_enabled = settings.plugins_enabled; config.routing_strategy = settings.routing_strategy.clone(); + config.proxy_url = settings.proxy_url.clone(); + config.routing_session_affinity = settings.routing_session_affinity; + config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone(); Ok(()) }) } @@ -1264,6 +1341,9 @@ impl From<&GuiConfigFile> for CoreConfigSettings { usage_statistics_enabled: config.usage_statistics_enabled, plugins_enabled: config.plugins_enabled, routing_strategy: config.routing_strategy.clone(), + proxy_url: config.proxy_url.clone(), + routing_session_affinity: config.routing_session_affinity, + routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(), management_secret_key: Some(config.management_secret_key.clone()), } } @@ -1281,8 +1361,13 @@ impl From<&GuiConfigFile> for CoreConfigView { }) .collect(), management_secret_configured: !config.management_secret_key.is_empty(), + port: config.port, + allow_lan: config.allow_lan, plugins_enabled: config.plugins_enabled, routing_strategy: config.routing_strategy.clone(), + proxy_url: config.proxy_url.clone(), + routing_session_affinity: config.routing_session_affinity, + routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(), } } } @@ -1334,6 +1419,75 @@ fn get_gui_settings( Ok(GuiSettings::from(&config)) } +#[tauri::command] +fn resolve_api_access_remarks( + queries: Vec, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result, String> { + let config = gui_config_state.snapshot()?; + queries + .into_iter() + .map(|query| { + validate_api_access_provider_section(&query.provider_section)?; + Ok(query + .api_keys + .iter() + .filter_map(|key| api_access_key_hash(key)) + .find_map(|hash| { + config.api_access_remarks.iter().find(|entry| { + entry.provider_section == query.provider_section + && entry.api_key_hash == hash + }) + }) + .map(|entry| entry.remark.clone()) + .unwrap_or_default()) + }) + .collect() +} + +#[tauri::command] +fn save_api_access_remark( + update: ApiAccessRemarkUpdate, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result<(), String> { + validate_api_access_provider_section(&update.provider_section)?; + let remark = update.remark.trim().to_string(); + validate_api_key_remark(&remark)?; + let previous_hashes = update + .previous_api_keys + .iter() + .filter_map(|key| api_access_key_hash(key)) + .collect::>(); + let next_hashes = update + .api_keys + .iter() + .filter_map(|key| api_access_key_hash(key)) + .collect::>(); + let hashes_to_replace = previous_hashes + .union(&next_hashes) + .cloned() + .collect::>(); + let provider_section = update.provider_section; + + gui_config_state.update(|config| { + config.api_access_remarks.retain(|entry| { + entry.provider_section != provider_section + || !hashes_to_replace.contains(&entry.api_key_hash) + }); + if !remark.is_empty() { + config + .api_access_remarks + .extend(next_hashes.iter().map(|hash| GuiApiAccessRemark { + provider_section: provider_section.clone(), + api_key_hash: hash.clone(), + remark: remark.clone(), + })); + } + Ok(()) + })?; + Ok(()) +} + #[tauri::command] fn set_app_locale( app: tauri::AppHandle, @@ -1594,6 +1748,7 @@ async fn apply_agent_config( gui_config_state: tauri::State<'_, GuiConfigState>, client: String, model: String, + models: Vec, oauth_configuration: bool, ) -> Result { let client = AgentClient::parse(&client)?; @@ -1604,10 +1759,9 @@ async fn apply_agent_config( let config = gui_config_state.snapshot()?; let api_key = effective_agent_api_key(&config); validate_agent_can_enable(client, &home, config.port, api_key)?; - let available_models = fetch_agent_models(config.port, api_key).await?; - let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?; + let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?; let codex_models = if client == AgentClient::Codex { - Some(fetch_codex_model_catalog_specs(&config, &available_models).await) + Some(fetch_codex_model_catalog_specs(&config, &models).await) } else { None }; @@ -1621,7 +1775,7 @@ async fn apply_agent_config( api_key, &model, AgentConfigurationOptions { - models: &available_models, + models: &models, codex_models: codex_models.as_deref(), oauth_configuration, }, @@ -1634,6 +1788,7 @@ async fn reset_agent_config_to_default( gui_config_state: tauri::State<'_, GuiConfigState>, client: String, model: String, + models: Vec, oauth_configuration: bool, ) -> Result { let client = AgentClient::parse(&client)?; @@ -1644,10 +1799,9 @@ async fn reset_agent_config_to_default( let config = gui_config_state.snapshot()?; let api_key = effective_agent_api_key(&config); validate_agent_can_enable(client, &home, config.port, api_key)?; - let available_models = fetch_agent_models(config.port, api_key).await?; - let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?; + let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?; let codex_models = if client == AgentClient::Codex { - Some(fetch_codex_model_catalog_specs(&config, &available_models).await) + Some(fetch_codex_model_catalog_specs(&config, &models).await) } else { None }; @@ -6276,6 +6430,62 @@ fn save_gui_settings( Ok(GuiSettings::from(&config)) } +#[tauri::command] +fn save_network_routing_settings( + gui_config_state: tauri::State<'_, GuiConfigState>, + cache: tauri::State<'_, AgentConfigStatusCache>, + settings: GuiNetworkRoutingSettings, +) -> Result { + if settings.port == 0 { + return Err("端口必须在 1 到 65535 之间".to_string()); + } + let proxy_url = normalize_optional_config_string(settings.proxy_url, "代理 URL")?; + let routing_session_affinity_ttl = + normalize_optional_config_string(settings.routing_session_affinity_ttl, "会话粘性 TTL")?; + + let _refresh_guard = cache + .refresh_lock + .lock() + .map_err(|_| "智能体配置状态刷新锁已损坏".to_string())?; + let previous = gui_config_state.snapshot()?; + let mut next = previous.clone(); + next.port = settings.port; + next.allow_lan = settings.allow_lan; + next.host = if settings.allow_lan { + "0.0.0.0" + } else { + "127.0.0.1" + } + .to_string(); + next.proxy_url = proxy_url.clone(); + next.routing_session_affinity = settings.routing_session_affinity; + next.routing_session_affinity_ttl = routing_session_affinity_ttl.clone(); + validate_gui_config(&next)?; + + patch_core_network_routing_settings(&next)?; + let config = match gui_config_state.update_network_routing( + settings.port, + settings.allow_lan, + &proxy_url, + settings.routing_session_affinity, + &routing_session_affinity_ttl, + ) { + Ok(config) => config, + Err(error) => { + let rollback_error = patch_core_network_routing_settings(&previous).err(); + return Err(config_update_error_with_rollback(error, rollback_error)); + } + }; + + if config.port != previous.port { + if let Err(error) = cache.clear() { + eprintln!("清空智能体配置状态缓存失败: {error}"); + } + } + + Ok(CoreConfigView::from(&config)) +} + #[tauri::command] fn get_core_config_settings( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -6440,6 +6650,65 @@ fn set_core_routing_strategy( Ok(CoreConfigView::from(&config)) } +#[tauri::command] +fn set_core_proxy_url( + gui_config_state: tauri::State<'_, GuiConfigState>, + proxy_url: String, +) -> Result { + let proxy_url = normalize_optional_config_string(proxy_url, "代理 URL")?; + let mut settings = current_core_config_settings(gui_config_state.inner())?; + let previous_proxy_url = settings.proxy_url.clone(); + settings.proxy_url = proxy_url; + patch_core_proxy_url(&settings.proxy_url)?; + let config = match gui_config_state.sync_core_settings(&settings) { + Ok(config) => config, + Err(error) => { + let rollback_error = patch_core_proxy_url(&previous_proxy_url).err(); + return Err(config_update_error_with_rollback(error, rollback_error)); + } + }; + Ok(CoreConfigView::from(&config)) +} + +#[tauri::command] +fn set_core_session_affinity( + gui_config_state: tauri::State<'_, GuiConfigState>, + enabled: bool, +) -> Result { + let mut settings = current_core_config_settings(gui_config_state.inner())?; + let previous_enabled = settings.routing_session_affinity; + settings.routing_session_affinity = enabled; + patch_core_session_affinity(settings.routing_session_affinity)?; + let config = match gui_config_state.sync_core_settings(&settings) { + Ok(config) => config, + Err(error) => { + let rollback_error = patch_core_session_affinity(previous_enabled).err(); + return Err(config_update_error_with_rollback(error, rollback_error)); + } + }; + Ok(CoreConfigView::from(&config)) +} + +#[tauri::command] +fn set_core_session_affinity_ttl( + gui_config_state: tauri::State<'_, GuiConfigState>, + ttl: String, +) -> Result { + let ttl = normalize_optional_config_string(ttl, "会话粘性 TTL")?; + let mut settings = current_core_config_settings(gui_config_state.inner())?; + let previous_ttl = settings.routing_session_affinity_ttl.clone(); + settings.routing_session_affinity_ttl = ttl; + patch_core_session_affinity_ttl(&settings.routing_session_affinity_ttl)?; + let config = match gui_config_state.sync_core_settings(&settings) { + Ok(config) => config, + Err(error) => { + let rollback_error = patch_core_session_affinity_ttl(&previous_ttl).err(); + return Err(config_update_error_with_rollback(error, rollback_error)); + } + }; + Ok(CoreConfigView::from(&config)) +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct OAuthStartResult { @@ -8124,6 +8393,21 @@ fn patch_core_network_settings(config: &GuiConfigFile) -> Result<(), String> { write_yaml_if_changed(&config_path, &updated).map(|_| ()) } +fn patch_core_network_routing_settings(config: &GuiConfigFile) -> Result<(), String> { + let _config_guard = lock_core_config_file()?; + let config_path = core_install_dir()?.join(CORE_CONFIG_FILE); + if !config_path.is_file() { + return Ok(()); + } + + let content = fs::read_to_string(&config_path) + .map_err(|err| format!("读取内核配置失败 {}: {err}", path_to_string(&config_path)))?; + let Some(updated) = patch_core_network_routing_yaml(&content, config)? else { + return Ok(()); + }; + write_yaml_if_changed(&config_path, &updated).map(|_| ()) +} + fn lock_core_config_file() -> Result, String> { CORE_CONFIG_FILE_LOCK .lock() @@ -8197,6 +8481,40 @@ fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> { }) } +fn patch_core_proxy_url(proxy_url: &str) -> Result<(), String> { + let proxy_url = proxy_url.to_string(); + patch_existing_core_config(move |document| { + set_core_yaml_top_level_value( + document, + "proxy-url", + serde_norway::Value::String(proxy_url), + ) + }) +} + +fn patch_core_session_affinity(enabled: bool) -> Result<(), String> { + patch_existing_core_config(move |document| { + set_core_yaml_nested_value( + document, + "routing", + "session-affinity", + serde_norway::Value::Bool(enabled), + ) + }) +} + +fn patch_core_session_affinity_ttl(ttl: &str) -> Result<(), String> { + let ttl = ttl.to_string(); + patch_existing_core_config(move |document| { + set_core_yaml_nested_value( + document, + "routing", + "session-affinity-ttl", + serde_norway::Value::String(ttl), + ) + }) +} + fn patch_existing_core_config(update: F) -> Result<(), String> where F: FnOnce(&mut serde_norway::Value) -> Result, @@ -8263,6 +8581,34 @@ fn render_yaml_value_changes( remaining_changes.push(change); } } + let mut missing_nested_groups: Vec<(String, Vec<(String, serde_norway::Value)>)> = Vec::new(); + for change in &remaining_changes { + if change.path.len() != 2 + || yaml_value_at_path(original, &change.path).is_some() + || yaml_value_at_path(original, &change.path[..1]) + .and_then(serde_norway::Value::as_mapping) + .is_none() + { + continue; + } + let section = change.path[0].clone(); + let entry = (change.path[1].clone(), change.value.clone()); + if let Some((_, entries)) = missing_nested_groups + .iter_mut() + .find(|(existing, _)| existing == §ion) + { + entries.push(entry); + } else { + missing_nested_groups.push((section, vec![entry])); + } + } + for (section, entries) in missing_nested_groups { + if let Some(updated_content) = + insert_yaml_block_mapping_values(&editable_content, §ion, &entries)? + { + editable_content = updated_content; + } + } let file = editable_content .parse::() .map_err(|err| format!("解析可编辑内核配置失败: {err}"))?; @@ -8386,6 +8732,61 @@ fn yaml_value_at_path<'a>( }) } +fn insert_yaml_block_mapping_values( + content: &str, + section: &str, + entries: &[(String, serde_norway::Value)], +) -> Result, String> { + let mut offset = 0; + let mut section_end = None; + let mut child_indent = None; + for line in content.split_inclusive('\n') { + let body = line.trim_end_matches(['\r', '\n']); + let trimmed = body.trim_start_matches([' ', '\t']); + let indent = body.len().saturating_sub(trimmed.len()); + if section_end.is_none() { + let section_header = trimmed + .split_once('#') + .map(|(value, _)| value) + .unwrap_or(trimmed) + .trim_end(); + if indent == 0 && section_header == format!("{section}:") { + section_end = Some(offset + line.len()); + } + } else if !trimmed.is_empty() && !trimmed.starts_with('#') { + if indent == 0 { + break; + } + child_indent.get_or_insert(indent); + } + offset += line.len(); + } + let Some(section_end) = section_end else { + return Ok(None); + }; + let newline = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let indent = " ".repeat(child_indent.unwrap_or(2)); + let mut insertion = String::new(); + for (key, value) in entries { + let value = serde_json::to_string(value) + .map_err(|error| format!("序列化内核配置值失败: {error}"))?; + insertion.push_str(&indent); + insertion.push_str(key); + insertion.push_str(": "); + insertion.push_str(&value); + insertion.push_str(newline); + } + let mut updated = String::with_capacity(content.len() + insertion.len()); + updated.push_str(&content[..section_end]); + updated.push_str(&insertion); + updated.push_str(&content[section_end..]); + Ok(Some(updated)) +} + fn replace_yaml_sequence_value( content: &str, path: &[String], @@ -8833,6 +9234,35 @@ fn core_config_settings_from_value( }) .transpose()? .unwrap_or_else(|| "round-robin".to_string()); + let proxy_url = yaml_mapping_value(root, "proxy-url") + .map(|value| { + value + .as_str() + .map(str::to_string) + .ok_or_else(|| "proxy-url 必须是字符串".to_string()) + }) + .transpose()? + .unwrap_or_default(); + let routing_session_affinity = nested_yaml_value(root, &["routing", "session-affinity"]) + .or_else(|| nested_yaml_value(root, &["routing", "sessionAffinity"])) + .map(|value| { + value + .as_bool() + .ok_or_else(|| "routing.session-affinity 必须是布尔值".to_string()) + }) + .transpose()? + .unwrap_or(false); + let routing_session_affinity_ttl = + nested_yaml_value(root, &["routing", "session-affinity-ttl"]) + .or_else(|| nested_yaml_value(root, &["routing", "sessionAffinityTTL"])) + .map(|value| { + value + .as_str() + .map(str::to_string) + .ok_or_else(|| "routing.session-affinity-ttl 必须是字符串".to_string()) + }) + .transpose()? + .unwrap_or_default(); Ok(CoreConfigSettings { host, @@ -8845,6 +9275,9 @@ fn core_config_settings_from_value( usage_statistics_enabled, plugins_enabled, routing_strategy, + proxy_url, + routing_session_affinity, + routing_session_affinity_ttl, management_secret_key, }) } @@ -9992,6 +10425,52 @@ fn validate_api_key_remark(remark: &str) -> Result<(), String> { Ok(()) } +fn validate_api_access_provider_section(section: &str) -> Result<(), String> { + if matches!( + section, + "gemini-api-key" | "codex-api-key" | "claude-api-key" | "openai-compatibility" + ) { + Ok(()) + } else { + Err("API 接入类型无效".to_string()) + } +} + +fn api_access_key_hash(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| sha256_bytes(value.as_bytes())) +} + +fn usage_provider_section(provider: &str) -> Option<&'static str> { + match provider.trim().to_ascii_lowercase().as_str() { + "codex" => Some("codex-api-key"), + "claude" => Some("claude-api-key"), + "gemini" | "aistudio" => Some("gemini-api-key"), + "openai" | "openai-compatibility" => Some("openai-compatibility"), + _ => None, + } +} + +impl GuiConfigFile { + fn api_access_remark_for_source(&self, provider: &str, source: &str) -> Option<&str> { + let hash = api_access_key_hash(source)?; + let preferred_section = usage_provider_section(provider); + self.api_access_remarks + .iter() + .find(|entry| { + preferred_section == Some(entry.provider_section.as_str()) + && entry.api_key_hash == hash + && !entry.remark.is_empty() + }) + .or_else(|| { + self.api_access_remarks + .iter() + .find(|entry| entry.api_key_hash == hash && !entry.remark.is_empty()) + }) + .map(|entry| entry.remark.as_str()) + } +} + fn validate_management_secret_key(secret_key: &str) -> Result<(), String> { if secret_key.chars().count() > 512 { return Err("管理密钥不能超过 512 个字符".to_string()); @@ -10028,6 +10507,21 @@ fn validate_routing_strategy(strategy: &str) -> Result<(), String> { Err("路由策略只支持 round-robin 或 fill-first".to_string()) } +fn normalize_optional_config_string(value: String, field_name: &str) -> Result { + let value = value.trim().to_string(); + if value.chars().any(char::is_control) { + return Err(format!("{field_name} 不能包含控制字符")); + } + Ok(value) +} + +fn config_update_error_with_rollback(error: String, rollback_error: Option) -> String { + match rollback_error { + Some(rollback_error) => format!("{error};回滚内核配置也失败: {rollback_error}"), + None => error, + } +} + fn merge_core_config_yaml( template: &str, current: Option<&str>, @@ -10058,6 +10552,34 @@ fn patch_core_network_yaml( }) } +fn patch_core_network_routing_yaml( + content: &str, + config: &GuiConfigFile, +) -> Result, String> { + patch_core_yaml_document(content, |document| { + let original = document.clone(); + apply_network_settings(document, config)?; + set_core_yaml_top_level_value( + document, + "proxy-url", + serde_norway::Value::String(config.proxy_url.clone()), + )?; + set_core_yaml_nested_value( + document, + "routing", + "session-affinity", + serde_norway::Value::Bool(config.routing_session_affinity), + )?; + set_core_yaml_nested_value( + document, + "routing", + "session-affinity-ttl", + serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), + )?; + Ok(*document != original) + }) +} + fn merge_yaml_values(base: &mut serde_norway::Value, current: serde_norway::Value) { match (base, current) { ( @@ -10139,6 +10661,23 @@ fn apply_gui_managed_settings(content: &str, config: &GuiConfigFile) -> Result Result { || presence.management_secret_key.is_none() || presence.usage_statistics_enabled.is_none() || presence.plugins_enabled.is_none() - || presence.routing_strategy.is_none(); + || presence.routing_strategy.is_none() + || presence.proxy_url.is_none() + || presence.routing_session_affinity.is_none() + || presence.routing_session_affinity_ttl.is_none(); if missing_core_settings && !gui_parse_failed { if let Ok(core_settings) = read_installed_core_config_settings() { if presence.host.is_none() { @@ -10400,6 +10942,15 @@ fn load_or_create_gui_config() -> Result { if presence.routing_strategy.is_none() { config.routing_strategy = core_settings.routing_strategy; } + if presence.proxy_url.is_none() { + config.proxy_url = core_settings.proxy_url; + } + if presence.routing_session_affinity.is_none() { + config.routing_session_affinity = core_settings.routing_session_affinity; + } + if presence.routing_session_affinity_ttl.is_none() { + config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl; + } } changed = true; } @@ -10454,6 +11005,9 @@ fn apply_core_settings_to_gui_config( config.usage_statistics_enabled = core_settings.usage_statistics_enabled; config.plugins_enabled = core_settings.plugins_enabled; config.routing_strategy = core_settings.routing_strategy.clone(); + config.proxy_url = core_settings.proxy_url.clone(); + config.routing_session_affinity = core_settings.routing_session_affinity; + config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl.clone(); } fn default_api_key_entry() -> GuiApiKeyEntry { @@ -10677,6 +11231,16 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { if config.api_keys != original_api_keys { changed = true; } + let proxy_url = config.proxy_url.trim().to_string(); + if config.proxy_url != proxy_url { + config.proxy_url = proxy_url; + changed = true; + } + let routing_session_affinity_ttl = config.routing_session_affinity_ttl.trim().to_string(); + if config.routing_session_affinity_ttl != routing_session_affinity_ttl { + config.routing_session_affinity_ttl = routing_session_affinity_ttl; + changed = true; + } let window_size = configured_window_size(config); let normalized_width = window_size.map(|size| size.width); let normalized_height = window_size.map(|size| size.height); @@ -10729,6 +11293,15 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul ), ("plugins-enabled", value(config.plugins_enabled)), ("routing-strategy", value(config.routing_strategy.as_str())), + ("proxy-url", value(config.proxy_url.as_str())), + ( + "routing-session-affinity", + value(config.routing_session_affinity), + ), + ( + "routing-session-affinity-ttl", + value(config.routing_session_affinity_ttl.as_str()), + ), ( "codex-session-repair-on-launch", value(config.codex_session_repair_on_launch), @@ -10754,6 +11327,22 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul api_keys.push(Value::InlineTable(table)); } set_codex_table_item(root, "api-keys", Item::Value(Value::Array(api_keys))); + let mut api_access_remarks = Array::new(); + for entry in &config.api_access_remarks { + let mut table = InlineTable::new(); + table.insert( + "provider-section", + Value::from(entry.provider_section.as_str()), + ); + table.insert("api-key-hash", Value::from(entry.api_key_hash.as_str())); + table.insert("remark", Value::from(entry.remark.as_str())); + api_access_remarks.push(Value::InlineTable(table)); + } + set_codex_table_item( + root, + "api-access-remarks", + Item::Value(Value::Array(api_access_remarks)), + ); let content = document.to_string(); toml::from_str::(&content) @@ -10778,8 +11367,30 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> { validate_core_api_key(&entry.key)?; validate_api_key_remark(&entry.remark)?; } + for entry in &config.api_access_remarks { + validate_api_access_provider_section(&entry.provider_section)?; + if entry.api_key_hash.len() != 64 + || !entry + .api_key_hash + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("API 接入备注的密钥指纹无效".to_string()); + } + validate_api_key_remark(&entry.remark)?; + } validate_management_secret_key(&config.management_secret_key)?; validate_routing_strategy(config.routing_strategy.trim())?; + if config.proxy_url.chars().any(char::is_control) { + return Err("代理 URL 不能包含控制字符".to_string()); + } + if config + .routing_session_affinity_ttl + .chars() + .any(char::is_control) + { + return Err("会话粘性 TTL 不能包含控制字符".to_string()); + } Ok(()) } @@ -12957,6 +13568,8 @@ fn main() { detect_core_platform, get_core_status, get_gui_settings, + resolve_api_access_remarks, + save_api_access_remark, set_app_locale, resolve_windows_close_request, get_agent_config_statuses, @@ -12974,6 +13587,7 @@ fn main() { launch_agent, get_lan_ipv4, save_gui_settings, + save_network_routing_settings, get_core_config_settings, add_core_api_key, update_core_api_key, @@ -12985,6 +13599,9 @@ fn main() { download_auth_file, set_core_plugins_enabled, set_core_routing_strategy, + set_core_proxy_url, + set_core_session_affinity, + set_core_session_affinity_ttl, start_oauth_login, get_oauth_status, submit_oauth_callback, @@ -14815,6 +15432,9 @@ custom_option = "keep-original" usage_statistics_enabled: false, plugins_enabled: true, routing_strategy: "fill-first".to_string(), + proxy_url: "http://127.0.0.1:8080".to_string(), + routing_session_affinity: true, + routing_session_affinity_ttl: "45m".to_string(), management_secret_key: Some("management-secret".to_string()), }; @@ -14832,6 +15452,9 @@ custom_option = "keep-original" assert!(!config.usage_statistics_enabled); assert!(config.plugins_enabled); assert_eq!(config.routing_strategy, "fill-first"); + assert_eq!(config.proxy_url, "http://127.0.0.1:8080"); + assert!(config.routing_session_affinity); + assert_eq!(config.routing_session_affinity_ttl, "45m"); assert!(config.run_on_startup); } @@ -14933,6 +15556,35 @@ custom_option = "keep-original" assert!(patch_core_network_yaml(input, &config).unwrap().is_none()); } + #[test] + fn confirmed_network_routing_patch_updates_all_fields_together() { + let config = GuiConfigFile { + port: 9527, + allow_lan: true, + host: "0.0.0.0".to_string(), + proxy_url: "socks5://127.0.0.1:7890".to_string(), + routing_session_affinity: true, + routing_session_affinity_ttl: "2h".to_string(), + ..GuiConfigFile::default() + }; + let input = "# network\nhost: 127.0.0.1\nport: 8317\nproxy-url: \"\"\n# routing\nrouting:\n strategy: round-robin\n# unrelated\ndebug: true\n"; + let updated = patch_core_network_routing_yaml(input, &config) + .unwrap() + .expect("confirmed settings should change"); + let document = serde_norway::from_str::(&updated).unwrap(); + + assert_eq!(document["host"], "0.0.0.0"); + assert_eq!(document["port"], 9527); + assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890"); + assert_eq!(document["routing"]["session-affinity"], true); + assert_eq!(document["routing"]["session-affinity-ttl"], "2h"); + assert_eq!(document["routing"]["strategy"], "round-robin"); + assert_eq!(document["debug"], true); + assert!(updated.contains("# network")); + assert!(updated.contains("# routing")); + assert!(updated.contains("# unrelated")); + } + #[test] fn core_config_controls_preserve_comments_and_unrelated_values() { let input = "# Client authentication\napi-keys:\n - old-key\n\n# Plugin runtime\nplugins:\n enabled: false # global switch\n dir: plugins\n\n# Credential routing\nrouting:\n strategy: round-robin # current strategy\n session-affinity: true\n\ndebug: true # untouched\n"; @@ -15180,6 +15832,66 @@ custom_option = "keep-original" assert_eq!(settings.routing_strategy, "round-robin"); } + #[test] + fn core_config_reads_proxy_and_session_affinity_fields() { + let canonical = serde_norway::from_str::( + "proxy-url: socks5://127.0.0.1:7890\nrouting:\n session-affinity: true\n session-affinity-ttl: 2h\n", + ) + .unwrap(); + let settings = core_config_settings_from_value(&canonical).unwrap(); + assert_eq!(settings.proxy_url, "socks5://127.0.0.1:7890"); + assert!(settings.routing_session_affinity); + assert_eq!(settings.routing_session_affinity_ttl, "2h"); + + let aliases = serde_norway::from_str::( + "routing:\n sessionAffinity: true\n sessionAffinityTTL: 30m\n", + ) + .unwrap(); + let settings = core_config_settings_from_value(&aliases).unwrap(); + assert!(settings.routing_session_affinity); + assert_eq!(settings.routing_session_affinity_ttl, "30m"); + + let defaults = + core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap(); + assert_eq!(defaults.proxy_url, ""); + assert!(!defaults.routing_session_affinity); + assert_eq!(defaults.routing_session_affinity_ttl, ""); + } + + #[test] + fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() { + let input = "# global proxy\nproxy-url: old\n# routing options\nrouting:\n strategy: round-robin\n sessionAffinity: false\n sessionAffinityTTL: 10m\n# unrelated option\ndebug: true\n"; + let config = GuiConfigFile { + proxy_url: "http://127.0.0.1:8080".to_string(), + routing_session_affinity: true, + routing_session_affinity_ttl: "1h".to_string(), + ..GuiConfigFile::default() + }; + let rendered = apply_gui_managed_settings(input, &config).unwrap(); + let document = serde_norway::from_str::(&rendered).unwrap(); + + assert_eq!(document["proxy-url"], "http://127.0.0.1:8080"); + assert_eq!(document["routing"]["session-affinity"], true); + assert_eq!(document["routing"]["session-affinity-ttl"], "1h"); + assert!(rendered.contains("# global proxy")); + assert!(rendered.contains("# unrelated option")); + assert_eq!(document["debug"], true); + } + + #[test] + fn optional_core_strings_are_trimmed_and_reject_control_characters() { + assert_eq!( + normalize_optional_config_string(" socks5://proxy:7890 ".to_string(), "代理 URL") + .unwrap(), + "socks5://proxy:7890" + ); + assert_eq!( + normalize_optional_config_string(" 1h ".to_string(), "TTL").unwrap(), + "1h" + ); + assert!(normalize_optional_config_string("bad\nvalue".to_string(), "代理 URL").is_err()); + } + #[test] fn core_config_validates_keys_and_routing_strategy() { assert!(validate_core_api_key("sk-valid_123").is_ok()); @@ -15229,10 +15941,14 @@ custom_option = "keep-original" remark: "测试密钥".to_string(), }, ], + api_access_remarks: Vec::new(), management_secret_key: String::new(), usage_statistics_enabled: false, plugins_enabled: true, routing_strategy: "fill-first".to_string(), + proxy_url: "socks5://127.0.0.1:7890".to_string(), + routing_session_affinity: true, + routing_session_affinity_ttl: "1h".to_string(), codex_session_repair_on_launch: false, }; let merged = merge_core_config_yaml(template, Some(current), &config).unwrap(); @@ -15252,6 +15968,9 @@ custom_option = "keep-original" assert_eq!(document["api-keys"][1], "gui-key"); assert_eq!(document["plugins"]["enabled"], true, "{merged}"); assert_eq!(document["routing"]["strategy"], "fill-first"); + assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890"); + assert_eq!(document["routing"]["session-affinity"], true); + assert_eq!(document["routing"]["session-affinity-ttl"], "1h"); assert_eq!(document["usage-statistics-enabled"], false); assert_eq!(document["new-option"], serde_norway::Value::Bool(true)); assert_eq!( diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 0937334..e93a7f3 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -98,6 +98,8 @@ pub(crate) struct UsageRecord { #[serde(default)] source: String, #[serde(default)] + source_display: String, + #[serde(default)] auth_index: String, #[serde(default)] failed: bool, @@ -769,6 +771,7 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result Result { +pub(crate) fn get_usage_analysis( + query: UsageQuery, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { let connection = open_usage_database()?; - load_usage_analysis(&connection, &query) + let config = gui_config_state.snapshot()?; + load_usage_analysis(&connection, &query, &config) } fn load_usage_analysis( connection: &Connection, query: &UsageQuery, + config: &GuiConfigFile, ) -> Result { Ok(UsageAnalysis { models: load_simple_categories(connection, query, "model", "unknown")?, providers: load_simple_categories(connection, query, "provider", "未知 Provider")?, - sources: load_simple_categories(connection, query, "source", "未知来源")?, + sources: load_source_categories(connection, query, config)?, api_keys: load_api_key_categories(connection, query)?, }) } +fn load_source_categories( + connection: &Connection, + query: &UsageQuery, + config: &GuiConfigFile, +) -> Result, String> { + let mut categories = load_simple_categories(connection, query, "source", "未知来源")?; + for category in &mut categories { + category.label = usage_source_display(config, "", &category.key); + } + Ok(categories) +} + fn load_simple_categories( connection: &Connection, query: &UsageQuery, @@ -1047,17 +1067,7 @@ fn load_api_key_categories( let key = row.get::<_, String>(0)?; let remark = row.get::<_, String>(1)?; let display = row.get::<_, String>(2)?; - let label = if !remark.is_empty() { - if display.is_empty() { - remark - } else { - format!("{remark} · {display}") - } - } else if !display.is_empty() { - display - } else { - "未记录密钥".to_string() - }; + let label = api_key_category_label(remark, display); Ok(UsageCategory { key, label, @@ -1073,14 +1083,19 @@ fn load_api_key_categories( } #[tauri::command] -pub(crate) fn get_usage_events(query: UsageQuery) -> Result { +pub(crate) fn get_usage_events( + query: UsageQuery, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { let connection = open_usage_database()?; - load_usage_events(&connection, &query) + let config = gui_config_state.snapshot()?; + load_usage_events(&connection, &query, &config) } fn load_usage_events( connection: &Connection, query: &UsageQuery, + config: &GuiConfigFile, ) -> Result { let filter = build_usage_filter(query); let total_sql = format!("SELECT COUNT(*) FROM usage_events{}", filter.clause); @@ -1117,11 +1132,14 @@ fn load_usage_events( let mut statement = connection .prepare(&sql) .map_err(|error| format!("准备 SQLite 使用事件查询失败: {error}"))?; - let items = statement + let mut items = statement .query_map(params_from_iter(values.iter()), usage_record_from_row) .map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))? .collect::, _>>() .map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?; + for item in &mut items { + item.source_display = usage_source_display(config, &item.provider, &item.source); + } Ok(UsageEventPage { items, total, @@ -1138,6 +1156,7 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result { latency_ms: from_sql_i64(row.get(2)?), ttft_ms: row.get::<_, Option>(3)?.map(from_sql_i64), source: row.get(4)?, + source_display: String::new(), auth_index: row.get(5)?, failed: row.get::<_, i64>(6)? != 0, provider: row.get(7)?, @@ -1296,6 +1315,38 @@ fn mask_api_key(value: &str) -> String { format!("{start}••••{end}") } +fn api_key_category_label(remark: String, display: String) -> String { + if !remark.is_empty() { + remark + } else if !display.is_empty() { + mask_api_key(&display) + } else { + "未记录密钥".to_string() + } +} + +fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> String { + let source = source.trim(); + if source.is_empty() { + return "未知来源".to_string(); + } + if let Some(remark) = config.api_access_remark_for_source(provider, source) { + return remark.to_string(); + } + let character_count = source.chars().count(); + let looks_like_secret = !source.contains('@') + && !source.chars().any(char::is_whitespace) + && (source.starts_with("sk-") + || source.starts_with("AIza") + || source.starts_with("key-") + || character_count >= 48); + if looks_like_secret { + mask_api_key(source) + } else { + source.to_string() + } +} + #[cfg(test)] mod tests { use super::*; @@ -1331,6 +1382,7 @@ mod tests { latency_ms: 100, ttft_ms: Some(20), source: "source".to_string(), + source_display: String::new(), auth_index: "auth".to_string(), failed: false, provider: "openai".to_string(), @@ -1363,6 +1415,39 @@ mod tests { assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890"); } + #[test] + fn api_key_category_uses_either_remark_or_masked_key() { + assert_eq!( + api_key_category_label("生产环境".to_string(), "12••••".to_string()), + "生产环境" + ); + assert_eq!( + api_key_category_label(String::new(), "123456".to_string()), + "12••••" + ); + } + + #[test] + fn usage_source_prefers_api_access_remark_and_masks_secret_fallbacks() { + let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz"; + let mut config = GuiConfigFile::default(); + config.api_access_remarks.push(crate::GuiApiAccessRemark { + provider_section: "codex-api-key".to_string(), + api_key_hash: hash_text(key), + remark: "生产环境".to_string(), + }); + + assert_eq!(usage_source_display(&config, "codex", key), "生产环境"); + assert_eq!( + usage_source_display(&GuiConfigFile::default(), "codex", key), + "sk-1••••wxyz" + ); + assert_eq!( + usage_source_display(&config, "codex", "account@example.com"), + "account@example.com" + ); + } + #[test] fn local_hour_key_uses_year_month_day_and_hour() { let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model"); @@ -1381,10 +1466,14 @@ mod tests { window_height: None, auth_dir: String::new(), api_keys: Vec::new(), + api_access_remarks: Vec::new(), management_secret_key: "123456".to_string(), usage_statistics_enabled: true, plugins_enabled: false, routing_strategy: "round-robin".to_string(), + proxy_url: String::new(), + routing_session_affinity: false, + routing_session_affinity_ttl: String::new(), codex_session_repair_on_launch: false, }; let record = normalize_usage_record( @@ -1519,8 +1608,9 @@ mod tests { }; let overview = load_usage_overview(&connection, &query).unwrap(); - let analysis = load_usage_analysis(&connection, &query).unwrap(); - let events = load_usage_events(&connection, &query).unwrap(); + let config = GuiConfigFile::default(); + let analysis = load_usage_analysis(&connection, &query, &config).unwrap(); + let events = load_usage_events(&connection, &query, &config).unwrap(); assert_eq!(overview.total_requests, 1); assert_eq!(overview.failure_count, 1); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2555a69..c230abd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "EasyCLIProxyAPI", - "version": "0.2.7", + "version": "0.2.8", "identifier": "com.cpa.gui", "build": { "beforeDevCommand": "bun run dev", @@ -14,7 +14,7 @@ { "label": "main", "title": "EasyCLIProxyAPI", - "width": 1380, + "width": 1500, "height": 815, "minWidth": 640, "minHeight": 600 diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index aef4fa2..f528ce5 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -12,7 +12,7 @@ export const jaOverrides = { 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API 接続', 'app.nav.authFiles': '認証ファイル', - 'app.nav.quota': 'クォータ', + 'app.nav.quota': 'クォータ照会', 'app.nav.usageRecords': '使用履歴', 'app.nav.agents': 'エージェント', 'app.coreRequired.title': '先にコアを起動してください', @@ -28,7 +28,7 @@ export const jaOverrides = { 'error.render.title': 'ページの描画中にエラーが発生しました', 'error.unknown': '不明なエラー', 'error.reload': 'ページを再読み込み', - 'quota.title': 'クォータ', + 'quota.title': 'クォータ照会', 'quota.queryableCredentials.one': '照会可能な認証情報 {count} 件', 'quota.queryableCredentials.other': '照会可能な認証情報 {count} 件', 'quota.credentials.one': '認証情報 {count} 件', @@ -220,6 +220,9 @@ export const jaOverrides = { 'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます', 'config.error.duplicateKey': '同じ認証キーがすでに存在します', 'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください', + 'config.error.portRange': 'ポートは 1〜65535 の整数で入力してください', + 'config.error.networkRestartFailed': '設定は保存されましたが、コアの再起動に失敗しました:{error}', + 'config.error.saveFailed': '保存に失敗しました:{error}', 'config.notice.keyAdded': '認証キーを追加しました', 'config.notice.keyUpdated': '認証キーを更新しました', 'config.notice.keyDeleted': '認証キーを削除しました', @@ -228,6 +231,13 @@ export const jaOverrides = { 'config.notice.pluginsEnabled': 'プラグインシステムを有効にしました', 'config.notice.pluginsDisabled': 'プラグインシステムを無効にしました', 'config.notice.routingUpdated': 'ルーティング方式を更新しました', + 'config.notice.networkUpdated': 'ネットワークとセッションルーティング設定を保存しました', + 'config.notice.networkRestarted': 'ネットワーク設定を保存し、コアを再起動しました', + 'config.notice.networkNextStart': 'ネットワーク設定を保存しました。次回のコア起動時に反映されます', + 'config.notice.proxyUpdated': 'プロキシ URL を保存しました', + 'config.notice.sessionAffinityEnabled': 'セッションアフィニティを有効にしました', + 'config.notice.sessionAffinityDisabled': 'セッションアフィニティを無効にしました', + 'config.notice.sessionTtlUpdated': 'セッションアフィニティ TTL を保存しました', 'config.keys.title': '認証キー', 'config.keys.count': 'キー数', 'config.keys.add': '認証キーを追加', @@ -248,6 +258,22 @@ export const jaOverrides = { 'common.disabled': '無効', 'config.plugins.enable': 'プラグインシステムを有効にする', 'config.routing.title': 'ルーティング方式', + 'config.network.title': 'ネットワークとセッションルーティング', + 'config.network.saving': '保存中', + 'config.network.saved': '保存済み', + 'config.network.unsaved': '未保存の変更あり', + 'config.network.confirmSave': '確認して保存', + 'config.network.port': 'ポート', + 'config.network.portHint': '範囲は 1〜65535。コア実行中の変更時は自動的に再起動します', + 'config.network.allowLan': 'LAN アクセスを許可', + 'config.network.allowLanHint': 'LAN 内の他のデバイスからこのプロキシへのアクセスを許可します', + 'config.network.proxyUrl': 'プロキシ URL', + 'config.network.proxyPlaceholder': '例:socks5://127.0.0.1:7890', + 'config.network.proxyHint': 'グローバル上流プロキシ。空欄の場合は使用しません', + 'config.network.sessionAffinity': 'セッションアフィニティ', + 'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します', + 'config.network.sessionTtl': 'セッションアフィニティ TTL', + 'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します', 'config.keys.addTitle': '認証キーを追加', 'config.keys.editTitle': '認証キーを編集', 'common.close': '閉じる', @@ -503,6 +529,7 @@ export const jaOverrides = { 'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください', 'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください', 'apiAccess.error.duplicate': '同じ接続設定がすでに存在します', + 'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください', 'apiAccess.notice.updated': '接続を更新しました', 'apiAccess.notice.added': '接続を追加しました', 'apiAccess.deleteConfirm': '「{name}」を削除しますか?', @@ -521,6 +548,7 @@ export const jaOverrides = { 'apiAccess.empty.addFirst': '右上の「追加」から最初の接続を追加してください', 'apiAccess.keys.count': 'キー {count} 件', 'apiAccess.keys.summary': '{key} · 合計 {count} 件', + 'apiAccess.remarkValue': 'メモ:{remark}', 'apiAccess.defaultUrl': '既定のアドレスを使用', 'apiAccess.models.summary': 'モデル {count} 件 · {models}', 'apiAccess.priorityValue': '優先度 {priority}', @@ -546,6 +574,8 @@ export const jaOverrides = { 'apiAccess.field.name': '名前', 'apiAccess.field.keysMany': 'API キー(1 行に 1 件)', 'apiAccess.field.key': 'API キー', + 'apiAccess.field.remark': 'メモ', + 'apiAccess.remarkPlaceholder': '例:本番環境、チームのメインアカウント', 'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用', 'apiAccess.preset.title': 'OpenAI 互換プリセット', @@ -643,8 +673,7 @@ export const jaOverrides = { 'appUpdate.current': '現在のバージョン', 'appUpdate.latest': '最新バージョン', 'appUpdate.status': '更新状態', - 'appUpdate.portableReady': 'ポータブル版の自動更新に対応', - 'appUpdate.manualOnly': 'このビルドは手動更新が必要です', + 'appUpdate.manualFallback': '検出に失敗した場合は、GitHub ページから手動で更新してください', 'appUpdate.upToDate': '最新バージョンです', 'appUpdate.available': 'バージョン {version} を利用できます', 'appUpdate.check': '更新を確認', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 67a3417..e510e89 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -15,7 +15,7 @@ export const zhCN = { 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API 接入', 'app.nav.authFiles': '认证文件', - 'app.nav.quota': '配额', + 'app.nav.quota': '额度查询', 'app.nav.usageRecords': '使用记录', 'app.nav.agents': '智能体', 'app.coreRequired.title': '请先启动内核', @@ -31,7 +31,7 @@ export const zhCN = { 'error.render.title': '页面渲染出现异常', 'error.unknown': '未知错误', 'error.reload': '重新加载页面', - 'quota.title': '配额', + 'quota.title': '额度查询', 'quota.queryableCredentials.one': '{count} 个可查询凭据', 'quota.queryableCredentials.other': '{count} 个可查询凭据', 'quota.credentials.one': '{count} 个凭据', @@ -223,6 +223,9 @@ export const zhCN = { 'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格', 'config.error.duplicateKey': '该鉴权密钥已经存在', 'config.error.remarkTooLong': '备注不能超过 80 个字符', + 'config.error.portRange': '端口必须是 1–65535 之间的整数', + 'config.error.networkRestartFailed': '设置已保存,但内核重启失败:{error}', + 'config.error.saveFailed': '保存失败:{error}', 'config.notice.keyAdded': '鉴权密钥已新增', 'config.notice.keyUpdated': '鉴权密钥已更新', 'config.notice.keyDeleted': '鉴权密钥已删除', @@ -231,6 +234,13 @@ export const zhCN = { 'config.notice.pluginsEnabled': '插件系统已启用', 'config.notice.pluginsDisabled': '插件系统已停用', 'config.notice.routingUpdated': '路由策略已更新', + 'config.notice.networkUpdated': '网络与会话路由设置已保存', + 'config.notice.networkRestarted': '网络设置已保存,内核已重启', + 'config.notice.networkNextStart': '网络设置已保存,将在下次启动内核时生效', + 'config.notice.proxyUpdated': '代理 URL 已保存', + 'config.notice.sessionAffinityEnabled': '会话粘性路由已启用', + 'config.notice.sessionAffinityDisabled': '会话粘性路由已停用', + 'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存', 'config.keys.title': '鉴权密钥', 'config.keys.count': '密钥数量', 'config.keys.add': '新增鉴权密钥', @@ -251,6 +261,22 @@ export const zhCN = { 'common.disabled': '已停用', 'config.plugins.enable': '启用插件系统', 'config.routing.title': '路由策略', + 'config.network.title': '网络与会话路由', + 'config.network.saving': '正在保存', + 'config.network.saved': '已保存', + 'config.network.unsaved': '有未保存修改', + 'config.network.confirmSave': '确认保存', + 'config.network.port': '端口', + 'config.network.portHint': '范围 1–65535;内核运行时修改会自动重启', + 'config.network.allowLan': '允许局域网', + 'config.network.allowLanHint': '允许其他局域网设备访问本机代理', + 'config.network.proxyUrl': '代理 URL', + 'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890', + 'config.network.proxyHint': '全局上游代理;留空表示不使用', + 'config.network.sessionAffinity': '会话粘性路由', + 'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据', + 'config.network.sessionTtl': '会话粘性 TTL', + 'config.network.sessionTtlHint': '例如 1h;留空使用内核默认值', 'config.keys.addTitle': '新增鉴权密钥', 'config.keys.editTitle': '编辑鉴权密钥', 'common.close': '关闭', @@ -506,6 +532,7 @@ export const zhCN = { 'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥', 'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试', 'apiAccess.error.duplicate': '相同的接入配置已经存在', + 'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符', 'apiAccess.notice.updated': '接入已更新', 'apiAccess.notice.added': '接入已新增', 'apiAccess.deleteConfirm': '确定删除“{name}”吗?', @@ -524,6 +551,7 @@ export const zhCN = { 'apiAccess.empty.addFirst': '点击右上角“新增”添加第一个接入', 'apiAccess.keys.count': '{count} 个密钥', 'apiAccess.keys.summary': '{key} · 共 {count} 个密钥', + 'apiAccess.remarkValue': '备注:{remark}', 'apiAccess.defaultUrl': '使用默认地址', 'apiAccess.models.summary': '模型 {count} 个 · {models}', 'apiAccess.priorityValue': '优先级 {priority}', @@ -549,6 +577,8 @@ export const zhCN = { 'apiAccess.field.name': '名称', 'apiAccess.field.keysMany': 'API 密钥(每行一个)', 'apiAccess.field.key': 'API 密钥', + 'apiAccess.field.remark': '备注', + 'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号', 'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址', 'apiAccess.preset.title': 'OpenAI 兼容预设', @@ -646,8 +676,7 @@ export const zhCN = { 'appUpdate.current': '当前版本', 'appUpdate.latest': '最新版本', 'appUpdate.status': '升级状态', - 'appUpdate.portableReady': '支持便携版自动升级', - 'appUpdate.manualOnly': '当前版本需要手动更新', + 'appUpdate.manualFallback': '如果检测失败,请手动到GitHub页面更新', 'appUpdate.upToDate': '已是最新版本', 'appUpdate.available': '发现新版本 {version}', 'appUpdate.check': '检查更新', @@ -802,7 +831,7 @@ export const en: Record = { 'app.nav.oauth': 'OAuth', 'app.nav.api': 'API Access', 'app.nav.authFiles': 'Auth Files', - 'app.nav.quota': 'Quota', + 'app.nav.quota': 'Quota Lookup', 'app.nav.usageRecords': 'Usage', 'app.nav.agents': 'Agents', 'app.coreRequired.title': 'Start the core first', @@ -818,7 +847,7 @@ export const en: Record = { 'error.render.title': 'Something went wrong while rendering this page', 'error.unknown': 'Unknown error', 'error.reload': 'Reload Page', - 'quota.title': 'Quota', + 'quota.title': 'Quota Lookup', 'quota.queryableCredentials.one': '{count} queryable credential', 'quota.queryableCredentials.other': '{count} queryable credentials', 'quota.credentials.one': '{count} credential', @@ -1010,6 +1039,9 @@ export const en: Record = { 'config.error.invalidKey': 'Use visible ASCII characters only, without spaces', 'config.error.duplicateKey': 'This authentication key already exists', 'config.error.remarkTooLong': 'The remark cannot exceed 80 characters', + 'config.error.portRange': 'The port must be an integer from 1 to 65535', + 'config.error.networkRestartFailed': 'Settings were saved, but the core failed to restart: {error}', + 'config.error.saveFailed': 'Failed to save: {error}', 'config.notice.keyAdded': 'Authentication key added', 'config.notice.keyUpdated': 'Authentication key updated', 'config.notice.keyDeleted': 'Authentication key deleted', @@ -1018,6 +1050,13 @@ export const en: Record = { 'config.notice.pluginsEnabled': 'Plugin system enabled', 'config.notice.pluginsDisabled': 'Plugin system disabled', 'config.notice.routingUpdated': 'Routing strategy updated', + 'config.notice.networkUpdated': 'Network and session routing settings saved', + 'config.notice.networkRestarted': 'Network settings saved and the core restarted', + 'config.notice.networkNextStart': 'Network settings saved and will apply on the next core start', + 'config.notice.proxyUpdated': 'Proxy URL saved', + 'config.notice.sessionAffinityEnabled': 'Session affinity enabled', + 'config.notice.sessionAffinityDisabled': 'Session affinity disabled', + 'config.notice.sessionTtlUpdated': 'Session affinity TTL saved', 'config.keys.title': 'Authentication Keys', 'config.keys.count': 'Key count', 'config.keys.add': 'Add Authentication Key', @@ -1038,6 +1077,22 @@ export const en: Record = { 'common.disabled': 'Disabled', 'config.plugins.enable': 'Enable plugin system', 'config.routing.title': 'Routing Strategy', + 'config.network.title': 'Network & Session Routing', + 'config.network.saving': 'Saving', + 'config.network.saved': 'Saved', + 'config.network.unsaved': 'Unsaved changes', + 'config.network.confirmSave': 'Confirm & Save', + 'config.network.port': 'Port', + 'config.network.portHint': 'Range 1–65535; changing it restarts a running core', + 'config.network.allowLan': 'Allow LAN', + 'config.network.allowLanHint': 'Allow other devices on the LAN to access this proxy', + 'config.network.proxyUrl': 'Proxy URL', + 'config.network.proxyPlaceholder': 'For example, socks5://127.0.0.1:7890', + 'config.network.proxyHint': 'Global upstream proxy; leave blank to disable', + 'config.network.sessionAffinity': 'Session Affinity', + 'config.network.sessionAffinityHint': 'Prefer the previously selected credential for the same session', + 'config.network.sessionTtl': 'Session Affinity TTL', + 'config.network.sessionTtlHint': 'For example, 1h; leave blank to use the core default', 'config.keys.addTitle': 'Add Authentication Key', 'config.keys.editTitle': 'Edit Authentication Key', 'common.close': 'Close', @@ -1293,6 +1348,7 @@ export const en: Record = { 'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.', 'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.', 'apiAccess.error.duplicate': 'An identical connection already exists', + 'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters', 'apiAccess.notice.updated': 'Connection updated', 'apiAccess.notice.added': 'Connection added', 'apiAccess.deleteConfirm': 'Delete “{name}”?', @@ -1311,6 +1367,7 @@ export const en: Record = { 'apiAccess.empty.addFirst': 'Click “Add” in the upper-right to create your first connection', 'apiAccess.keys.count': '{count} keys', 'apiAccess.keys.summary': '{key} · {count} keys total', + 'apiAccess.remarkValue': 'Remark: {remark}', 'apiAccess.defaultUrl': 'Using default address', 'apiAccess.models.summary': '{count} models · {models}', 'apiAccess.priorityValue': 'Priority {priority}', @@ -1336,6 +1393,8 @@ export const en: Record = { 'apiAccess.field.name': 'Name', 'apiAccess.field.keysMany': 'API Keys (one per line)', 'apiAccess.field.key': 'API Key', + 'apiAccess.field.remark': 'Remark', + 'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account', 'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com', 'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address', 'apiAccess.preset.title': 'OpenAI-Compatible Preset', @@ -1433,8 +1492,7 @@ export const en: Record = { 'appUpdate.current': 'Current Version', 'appUpdate.latest': 'Latest Version', 'appUpdate.status': 'Update Status', - 'appUpdate.portableReady': 'Portable auto-update supported', - 'appUpdate.manualOnly': 'This build requires a manual update', + 'appUpdate.manualFallback': 'If detection fails, update manually from the GitHub page', 'appUpdate.upToDate': 'Up to Date', 'appUpdate.available': 'Version {version} is available', 'appUpdate.check': 'Check for Updates', diff --git a/src/navigation.ts b/src/navigation.ts index 7e6103f..df48fcc 100644 --- a/src/navigation.ts +++ b/src/navigation.ts @@ -1,4 +1,4 @@ -const alwaysAvailablePages = new Set(['home', 'versions']); +const alwaysAvailablePages = new Set(['home', 'versions', 'config']); export function isAlwaysAvailablePage(pageId: string) { return alwaysAvailablePages.has(pageId); diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 10244df..d9d58bd 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -511,6 +511,7 @@ export function AgentsPage() { const [closeAppNotice, setCloseAppNotice] = useState(''); const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false); const [oauthConfiguration, setOauthConfiguration] = useState(false); + const autoLoadedModelClientRef = useRef(null); const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh @@ -543,21 +544,27 @@ export function AgentsPage() { setLoading(true); setDetectionError(''); try { - await Promise.all([loadStatuses(true), loadModels()]); + await loadStatuses(true); } catch (requestError) { setDetectionError(String(requestError)); } finally { setLoading(false); } - }, [loadModels, loadStatuses]); + }, [loadStatuses]); useEffect(() => { setLoading(true); setDetectionError(''); - void Promise.all([loadStatuses(), loadModels()]) + void loadStatuses() .catch((requestError) => setDetectionError(String(requestError))) .finally(() => setLoading(false)); - }, [loadModels, loadStatuses]); + }, [loadStatuses]); + + useEffect(() => { + if (autoLoadedModelClientRef.current === selected) return; + autoLoadedModelClientRef.current = selected; + void loadModels(); + }, [loadModels, selected]); useEffect(() => { let disposed = false; @@ -565,7 +572,7 @@ export function AgentsPage() { void listen('config-files-changed', () => { if (disposed) return; setDetectionError(''); - void Promise.all([loadStatuses(true), loadModels()]).catch((requestError) => { + void loadStatuses(true).catch((requestError) => { if (!disposed) setDetectionError(String(requestError)); }); }).then((unlisten) => { @@ -576,7 +583,7 @@ export function AgentsPage() { disposed = true; stop?.(); }; - }, [loadModels, loadStatuses]); + }, [loadStatuses]); useEffect(() => { writeSelectedAgentClient(selected); @@ -724,6 +731,7 @@ export function AgentsPage() { await invoke('apply_agent_config', { client: selected, model, + models, oauthConfiguration, }); await reloadStatusesAfterAction(); @@ -743,6 +751,7 @@ export function AgentsPage() { await invoke('reset_agent_config_to_default', { client: selected, model, + models, oauthConfiguration, }); setDefaultConfirmOpen(false); diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index a8fd35d..f0b532c 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,4 +1,5 @@ import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { Check, Edit3, @@ -67,11 +68,13 @@ type ProviderRow = { disabled: boolean; priority: number | null; authIndex: string; + remark: string; }; export type ProviderDraft = { name: string; apiKey: string; + remark: string; baseUrl: string; priority: string; models: ModelOption[]; @@ -182,9 +185,13 @@ const rowFromRecord = ( authIndex: entry ? readString(entry, 'auth-index', 'authIndex') : readString(record, 'auth-index', 'authIndex'), + remark: '', }; }; +const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) => + `${section}\u0000${apiKeys.join('\u0000')}`; + export const stripResponseFields = (record: Record) => { const next = { ...record }; delete next['auth-index']; @@ -316,6 +323,7 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => { const draftFromRow = (row: ProviderRow): ProviderDraft => ({ name: row.name, apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey, + remark: row.remark, baseUrl: row.baseUrl, priority: row.priority === null ? '' : String(row.priority), models: row.models, @@ -351,6 +359,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => ({ const emptyProviderDraft = (): ProviderDraft => ({ name: '', apiKey: '', + remark: '', baseUrl: '', priority: '', models: [], @@ -529,7 +538,10 @@ export const buildProviderRecord = ( return applyAdvancedFields(next, section, draft); }; -const providerIdentityMatches = (row: ProviderRow, record: Record) => { +const providerIdentityMatches = ( + row: ProviderRecordIdentity, + record: Record, +) => { if (definitionFor(row.section).openAi) { return readString(record, 'name') === row.name; } @@ -539,6 +551,56 @@ const providerIdentityMatches = (row: ProviderRow, record: Record; + +const providerPrimaryIdentityMatches = ( + row: ProviderRecordIdentity, + record: Record, +) => definitionFor(row.section).openAi + ? readString(record, 'name') === row.name + : readString(record, 'api-key', 'apiKey') === row.apiKey; + +export const resolveProviderRecordIndex = ( + records: Record[], + row: ProviderRecordIdentity, +) => { + const exactIndex = records.findIndex((record) => providerIdentityMatches(row, record)); + if (exactIndex >= 0) return exactIndex; + + const indexedRecord = records[row.index]; + if (indexedRecord && providerPrimaryIdentityMatches(row, indexedRecord)) { + return row.index; + } + + const primaryMatches = records + .map((record, index) => ({ record, index })) + .filter(({ record }) => providerPrimaryIdentityMatches(row, record)); + return primaryMatches.length === 1 ? primaryMatches[0].index : -1; +}; + +export const providerRecordWithDisabledState = ( + section: ProviderSection, + record: Record, + disabled: boolean, +) => { + const nextRecord = stripResponseFields(record); + if (definitionFor(section).openAi) { + nextRecord.disabled = disabled; + return nextRecord; + } + + const excludedModels = Array.isArray(nextRecord['excluded-models']) + ? nextRecord['excluded-models'].map(String).filter((model) => model.trim() !== '*') + : []; + if (disabled) excludedModels.push('*'); + if (excludedModels.length > 0) nextRecord['excluded-models'] = excludedModels; + else delete nextRecord['excluded-models']; + return nextRecord; +}; + export function ApiAccessPage() { const { t } = useI18n(); const [records, setRecords] = useState(emptyRecords); @@ -551,6 +613,7 @@ export function ApiAccessPage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingRow, setEditingRow] = useState(null); const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft); + const [apiAccessRemarks, setApiAccessRemarks] = useState>({}); const activeDefinition = definitionFor(activeCategory); const activeSection = activeDefinition.section; @@ -594,20 +657,51 @@ export function ApiAccessPage() { void loadProviders(); }, [loadProviders]); + useEffect(() => { + const providerRows = (Object.entries(records) as [ProviderSection, Record[]][]) + .flatMap(([section, items]) => items.map((record, index) => rowFromRecord(section, record, index))); + if (providerRows.length === 0) { + setApiAccessRemarks({}); + return; + } + let disposed = false; + void invoke('resolve_api_access_remarks', { + queries: providerRows.map((row) => ({ + providerSection: row.section, + apiKeys: row.apiKeys, + })), + }).then((remarks) => { + if (disposed) return; + setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [ + providerRemarkIdentity(row.section, row.apiKeys), + remarks[index] ?? '', + ]))); + }).catch(() => { + if (!disposed) setApiAccessRemarks({}); + }); + return () => { + disposed = true; + }; + }, [records]); + const rows = useMemo( () => records[activeSection] .map((record, index) => rowFromRecord(activeSection, record, index)) + .map((row) => ({ + ...row, + remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '', + })) .filter((row) => providerCategoryMatchesRecord(activeCategory, row.record)) .filter((row) => { const query = filter.trim().toLowerCase(); if (!query) return true; - return [row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')] + return [row.name, row.remark, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')] .join(' ') .toLowerCase() .includes(query); }), - [activeCategory, activeSection, filter, records], + [activeCategory, activeSection, apiAccessRemarks, filter, records], ); const openCreate = () => { @@ -647,6 +741,10 @@ export function ApiAccessPage() { ); return false; } + if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) { + setError(t('apiAccess.error.remarkInvalid')); + return false; + } let baseUrl = preparedDraft.baseUrl.trim(); let providerHeaders: Record = {}; try { @@ -682,9 +780,7 @@ export function ApiAccessPage() { let nextList: Record[]; if (editingRow) { - const targetIndex = current.findIndex((record) => - providerIdentityMatches(editingRow, record), - ); + const targetIndex = resolveProviderRecordIndex(current, editingRow); if (targetIndex < 0) { throw new Error(t('apiAccess.error.stale')); } @@ -711,6 +807,14 @@ export function ApiAccessPage() { } await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields)); + await invoke('save_api_access_remark', { + update: { + providerSection: activeSection, + previousApiKeys: editingRow?.apiKeys ?? [], + apiKeys: parsedApiKeys, + remark: draftToSave.remark, + }, + }); setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added')); await loadProviders(); return true; @@ -734,6 +838,14 @@ export function ApiAccessPage() { query: { 'api-key': row.apiKey, 'base-url': row.baseUrl }, }); } + await invoke('save_api_access_remark', { + update: { + providerSection: row.section, + previousApiKeys: row.apiKeys, + apiKeys: [], + remark: '', + }, + }); setNotice(t('apiAccess.notice.deleted')); await loadProviders(); } catch (requestError) { @@ -749,7 +861,7 @@ export function ApiAccessPage() { try { const latestConfig = await managementApi.get('/config'); const latestRows = sectionRecordsFromConfig(latestConfig, row.section); - const targetIndex = latestRows.findIndex((record) => providerIdentityMatches(row, record)); + const targetIndex = resolveProviderRecordIndex(latestRows, row); if (targetIndex < 0) { throw new Error(t('apiAccess.error.stale')); } @@ -765,17 +877,15 @@ export function ApiAccessPage() { value: { disabled: !currentlyDisabled }, }); } else { - const nextRecord = stripResponseFields(latestRecord); - const excludedModels = Array.isArray(nextRecord['excluded-models']) - ? nextRecord['excluded-models'].map(String).filter((model) => model.trim() !== '*') - : []; - if (!currentlyDisabled) excludedModels.push('*'); - if (excludedModels.length > 0) nextRecord['excluded-models'] = excludedModels; - else delete nextRecord['excluded-models']; - await managementApi.patch(`/${row.section}`, { - index: targetIndex, - value: nextRecord, - }); + const nextRecord = providerRecordWithDisabledState( + row.section, + latestRecord, + !currentlyDisabled, + ); + const nextRows = latestRows.map((record, index) => + index === targetIndex ? nextRecord : stripResponseFields(record), + ); + await managementApi.put(`/${row.section}`, nextRows); } setNotice(currentlyDisabled ? t('apiAccess.notice.enabled') : t('apiAccess.notice.disabled')); await loadProviders(); @@ -859,7 +969,7 @@ export function ApiAccessPage() {
- {row.name} + {row.remark || row.name}
{definitionFor(row.section).openAi && row.apiKeys.length > 1 @@ -967,7 +1077,7 @@ function ApiProviderDialog({ && visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase())); const updateTextField = ( - field: 'name' | 'apiKey' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', + field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', value: string, ) => { setDraft((current) => ({ ...current, [field]: value })); @@ -1140,6 +1250,7 @@ function ApiProviderDialog({ updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." /> )} + {activeCategory === 'deepseek' ? (
diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 8633050..fb19da1 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -5,9 +5,12 @@ import { AlertCircle, Check, Copy, + Clock3, Eye, EyeOff, KeyRound, + Link2, + Network, Pencil, Plus, Plug, @@ -17,12 +20,18 @@ import { Trash2, X, } from 'lucide-react'; +import { useCoreRuntime, type CoreStatus } from '../coreRuntime'; import { useI18n } from '../i18n'; type CoreConfigSettings = { apiKeys: CoreApiKey[]; + port: number; + allowLan: boolean; pluginsEnabled: boolean; routingStrategy: string; + proxyUrl: string; + routingSessionAffinity: boolean; + routingSessionAffinityTtl: string; }; type CoreApiKey = { @@ -30,7 +39,14 @@ type CoreApiKey = { remark: string; }; -type ConfigAction = 'add-key' | 'update-key' | 'delete-key' | 'plugins' | 'routing' | null; +type ConfigAction = + | 'add-key' + | 'update-key' + | 'delete-key' + | 'plugins' + | 'routing' + | 'network' + | null; type NoticeTone = 'success' | 'error'; const ROUTING_OPTIONS = [ @@ -40,6 +56,7 @@ const ROUTING_OPTIONS = [ export function ConfigPanelPage() { const { t } = useI18n(); + const { status: coreStatus, publishStatus, refreshStatus } = useCoreRuntime(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); @@ -53,6 +70,12 @@ export function ConfigPanelPage() { const [formError, setFormError] = useState(''); const [copiedIndex, setCopiedIndex] = useState(null); const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null); + const [portDraft, setPortDraft] = useState('8317'); + const [allowLanDraft, setAllowLanDraft] = useState(false); + const [proxyUrlDraft, setProxyUrlDraft] = useState(''); + const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false); + const [sessionTtlDraft, setSessionTtlDraft] = useState(''); + const [portError, setPortError] = useState(''); const noticeTimerRef = useRef(null); const copyTimerRef = useRef(null); @@ -89,12 +112,24 @@ export function ConfigPanelPage() { }, 3200); }; - async function loadSettings() { + const applySettings = (result: CoreConfigSettings, syncNetworkDrafts = true) => { + setSettings(result); + if (syncNetworkDrafts) { + setPortDraft(String(result.port)); + setAllowLanDraft(result.allowLan); + setProxyUrlDraft(result.proxyUrl); + setSessionAffinityDraft(result.routingSessionAffinity); + setSessionTtlDraft(result.routingSessionAffinityTtl); + setPortError(''); + } + }; + + async function loadSettings(syncNetworkDrafts = true) { setLoading(true); setLoadError(''); try { const result = await invoke('get_core_config_settings'); - setSettings(result); + applySettings(result, syncNetworkDrafts); } catch (error) { setSettings(null); setLoadError(String(error)); @@ -112,13 +147,14 @@ export function ConfigPanelPage() { setBusyAction(action); try { const result = await invoke(command, args); - setSettings(result); + applySettings(result, false); setLoadError(''); showNotice(successMessage, 'success'); return true; } catch (error) { - showNotice(String(error), 'error'); - void loadSettings(); + if (settings) applySettings(settings, false); + showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); + void loadSettings(false); return false; } finally { setBusyAction(null); @@ -250,7 +286,64 @@ export function ConfigPanelPage() { ); }; + const saveNetworkRoutingSettings = async () => { + if (!settings || busyAction !== null) return; + const port = Number(portDraft); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + setPortError(t('config.error.portRange')); + showNotice(t('config.error.portRange'), 'error'); + return; + } + + const proxyUrl = proxyUrlDraft.trim(); + const routingSessionAffinityTtl = sessionTtlDraft.trim(); + const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan; + setPortError(''); + setBusyAction('network'); + try { + const result = await invoke('save_network_routing_settings', { + settings: { + port, + allowLan: allowLanDraft, + proxyUrl, + routingSessionAffinity: sessionAffinityDraft, + routingSessionAffinityTtl, + }, + }); + applySettings(result); + setLoadError(''); + + if (networkChanged && coreStatus?.running) { + try { + const status = await invoke('restart_core_process'); + publishStatus(status); + showNotice(t('config.notice.networkRestarted'), 'success'); + } catch (error) { + await refreshStatus(); + showNotice(t('config.error.networkRestartFailed', { error: String(error) }), 'error'); + } + } else if (networkChanged) { + showNotice(t('config.notice.networkNextStart'), 'success'); + } else { + showNotice(t('config.notice.networkUpdated'), 'success'); + } + } catch (error) { + applySettings(settings); + showNotice(t('config.error.saveFailed', { error: String(error) }), 'error'); + void loadSettings(); + } finally { + setBusyAction(null); + } + }; + const controlsDisabled = loading || settings === null || busyAction !== null; + const networkRoutingDirty = Boolean(settings) && ( + portDraft !== String(settings?.port) + || allowLanDraft !== settings?.allowLan + || proxyUrlDraft.trim() !== settings?.proxyUrl + || sessionAffinityDraft !== settings?.routingSessionAffinity + || sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl + ); const selectedDeleteKey = deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || ''; const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1; @@ -295,7 +388,7 @@ export function ConfigPanelPage() {
+
+
+
+
+
+ + {loading + ? t('common.loading') + : settings === null + ? t('common.unavailable') + : busyAction === 'network' + ? t('config.network.saving') + : networkRoutingDirty + ? t('config.network.unsaved') + : t('config.network.saved')} + + +
+
+ +
+ + +
+
+ {t('config.network.allowLan')} + {t('config.network.allowLanHint')} +
+ +
+ + + +
+
+ {t('config.network.sessionAffinity')} + {t('config.network.sessionAffinityHint')} +
+ +
+ + +
+
+ {addDialogOpen ? (
{ if (event.currentTarget === event.target) closeAddDialog(); diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index 5f83c81..4ff762e 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -123,11 +123,8 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise)); const [allowLanAccess, setAllowLanAccess] = useState(false); const [customPort, setCustomPort] = useState('8317'); - const [settingsLoaded, setSettingsLoaded] = useState(false); - const [settingsError, setSettingsError] = useState(''); const [installing, setInstalling] = useState(false); const [processBusy, setProcessBusy] = useState(false); - const [networkBusy, setNetworkBusy] = useState(false); const [processNotice, setProcessNotice] = useState<{ message: string; tone: MessageType; @@ -145,8 +142,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { const [lanIpChecked, setLanIpChecked] = useState(false); const installDialogRef = useRef(null); const savedPortRef = useRef(8317); - const savedAllowLanRef = useRef(false); - const settingsSaveRequestRef = useRef(0); const processNoticeTimerRef = useRef(null); const copiedApiTimerRef = useRef(null); @@ -347,13 +342,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { setAllowLanAccess(settings.allowLan); setCustomPort(String(settings.port)); savedPortRef.current = settings.port; - savedAllowLanRef.current = settings.allowLan; - setSettingsError(''); - } catch (error) { - setSettingsError(String(error)); - } finally { - setSettingsLoaded(true); - } + } catch {} }; const loadHomeApiKey = async () => { @@ -367,73 +356,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } }; - const saveNetworkSettings = async ( - allowLan: boolean, - port: number, - restartAfterSave = false, - ) => { - const requestId = ++settingsSaveRequestRef.current; - - try { - const settings = await invoke('save_gui_settings', { - settings: { allowLan, port }, - }); - if (requestId !== settingsSaveRequestRef.current) { - return; - } - setAllowLanAccess(settings.allowLan); - setCustomPort(String(settings.port)); - savedPortRef.current = settings.port; - savedAllowLanRef.current = settings.allowLan; - setSettingsError(''); - - if (restartAfterSave) { - if (coreStatus?.running) { - await runCoreProcessCommand('restart_core_process', { - success: t('kernel.notice.networkRestarted'), - failure: t('kernel.notice.networkRestartFailed'), - }); - } else { - showProcessNotice(t('kernel.notice.networkNextStart'), 'info'); - } - } - } catch (error) { - if (requestId !== settingsSaveRequestRef.current) { - return; - } - setAllowLanAccess(savedAllowLanRef.current); - setCustomPort(String(savedPortRef.current)); - setSettingsError(String(error)); - if (restartAfterSave) { - showProcessNotice(t('kernel.notice.networkSaveFailed', { error: String(error) }), 'error'); - } - } - }; - - const updateAllowLanAccess = (allowLan: boolean) => { - const editedPort = Number(customPort); - const port = - Number.isInteger(editedPort) && editedPort >= 1 && editedPort <= 65535 - ? editedPort - : savedPortRef.current; - setAllowLanAccess(allowLan); - setNetworkBusy(true); - void saveNetworkSettings(allowLan, port, true).finally(() => setNetworkBusy(false)); - }; - - const commitCustomPort = () => { - const port = Number(customPort); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - setCustomPort(String(savedPortRef.current)); - setSettingsError(t('kernel.error.port')); - return; - } - - setNetworkBusy(true); - void saveNetworkSettings(allowLanAccess, port, port !== savedPortRef.current) - .finally(() => setNetworkBusy(false)); - }; - const loadPlatform = async () => { try { const result = await invoke('detect_core_platform'); @@ -746,7 +668,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {

{t('kernel.control.title')}

- {processBusy || networkBusy ? t('common.processing') : statusLabel} + {processBusy ? t('common.processing') : statusLabel} @@ -764,48 +686,15 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
{coreStatus?.processId || t('kernel.control.noPid')}
-
{t('kernel.control.allowLan')}
-
- - updateAllowLanAccess(event.currentTarget.checked)} - /> - - +
{t('kernel.overview.coreVersion')}
+
+ {currentVersion + || (coreInstalled ? t('common.unavailable') : t('kernel.status.notInstalled'))}
-
- -
-
- - setCustomPort(event.currentTarget.value.replace(/\D/g, '').slice(0, 5)) - } - onBlur={commitCustomPort} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.currentTarget.blur(); - } - }} - /> -
+
{t('kernel.overview.appVersion')}
+
{currentAppVersion}
@@ -813,7 +702,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { ; + return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ?
{events.items.map((record) => )}
{t('usage.column.time')}{t('usage.column.model')}Provider{t('usage.column.source')}API Key{t('usage.column.result')}{t('usage.column.latency')}TTFT{t('usage.column.input')}{t('usage.column.output')}{t('usage.column.reasoning')}{t('usage.column.cache')}{t('usage.column.total')}
{formatTime(record.timestamp)}{record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null}{record.provider || '—'}{record.source_display || '—'}{record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'}{record.failed ? t('usage.result.failed') : t('usage.result.success')}{record.latency_ms} ms{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}{compactNumber(record.tokens.input_tokens)}{compactNumber(record.tokens.output_tokens)}{compactNumber(record.tokens.reasoning_tokens)}{compactNumber(record.tokens.cache_read_tokens)}{compactNumber(record.tokens.total_tokens)}
: }
; } function UsageEmpty() { diff --git a/src/styles.css b/src/styles.css index 5f7adab..1eb3870 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1244,6 +1244,101 @@ input:focus-visible { height: 206px; } +.config-network-panel { + display: grid; + gap: 18px; +} + +.config-network-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-rows: 154px; + gap: 16px 18px; +} + +.config-network-field { + display: grid; + height: 100%; + min-width: 0; + align-content: start; + gap: 8px; + padding: 14px; + border: 1px solid #e3e1db; + border-radius: 7px; + background: #faf8f2; +} + +.config-network-field > span, +.config-network-toggle > div > span { + color: #39352f; + font-size: 13px; + font-weight: 700; +} + +.config-network-field small { + color: #7a746c; + font-size: 11px; + line-height: 1.45; +} + +.config-network-label { + display: flex; + align-items: center; + gap: 7px; +} + +.config-network-label svg { + flex: none; + color: #777067; +} + +.config-network-input { + width: 100%; + height: 38px; + min-width: 0; + padding: 0 11px; + border: 1px solid #d5d2cb; + border-radius: 6px; + outline: none; + background: #fffdf8; + color: #2d2a26; + font: inherit; + font-size: 13px; +} + +.config-network-input:focus { + border-color: #777067; + box-shadow: 0 0 0 3px rgba(119, 112, 103, 0.12); +} + +.config-network-input.error { + border-color: #b64132; + box-shadow: 0 0 0 3px rgba(182, 65, 50, 0.1); +} + +.config-network-input:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.config-network-port-field .config-network-input { + width: 120px; + max-width: 100%; +} + +.config-network-toggle { + grid-template-columns: minmax(0, 1fr) auto; + align-content: center; + align-items: center; + gap: 16px; +} + +.config-network-toggle > div { + display: grid; + min-width: 0; + gap: 7px; +} + .config-panel-heading { display: flex; align-items: center; @@ -5225,12 +5320,16 @@ input:focus-visible { .usage-token-breakdown, .usage-category-list { display: grid; + width: 100%; + min-width: 0; gap: 12px; } .usage-token-breakdown > div, .usage-category-list > div { display: grid; + grid-template-columns: minmax(0, 1fr); + min-width: 0; gap: 6px; } @@ -5239,11 +5338,14 @@ input:focus-visible { display: flex; align-items: center; justify-content: space-between; + width: 100%; + min-width: 0; gap: 8px; } .usage-token-breakdown strong, .usage-category-list strong { + flex: 1 1 auto; min-width: 0; overflow: hidden; color: #5f5a53; @@ -5262,6 +5364,9 @@ input:focus-visible { .usage-token-breakdown i, .usage-category-list i { display: block; + width: 100%; + min-width: 0; + max-width: 100%; overflow: hidden; height: 5px; border-radius: 999px; @@ -5286,6 +5391,11 @@ input:focus-visible { min-height: 290px; } +.usage-category-panel.compact-labels .usage-category-list strong { + flex: 0 1 auto; + max-width: min(46%, 160px); +} + .usage-events-panel { display: grid; gap: 10px; @@ -6184,6 +6294,11 @@ input:focus-visible { grid-template-columns: 1fr; } + .config-network-grid { + grid-template-columns: 1fr; + grid-auto-rows: auto; + } + .client-api-grid { grid-template-columns: 1fr; } diff --git a/tests/navigationAndUpdate.test.ts b/tests/navigationAndUpdate.test.ts index 7a2ea90..df52750 100644 --- a/tests/navigationAndUpdate.test.ts +++ b/tests/navigationAndUpdate.test.ts @@ -2,13 +2,14 @@ import { describe, expect, test } from 'bun:test'; import { appUpdateIndicatorState } from '../src/appUpdateModel'; import { canOpenAppPage, isAlwaysAvailablePage } from '../src/navigation'; -describe('首页与版本管理导航', () => { - test('内核停止时仅首页和版本管理始终可进入', () => { +describe('首页、配置与版本管理导航', () => { + test('内核停止时首页、配置和版本管理始终可进入', () => { expect(isAlwaysAvailablePage('home')).toBe(true); expect(isAlwaysAvailablePage('versions')).toBe(true); + expect(isAlwaysAvailablePage('config')).toBe(true); expect(canOpenAppPage('home', false)).toBe(true); expect(canOpenAppPage('versions', false)).toBe(true); - expect(canOpenAppPage('config', false)).toBe(false); + expect(canOpenAppPage('config', false)).toBe(true); expect(canOpenAppPage('quota', false)).toBe(false); }); diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index f31d701..a7f9d24 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -10,7 +10,9 @@ import { modelSelectionForDiscovery, parseProviderHeaders, providerCategoryMatchesRecord, + providerRecordWithDisabledState, providerSectionOrder, + resolveProviderRecordIndex, sectionRecordsFromConfig, stripResponseFields, } from '../src/pages/ApiAccessPage'; @@ -209,6 +211,54 @@ describe('API 接入配置合并', () => { expect(result['excluded-models']).toEqual(['claude-old-*', '*']); }); + it('重新启用仅含全停用规则的普通提供商时删除停用规则', () => { + const result = providerRecordWithDisabledState( + 'claude-api-key', + { + 'api-key': 'claude-key', + 'excluded-models': ['*'], + custom: { keep: true }, + 'auth-index': 'runtime-only', + }, + false, + ); + + expect(result['excluded-models']).toBeUndefined(); + expect(result.custom).toEqual({ keep: true }); + expect(result['auth-index']).toBeUndefined(); + }); + + it('切换普通提供商时保留其他模型排除规则且不会重复追加停用规则', () => { + const disabled = providerRecordWithDisabledState( + 'codex-api-key', + { + 'api-key': 'codex-key', + 'excluded-models': ['preview-*', '*'], + }, + true, + ); + const enabled = providerRecordWithDisabledState('codex-api-key', disabled, false); + + expect(disabled['excluded-models']).toEqual(['preview-*', '*']); + expect(enabled['excluded-models']).toEqual(['preview-*']); + }); + + it('运行时补全默认 Base URL 后仍能按原始索引找到配置记录', () => { + const records = [ + { 'api-key': 'first-key', 'base-url': 'https://first.example' }, + { 'api-key': 'target-key' }, + ]; + const index = resolveProviderRecordIndex(records, { + section: 'claude-api-key', + index: 1, + name: 'Claude', + apiKey: 'target-key', + baseUrl: 'https://api.anthropic.com', + }); + + expect(index).toBe(1); + }); + it('校验自定义请求头格式', () => { expect(parseProviderHeaders('Authorization: Bearer abc:def')).toEqual({ Authorization: 'Bearer abc:def',