feat: enhance network configuration settings and UI
- Added new fields for proxy URL, session affinity, and session affinity TTL in usage tracking. - Updated tauri configuration to increase window width. - Expanded Japanese and Chinese translations for new error messages and notices related to network settings. - Introduced new network configuration panel in the UI, allowing users to set port, LAN access, proxy URL, and session affinity options. - Implemented validation for port input and added error handling for network settings. - Refactored API access logic to support new provider record management functions. - Updated navigation to ensure the config page is always accessible. - Added tests for new provider record functionalities and network settings.
This commit is contained in:
1 parent
be2336320d
commit
ea4adbcba8
12 files changed
+1117
-160
No files matched your search
+546
-1
@@ -440,6 +440,9 @@ struct GuiConfigFile {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -503,6 +506,9 @@ impl Default for GuiConfigFile {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -522,6 +528,9 @@ struct GuiConfigPresence {
|
||||
usage_statistics_enabled: Option<bool>,
|
||||
plugins_enabled: Option<bool>,
|
||||
routing_strategy: Option<String>,
|
||||
proxy_url: Option<String>,
|
||||
routing_session_affinity: Option<bool>,
|
||||
routing_session_affinity_ttl: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -771,6 +780,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 +805,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 +826,13 @@ struct CoreApiKeyView {
|
||||
struct CoreConfigView {
|
||||
api_keys: Vec<CoreApiKeyView>,
|
||||
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 +1184,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<GuiConfigFile, String> {
|
||||
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<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.run_on_startup = run_on_startup;
|
||||
@@ -1223,6 +1269,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 +1313,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 +1333,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6276,6 +6333,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<CoreConfigView, String> {
|
||||
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 +6553,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<CoreConfigView, String> {
|
||||
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<CoreConfigView, String> {
|
||||
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<CoreConfigView, String> {
|
||||
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 +8296,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<std::sync::MutexGuard<'static, ()>, String> {
|
||||
CORE_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
@@ -8197,6 +8384,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<F>(update: F) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(&mut serde_norway::Value) -> Result<bool, String>,
|
||||
@@ -8263,6 +8484,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::<yaml_edit::YamlFile>()
|
||||
.map_err(|err| format!("解析可编辑内核配置失败: {err}"))?;
|
||||
@@ -8386,6 +8635,61 @@ fn yaml_value_at_path<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_yaml_block_mapping_values(
|
||||
content: &str,
|
||||
section: &str,
|
||||
entries: &[(String, serde_norway::Value)],
|
||||
) -> Result<Option<String>, 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 +9137,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 +9178,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,
|
||||
})
|
||||
}
|
||||
@@ -10028,6 +10364,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<String, String> {
|
||||
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>) -> 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 +10409,34 @@ fn patch_core_network_yaml(
|
||||
})
|
||||
}
|
||||
|
||||
fn patch_core_network_routing_yaml(
|
||||
content: &str,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<Option<String>, 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 +10518,23 @@ fn apply_gui_managed_settings(content: &str, config: &GuiConfigFile) -> Result<S
|
||||
"strategy",
|
||||
serde_norway::Value::String(config.routing_strategy.clone()),
|
||||
)?;
|
||||
changed |= set_core_yaml_top_level_value(
|
||||
document,
|
||||
"proxy-url",
|
||||
serde_norway::Value::String(config.proxy_url.clone()),
|
||||
)?;
|
||||
changed |= set_core_yaml_nested_value(
|
||||
document,
|
||||
"routing",
|
||||
"session-affinity",
|
||||
serde_norway::Value::Bool(config.routing_session_affinity),
|
||||
)?;
|
||||
changed |= set_core_yaml_nested_value(
|
||||
document,
|
||||
"routing",
|
||||
"session-affinity-ttl",
|
||||
serde_norway::Value::String(config.routing_session_affinity_ttl.clone()),
|
||||
)?;
|
||||
Ok(changed)
|
||||
})?
|
||||
.unwrap_or_else(|| content.to_string());
|
||||
@@ -10361,7 +10757,10 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
|| 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 +10799,15 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
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 +10862,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 +11088,16 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
|
||||
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 +11150,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),
|
||||
@@ -10780,6 +11210,16 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> {
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -12974,6 +13414,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 +13426,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 +15259,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 +15279,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 +15383,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::<serde_norway::Value>(&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 +15659,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::<serde_norway::Value>(
|
||||
"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::<serde_norway::Value>(
|
||||
"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::<serde_norway::Value>(&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());
|
||||
@@ -15233,6 +15772,9 @@ custom_option = "keep-original"
|
||||
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 +15794,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!(
|
||||
|
||||
@@ -1385,6 +1385,9 @@ mod tests {
|
||||
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(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
"label": "main",
|
||||
"title": "EasyCLIProxyAPI",
|
||||
"width": 1380,
|
||||
"width": 1400,
|
||||
"height": 815,
|
||||
"minWidth": 640,
|
||||
"minHeight": 600
|
||||
|
||||
+27
-2
@@ -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': '閉じる',
|
||||
@@ -643,8 +669,7 @@ export const jaOverrides = {
|
||||
'appUpdate.current': '現在のバージョン',
|
||||
'appUpdate.latest': '最新バージョン',
|
||||
'appUpdate.status': '更新状態',
|
||||
'appUpdate.portableReady': 'ポータブル版の自動更新に対応',
|
||||
'appUpdate.manualOnly': 'このビルドは手動更新が必要です',
|
||||
'appUpdate.manualFallback': '検出に失敗した場合は、GitHub ページから手動で更新してください',
|
||||
'appUpdate.upToDate': '最新バージョンです',
|
||||
'appUpdate.available': 'バージョン {version} を利用できます',
|
||||
'appUpdate.check': '更新を確認',
|
||||
|
||||
+54
-4
@@ -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': '关闭',
|
||||
@@ -646,8 +672,7 @@ export const zhCN = {
|
||||
'appUpdate.current': '当前版本',
|
||||
'appUpdate.latest': '最新版本',
|
||||
'appUpdate.status': '升级状态',
|
||||
'appUpdate.portableReady': '支持便携版自动升级',
|
||||
'appUpdate.manualOnly': '当前版本需要手动更新',
|
||||
'appUpdate.manualFallback': '如果检测失败,请手动到GitHub页面更新',
|
||||
'appUpdate.upToDate': '已是最新版本',
|
||||
'appUpdate.available': '发现新版本 {version}',
|
||||
'appUpdate.check': '检查更新',
|
||||
@@ -1010,6 +1035,9 @@ export const en: Record<MessageKey, string> = {
|
||||
'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 +1046,13 @@ export const en: Record<MessageKey, string> = {
|
||||
'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 +1073,22 @@ export const en: Record<MessageKey, string> = {
|
||||
'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',
|
||||
@@ -1433,8 +1484,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'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',
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+65
-16
@@ -529,7 +529,10 @@ export const buildProviderRecord = (
|
||||
return applyAdvancedFields(next, section, draft);
|
||||
};
|
||||
|
||||
const providerIdentityMatches = (row: ProviderRow, record: Record<string, unknown>) => {
|
||||
const providerIdentityMatches = (
|
||||
row: ProviderRecordIdentity,
|
||||
record: Record<string, unknown>,
|
||||
) => {
|
||||
if (definitionFor(row.section).openAi) {
|
||||
return readString(record, 'name') === row.name;
|
||||
}
|
||||
@@ -539,6 +542,56 @@ const providerIdentityMatches = (row: ProviderRow, record: Record<string, unknow
|
||||
);
|
||||
};
|
||||
|
||||
type ProviderRecordIdentity = Pick<
|
||||
ProviderRow,
|
||||
'section' | 'index' | 'name' | 'apiKey' | 'baseUrl'
|
||||
>;
|
||||
|
||||
const providerPrimaryIdentityMatches = (
|
||||
row: ProviderRecordIdentity,
|
||||
record: Record<string, unknown>,
|
||||
) => definitionFor(row.section).openAi
|
||||
? readString(record, 'name') === row.name
|
||||
: readString(record, 'api-key', 'apiKey') === row.apiKey;
|
||||
|
||||
export const resolveProviderRecordIndex = (
|
||||
records: Record<string, unknown>[],
|
||||
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<string, unknown>,
|
||||
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);
|
||||
@@ -682,9 +735,7 @@ export function ApiAccessPage() {
|
||||
let nextList: Record<string, unknown>[];
|
||||
|
||||
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'));
|
||||
}
|
||||
@@ -749,7 +800,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 +816,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();
|
||||
|
||||
+240
-7
@@ -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<CoreConfigSettings | null>(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<number | null>(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<number | null>(null);
|
||||
const copyTimerRef = useRef<number | null>(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<CoreConfigSettings>('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<CoreConfigSettings>(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<CoreConfigSettings>('save_network_routing_settings', {
|
||||
settings: {
|
||||
port,
|
||||
allowLan: allowLanDraft,
|
||||
proxyUrl,
|
||||
routingSessionAffinity: sessionAffinityDraft,
|
||||
routingSessionAffinityTtl,
|
||||
},
|
||||
});
|
||||
applySettings(result);
|
||||
setLoadError('');
|
||||
|
||||
if (networkChanged && coreStatus?.running) {
|
||||
try {
|
||||
const status = await invoke<CoreStatus>('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() {
|
||||
<AlertCircle size={24} aria-hidden="true" />
|
||||
<strong>{t('config.unavailable')}</strong>
|
||||
<span title={loadError}>{loadError}</span>
|
||||
<button type="button" className="secondary-button compact-button" onClick={loadSettings}>
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadSettings()}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
@@ -426,6 +519,146 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="panel config-network-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<Network size={18} aria-hidden="true" />
|
||||
<h2>{t('config.network.title')}</h2>
|
||||
</div>
|
||||
<div className="config-heading-actions">
|
||||
<span className={`state-pill ${settings !== null && !networkRoutingDirty ? 'success' : ''}`}>
|
||||
{loading
|
||||
? t('common.loading')
|
||||
: settings === null
|
||||
? t('common.unavailable')
|
||||
: busyAction === 'network'
|
||||
? t('config.network.saving')
|
||||
: networkRoutingDirty
|
||||
? t('config.network.unsaved')
|
||||
: t('config.network.saved')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !networkRoutingDirty}
|
||||
onClick={() => void saveNetworkRoutingSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'network'
|
||||
? t('config.network.saving')
|
||||
: t('config.network.confirmSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-network-grid">
|
||||
<label className="config-network-field config-network-port-field">
|
||||
<span>{t('config.network.port')}</span>
|
||||
<input
|
||||
className={`config-network-input ${portError ? 'error' : ''}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={5}
|
||||
value={portDraft}
|
||||
disabled={controlsDisabled}
|
||||
aria-invalid={Boolean(portError)}
|
||||
title={portError || t('config.network.portHint')}
|
||||
onChange={(event) => {
|
||||
setPortDraft(event.currentTarget.value.replace(/\D/g, '').slice(0, 5));
|
||||
setPortError('');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
setPortDraft(String(settings.port));
|
||||
setPortError('');
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('config.network.portHint')}</small>
|
||||
</label>
|
||||
|
||||
<div className="config-network-field config-network-toggle">
|
||||
<div>
|
||||
<span>{t('config.network.allowLan')}</span>
|
||||
<small>{t('config.network.allowLanHint')}</small>
|
||||
</div>
|
||||
<label className="switch-control" title={t('config.network.allowLan')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('config.network.allowLan')}
|
||||
checked={allowLanDraft}
|
||||
disabled={controlsDisabled}
|
||||
onChange={(event) => setAllowLanDraft(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="config-network-field">
|
||||
<span className="config-network-label">
|
||||
<Link2 size={15} aria-hidden="true" />
|
||||
{t('config.network.proxyUrl')}
|
||||
</span>
|
||||
<input
|
||||
className="config-network-input"
|
||||
type="text"
|
||||
value={proxyUrlDraft}
|
||||
disabled={controlsDisabled}
|
||||
placeholder={t('config.network.proxyPlaceholder')}
|
||||
onChange={(event) => setProxyUrlDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
setProxyUrlDraft(settings.proxyUrl);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('config.network.proxyHint')}</small>
|
||||
</label>
|
||||
|
||||
<div className="config-network-field config-network-toggle">
|
||||
<div>
|
||||
<span>{t('config.network.sessionAffinity')}</span>
|
||||
<small>{t('config.network.sessionAffinityHint')}</small>
|
||||
</div>
|
||||
<label className="switch-control" title={t('config.network.sessionAffinity')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('config.network.sessionAffinity')}
|
||||
checked={sessionAffinityDraft}
|
||||
disabled={controlsDisabled}
|
||||
onChange={(event) => setSessionAffinityDraft(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="config-network-field">
|
||||
<span className="config-network-label">
|
||||
<Clock3 size={15} aria-hidden="true" />
|
||||
{t('config.network.sessionTtl')}
|
||||
</span>
|
||||
<input
|
||||
className="config-network-input"
|
||||
type="text"
|
||||
value={sessionTtlDraft}
|
||||
disabled={controlsDisabled}
|
||||
placeholder="1h"
|
||||
onChange={(event) => setSessionTtlDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
setSessionTtlDraft(settings.routingSessionAffinityTtl);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<small>{t('config.network.sessionTtlHint')}</small>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{addDialogOpen ? (
|
||||
<div className="config-dialog-backdrop" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) closeAddDialog();
|
||||
|
||||
+16
-125
@@ -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<HTMLDivElement>(null);
|
||||
const savedPortRef = useRef(8317);
|
||||
const savedAllowLanRef = useRef(false);
|
||||
const settingsSaveRequestRef = useRef(0);
|
||||
const processNoticeTimerRef = useRef<number | null>(null);
|
||||
const copiedApiTimerRef = useRef<number | null>(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<GuiSettings>('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<CorePlatform>('detect_core_platform');
|
||||
@@ -746,7 +668,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<h2>{t('kernel.control.title')}</h2>
|
||||
</div>
|
||||
<span className={`state-pill ${statusTone}`} title={statusError || undefined}>
|
||||
{processBusy || networkBusy ? t('common.processing') : statusLabel}
|
||||
{processBusy ? t('common.processing') : statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -764,48 +686,15 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<dd>{coreStatus?.processId || t('kernel.control.noPid')}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>{t('kernel.control.allowLan')}</dt>
|
||||
<dd className="detail-control-cell">
|
||||
<span className="switch-control" title={t('kernel.control.lanRestartHint')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('kernel.control.allowLanAria')}
|
||||
disabled={!settingsLoaded || networkBusy || processBusy}
|
||||
checked={allowLanAccess}
|
||||
onChange={(event) => updateAllowLanAccess(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
<dt>{t('kernel.overview.coreVersion')}</dt>
|
||||
<dd>
|
||||
{currentVersion
|
||||
|| (coreInstalled ? t('common.unavailable') : t('kernel.status.notInstalled'))}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>
|
||||
<label htmlFor="custom-port">{t('kernel.control.port')}</label>
|
||||
</dt>
|
||||
<dd className="detail-control-cell">
|
||||
<input
|
||||
id="custom-port"
|
||||
className={`compact-text-input ${settingsError ? 'error' : ''}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={5}
|
||||
placeholder={t('kernel.control.portPlaceholder')}
|
||||
title={settingsError || t('kernel.control.portRange')}
|
||||
aria-invalid={Boolean(settingsError)}
|
||||
disabled={!settingsLoaded || networkBusy || processBusy}
|
||||
value={customPort}
|
||||
onChange={(event) =>
|
||||
setCustomPort(event.currentTarget.value.replace(/\D/g, '').slice(0, 5))
|
||||
}
|
||||
onBlur={commitCustomPort}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</dd>
|
||||
<dt>{t('kernel.overview.appVersion')}</dt>
|
||||
<dd>{currentAppVersion}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -813,7 +702,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<button
|
||||
type="button"
|
||||
className={coreRunning ? 'danger-button' : 'primary-button'}
|
||||
disabled={!coreInstalled || installing || processBusy || networkBusy}
|
||||
disabled={!coreInstalled || installing || processBusy}
|
||||
onClick={() =>
|
||||
void runCoreProcessCommand(
|
||||
coreRunning ? 'stop_core_process' : 'start_core_process',
|
||||
@@ -826,7 +715,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={!coreInstalled || !coreRunning || installing || processBusy || networkBusy}
|
||||
disabled={!coreInstalled || !coreRunning || installing || processBusy}
|
||||
onClick={() =>
|
||||
void runCoreProcessCommand('restart_core_process', { success: t('kernel.notice.restarted') })
|
||||
}
|
||||
@@ -836,7 +725,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={processBusy || networkBusy}
|
||||
disabled={processBusy}
|
||||
onClick={() => void refreshStatus()}
|
||||
>
|
||||
{t('kernel.control.refresh')}
|
||||
@@ -860,9 +749,11 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
: t('appUpdate.phase.checking'))}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`state-pill ${appUpdate?.updateAvailable ? 'update' : appUpdateError ? 'error' : 'success'}`}>
|
||||
{appUpdate?.autoUpdateSupported ? t('appUpdate.portableReady') : t('appUpdate.manualOnly')}
|
||||
</span>
|
||||
{!appUpdate?.autoUpdateSupported && (
|
||||
<span className={`state-pill ${appUpdateError ? 'error' : 'success'}`}>
|
||||
{t('appUpdate.manualFallback')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="panel-detail-grid software-update-details">
|
||||
|
||||
+110
@@ -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;
|
||||
@@ -6184,6 +6289,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user