From 3140d205cc65a27b8bb7baf29d831d4954f18b9d Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Mon, 27 Jul 2026 16:00:44 +0800
Subject: [PATCH 1/7] fix: fetch agent config only on page load and refresh
---
src-tauri/src/main.rs | 16 ++++++++--------
src/pages/AgentsPage.tsx | 21 +++++++++++++++------
2 files changed, 23 insertions(+), 14 deletions(-)
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 23ea007..e47c869 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -573,7 +573,7 @@ struct AgentConfigActionResult {
conflict_files: Vec,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentModelOption {
name: String,
@@ -1594,6 +1594,7 @@ async fn apply_agent_config(
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
model: String,
+ models: Vec,
oauth_configuration: bool,
) -> Result {
let client = AgentClient::parse(&client)?;
@@ -1604,10 +1605,9 @@ async fn apply_agent_config(
let config = gui_config_state.snapshot()?;
let api_key = effective_agent_api_key(&config);
validate_agent_can_enable(client, &home, config.port, api_key)?;
- let available_models = fetch_agent_models(config.port, api_key).await?;
- let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
+ let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?;
let codex_models = if client == AgentClient::Codex {
- Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
+ Some(fetch_codex_model_catalog_specs(&config, &models).await)
} else {
None
};
@@ -1621,7 +1621,7 @@ async fn apply_agent_config(
api_key,
&model,
AgentConfigurationOptions {
- models: &available_models,
+ models: &models,
codex_models: codex_models.as_deref(),
oauth_configuration,
},
@@ -1634,6 +1634,7 @@ async fn reset_agent_config_to_default(
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
model: String,
+ models: Vec,
oauth_configuration: bool,
) -> Result {
let client = AgentClient::parse(&client)?;
@@ -1644,10 +1645,9 @@ async fn reset_agent_config_to_default(
let config = gui_config_state.snapshot()?;
let api_key = effective_agent_api_key(&config);
validate_agent_can_enable(client, &home, config.port, api_key)?;
- let available_models = fetch_agent_models(config.port, api_key).await?;
- let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
+ let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?;
let codex_models = if client == AgentClient::Codex {
- Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
+ Some(fetch_codex_model_catalog_specs(&config, &models).await)
} else {
None
};
diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx
index 10244df..d9d58bd 100644
--- a/src/pages/AgentsPage.tsx
+++ b/src/pages/AgentsPage.tsx
@@ -511,6 +511,7 @@ export function AgentsPage() {
const [closeAppNotice, setCloseAppNotice] = useState('');
const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false);
const [oauthConfiguration, setOauthConfiguration] = useState(false);
+ const autoLoadedModelClientRef = useRef(null);
const loadStatuses = useCallback(async (forceRefresh = false) => {
const command = forceRefresh
@@ -543,21 +544,27 @@ export function AgentsPage() {
setLoading(true);
setDetectionError('');
try {
- await Promise.all([loadStatuses(true), loadModels()]);
+ await loadStatuses(true);
} catch (requestError) {
setDetectionError(String(requestError));
} finally {
setLoading(false);
}
- }, [loadModels, loadStatuses]);
+ }, [loadStatuses]);
useEffect(() => {
setLoading(true);
setDetectionError('');
- void Promise.all([loadStatuses(), loadModels()])
+ void loadStatuses()
.catch((requestError) => setDetectionError(String(requestError)))
.finally(() => setLoading(false));
- }, [loadModels, loadStatuses]);
+ }, [loadStatuses]);
+
+ useEffect(() => {
+ if (autoLoadedModelClientRef.current === selected) return;
+ autoLoadedModelClientRef.current = selected;
+ void loadModels();
+ }, [loadModels, selected]);
useEffect(() => {
let disposed = false;
@@ -565,7 +572,7 @@ export function AgentsPage() {
void listen('config-files-changed', () => {
if (disposed) return;
setDetectionError('');
- void Promise.all([loadStatuses(true), loadModels()]).catch((requestError) => {
+ void loadStatuses(true).catch((requestError) => {
if (!disposed) setDetectionError(String(requestError));
});
}).then((unlisten) => {
@@ -576,7 +583,7 @@ export function AgentsPage() {
disposed = true;
stop?.();
};
- }, [loadModels, loadStatuses]);
+ }, [loadStatuses]);
useEffect(() => {
writeSelectedAgentClient(selected);
@@ -724,6 +731,7 @@ export function AgentsPage() {
await invoke('apply_agent_config', {
client: selected,
model,
+ models,
oauthConfiguration,
});
await reloadStatusesAfterAction();
@@ -743,6 +751,7 @@ export function AgentsPage() {
await invoke('reset_agent_config_to_default', {
client: selected,
model,
+ models,
oauthConfiguration,
});
setDefaultConfirmOpen(false);
From be2336320d793527a62fb3202bc14f653d2faf17 Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Mon, 27 Jul 2026 16:03:41 +0800
Subject: [PATCH 2/7] feat: rename quota to balance inquiry
---
src/i18n/ja.ts | 4 ++--
src/i18n/resources.ts | 8 ++++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index aef4fa2..da82ffa 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -12,7 +12,7 @@ export const jaOverrides = {
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接続',
'app.nav.authFiles': '認証ファイル',
- 'app.nav.quota': 'クォータ',
+ 'app.nav.quota': 'クォータ照会',
'app.nav.usageRecords': '使用履歴',
'app.nav.agents': 'エージェント',
'app.coreRequired.title': '先にコアを起動してください',
@@ -28,7 +28,7 @@ export const jaOverrides = {
'error.render.title': 'ページの描画中にエラーが発生しました',
'error.unknown': '不明なエラー',
'error.reload': 'ページを再読み込み',
- 'quota.title': 'クォータ',
+ 'quota.title': 'クォータ照会',
'quota.queryableCredentials.one': '照会可能な認証情報 {count} 件',
'quota.queryableCredentials.other': '照会可能な認証情報 {count} 件',
'quota.credentials.one': '認証情報 {count} 件',
diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts
index 67a3417..5945359 100644
--- a/src/i18n/resources.ts
+++ b/src/i18n/resources.ts
@@ -15,7 +15,7 @@ export const zhCN = {
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API 接入',
'app.nav.authFiles': '认证文件',
- 'app.nav.quota': '配额',
+ 'app.nav.quota': '额度查询',
'app.nav.usageRecords': '使用记录',
'app.nav.agents': '智能体',
'app.coreRequired.title': '请先启动内核',
@@ -31,7 +31,7 @@ export const zhCN = {
'error.render.title': '页面渲染出现异常',
'error.unknown': '未知错误',
'error.reload': '重新加载页面',
- 'quota.title': '配额',
+ 'quota.title': '额度查询',
'quota.queryableCredentials.one': '{count} 个可查询凭据',
'quota.queryableCredentials.other': '{count} 个可查询凭据',
'quota.credentials.one': '{count} 个凭据',
@@ -802,7 +802,7 @@ export const en: Record = {
'app.nav.oauth': 'OAuth',
'app.nav.api': 'API Access',
'app.nav.authFiles': 'Auth Files',
- 'app.nav.quota': 'Quota',
+ 'app.nav.quota': 'Quota Lookup',
'app.nav.usageRecords': 'Usage',
'app.nav.agents': 'Agents',
'app.coreRequired.title': 'Start the core first',
@@ -818,7 +818,7 @@ export const en: Record = {
'error.render.title': 'Something went wrong while rendering this page',
'error.unknown': 'Unknown error',
'error.reload': 'Reload Page',
- 'quota.title': 'Quota',
+ 'quota.title': 'Quota Lookup',
'quota.queryableCredentials.one': '{count} queryable credential',
'quota.queryableCredentials.other': '{count} queryable credentials',
'quota.credentials.one': '{count} credential',
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 3/7] 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',
From 027bc0b73b46e7d6c517e794e1de7e5cc4d61c3d Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Mon, 27 Jul 2026 20:44:34 +0800
Subject: [PATCH 4/7] feat: increase main window width and add compact labels
option for usage category panel
---
src-tauri/tauri.conf.json | 2 +-
src/pages/UsageRecordsPage.tsx | 6 +++---
src/styles.css | 5 +++++
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index dae2ae6..de04409 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -14,7 +14,7 @@
{
"label": "main",
"title": "EasyCLIProxyAPI",
- "width": 1400,
+ "width": 1500,
"height": 815,
"minWidth": 640,
"minHeight": 600
diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx
index f078286..b18e7c6 100644
--- a/src/pages/UsageRecordsPage.tsx
+++ b/src/pages/UsageRecordsPage.tsx
@@ -372,14 +372,14 @@ function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overvie
failures: point.failure,
tokens: point.tokens,
})).sort((left, right) => right.tokens - left.tokens);
- return
;
+ return
;
}
-function CategoryPanel({ title, items }: { title: string; items: UsageCategory[] }) {
+function CategoryPanel({ title, items, compactLabels = false }: { title: string; items: UsageCategory[]; compactLabels?: boolean }) {
const { t } = useI18n();
const max = Math.max(...items.map((item) => item.tokens), 1);
const total = items.reduce((sum, item) => sum + item.tokens, 0);
- return {title}{t('usage.analysis.sortedByTokens')}
{items.length ? {items.slice(0, 10).map((item) =>
{item.label}{t('usage.analysis.itemMeta', { requests: compactNumber(item.requests), percent: total ? (item.tokens * 100 / total).toFixed(1) : '0.0', tokens: compactNumber(item.tokens) })}
)}
: };
+ return {title}{t('usage.analysis.sortedByTokens')}
{items.length ? {items.slice(0, 10).map((item) =>
{item.label}{t('usage.analysis.itemMeta', { requests: compactNumber(item.requests), percent: total ? (item.tokens * 100 / total).toFixed(1) : '0.0', tokens: compactNumber(item.tokens) })}
)}
: };
}
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
diff --git a/src/styles.css b/src/styles.css
index 75a994e..1eb3870 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -5391,6 +5391,11 @@ input:focus-visible {
min-height: 290px;
}
+.usage-category-panel.compact-labels .usage-category-list strong {
+ flex: 0 1 auto;
+ max-width: min(46%, 160px);
+}
+
.usage-events-panel {
display: grid;
gap: 10px;
From 47cd4b5bd275fe3e936a60d8404fab4be4a7a8ed Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Mon, 27 Jul 2026 21:50:25 +0800
Subject: [PATCH 5/7] feat: add API access remarks functionality and validation
across the application
---
src-tauri/src/main.rs | 174 +++++++++++++++++++++++++++++++++
src-tauri/src/usage.rs | 91 +++++++++++++++--
src/i18n/ja.ts | 4 +
src/i18n/resources.ts | 8 ++
src/pages/ApiAccessPage.tsx | 69 ++++++++++++-
src/pages/UsageRecordsPage.tsx | 3 +-
6 files changed, 337 insertions(+), 12 deletions(-)
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 2433a59..f88639b 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{
+ collections::HashSet,
env, fs,
fs::File,
io::{self, Read, Seek, SeekFrom, Write},
@@ -436,6 +437,7 @@ struct GuiConfigFile {
auth_dir: String,
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec,
+ api_access_remarks: Vec,
management_secret_key: String,
usage_statistics_enabled: bool,
plugins_enabled: bool,
@@ -460,6 +462,30 @@ struct GuiApiKeyEntry {
remark: String,
}
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+struct GuiApiAccessRemark {
+ provider_section: String,
+ api_key_hash: String,
+ remark: String,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct ApiAccessRemarkQuery {
+ provider_section: String,
+ api_keys: Vec,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct ApiAccessRemarkUpdate {
+ provider_section: String,
+ previous_api_keys: Vec,
+ api_keys: Vec,
+ remark: String,
+}
+
#[derive(Deserialize)]
#[serde(untagged)]
enum GuiApiKeyInput {
@@ -500,6 +526,7 @@ impl Default for GuiConfigFile {
.map(|path| path_to_string(&path))
.unwrap_or_else(|| OAUTH_DIR_NAME.to_string()),
api_keys: vec![default_api_key_entry()],
+ api_access_remarks: Vec::new(),
// Keep plaintext here for management API auth. Core hashes the
// value written into config.yaml on startup.
management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
@@ -523,6 +550,7 @@ struct GuiConfigPresence {
host: Option,
auth_dir: Option,
api_keys: Option>,
+ api_access_remarks: Option>,
management_secret_key: Option,
codex_session_repair_on_launch: Option,
usage_statistics_enabled: Option,
@@ -1391,6 +1419,75 @@ fn get_gui_settings(
Ok(GuiSettings::from(&config))
}
+#[tauri::command]
+fn resolve_api_access_remarks(
+ queries: Vec,
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+) -> Result, String> {
+ let config = gui_config_state.snapshot()?;
+ queries
+ .into_iter()
+ .map(|query| {
+ validate_api_access_provider_section(&query.provider_section)?;
+ Ok(query
+ .api_keys
+ .iter()
+ .filter_map(|key| api_access_key_hash(key))
+ .find_map(|hash| {
+ config.api_access_remarks.iter().find(|entry| {
+ entry.provider_section == query.provider_section
+ && entry.api_key_hash == hash
+ })
+ })
+ .map(|entry| entry.remark.clone())
+ .unwrap_or_default())
+ })
+ .collect()
+}
+
+#[tauri::command]
+fn save_api_access_remark(
+ update: ApiAccessRemarkUpdate,
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+) -> Result<(), String> {
+ validate_api_access_provider_section(&update.provider_section)?;
+ let remark = update.remark.trim().to_string();
+ validate_api_key_remark(&remark)?;
+ let previous_hashes = update
+ .previous_api_keys
+ .iter()
+ .filter_map(|key| api_access_key_hash(key))
+ .collect::>();
+ let next_hashes = update
+ .api_keys
+ .iter()
+ .filter_map(|key| api_access_key_hash(key))
+ .collect::>();
+ let hashes_to_replace = previous_hashes
+ .union(&next_hashes)
+ .cloned()
+ .collect::>();
+ let provider_section = update.provider_section;
+
+ gui_config_state.update(|config| {
+ config.api_access_remarks.retain(|entry| {
+ entry.provider_section != provider_section
+ || !hashes_to_replace.contains(&entry.api_key_hash)
+ });
+ if !remark.is_empty() {
+ config
+ .api_access_remarks
+ .extend(next_hashes.iter().map(|hash| GuiApiAccessRemark {
+ provider_section: provider_section.clone(),
+ api_key_hash: hash.clone(),
+ remark: remark.clone(),
+ }));
+ }
+ Ok(())
+ })?;
+ Ok(())
+}
+
#[tauri::command]
fn set_app_locale(
app: tauri::AppHandle,
@@ -10328,6 +10425,52 @@ fn validate_api_key_remark(remark: &str) -> Result<(), String> {
Ok(())
}
+fn validate_api_access_provider_section(section: &str) -> Result<(), String> {
+ if matches!(
+ section,
+ "gemini-api-key" | "codex-api-key" | "claude-api-key" | "openai-compatibility"
+ ) {
+ Ok(())
+ } else {
+ Err("API 接入类型无效".to_string())
+ }
+}
+
+fn api_access_key_hash(value: &str) -> Option {
+ let value = value.trim();
+ (!value.is_empty()).then(|| sha256_bytes(value.as_bytes()))
+}
+
+fn usage_provider_section(provider: &str) -> Option<&'static str> {
+ match provider.trim().to_ascii_lowercase().as_str() {
+ "codex" => Some("codex-api-key"),
+ "claude" => Some("claude-api-key"),
+ "gemini" | "aistudio" => Some("gemini-api-key"),
+ "openai" | "openai-compatibility" => Some("openai-compatibility"),
+ _ => None,
+ }
+}
+
+impl GuiConfigFile {
+ fn api_access_remark_for_source(&self, provider: &str, source: &str) -> Option<&str> {
+ let hash = api_access_key_hash(source)?;
+ let preferred_section = usage_provider_section(provider);
+ self.api_access_remarks
+ .iter()
+ .find(|entry| {
+ preferred_section == Some(entry.provider_section.as_str())
+ && entry.api_key_hash == hash
+ && !entry.remark.is_empty()
+ })
+ .or_else(|| {
+ self.api_access_remarks
+ .iter()
+ .find(|entry| entry.api_key_hash == hash && !entry.remark.is_empty())
+ })
+ .map(|entry| entry.remark.as_str())
+ }
+}
+
fn validate_management_secret_key(secret_key: &str) -> Result<(), String> {
if secret_key.chars().count() > 512 {
return Err("管理密钥不能超过 512 个字符".to_string());
@@ -11184,6 +11327,22 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul
api_keys.push(Value::InlineTable(table));
}
set_codex_table_item(root, "api-keys", Item::Value(Value::Array(api_keys)));
+ let mut api_access_remarks = Array::new();
+ for entry in &config.api_access_remarks {
+ let mut table = InlineTable::new();
+ table.insert(
+ "provider-section",
+ Value::from(entry.provider_section.as_str()),
+ );
+ table.insert("api-key-hash", Value::from(entry.api_key_hash.as_str()));
+ table.insert("remark", Value::from(entry.remark.as_str()));
+ api_access_remarks.push(Value::InlineTable(table));
+ }
+ set_codex_table_item(
+ root,
+ "api-access-remarks",
+ Item::Value(Value::Array(api_access_remarks)),
+ );
let content = document.to_string();
toml::from_str::(&content)
@@ -11208,6 +11367,18 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> {
validate_core_api_key(&entry.key)?;
validate_api_key_remark(&entry.remark)?;
}
+ for entry in &config.api_access_remarks {
+ validate_api_access_provider_section(&entry.provider_section)?;
+ if entry.api_key_hash.len() != 64
+ || !entry
+ .api_key_hash
+ .chars()
+ .all(|character| character.is_ascii_hexdigit())
+ {
+ return Err("API 接入备注的密钥指纹无效".to_string());
+ }
+ validate_api_key_remark(&entry.remark)?;
+ }
validate_management_secret_key(&config.management_secret_key)?;
validate_routing_strategy(config.routing_strategy.trim())?;
if config.proxy_url.chars().any(char::is_control) {
@@ -13397,6 +13568,8 @@ fn main() {
detect_core_platform,
get_core_status,
get_gui_settings,
+ resolve_api_access_remarks,
+ save_api_access_remark,
set_app_locale,
resolve_windows_close_request,
get_agent_config_statuses,
@@ -15768,6 +15941,7 @@ custom_option = "keep-original"
remark: "测试密钥".to_string(),
},
],
+ api_access_remarks: Vec::new(),
management_secret_key: String::new(),
usage_statistics_enabled: false,
plugins_enabled: true,
diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs
index 2bf9b18..b99e902 100644
--- a/src-tauri/src/usage.rs
+++ b/src-tauri/src/usage.rs
@@ -98,6 +98,8 @@ pub(crate) struct UsageRecord {
#[serde(default)]
source: String,
#[serde(default)]
+ source_display: String,
+ #[serde(default)]
auth_index: String,
#[serde(default)]
failed: bool,
@@ -769,6 +771,7 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result Result {
+pub(crate) fn get_usage_analysis(
+ query: UsageQuery,
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+) -> Result {
let connection = open_usage_database()?;
- load_usage_analysis(&connection, &query)
+ let config = gui_config_state.snapshot()?;
+ load_usage_analysis(&connection, &query, &config)
}
fn load_usage_analysis(
connection: &Connection,
query: &UsageQuery,
+ config: &GuiConfigFile,
) -> Result {
Ok(UsageAnalysis {
models: load_simple_categories(connection, query, "model", "unknown")?,
providers: load_simple_categories(connection, query, "provider", "未知 Provider")?,
- sources: load_simple_categories(connection, query, "source", "未知来源")?,
+ sources: load_source_categories(connection, query, config)?,
api_keys: load_api_key_categories(connection, query)?,
})
}
+fn load_source_categories(
+ connection: &Connection,
+ query: &UsageQuery,
+ config: &GuiConfigFile,
+) -> Result, String> {
+ let mut categories = load_simple_categories(connection, query, "source", "未知来源")?;
+ for category in &mut categories {
+ category.label = usage_source_display(config, "", &category.key);
+ }
+ Ok(categories)
+}
+
fn load_simple_categories(
connection: &Connection,
query: &UsageQuery,
@@ -1073,14 +1093,19 @@ fn load_api_key_categories(
}
#[tauri::command]
-pub(crate) fn get_usage_events(query: UsageQuery) -> Result {
+pub(crate) fn get_usage_events(
+ query: UsageQuery,
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+) -> Result {
let connection = open_usage_database()?;
- load_usage_events(&connection, &query)
+ let config = gui_config_state.snapshot()?;
+ load_usage_events(&connection, &query, &config)
}
fn load_usage_events(
connection: &Connection,
query: &UsageQuery,
+ config: &GuiConfigFile,
) -> Result {
let filter = build_usage_filter(query);
let total_sql = format!("SELECT COUNT(*) FROM usage_events{}", filter.clause);
@@ -1117,11 +1142,14 @@ fn load_usage_events(
let mut statement = connection
.prepare(&sql)
.map_err(|error| format!("准备 SQLite 使用事件查询失败: {error}"))?;
- let items = statement
+ let mut items = statement
.query_map(params_from_iter(values.iter()), usage_record_from_row)
.map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))?
.collect::, _>>()
.map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?;
+ for item in &mut items {
+ item.source_display = usage_source_display(config, &item.provider, &item.source);
+ }
Ok(UsageEventPage {
items,
total,
@@ -1138,6 +1166,7 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result {
latency_ms: from_sql_i64(row.get(2)?),
ttft_ms: row.get::<_, Option>(3)?.map(from_sql_i64),
source: row.get(4)?,
+ source_display: String::new(),
auth_index: row.get(5)?,
failed: row.get::<_, i64>(6)? != 0,
provider: row.get(7)?,
@@ -1296,6 +1325,28 @@ fn mask_api_key(value: &str) -> String {
format!("{start}••••{end}")
}
+fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> String {
+ let source = source.trim();
+ if source.is_empty() {
+ return "未知来源".to_string();
+ }
+ if let Some(remark) = config.api_access_remark_for_source(provider, source) {
+ return remark.to_string();
+ }
+ let character_count = source.chars().count();
+ let looks_like_secret = !source.contains('@')
+ && !source.chars().any(char::is_whitespace)
+ && (source.starts_with("sk-")
+ || source.starts_with("AIza")
+ || source.starts_with("key-")
+ || character_count >= 48);
+ if looks_like_secret {
+ mask_api_key(source)
+ } else {
+ source.to_string()
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1331,6 +1382,7 @@ mod tests {
latency_ms: 100,
ttft_ms: Some(20),
source: "source".to_string(),
+ source_display: String::new(),
auth_index: "auth".to_string(),
failed: false,
provider: "openai".to_string(),
@@ -1363,6 +1415,27 @@ mod tests {
assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890");
}
+ #[test]
+ fn usage_source_prefers_api_access_remark_and_masks_secret_fallbacks() {
+ let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
+ let mut config = GuiConfigFile::default();
+ config.api_access_remarks.push(crate::GuiApiAccessRemark {
+ provider_section: "codex-api-key".to_string(),
+ api_key_hash: hash_text(key),
+ remark: "生产环境".to_string(),
+ });
+
+ assert_eq!(usage_source_display(&config, "codex", key), "生产环境");
+ assert_eq!(
+ usage_source_display(&GuiConfigFile::default(), "codex", key),
+ "sk-1••••wxyz"
+ );
+ assert_eq!(
+ usage_source_display(&config, "codex", "account@example.com"),
+ "account@example.com"
+ );
+ }
+
#[test]
fn local_hour_key_uses_year_month_day_and_hour() {
let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model");
@@ -1381,6 +1454,7 @@ mod tests {
window_height: None,
auth_dir: String::new(),
api_keys: Vec::new(),
+ api_access_remarks: Vec::new(),
management_secret_key: "123456".to_string(),
usage_statistics_enabled: true,
plugins_enabled: false,
@@ -1522,8 +1596,9 @@ mod tests {
};
let overview = load_usage_overview(&connection, &query).unwrap();
- let analysis = load_usage_analysis(&connection, &query).unwrap();
- let events = load_usage_events(&connection, &query).unwrap();
+ let config = GuiConfigFile::default();
+ let analysis = load_usage_analysis(&connection, &query, &config).unwrap();
+ let events = load_usage_events(&connection, &query, &config).unwrap();
assert_eq!(overview.total_requests, 1);
assert_eq!(overview.failure_count, 1);
diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts
index 27c944d..f528ce5 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -529,6 +529,7 @@ export const jaOverrides = {
'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください',
'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください',
'apiAccess.error.duplicate': '同じ接続設定がすでに存在します',
+ 'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください',
'apiAccess.notice.updated': '接続を更新しました',
'apiAccess.notice.added': '接続を追加しました',
'apiAccess.deleteConfirm': '「{name}」を削除しますか?',
@@ -547,6 +548,7 @@ export const jaOverrides = {
'apiAccess.empty.addFirst': '右上の「追加」から最初の接続を追加してください',
'apiAccess.keys.count': 'キー {count} 件',
'apiAccess.keys.summary': '{key} · 合計 {count} 件',
+ 'apiAccess.remarkValue': 'メモ:{remark}',
'apiAccess.defaultUrl': '既定のアドレスを使用',
'apiAccess.models.summary': 'モデル {count} 件 · {models}',
'apiAccess.priorityValue': '優先度 {priority}',
@@ -572,6 +574,8 @@ export const jaOverrides = {
'apiAccess.field.name': '名前',
'apiAccess.field.keysMany': 'API キー(1 行に 1 件)',
'apiAccess.field.key': 'API キー',
+ 'apiAccess.field.remark': 'メモ',
+ 'apiAccess.remarkPlaceholder': '例:本番環境、チームのメインアカウント',
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
'apiAccess.preset.title': 'OpenAI 互換プリセット',
diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts
index db97f6a..e510e89 100644
--- a/src/i18n/resources.ts
+++ b/src/i18n/resources.ts
@@ -532,6 +532,7 @@ export const zhCN = {
'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥',
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
'apiAccess.error.duplicate': '相同的接入配置已经存在',
+ 'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
'apiAccess.notice.updated': '接入已更新',
'apiAccess.notice.added': '接入已新增',
'apiAccess.deleteConfirm': '确定删除“{name}”吗?',
@@ -550,6 +551,7 @@ export const zhCN = {
'apiAccess.empty.addFirst': '点击右上角“新增”添加第一个接入',
'apiAccess.keys.count': '{count} 个密钥',
'apiAccess.keys.summary': '{key} · 共 {count} 个密钥',
+ 'apiAccess.remarkValue': '备注:{remark}',
'apiAccess.defaultUrl': '使用默认地址',
'apiAccess.models.summary': '模型 {count} 个 · {models}',
'apiAccess.priorityValue': '优先级 {priority}',
@@ -575,6 +577,8 @@ export const zhCN = {
'apiAccess.field.name': '名称',
'apiAccess.field.keysMany': 'API 密钥(每行一个)',
'apiAccess.field.key': 'API 密钥',
+ 'apiAccess.field.remark': '备注',
+ 'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号',
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
'apiAccess.preset.title': 'OpenAI 兼容预设',
@@ -1344,6 +1348,7 @@ export const en: Record = {
'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.',
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
'apiAccess.error.duplicate': 'An identical connection already exists',
+ 'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
'apiAccess.notice.updated': 'Connection updated',
'apiAccess.notice.added': 'Connection added',
'apiAccess.deleteConfirm': 'Delete “{name}”?',
@@ -1362,6 +1367,7 @@ export const en: Record = {
'apiAccess.empty.addFirst': 'Click “Add” in the upper-right to create your first connection',
'apiAccess.keys.count': '{count} keys',
'apiAccess.keys.summary': '{key} · {count} keys total',
+ 'apiAccess.remarkValue': 'Remark: {remark}',
'apiAccess.defaultUrl': 'Using default address',
'apiAccess.models.summary': '{count} models · {models}',
'apiAccess.priorityValue': 'Priority {priority}',
@@ -1387,6 +1393,8 @@ export const en: Record = {
'apiAccess.field.name': 'Name',
'apiAccess.field.keysMany': 'API Keys (one per line)',
'apiAccess.field.key': 'API Key',
+ 'apiAccess.field.remark': 'Remark',
+ 'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account',
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
'apiAccess.preset.title': 'OpenAI-Compatible Preset',
diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx
index 96163e4..d09c9d9 100644
--- a/src/pages/ApiAccessPage.tsx
+++ b/src/pages/ApiAccessPage.tsx
@@ -1,4 +1,5 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
import {
Check,
Edit3,
@@ -67,11 +68,13 @@ type ProviderRow = {
disabled: boolean;
priority: number | null;
authIndex: string;
+ remark: string;
};
export type ProviderDraft = {
name: string;
apiKey: string;
+ remark: string;
baseUrl: string;
priority: string;
models: ModelOption[];
@@ -182,9 +185,13 @@ const rowFromRecord = (
authIndex: entry
? readString(entry, 'auth-index', 'authIndex')
: readString(record, 'auth-index', 'authIndex'),
+ remark: '',
};
};
+const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) =>
+ `${section}\u0000${apiKeys.join('\u0000')}`;
+
export const stripResponseFields = (record: Record) => {
const next = { ...record };
delete next['auth-index'];
@@ -316,6 +323,7 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
const draftFromRow = (row: ProviderRow): ProviderDraft => ({
name: row.name,
apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey,
+ remark: row.remark,
baseUrl: row.baseUrl,
priority: row.priority === null ? '' : String(row.priority),
models: row.models,
@@ -351,6 +359,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => ({
const emptyProviderDraft = (): ProviderDraft => ({
name: '',
apiKey: '',
+ remark: '',
baseUrl: '',
priority: '',
models: [],
@@ -604,6 +613,7 @@ export function ApiAccessPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRow, setEditingRow] = useState(null);
const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft);
+ const [apiAccessRemarks, setApiAccessRemarks] = useState>({});
const activeDefinition = definitionFor(activeCategory);
const activeSection = activeDefinition.section;
@@ -647,20 +657,51 @@ export function ApiAccessPage() {
void loadProviders();
}, [loadProviders]);
+ useEffect(() => {
+ const providerRows = (Object.entries(records) as [ProviderSection, Record[]][])
+ .flatMap(([section, items]) => items.map((record, index) => rowFromRecord(section, record, index)));
+ if (providerRows.length === 0) {
+ setApiAccessRemarks({});
+ return;
+ }
+ let disposed = false;
+ void invoke('resolve_api_access_remarks', {
+ queries: providerRows.map((row) => ({
+ providerSection: row.section,
+ apiKeys: row.apiKeys,
+ })),
+ }).then((remarks) => {
+ if (disposed) return;
+ setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [
+ providerRemarkIdentity(row.section, row.apiKeys),
+ remarks[index] ?? '',
+ ])));
+ }).catch(() => {
+ if (!disposed) setApiAccessRemarks({});
+ });
+ return () => {
+ disposed = true;
+ };
+ }, [records]);
+
const rows = useMemo(
() =>
records[activeSection]
.map((record, index) => rowFromRecord(activeSection, record, index))
+ .map((row) => ({
+ ...row,
+ remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '',
+ }))
.filter((row) => providerCategoryMatchesRecord(activeCategory, row.record))
.filter((row) => {
const query = filter.trim().toLowerCase();
if (!query) return true;
- return [row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
+ return [row.name, row.remark, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
.join(' ')
.toLowerCase()
.includes(query);
}),
- [activeCategory, activeSection, filter, records],
+ [activeCategory, activeSection, apiAccessRemarks, filter, records],
);
const openCreate = () => {
@@ -700,6 +741,10 @@ export function ApiAccessPage() {
);
return false;
}
+ if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) {
+ setError(t('apiAccess.error.remarkInvalid'));
+ return false;
+ }
let baseUrl = preparedDraft.baseUrl.trim();
let providerHeaders: Record = {};
try {
@@ -762,6 +807,14 @@ export function ApiAccessPage() {
}
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
+ await invoke('save_api_access_remark', {
+ update: {
+ providerSection: activeSection,
+ previousApiKeys: editingRow?.apiKeys ?? [],
+ apiKeys: parsedApiKeys,
+ remark: draftToSave.remark,
+ },
+ });
setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added'));
await loadProviders();
return true;
@@ -785,6 +838,14 @@ export function ApiAccessPage() {
query: { 'api-key': row.apiKey, 'base-url': row.baseUrl },
});
}
+ await invoke('save_api_access_remark', {
+ update: {
+ providerSection: row.section,
+ previousApiKeys: row.apiKeys,
+ apiKeys: [],
+ remark: '',
+ },
+ });
setNotice(t('apiAccess.notice.deleted'));
await loadProviders();
} catch (requestError) {
@@ -915,6 +976,7 @@ export function ApiAccessPage() {
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
: maskSecret(row.apiKey)}
+ {row.remark ? {t('apiAccess.remarkValue', { remark: row.remark })} : null}
{row.baseUrl || t('apiAccess.defaultUrl')}
{row.models.length > 0 ? model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })} : null}
@@ -1016,7 +1078,7 @@ function ApiProviderDialog({
&& visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase()));
const updateTextField = (
- field: 'name' | 'apiKey' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
+ field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
value: string,
) => {
setDraft((current) => ({ ...current, [field]: value }));
@@ -1189,6 +1251,7 @@ function ApiProviderDialog({
updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." />
)}
+
{activeCategory === 'deepseek' ? (
diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx
index b18e7c6..bc1590e 100644
--- a/src/pages/UsageRecordsPage.tsx
+++ b/src/pages/UsageRecordsPage.tsx
@@ -60,6 +60,7 @@ type UsageRecord = {
latency_ms: number;
ttft_ms: number | null;
source: string;
+ source_display: string;
failed: boolean;
provider: string;
model: string;
@@ -384,7 +385,7 @@ function CategoryPanel({ title, items, compactLabels = false }: { title: string;
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
const { t } = useI18n();
- return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ? | {t('usage.column.time')} | {t('usage.column.model')} | Provider | {t('usage.column.source')} | API Key | {t('usage.column.result')} | {t('usage.column.latency')} | TTFT | {t('usage.column.input')} | {t('usage.column.output')} | {t('usage.column.reasoning')} | {t('usage.column.cache')} | {t('usage.column.total')} |
{events.items.map((record) => | {formatTime(record.timestamp)} | {record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null} | {record.provider || '—'} | {record.source || '—'} | {record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'} | {record.failed ? t('usage.result.failed') : t('usage.result.success')} | {record.latency_ms} ms | {record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`} | {compactNumber(record.tokens.input_tokens)} | {compactNumber(record.tokens.output_tokens)} | {compactNumber(record.tokens.reasoning_tokens)} | {compactNumber(record.tokens.cache_read_tokens)} | {compactNumber(record.tokens.total_tokens)} |
)}
: } onPage(events.page - 1)}>{t('usage.previous')}= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}
;
+ return
{t('usage.events.total', { count: compactNumber(events.total) })}{t('usage.events.page', { page: events.page, total: events.totalPages })}
{events.items.length ? | {t('usage.column.time')} | {t('usage.column.model')} | Provider | {t('usage.column.source')} | API Key | {t('usage.column.result')} | {t('usage.column.latency')} | TTFT | {t('usage.column.input')} | {t('usage.column.output')} | {t('usage.column.reasoning')} | {t('usage.column.cache')} | {t('usage.column.total')} |
{events.items.map((record) => | {formatTime(record.timestamp)} | {record.alias || record.model}{record.alias || record.reasoning_effort ? {record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort} : null} | {record.provider || '—'} | {record.source_display || '—'} | {record.api_key_remark || t('usage.key.noRemark')}{record.api_key_display || '—'} | {record.failed ? t('usage.result.failed') : t('usage.result.success')} | {record.latency_ms} ms | {record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`} | {compactNumber(record.tokens.input_tokens)} | {compactNumber(record.tokens.output_tokens)} | {compactNumber(record.tokens.reasoning_tokens)} | {compactNumber(record.tokens.cache_read_tokens)} | {compactNumber(record.tokens.total_tokens)} |
)}
: } onPage(events.page - 1)}>{t('usage.previous')}= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}
;
}
function UsageEmpty() {
From c1959dedbcf5d347720bdd4436c3dcead9997053 Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Mon, 27 Jul 2026 22:19:20 +0800
Subject: [PATCH 6/7] `fix: prioritize API remarks and mask authentication
keys`
---
src-tauri/src/usage.rs | 34 +++++++++++++++++++++++-----------
src/pages/ApiAccessPage.tsx | 3 +--
2 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs
index b99e902..e93a7f3 100644
--- a/src-tauri/src/usage.rs
+++ b/src-tauri/src/usage.rs
@@ -1067,17 +1067,7 @@ fn load_api_key_categories(
let key = row.get::<_, String>(0)?;
let remark = row.get::<_, String>(1)?;
let display = row.get::<_, String>(2)?;
- let label = if !remark.is_empty() {
- if display.is_empty() {
- remark
- } else {
- format!("{remark} · {display}")
- }
- } else if !display.is_empty() {
- display
- } else {
- "未记录密钥".to_string()
- };
+ let label = api_key_category_label(remark, display);
Ok(UsageCategory {
key,
label,
@@ -1325,6 +1315,16 @@ fn mask_api_key(value: &str) -> String {
format!("{start}••••{end}")
}
+fn api_key_category_label(remark: String, display: String) -> String {
+ if !remark.is_empty() {
+ remark
+ } else if !display.is_empty() {
+ mask_api_key(&display)
+ } else {
+ "未记录密钥".to_string()
+ }
+}
+
fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> String {
let source = source.trim();
if source.is_empty() {
@@ -1415,6 +1415,18 @@ mod tests {
assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890");
}
+ #[test]
+ fn api_key_category_uses_either_remark_or_masked_key() {
+ assert_eq!(
+ api_key_category_label("生产环境".to_string(), "12••••".to_string()),
+ "生产环境"
+ );
+ assert_eq!(
+ api_key_category_label(String::new(), "123456".to_string()),
+ "12••••"
+ );
+ }
+
#[test]
fn usage_source_prefers_api_access_remark_and_masks_secret_fallbacks() {
let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx
index d09c9d9..f0b532c 100644
--- a/src/pages/ApiAccessPage.tsx
+++ b/src/pages/ApiAccessPage.tsx
@@ -969,14 +969,13 @@ export function ApiAccessPage() {
- {row.name}
+ {row.remark || row.name}
{definitionFor(row.section).openAi && row.apiKeys.length > 1
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
: maskSecret(row.apiKey)}
- {row.remark ?
{t('apiAccess.remarkValue', { remark: row.remark })} : null}
{row.baseUrl || t('apiAccess.defaultUrl')}
{row.models.length > 0 ?
model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })} : null}
From 48cbadf82489f82adec78ecf95631dbad2aa449c Mon Sep 17 00:00:00 2001
From: lzt404 <2596933790@qq.com>
Date: Mon, 27 Jul 2026 22:36:51 +0800
Subject: [PATCH 7/7] chore: bump version to v0.2.8
---
package.json | 2 +-
src-tauri/Cargo.lock | 2 +-
src-tauri/Cargo.toml | 2 +-
src-tauri/tauri.conf.json | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/package.json b/package.json
index 5a81bab..b9566ea 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "cpa-gui",
"private": true,
- "version": "0.2.7",
+ "version": "0.2.8",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index ee2a3af..16a578e 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
-version = "0.2.7"
+version = "0.2.8"
dependencies = [
"base64 0.22.1",
"chrono",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index b517c94..c08f0fa 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "cpa-gui"
-version = "0.2.7"
+version = "0.2.8"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index de04409..c230abd 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EasyCLIProxyAPI",
- "version": "0.2.7",
+ "version": "0.2.8",
"identifier": "com.cpa.gui",
"build": {
"beforeDevCommand": "bun run dev",