From ea4adbcba8a86865d3669ec0637d0631cdec5a56 Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Mon, 27 Jul 2026 19:59:52 +0800
Subject: [PATCH] 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.
---
src-tauri/src/main.rs | 547 +++++++++++++++++++++++++++++-
src-tauri/src/usage.rs | 3 +
src-tauri/tauri.conf.json | 2 +-
src/i18n/ja.ts | 29 +-
src/i18n/resources.ts | 58 +++-
src/navigation.ts | 2 +-
src/pages/ApiAccessPage.tsx | 81 ++++-
src/pages/ConfigPanel.tsx | 247 +++++++++++++-
src/pages/Kernel.tsx | 141 +-------
src/styles.css | 110 ++++++
tests/navigationAndUpdate.test.ts | 7 +-
tests/providerConfig.test.ts | 50 +++
12 files changed, 1117 insertions(+), 160 deletions(-)
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index e47c869..2433a59 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -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,
plugins_enabled: Option,
routing_strategy: Option,
+ proxy_url: Option,
+ routing_session_affinity: Option,
+ routing_session_affinity_ttl: Option,
}
#[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,
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 {
+ 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 +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 {
+ 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 {
+ 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 +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, 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(update: F) -> Result<(), String>
where
F: FnOnce(&mut serde_norway::Value) -> Result,
@@ -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::()
.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
-
-
-
-
-
- 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 }) {
void runCoreProcessCommand(
coreRunning ? 'stop_core_process' : 'start_core_process',
@@ -826,7 +715,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
void runCoreProcessCommand('restart_core_process', { success: t('kernel.notice.restarted') })
}
@@ -836,7 +725,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
void refreshStatus()}
>
{t('kernel.control.refresh')}
@@ -860,9 +749,11 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
: t('appUpdate.phase.checking'))}
-
- {appUpdate?.autoUpdateSupported ? t('appUpdate.portableReady') : t('appUpdate.manualOnly')}
-
+ {!appUpdate?.autoUpdateSupported && (
+
+ {t('appUpdate.manualFallback')}
+
+ )}
diff --git a/src/styles.css b/src/styles.css
index 5f7adab..75a994e 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;
@@ -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;
}
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',