diff --git a/package.json b/package.json
index 5a81bab..b9566ea 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "cpa-gui",
"private": true,
- "version": "0.2.7",
+ "version": "0.2.8",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index ee2a3af..16a578e 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
-version = "0.2.7"
+version = "0.2.8"
dependencies = [
"base64 0.22.1",
"chrono",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index b517c94..c08f0fa 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "cpa-gui"
-version = "0.2.7"
+version = "0.2.8"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 23ea007..f88639b 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{
+ collections::HashSet,
env, fs,
fs::File,
io::{self, Read, Seek, SeekFrom, Write},
@@ -436,10 +437,14 @@ struct GuiConfigFile {
auth_dir: String,
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec,
+ api_access_remarks: Vec,
management_secret_key: String,
usage_statistics_enabled: bool,
plugins_enabled: bool,
routing_strategy: String,
+ proxy_url: String,
+ routing_session_affinity: bool,
+ routing_session_affinity_ttl: String,
codex_session_repair_on_launch: bool,
}
@@ -457,6 +462,30 @@ struct GuiApiKeyEntry {
remark: String,
}
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+struct GuiApiAccessRemark {
+ provider_section: String,
+ api_key_hash: String,
+ remark: String,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct ApiAccessRemarkQuery {
+ provider_section: String,
+ api_keys: Vec,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct ApiAccessRemarkUpdate {
+ provider_section: String,
+ previous_api_keys: Vec,
+ api_keys: Vec,
+ remark: String,
+}
+
#[derive(Deserialize)]
#[serde(untagged)]
enum GuiApiKeyInput {
@@ -497,12 +526,16 @@ impl Default for GuiConfigFile {
.map(|path| path_to_string(&path))
.unwrap_or_else(|| OAUTH_DIR_NAME.to_string()),
api_keys: vec![default_api_key_entry()],
+ api_access_remarks: Vec::new(),
// Keep plaintext here for management API auth. Core hashes the
// value written into config.yaml on startup.
management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
usage_statistics_enabled: true,
plugins_enabled: false,
routing_strategy: "round-robin".to_string(),
+ proxy_url: String::new(),
+ routing_session_affinity: false,
+ routing_session_affinity_ttl: String::new(),
codex_session_repair_on_launch: false,
}
}
@@ -517,11 +550,15 @@ struct GuiConfigPresence {
host: Option,
auth_dir: Option,
api_keys: Option>,
+ api_access_remarks: Option>,
management_secret_key: Option,
codex_session_repair_on_launch: Option,
usage_statistics_enabled: Option,
plugins_enabled: Option,
routing_strategy: Option,
+ proxy_url: Option,
+ routing_session_affinity: Option,
+ routing_session_affinity_ttl: Option,
}
#[derive(Clone, Serialize)]
@@ -573,7 +610,7 @@ struct AgentConfigActionResult {
conflict_files: Vec,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentModelOption {
name: String,
@@ -771,6 +808,16 @@ struct GuiNetworkSettings {
allow_lan: bool,
}
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct GuiNetworkRoutingSettings {
+ port: u16,
+ allow_lan: bool,
+ proxy_url: String,
+ routing_session_affinity: bool,
+ routing_session_affinity_ttl: String,
+}
+
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct CoreConfigSettings {
@@ -786,6 +833,9 @@ struct CoreConfigSettings {
usage_statistics_enabled: bool,
plugins_enabled: bool,
routing_strategy: String,
+ proxy_url: String,
+ routing_session_affinity: bool,
+ routing_session_affinity_ttl: String,
// Kept for internal config migration/tests; never exposed to the WebView.
#[allow(dead_code)]
#[serde(skip_serializing)]
@@ -804,8 +854,13 @@ struct CoreApiKeyView {
struct CoreConfigView {
api_keys: Vec,
management_secret_configured: bool,
+ port: u16,
+ allow_lan: bool,
plugins_enabled: bool,
routing_strategy: String,
+ proxy_url: String,
+ routing_session_affinity: bool,
+ routing_session_affinity_ttl: String,
}
impl Default for CoreInstallTask {
@@ -1157,6 +1212,25 @@ impl GuiConfigState {
})
}
+ fn update_network_routing(
+ &self,
+ port: u16,
+ allow_lan: bool,
+ proxy_url: &str,
+ routing_session_affinity: bool,
+ routing_session_affinity_ttl: &str,
+ ) -> Result {
+ self.update(|config| {
+ config.port = port;
+ config.allow_lan = allow_lan;
+ config.host = if allow_lan { "0.0.0.0" } else { "127.0.0.1" }.to_string();
+ config.proxy_url = proxy_url.to_string();
+ config.routing_session_affinity = routing_session_affinity;
+ config.routing_session_affinity_ttl = routing_session_affinity_ttl.to_string();
+ Ok(())
+ })
+ }
+
fn set_run_on_startup(&self, run_on_startup: bool) -> Result {
self.update(|config| {
config.run_on_startup = run_on_startup;
@@ -1223,6 +1297,9 @@ impl GuiConfigState {
}
config.plugins_enabled = settings.plugins_enabled;
config.routing_strategy = settings.routing_strategy.clone();
+ config.proxy_url = settings.proxy_url.clone();
+ config.routing_session_affinity = settings.routing_session_affinity;
+ config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone();
Ok(())
})
}
@@ -1264,6 +1341,9 @@ impl From<&GuiConfigFile> for CoreConfigSettings {
usage_statistics_enabled: config.usage_statistics_enabled,
plugins_enabled: config.plugins_enabled,
routing_strategy: config.routing_strategy.clone(),
+ proxy_url: config.proxy_url.clone(),
+ routing_session_affinity: config.routing_session_affinity,
+ routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(),
management_secret_key: Some(config.management_secret_key.clone()),
}
}
@@ -1281,8 +1361,13 @@ impl From<&GuiConfigFile> for CoreConfigView {
})
.collect(),
management_secret_configured: !config.management_secret_key.is_empty(),
+ port: config.port,
+ allow_lan: config.allow_lan,
plugins_enabled: config.plugins_enabled,
routing_strategy: config.routing_strategy.clone(),
+ proxy_url: config.proxy_url.clone(),
+ routing_session_affinity: config.routing_session_affinity,
+ routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(),
}
}
}
@@ -1334,6 +1419,75 @@ fn get_gui_settings(
Ok(GuiSettings::from(&config))
}
+#[tauri::command]
+fn resolve_api_access_remarks(
+ queries: Vec,
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+) -> Result, String> {
+ let config = gui_config_state.snapshot()?;
+ queries
+ .into_iter()
+ .map(|query| {
+ validate_api_access_provider_section(&query.provider_section)?;
+ Ok(query
+ .api_keys
+ .iter()
+ .filter_map(|key| api_access_key_hash(key))
+ .find_map(|hash| {
+ config.api_access_remarks.iter().find(|entry| {
+ entry.provider_section == query.provider_section
+ && entry.api_key_hash == hash
+ })
+ })
+ .map(|entry| entry.remark.clone())
+ .unwrap_or_default())
+ })
+ .collect()
+}
+
+#[tauri::command]
+fn save_api_access_remark(
+ update: ApiAccessRemarkUpdate,
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+) -> Result<(), String> {
+ validate_api_access_provider_section(&update.provider_section)?;
+ let remark = update.remark.trim().to_string();
+ validate_api_key_remark(&remark)?;
+ let previous_hashes = update
+ .previous_api_keys
+ .iter()
+ .filter_map(|key| api_access_key_hash(key))
+ .collect::>();
+ let next_hashes = update
+ .api_keys
+ .iter()
+ .filter_map(|key| api_access_key_hash(key))
+ .collect::>();
+ let hashes_to_replace = previous_hashes
+ .union(&next_hashes)
+ .cloned()
+ .collect::>();
+ let provider_section = update.provider_section;
+
+ gui_config_state.update(|config| {
+ config.api_access_remarks.retain(|entry| {
+ entry.provider_section != provider_section
+ || !hashes_to_replace.contains(&entry.api_key_hash)
+ });
+ if !remark.is_empty() {
+ config
+ .api_access_remarks
+ .extend(next_hashes.iter().map(|hash| GuiApiAccessRemark {
+ provider_section: provider_section.clone(),
+ api_key_hash: hash.clone(),
+ remark: remark.clone(),
+ }));
+ }
+ Ok(())
+ })?;
+ Ok(())
+}
+
#[tauri::command]
fn set_app_locale(
app: tauri::AppHandle,
@@ -1594,6 +1748,7 @@ async fn apply_agent_config(
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
model: String,
+ models: Vec,
oauth_configuration: bool,
) -> Result {
let client = AgentClient::parse(&client)?;
@@ -1604,10 +1759,9 @@ async fn apply_agent_config(
let config = gui_config_state.snapshot()?;
let api_key = effective_agent_api_key(&config);
validate_agent_can_enable(client, &home, config.port, api_key)?;
- let available_models = fetch_agent_models(config.port, api_key).await?;
- let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
+ let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?;
let codex_models = if client == AgentClient::Codex {
- Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
+ Some(fetch_codex_model_catalog_specs(&config, &models).await)
} else {
None
};
@@ -1621,7 +1775,7 @@ async fn apply_agent_config(
api_key,
&model,
AgentConfigurationOptions {
- models: &available_models,
+ models: &models,
codex_models: codex_models.as_deref(),
oauth_configuration,
},
@@ -1634,6 +1788,7 @@ async fn reset_agent_config_to_default(
gui_config_state: tauri::State<'_, GuiConfigState>,
client: String,
model: String,
+ models: Vec,
oauth_configuration: bool,
) -> Result {
let client = AgentClient::parse(&client)?;
@@ -1644,10 +1799,9 @@ async fn reset_agent_config_to_default(
let config = gui_config_state.snapshot()?;
let api_key = effective_agent_api_key(&config);
validate_agent_can_enable(client, &home, config.port, api_key)?;
- let available_models = fetch_agent_models(config.port, api_key).await?;
- let model = resolve_available_agent_model(&available_models, &validate_agent_model(&model)?)?;
+ let model = resolve_available_agent_model(&models, &validate_agent_model(&model)?)?;
let codex_models = if client == AgentClient::Codex {
- Some(fetch_codex_model_catalog_specs(&config, &available_models).await)
+ Some(fetch_codex_model_catalog_specs(&config, &models).await)
} else {
None
};
@@ -6276,6 +6430,62 @@ fn save_gui_settings(
Ok(GuiSettings::from(&config))
}
+#[tauri::command]
+fn save_network_routing_settings(
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+ cache: tauri::State<'_, AgentConfigStatusCache>,
+ settings: GuiNetworkRoutingSettings,
+) -> Result {
+ if settings.port == 0 {
+ return Err("端口必须在 1 到 65535 之间".to_string());
+ }
+ let proxy_url = normalize_optional_config_string(settings.proxy_url, "代理 URL")?;
+ let routing_session_affinity_ttl =
+ normalize_optional_config_string(settings.routing_session_affinity_ttl, "会话粘性 TTL")?;
+
+ let _refresh_guard = cache
+ .refresh_lock
+ .lock()
+ .map_err(|_| "智能体配置状态刷新锁已损坏".to_string())?;
+ let previous = gui_config_state.snapshot()?;
+ let mut next = previous.clone();
+ next.port = settings.port;
+ next.allow_lan = settings.allow_lan;
+ next.host = if settings.allow_lan {
+ "0.0.0.0"
+ } else {
+ "127.0.0.1"
+ }
+ .to_string();
+ next.proxy_url = proxy_url.clone();
+ next.routing_session_affinity = settings.routing_session_affinity;
+ next.routing_session_affinity_ttl = routing_session_affinity_ttl.clone();
+ validate_gui_config(&next)?;
+
+ patch_core_network_routing_settings(&next)?;
+ let config = match gui_config_state.update_network_routing(
+ settings.port,
+ settings.allow_lan,
+ &proxy_url,
+ settings.routing_session_affinity,
+ &routing_session_affinity_ttl,
+ ) {
+ Ok(config) => config,
+ Err(error) => {
+ let rollback_error = patch_core_network_routing_settings(&previous).err();
+ return Err(config_update_error_with_rollback(error, rollback_error));
+ }
+ };
+
+ if config.port != previous.port {
+ if let Err(error) = cache.clear() {
+ eprintln!("清空智能体配置状态缓存失败: {error}");
+ }
+ }
+
+ Ok(CoreConfigView::from(&config))
+}
+
#[tauri::command]
fn get_core_config_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
@@ -6440,6 +6650,65 @@ fn set_core_routing_strategy(
Ok(CoreConfigView::from(&config))
}
+#[tauri::command]
+fn set_core_proxy_url(
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+ proxy_url: String,
+) -> Result {
+ let proxy_url = normalize_optional_config_string(proxy_url, "代理 URL")?;
+ let mut settings = current_core_config_settings(gui_config_state.inner())?;
+ let previous_proxy_url = settings.proxy_url.clone();
+ settings.proxy_url = proxy_url;
+ patch_core_proxy_url(&settings.proxy_url)?;
+ let config = match gui_config_state.sync_core_settings(&settings) {
+ Ok(config) => config,
+ Err(error) => {
+ let rollback_error = patch_core_proxy_url(&previous_proxy_url).err();
+ return Err(config_update_error_with_rollback(error, rollback_error));
+ }
+ };
+ Ok(CoreConfigView::from(&config))
+}
+
+#[tauri::command]
+fn set_core_session_affinity(
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+ enabled: bool,
+) -> Result {
+ let mut settings = current_core_config_settings(gui_config_state.inner())?;
+ let previous_enabled = settings.routing_session_affinity;
+ settings.routing_session_affinity = enabled;
+ patch_core_session_affinity(settings.routing_session_affinity)?;
+ let config = match gui_config_state.sync_core_settings(&settings) {
+ Ok(config) => config,
+ Err(error) => {
+ let rollback_error = patch_core_session_affinity(previous_enabled).err();
+ return Err(config_update_error_with_rollback(error, rollback_error));
+ }
+ };
+ Ok(CoreConfigView::from(&config))
+}
+
+#[tauri::command]
+fn set_core_session_affinity_ttl(
+ gui_config_state: tauri::State<'_, GuiConfigState>,
+ ttl: String,
+) -> Result {
+ let ttl = normalize_optional_config_string(ttl, "会话粘性 TTL")?;
+ let mut settings = current_core_config_settings(gui_config_state.inner())?;
+ let previous_ttl = settings.routing_session_affinity_ttl.clone();
+ settings.routing_session_affinity_ttl = ttl;
+ patch_core_session_affinity_ttl(&settings.routing_session_affinity_ttl)?;
+ let config = match gui_config_state.sync_core_settings(&settings) {
+ Ok(config) => config,
+ Err(error) => {
+ let rollback_error = patch_core_session_affinity_ttl(&previous_ttl).err();
+ return Err(config_update_error_with_rollback(error, rollback_error));
+ }
+ };
+ Ok(CoreConfigView::from(&config))
+}
+
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct OAuthStartResult {
@@ -8124,6 +8393,21 @@ fn patch_core_network_settings(config: &GuiConfigFile) -> Result<(), String> {
write_yaml_if_changed(&config_path, &updated).map(|_| ())
}
+fn patch_core_network_routing_settings(config: &GuiConfigFile) -> Result<(), String> {
+ let _config_guard = lock_core_config_file()?;
+ let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
+ if !config_path.is_file() {
+ return Ok(());
+ }
+
+ let content = fs::read_to_string(&config_path)
+ .map_err(|err| format!("读取内核配置失败 {}: {err}", path_to_string(&config_path)))?;
+ let Some(updated) = patch_core_network_routing_yaml(&content, config)? else {
+ return Ok(());
+ };
+ write_yaml_if_changed(&config_path, &updated).map(|_| ())
+}
+
fn lock_core_config_file() -> Result, String> {
CORE_CONFIG_FILE_LOCK
.lock()
@@ -8197,6 +8481,40 @@ fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> {
})
}
+fn patch_core_proxy_url(proxy_url: &str) -> Result<(), String> {
+ let proxy_url = proxy_url.to_string();
+ patch_existing_core_config(move |document| {
+ set_core_yaml_top_level_value(
+ document,
+ "proxy-url",
+ serde_norway::Value::String(proxy_url),
+ )
+ })
+}
+
+fn patch_core_session_affinity(enabled: bool) -> Result<(), String> {
+ patch_existing_core_config(move |document| {
+ set_core_yaml_nested_value(
+ document,
+ "routing",
+ "session-affinity",
+ serde_norway::Value::Bool(enabled),
+ )
+ })
+}
+
+fn patch_core_session_affinity_ttl(ttl: &str) -> Result<(), String> {
+ let ttl = ttl.to_string();
+ patch_existing_core_config(move |document| {
+ set_core_yaml_nested_value(
+ document,
+ "routing",
+ "session-affinity-ttl",
+ serde_norway::Value::String(ttl),
+ )
+ })
+}
+
fn patch_existing_core_config(update: F) -> Result<(), String>
where
F: FnOnce(&mut serde_norway::Value) -> Result,
@@ -8263,6 +8581,34 @@ fn render_yaml_value_changes(
remaining_changes.push(change);
}
}
+ let mut missing_nested_groups: Vec<(String, Vec<(String, serde_norway::Value)>)> = Vec::new();
+ for change in &remaining_changes {
+ if change.path.len() != 2
+ || yaml_value_at_path(original, &change.path).is_some()
+ || yaml_value_at_path(original, &change.path[..1])
+ .and_then(serde_norway::Value::as_mapping)
+ .is_none()
+ {
+ continue;
+ }
+ let section = change.path[0].clone();
+ let entry = (change.path[1].clone(), change.value.clone());
+ if let Some((_, entries)) = missing_nested_groups
+ .iter_mut()
+ .find(|(existing, _)| existing == §ion)
+ {
+ entries.push(entry);
+ } else {
+ missing_nested_groups.push((section, vec![entry]));
+ }
+ }
+ for (section, entries) in missing_nested_groups {
+ if let Some(updated_content) =
+ insert_yaml_block_mapping_values(&editable_content, §ion, &entries)?
+ {
+ editable_content = updated_content;
+ }
+ }
let file = editable_content
.parse::()
.map_err(|err| format!("解析可编辑内核配置失败: {err}"))?;
@@ -8386,6 +8732,61 @@ fn yaml_value_at_path<'a>(
})
}
+fn insert_yaml_block_mapping_values(
+ content: &str,
+ section: &str,
+ entries: &[(String, serde_norway::Value)],
+) -> Result
-
-
-
-
-
- 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/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx
index f078286..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;
@@ -372,19 +373,19 @@ 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 }) {
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() {
diff --git a/src/styles.css b/src/styles.css
index 5f7adab..1eb3870 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1244,6 +1244,101 @@ input:focus-visible {
height: 206px;
}
+.config-network-panel {
+ display: grid;
+ gap: 18px;
+}
+
+.config-network-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-auto-rows: 154px;
+ gap: 16px 18px;
+}
+
+.config-network-field {
+ display: grid;
+ height: 100%;
+ min-width: 0;
+ align-content: start;
+ gap: 8px;
+ padding: 14px;
+ border: 1px solid #e3e1db;
+ border-radius: 7px;
+ background: #faf8f2;
+}
+
+.config-network-field > span,
+.config-network-toggle > div > span {
+ color: #39352f;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.config-network-field small {
+ color: #7a746c;
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.config-network-label {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.config-network-label svg {
+ flex: none;
+ color: #777067;
+}
+
+.config-network-input {
+ width: 100%;
+ height: 38px;
+ min-width: 0;
+ padding: 0 11px;
+ border: 1px solid #d5d2cb;
+ border-radius: 6px;
+ outline: none;
+ background: #fffdf8;
+ color: #2d2a26;
+ font: inherit;
+ font-size: 13px;
+}
+
+.config-network-input:focus {
+ border-color: #777067;
+ box-shadow: 0 0 0 3px rgba(119, 112, 103, 0.12);
+}
+
+.config-network-input.error {
+ border-color: #b64132;
+ box-shadow: 0 0 0 3px rgba(182, 65, 50, 0.1);
+}
+
+.config-network-input:disabled {
+ cursor: not-allowed;
+ opacity: 0.58;
+}
+
+.config-network-port-field .config-network-input {
+ width: 120px;
+ max-width: 100%;
+}
+
+.config-network-toggle {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-content: center;
+ align-items: center;
+ gap: 16px;
+}
+
+.config-network-toggle > div {
+ display: grid;
+ min-width: 0;
+ gap: 7px;
+}
+
.config-panel-heading {
display: flex;
align-items: center;
@@ -5225,12 +5320,16 @@ input:focus-visible {
.usage-token-breakdown,
.usage-category-list {
display: grid;
+ width: 100%;
+ min-width: 0;
gap: 12px;
}
.usage-token-breakdown > div,
.usage-category-list > div {
display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ min-width: 0;
gap: 6px;
}
@@ -5239,11 +5338,14 @@ input:focus-visible {
display: flex;
align-items: center;
justify-content: space-between;
+ width: 100%;
+ min-width: 0;
gap: 8px;
}
.usage-token-breakdown strong,
.usage-category-list strong {
+ flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: #5f5a53;
@@ -5262,6 +5364,9 @@ input:focus-visible {
.usage-token-breakdown i,
.usage-category-list i {
display: block;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
overflow: hidden;
height: 5px;
border-radius: 999px;
@@ -5286,6 +5391,11 @@ input:focus-visible {
min-height: 290px;
}
+.usage-category-panel.compact-labels .usage-category-list strong {
+ flex: 0 1 auto;
+ max-width: min(46%, 160px);
+}
+
.usage-events-panel {
display: grid;
gap: 10px;
@@ -6184,6 +6294,11 @@ input:focus-visible {
grid-template-columns: 1fr;
}
+ .config-network-grid {
+ grid-template-columns: 1fr;
+ grid-auto-rows: auto;
+ }
+
.client-api-grid {
grid-template-columns: 1fr;
}
diff --git a/tests/navigationAndUpdate.test.ts b/tests/navigationAndUpdate.test.ts
index 7a2ea90..df52750 100644
--- a/tests/navigationAndUpdate.test.ts
+++ b/tests/navigationAndUpdate.test.ts
@@ -2,13 +2,14 @@ import { describe, expect, test } from 'bun:test';
import { appUpdateIndicatorState } from '../src/appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from '../src/navigation';
-describe('首页与版本管理导航', () => {
- test('内核停止时仅首页和版本管理始终可进入', () => {
+describe('首页、配置与版本管理导航', () => {
+ test('内核停止时首页、配置和版本管理始终可进入', () => {
expect(isAlwaysAvailablePage('home')).toBe(true);
expect(isAlwaysAvailablePage('versions')).toBe(true);
+ expect(isAlwaysAvailablePage('config')).toBe(true);
expect(canOpenAppPage('home', false)).toBe(true);
expect(canOpenAppPage('versions', false)).toBe(true);
- expect(canOpenAppPage('config', false)).toBe(false);
+ expect(canOpenAppPage('config', false)).toBe(true);
expect(canOpenAppPage('quota', false)).toBe(false);
});
diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts
index f31d701..a7f9d24 100644
--- a/tests/providerConfig.test.ts
+++ b/tests/providerConfig.test.ts
@@ -10,7 +10,9 @@ import {
modelSelectionForDiscovery,
parseProviderHeaders,
providerCategoryMatchesRecord,
+ providerRecordWithDisabledState,
providerSectionOrder,
+ resolveProviderRecordIndex,
sectionRecordsFromConfig,
stripResponseFields,
} from '../src/pages/ApiAccessPage';
@@ -209,6 +211,54 @@ describe('API 接入配置合并', () => {
expect(result['excluded-models']).toEqual(['claude-old-*', '*']);
});
+ it('重新启用仅含全停用规则的普通提供商时删除停用规则', () => {
+ const result = providerRecordWithDisabledState(
+ 'claude-api-key',
+ {
+ 'api-key': 'claude-key',
+ 'excluded-models': ['*'],
+ custom: { keep: true },
+ 'auth-index': 'runtime-only',
+ },
+ false,
+ );
+
+ expect(result['excluded-models']).toBeUndefined();
+ expect(result.custom).toEqual({ keep: true });
+ expect(result['auth-index']).toBeUndefined();
+ });
+
+ it('切换普通提供商时保留其他模型排除规则且不会重复追加停用规则', () => {
+ const disabled = providerRecordWithDisabledState(
+ 'codex-api-key',
+ {
+ 'api-key': 'codex-key',
+ 'excluded-models': ['preview-*', '*'],
+ },
+ true,
+ );
+ const enabled = providerRecordWithDisabledState('codex-api-key', disabled, false);
+
+ expect(disabled['excluded-models']).toEqual(['preview-*', '*']);
+ expect(enabled['excluded-models']).toEqual(['preview-*']);
+ });
+
+ it('运行时补全默认 Base URL 后仍能按原始索引找到配置记录', () => {
+ const records = [
+ { 'api-key': 'first-key', 'base-url': 'https://first.example' },
+ { 'api-key': 'target-key' },
+ ];
+ const index = resolveProviderRecordIndex(records, {
+ section: 'claude-api-key',
+ index: 1,
+ name: 'Claude',
+ apiKey: 'target-key',
+ baseUrl: 'https://api.anthropic.com',
+ });
+
+ expect(index).toBe(1);
+ });
+
it('校验自定义请求头格式', () => {
expect(parseProviderHeaders('Authorization: Bearer abc:def')).toEqual({
Authorization: 'Bearer abc:def',