feat(config): redesign settings navigation and preserve v7 config migration
- reorganize advanced settings with horizontal navigation, search, and contextual help - remove LAN discovery and trusted proxy settings - add robust v7-to-v8 migration for custom, plugin, routing, TLS, and credential fields - preserve unsaved drafts during external configuration refresh - add migration and settings UI coverage
This commit is contained in:
1 parent
c89e2fb020
commit
fdf56ee7eb
22 files changed
+1480
-615
No files matched your search
@@ -12,6 +12,7 @@
|
||||
"test:ui:api-provider-dialog": "node tests/api-provider-dialog-layout-ui.cjs",
|
||||
"test:ui:api-access-layout": "node tests/api-access-layout-ui.cjs",
|
||||
"test:ui:responsive": "node tests/responsive-layout-ui.cjs",
|
||||
"test:ui:settings": "node tests/config-settings-ui.cjs",
|
||||
"screenshots": "node scripts/capture-readme-screenshots.cjs",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
|
||||
@@ -65,10 +65,8 @@ const PATHS: &[(&str, &str)] = &[
|
||||
("oauth/providers/xai", "xai"),
|
||||
("server/host", "host"),
|
||||
("server/port", "port"),
|
||||
("server/trusted-proxies", "trusted-proxies"),
|
||||
("server/tls", "tls"),
|
||||
("server/commercial-mode", "commercial-mode"),
|
||||
("server/discovery", "discovery"),
|
||||
("management", "remote-management"),
|
||||
("credentials/concurrency", "credential-concurrency"),
|
||||
("credentials/in-flight", "credential-in-flight"),
|
||||
@@ -139,6 +137,18 @@ const PATHS: &[(&str, &str)] = &[
|
||||
("observability/pprof", "pprof"),
|
||||
];
|
||||
|
||||
pub(crate) fn legacy_extended_config_paths() -> Vec<(Vec<&'static str>, Vec<&'static str>)> {
|
||||
PATHS
|
||||
.iter()
|
||||
.map(|(native, legacy)| {
|
||||
(
|
||||
legacy.split('/').collect::<Vec<_>>(),
|
||||
native.split('/').collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parts(path: &str) -> Vec<String> {
|
||||
path.split('/').map(str::to_owned).collect()
|
||||
}
|
||||
@@ -285,8 +295,6 @@ fn legacy_path(path: &[String]) -> Vec<String> {
|
||||
// Only template-backed settings are writable. Existing GUI-owned settings and
|
||||
// system metadata cannot be changed by this command, even via a parent object.
|
||||
const OBJECT_PATHS: &[&str] = &[
|
||||
"server/discovery",
|
||||
"server/discovery/interfaces",
|
||||
"credentials/concurrency",
|
||||
"credentials/in-flight",
|
||||
"client/codex",
|
||||
@@ -306,11 +314,6 @@ const OBJECT_PATHS: &[&str] = &[
|
||||
"oauth/providers/xai",
|
||||
];
|
||||
const FIELD_GROUPS: &[(&str, &str, &str)] = &[
|
||||
("server", "strings", "trusted-proxies"),
|
||||
("server/discovery", "bool", "enabled auth-required advertise-management"),
|
||||
("server/discovery", "string", "service-name service-type"),
|
||||
("server/discovery", "strings", "subtypes"),
|
||||
("server/discovery/interfaces", "strings", "include exclude"),
|
||||
("management", "bool", "allow-remote disable-control-panel disable-auto-update-panel"),
|
||||
("management", "string", "base-url panel-github-repository"),
|
||||
("credentials/concurrency", "string", "cpa-heartbeat-timeout cpa-cancel-bound reclaim-grace cleanup-interval release-flush-interval release-max-backoff busy-retry-min busy-retry-max"),
|
||||
@@ -421,17 +424,6 @@ fn duration_nanos(value: &str) -> Option<i64> {
|
||||
(end == text.len() && nanos < i64::MAX as f64)
|
||||
.then(|| (nanos as i64) * if value.starts_with('-') { -1 } else { 1 })
|
||||
}
|
||||
fn valid_ip_or_cidr(value: &str) -> bool {
|
||||
if let Some((address, prefix)) = value.split_once('/') {
|
||||
address.parse::<IpAddr>().is_ok_and(|address| {
|
||||
prefix
|
||||
.parse::<u8>()
|
||||
.is_ok_and(|prefix| prefix <= if address.is_ipv4() { 32 } else { 128 })
|
||||
})
|
||||
} else {
|
||||
value.parse::<IpAddr>().is_ok()
|
||||
}
|
||||
}
|
||||
fn valid_ice_url(value: &str) -> bool {
|
||||
let Some((scheme, address)) = value.split_once(':') else {
|
||||
return false;
|
||||
@@ -722,10 +714,9 @@ fn validate_value(path: &[String], value: &Json, before: &Json) -> Result<(), St
|
||||
Some("strings")
|
||||
if value.as_array().is_some_and(|items| {
|
||||
items.iter().all(|value| {
|
||||
value.as_str().is_some_and(|text| {
|
||||
!text.chars().any(char::is_control)
|
||||
&& (key != "server/trusted-proxies" || valid_ip_or_cidr(text))
|
||||
})
|
||||
value
|
||||
.as_str()
|
||||
.is_some_and(|text| !text.chars().any(char::is_control))
|
||||
})
|
||||
}) => {}
|
||||
Some("image-mode")
|
||||
@@ -1075,7 +1066,6 @@ fn prepare_patch(
|
||||
"server/host",
|
||||
"server/port",
|
||||
"server/tls",
|
||||
"server/trusted-proxies",
|
||||
"management",
|
||||
"oauth/auth-dir",
|
||||
]
|
||||
@@ -1242,7 +1232,7 @@ client: {codex: {optimize-multi-agent-v2: false}}
|
||||
json!(true),
|
||||
false,
|
||||
),
|
||||
change(content, "server/trusted-proxies", json!([]), false),
|
||||
change(content, "management/allow-remote", json!(true), false),
|
||||
];
|
||||
let (rendered, result) = prepare_patch(content, &changes).unwrap();
|
||||
assert!(rendered.contains("# keep heading"));
|
||||
@@ -1404,7 +1394,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
|
||||
"999999999999999999"
|
||||
));
|
||||
let rejected = [
|
||||
("server/trusted-proxies", json!(["192.0.2.1/40"])),
|
||||
("observability/pprof/addr", json!("bad-address")),
|
||||
(
|
||||
"oauth/providers/codex/live-media-relay/public-ip",
|
||||
@@ -1449,7 +1438,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
|
||||
);
|
||||
}
|
||||
let accepted = [
|
||||
("server/trusted-proxies", json!(["192.0.2.1/24", "::1/128"])),
|
||||
("observability/pprof/addr", json!("[::1]:8316")),
|
||||
(
|
||||
"oauth/providers/codex/live-media-relay/public-ip",
|
||||
@@ -1526,8 +1514,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
|
||||
fn extended_restart_hint_distinguishes_live_updates_from_startup_settings() {
|
||||
let content = "config-version: 8\n";
|
||||
for (path, value) in [
|
||||
("server/discovery", json!({"enabled": true})),
|
||||
("server/discovery/service-name", json!("office")),
|
||||
(
|
||||
"observability/pprof",
|
||||
json!({"enable": true, "addr": "127.0.0.1:8316"}),
|
||||
@@ -1541,7 +1527,6 @@ client: {codex: {optimize-multi-agent-v2: false}}
|
||||
assert!(!result.restart_required, "{path} supports live updates");
|
||||
}
|
||||
for (path, value) in [
|
||||
("server/trusted-proxies", json!(["127.0.0.1"])),
|
||||
("management/allow-remote", json!(true)),
|
||||
("oauth/auth-dir", json!("~/selected-oauth")),
|
||||
] {
|
||||
@@ -1549,13 +1534,13 @@ client: {codex: {optimize-multi-agent-v2: false}}
|
||||
prepare_patch(content, &[change(content, path, value, false)]).unwrap();
|
||||
assert!(result.restart_required, "{path} requires a restart");
|
||||
}
|
||||
let unchanged = "config-version: 8\nserver: {trusted-proxies: []}\n";
|
||||
let unchanged = "config-version: 8\nmanagement: {allow-remote: false}\n";
|
||||
let (_, result) = prepare_patch(
|
||||
unchanged,
|
||||
&[change(
|
||||
unchanged,
|
||||
"server/trusted-proxies",
|
||||
json!([]),
|
||||
"management/allow-remote",
|
||||
json!(false),
|
||||
false,
|
||||
)],
|
||||
)
|
||||
|
||||
@@ -1189,6 +1189,14 @@ pub(crate) fn core_config_uses_v8(document: &serde_norway::Value) -> bool {
|
||||
|| yaml_mapping_value(root, "api-keys").is_some_and(serde_norway::Value::is_mapping)
|
||||
}
|
||||
|
||||
pub(crate) fn core_config_declares_v8(document: &serde_norway::Value) -> bool {
|
||||
document
|
||||
.as_mapping()
|
||||
.and_then(|root| yaml_mapping_value(root, "config-version"))
|
||||
.and_then(serde_norway::Value::as_u64)
|
||||
.is_some_and(|version| version >= 8)
|
||||
}
|
||||
|
||||
pub(crate) fn set_core_yaml_path_value(
|
||||
document: &mut serde_norway::Value,
|
||||
path: &[&str],
|
||||
@@ -1219,6 +1227,156 @@ pub(crate) fn set_core_yaml_path_value(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_legacy_core_config_to_v8(
|
||||
template: &str,
|
||||
legacy: &str,
|
||||
) -> Result<String, String> {
|
||||
let template_document = serde_norway::from_str::<serde_norway::Value>(template)
|
||||
.map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?;
|
||||
if !core_config_uses_v8(&template_document) {
|
||||
return Err("New kernel configuration template is not v8".to_string());
|
||||
}
|
||||
let legacy_document = serde_norway::from_str::<serde_norway::Value>(legacy)
|
||||
.map_err(|error| format!("Failed to parse existing kernel configuration: {error}"))?;
|
||||
if core_config_declares_v8(&legacy_document) {
|
||||
return Ok(legacy.to_string());
|
||||
}
|
||||
let legacy_root = legacy_document
|
||||
.as_mapping()
|
||||
.ok_or_else(|| "Existing kernel configuration root must be a YAML mapping".to_string())?;
|
||||
let mut migrated = template_document.clone();
|
||||
// Start from the new template so newly introduced defaults remain available,
|
||||
// then overlay the complete legacy document. The explicit lifts below only
|
||||
// move fields whose spelling changed between schemas; all other user-owned
|
||||
// sections must survive the upgrade as-is.
|
||||
merge_yaml_values(&mut migrated, legacy_document.clone());
|
||||
if let Some(version) = yaml_mapping_value(
|
||||
template_document
|
||||
.as_mapping()
|
||||
.ok_or_else(|| "New kernel configuration template root must be a YAML mapping".to_string())?,
|
||||
"config-version",
|
||||
).cloned() {
|
||||
set_core_yaml_path_value(&mut migrated, &["config-version"], version)?;
|
||||
} else {
|
||||
remove_core_yaml_path_value(&mut migrated, &["config-version"]);
|
||||
}
|
||||
lift_legacy_core_config_fields(
|
||||
&mut migrated,
|
||||
legacy_root,
|
||||
template_document
|
||||
.as_mapping()
|
||||
.ok_or_else(|| "New kernel configuration template root must be a YAML mapping".to_string())?,
|
||||
)?;
|
||||
render_yaml_value_changes(template, &template_document, &migrated)
|
||||
}
|
||||
|
||||
fn lift_legacy_core_config_fields(
|
||||
document: &mut serde_norway::Value,
|
||||
legacy: &serde_norway::Mapping,
|
||||
template: &serde_norway::Mapping,
|
||||
) -> Result<(), String> {
|
||||
let copy = |document: &mut serde_norway::Value, legacy_path: &[&str], v8_path: &[&str]| -> Result<(), String> {
|
||||
let Some(value) = nested_yaml_value(legacy, legacy_path).cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
// A partially migrated file can contain both spellings. Prefer the
|
||||
// canonical v8 value in that case; otherwise move the legacy value.
|
||||
if nested_yaml_value(legacy, v8_path).is_none() {
|
||||
set_core_yaml_path_value(document, v8_path, value)?;
|
||||
}
|
||||
if nested_yaml_value(template, legacy_path).is_none() {
|
||||
remove_core_yaml_path_value(document, legacy_path);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for (legacy_path, v8_path) in [
|
||||
(&["host"][..], &["server", "host"][..]),
|
||||
(&["port"], &["server", "port"]),
|
||||
(&["commercial-mode"], &["server", "commercial-mode"]),
|
||||
(&["auth-dir"], &["oauth", "auth-dir"]),
|
||||
(&["debug"], &["observability", "logs", "debug"]),
|
||||
(&["logging-to-file"], &["observability", "logs", "logging-to-file"]),
|
||||
(&["logs-max-total-size-mb"], &["observability", "logs", "logs-max-total-size-mb"]),
|
||||
(&["error-logs-max-files"], &["observability", "logs", "error-logs-max-files"]),
|
||||
(&["request-log"], &["observability", "logs", "request-log"]),
|
||||
(&["usage-statistics-enabled"], &["observability", "usage", "usage-statistics-enabled"]),
|
||||
(&["redis-usage-queue-retention-seconds"], &["observability", "usage", "redis-usage-queue-retention-seconds"]),
|
||||
(&["proxy-url"], &["requests", "proxy-url"]),
|
||||
(&["disable-cooling"], &["routing", "cooldown", "disable-cooling"]),
|
||||
(&["request-retry"], &["routing", "retry", "request-retry"]),
|
||||
(&["max-retry-credentials"], &["routing", "retry", "max-retry-credentials"]),
|
||||
(&["max-retry-interval"], &["routing", "retry", "max-retry-interval"]),
|
||||
(&["streaming", "bootstrap-retries"], &["requests", "streaming", "bootstrap-retries"]),
|
||||
(&["remote-management", "secret-key"], &["management", "secret-key"]),
|
||||
(&["oauth-model-alias"], &["oauth", "model-alias"]),
|
||||
(&["payload"], &["requests", "payload"]),
|
||||
] {
|
||||
copy(document, legacy_path, v8_path)?;
|
||||
}
|
||||
if let Some(tls) = yaml_mapping_value(legacy, "tls").filter(|value| value.is_mapping()).cloned() {
|
||||
if nested_yaml_value(legacy, &["server", "tls"]).is_none() {
|
||||
set_core_yaml_path_value(document, &["server", "tls"], tls)?;
|
||||
}
|
||||
if yaml_mapping_value(template, "tls").is_none() {
|
||||
remove_core_yaml_path_value(document, &["tls"]);
|
||||
}
|
||||
}
|
||||
if let Some(keys) = yaml_mapping_value(legacy, "api-keys").filter(|value| value.is_sequence()).cloned() {
|
||||
if nested_yaml_value(legacy, &["access", "api-keys"]).is_none() {
|
||||
set_core_yaml_path_value(document, &["access", "api-keys"], keys)?;
|
||||
}
|
||||
if let Some(template_upstreams) = yaml_mapping_value(template, "api-keys")
|
||||
.filter(|value| value.is_mapping())
|
||||
.cloned()
|
||||
{
|
||||
// A v8 template may already contain the upstream credential map at
|
||||
// the root. The legacy client-key sequence must not replace it.
|
||||
set_core_yaml_path_value(document, &["api-keys"], template_upstreams)?;
|
||||
} else {
|
||||
remove_core_yaml_path_value(document, &["api-keys"]);
|
||||
}
|
||||
}
|
||||
let has_canonical_upstream_mapping = yaml_mapping_value(legacy, "api-keys")
|
||||
.is_some_and(serde_norway::Value::is_mapping);
|
||||
for (legacy_name, provider) in V8_PROVIDER_FAMILIES {
|
||||
let Some(records) = yaml_mapping_value(legacy, legacy_name) else {
|
||||
continue;
|
||||
};
|
||||
if !has_canonical_upstream_mapping
|
||||
&& nested_yaml_value(legacy, &["api-keys", provider]).is_none()
|
||||
{
|
||||
let grouped = group_legacy_provider_records(provider, records)?;
|
||||
set_core_yaml_path_value(document, &["api-keys", provider], grouped)?;
|
||||
}
|
||||
if yaml_mapping_value(template, legacy_name).is_none() {
|
||||
remove_core_yaml_path_value(document, &[legacy_name]);
|
||||
}
|
||||
}
|
||||
for (legacy_path, v8_path) in legacy_extended_config_paths() {
|
||||
copy(document, &legacy_path, &v8_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_core_yaml_path_value(document: &mut serde_norway::Value, path: &[&str]) -> bool {
|
||||
let Some((key, parents)) = path.split_last() else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut mapping) = document.as_mapping_mut() else {
|
||||
return false;
|
||||
};
|
||||
for section in parents {
|
||||
let Some(parent) = mapping.get_mut(yaml_key(section)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(parent) = parent.as_mapping_mut() else {
|
||||
return false;
|
||||
};
|
||||
mapping = parent;
|
||||
}
|
||||
mapping.remove(yaml_key(key)).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn set_core_yaml_schema_value(
|
||||
document: &mut serde_norway::Value,
|
||||
legacy_path: &[&str],
|
||||
|
||||
@@ -1957,7 +1957,11 @@ pub(crate) fn migrate_core_config_for_update(
|
||||
}
|
||||
let template_document = serde_norway::from_str::<serde_norway::Value>(&template)
|
||||
.map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?;
|
||||
let migrated = if core_config_uses_v8(&template_document) {
|
||||
let migrated = if (core_config_declares_v8(&template_document) || core_config_uses_v8(&template_document))
|
||||
&& !core_config_declares_v8(&old_document)
|
||||
{
|
||||
migrate_legacy_core_config_to_v8(&template, &old_config)?
|
||||
} else if core_config_uses_v8(&template_document) {
|
||||
old_config
|
||||
} else {
|
||||
merge_core_config_fields(&template, Some(&old_config))?
|
||||
|
||||
@@ -594,20 +594,36 @@ fn replacing_a_core_migrates_old_fields_into_the_new_template() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_v7_with_v8_preserves_the_legacy_config_byte_for_byte() {
|
||||
fn replacing_v7_with_v8_lifts_legacy_values_into_the_new_layout() {
|
||||
let root = agent_test_home("core-config-v8-preserve");
|
||||
let source = root.join("source");
|
||||
let target = root.join("target");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let old_config = "# User v7 config\nhost: 127.0.0.1\nport: 9527\nremote-management:\n secret-key: user-secret\napi-keys: [client-key]\nproxy-url: direct\n";
|
||||
let v8_template = "config-version: 8\nserver: {host: '', port: 8317}\nmanagement: {secret-key: ''}\naccess: {api-keys: [template-key]}\nrequests: {proxy-url: ''}\n";
|
||||
let old_config = "# User v7 config\nhost: 127.0.0.1\nport: 9527\nremote-management:\n secret-key: user-secret\napi-keys: [client-key]\nproxy-url: direct\nrouting:\n strategy: fill-first\n session-affinity: true\n session-affinity-ttl: 2h\nplugins:\n configs:\n local:\n enabled: true\n store-auth:\n registry: stored-token\n";
|
||||
let v8_template = "config-version: 8\nserver: {host: '', port: 8317}\nmanagement: {secret-key: ''}\naccess: {api-keys: [template-key]}\napi-keys: {codex: [{name: template-upstream, keys: []}]}\nrequests: {proxy-url: ''}\nrouting: {strategy: round-robin, session-affinity: false, session-affinity-ttl: 30m}\nplugins: {configs: {}, store-auth: {}}\n";
|
||||
fs::write(source.join(CORE_CONFIG_FILE), old_config).unwrap();
|
||||
fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), v8_template).unwrap();
|
||||
|
||||
migrate_core_config_for_update(&source, &target).unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(), old_config);
|
||||
let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
|
||||
let settings = core_config_settings_from_value(&document).unwrap();
|
||||
assert_eq!(settings.host, "127.0.0.1");
|
||||
assert_eq!(settings.port, 9527);
|
||||
assert_eq!(settings.api_keys, vec!["client-key"]);
|
||||
assert_eq!(settings.management_secret_key.as_deref(), Some("user-secret"));
|
||||
assert_eq!(settings.proxy_url, "direct");
|
||||
assert_eq!(document["routing"]["strategy"], "fill-first");
|
||||
assert_eq!(document["routing"]["session-affinity"], true);
|
||||
assert_eq!(document["routing"]["session-affinity-ttl"], "2h");
|
||||
assert_eq!(document["plugins"]["configs"]["local"]["enabled"], true);
|
||||
assert_eq!(document["plugins"]["store-auth"]["registry"], "stored-token");
|
||||
assert_eq!(document["api-keys"]["codex"][0]["name"], "template-upstream");
|
||||
assert!(migrated.starts_with("config-version:"));
|
||||
assert!(document.get("host").is_none());
|
||||
assert!(document.get("port").is_none());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
@@ -623,16 +639,17 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
|
||||
let legacy = "# User configuration\nusage-statistics-enabled: true\nhost: 127.0.0.1\nport: 9527\nauth-dir: ../oauth\napi-keys: [client-key]\nremote-management: {secret-key: user-secret}\nproxy-url: direct\nrequest-retry: 9\ncodex-api-key: [{api-key: upstream-key, base-url: 'https://example.invalid/v1'}]\noauth-model-alias: {codex: [{name: model, alias: custom-model}]}\n";
|
||||
let template = "observability: {usage: {usage-statistics-enabled: false}}\nserver: {host: '', port: 8317}\noauth: {auth-dir: '~/.cli-proxy-api'}\naccess: {api-keys: [your-api-key-1]}\nmanagement: {secret-key: ''}\nrequests: {proxy-url: ''}\nrouting: {retry: {request-retry: 3}}\n";
|
||||
|
||||
for prefix in ["", "config-version: 8\n", "server: {host: 127.0.0.1}\n", "config-version: 8\nserver: {host: 127.0.0.1}\n"] {
|
||||
for prefix in ["", "server: {host: 127.0.0.1}\n"] {
|
||||
let original = format!("{prefix}{legacy}");
|
||||
fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap();
|
||||
for template_prefix in ["config-version: 8\n", ""] {
|
||||
fs::create_dir_all(&staging_dir).unwrap();
|
||||
fs::write(staging_dir.join(CORE_EXAMPLE_CONFIG_FILE), format!("{template_prefix}{template}")).unwrap();
|
||||
migrate_core_config_for_update(&install_dir, &staging_dir).unwrap();
|
||||
let migrated = fs::read_to_string(staging_dir.join(CORE_CONFIG_FILE)).unwrap();
|
||||
overlay_install_dir(&install_dir, &staging_dir).unwrap();
|
||||
fs::write(install_dir.join(CORE_CONFIG_FILE), &original).unwrap();
|
||||
|
||||
let migrated = fs::read_to_string(install_dir.join(CORE_CONFIG_FILE)).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
|
||||
let settings = core_config_settings_from_value(&document).unwrap();
|
||||
assert_eq!(settings.auth_dir, "../oauth", "credential directory changed during update");
|
||||
@@ -642,7 +659,12 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
|
||||
assert_eq!(settings.management_secret_key.as_deref(), Some("user-secret"));
|
||||
assert_eq!(settings.proxy_url, "direct");
|
||||
assert_eq!(settings.request_retry, 9);
|
||||
assert_eq!(migrated, original, "update must preserve every existing field and comment");
|
||||
assert!(migrated.contains("server:"), "legacy configuration must be lifted into the v8 layout: {migrated}");
|
||||
assert!(document.get("host").is_none(), "{migrated}");
|
||||
assert!(document.get("auth-dir").is_none());
|
||||
assert!(document.get("codex-api-key").is_none());
|
||||
assert_eq!(document["oauth"]["model-alias"]["codex"][0]["alias"], "custom-model");
|
||||
assert_eq!(document["api-keys"]["codex"][0]["keys"][0]["api-key"], "upstream-key");
|
||||
|
||||
let mut gui = GuiConfigFile::default();
|
||||
apply_core_settings_to_gui_config(&mut gui, &settings);
|
||||
@@ -652,8 +674,8 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() {
|
||||
let effective = core_config_settings_from_value(&startup).unwrap();
|
||||
assert_eq!(auth_dir_path_for_core(&effective.auth_dir, &install_dir).unwrap(), root.join("oauth"));
|
||||
assert!(effective.usage_statistics_enabled);
|
||||
assert_eq!(startup["codex-api-key"], document["codex-api-key"]);
|
||||
assert_eq!(startup["oauth-model-alias"], document["oauth-model-alias"]);
|
||||
assert_eq!(startup["api-keys"]["codex"], document["api-keys"]["codex"]);
|
||||
assert_eq!(startup["oauth"]["model-alias"], document["oauth"]["model-alias"]);
|
||||
assert_eq!(fs::read(&credential_path).unwrap(), b"existing-credential");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export function SettingsHelp({ label, children }: { label: string; children: ReactNode }) {
|
||||
const { locale } = useI18n();
|
||||
const helpLabel = locale === 'zh-CN' ? '说明' : locale === 'ja' ? '説明' : 'Help';
|
||||
const id = useId();
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const popover = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const place = () => {
|
||||
if (!trigger.current || !popover.current) return;
|
||||
const anchor = trigger.current.getBoundingClientRect();
|
||||
// Close help when its setting is hidden by a category or search change.
|
||||
if (!anchor.width || !anchor.height) { setOpen(false); return; }
|
||||
const bounds = popover.current.getBoundingClientRect();
|
||||
const left = Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12));
|
||||
const below = anchor.bottom + 8;
|
||||
const top = Math.max(12, below + bounds.height <= window.innerHeight - 12 ? below : anchor.top - bounds.height - 8);
|
||||
setPosition({ left, top });
|
||||
};
|
||||
place();
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
const observer = new ResizeObserver(place);
|
||||
if (trigger.current) observer.observe(trigger.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dismiss = (event: PointerEvent) => {
|
||||
if (!trigger.current?.contains(event.target as Node) && !popover.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
trigger.current?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', dismiss);
|
||||
window.addEventListener('keydown', escape, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', dismiss);
|
||||
window.removeEventListener('keydown', escape, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return <span className="settings-help">
|
||||
<button ref={trigger} type="button" className="settings-help-trigger" aria-label={`${label} · ${helpLabel}`} aria-expanded={open} aria-controls={id} aria-describedby={open ? id : undefined} onClick={() => { setPosition(null); setOpen(previous => !previous); }}>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? createPortal(<span ref={popover} id={id} role="tooltip" className="settings-help-popover" style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? 'visible' : 'hidden' }}>{children}</span>, document.body) : null}
|
||||
</span>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, Plus, Trash2, Undo2 } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { templateText } from '../i18n/templateConfig';
|
||||
import { configRecord, defaultStructuredValue, type ConfigShape, type ConfigText } from '../services/structuredConfig';
|
||||
import { SettingsHelp } from './SettingsHelp';
|
||||
import './StructuredConfigEditor.css';
|
||||
|
||||
export function useConfigText() {
|
||||
@@ -75,11 +76,11 @@ export function StructuredConfigEditor({ shape, value, onChange, disabled = fals
|
||||
return <div className="structured-object">
|
||||
{Object.entries(shape.fields ?? {}).map(([key, field]) => <div key={key} className={`structured-field${['array', 'map', 'object', 'any'].includes(field.type) ? ' structured-field-wide' : ''}`}>
|
||||
<div className="structured-label"><label htmlFor={`${id}-${key}`}>{tx(field.label) || key}</label>
|
||||
{field.hint && <SettingsHelp label={tx(field.label) || key}>{tx(field.hint)}</SettingsHelp>}
|
||||
{field.optional && record[key] !== undefined && <button type="button" className="icon-button quiet" disabled={disabled} onClick={() => update(key, undefined)} title={inheritText} aria-label={`${inheritText}: ${tx(field.label) || key}`}><Undo2 size={13} /></button>}
|
||||
</div>
|
||||
{field.optional && record[key] == null ? <button type="button" className="secondary-button structured-unset" disabled={disabled} aria-label={`${tx(field.label) || key}: ${inheritText}`} onClick={() => update(key, defaultStructuredValue(field))}>{inheritText} · {tx({ zh: '设置', en: 'Set', ja: '設定' })}</button>
|
||||
: <StructuredConfigEditor shape={field} value={record[key]} onChange={next => update(key, next)} disabled={disabled} id={`${id}-${key}`} />}
|
||||
{field.hint && <small>{tx(field.hint)}</small>}
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -2,19 +2,23 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { RotateCcw, Save, Settings2 } from 'lucide-react';
|
||||
import { RotateCcw, Save } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { templateMessages, templateText } from '../i18n/templateConfig';
|
||||
import { useCoreRuntime, type CoreStatus } from '../coreRuntime';
|
||||
import { SettingsHelp } from './SettingsHelp';
|
||||
import {
|
||||
applyTemplateChanges, draftTemplateField, readTemplatePath, templateFieldKey, templateFieldValidation, templateFieldSaveValue,
|
||||
type TemplateConfigChange, type TemplateConfigDraft, type TemplateConfigField, type TemplateConfigGroup, type TemplateConfigSaveResult,
|
||||
type TemplateConfigChange, type TemplateConfigDraft, type TemplateConfigField, type TemplateConfigGroup, type TemplateConfigSaveResult, type TemplateText,
|
||||
} from '../services/templateConfig';
|
||||
import '../styles/template-config.css';
|
||||
|
||||
export function TemplateConfigSection({ groups, visibleGroups }: {
|
||||
const restartHint = { zh: '需重启内核', en: 'Core restart required', ja: 'コアの再起動が必要' } satisfies TemplateText;
|
||||
|
||||
export function TemplateConfigSection({ groups, visibleGroups, onDirtyGroupsChange }: {
|
||||
groups: readonly TemplateConfigGroup[];
|
||||
visibleGroups?: readonly string[];
|
||||
onDirtyGroupsChange?: (groupIds: readonly string[]) => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const { status, publishStatus, refreshStatus } = useCoreRuntime();
|
||||
@@ -27,8 +31,18 @@ export function TemplateConfigSection({ groups, visibleGroups }: {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const revision = useRef(0);
|
||||
const reportedDirtyGroups = useRef<string | null>(null);
|
||||
const tr = (message: keyof typeof templateMessages) => templateText(templateMessages[message], locale);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onDirtyGroupsChange) return;
|
||||
const dirtyGroups = groups.filter((group) => group.fields.some((field) => drafts[templateFieldKey(field.path)])).map((group) => group.id);
|
||||
const signature = JSON.stringify(dirtyGroups);
|
||||
if (reportedDirtyGroups.current === signature) return;
|
||||
reportedDirtyGroups.current = signature;
|
||||
onDirtyGroupsChange(dirtyGroups);
|
||||
}, [drafts, groups, onDirtyGroupsChange]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const request = ++revision.current;
|
||||
try {
|
||||
@@ -132,9 +146,9 @@ export function TemplateConfigSection({ groups, visibleGroups }: {
|
||||
const control = (group: TemplateConfigGroup, field: TemplateConfigField, value: unknown, id: string, invalid: boolean) => {
|
||||
const disabled = busy !== null || config === null;
|
||||
const onChange = (next: unknown) => changeField(group, field, next);
|
||||
const common = { id, disabled, 'aria-invalid': invalid || undefined, 'aria-describedby': `${id}-description` };
|
||||
const common = { id, disabled, 'aria-invalid': invalid || undefined, 'aria-describedby': invalid ? `${id}-error` : undefined };
|
||||
if (field.type === 'custom' && field.render) return field.render({ value, onChange, disabled, id });
|
||||
if (field.type === 'boolean') return <label className="switch-control"><input {...common} type="checkbox" checked={value === true} onChange={(event) => onChange(event.currentTarget.checked)} /><span className="switch-track" /></label>;
|
||||
if (field.type === 'boolean') return <label className="switch-control template-config-switch"><input {...common} type="checkbox" checked={value === true} onChange={(event) => onChange(event.currentTarget.checked)} /><span className="switch-track" /></label>;
|
||||
if (field.type === 'select') {
|
||||
const selected = field.options?.findIndex((option) => Object.is(option.value, value)) ?? -1;
|
||||
return <select {...common} className="config-network-input" value={String(selected)} onChange={(event) => onChange(field.options?.[Number(event.currentTarget.value)]?.value)}>
|
||||
@@ -160,8 +174,9 @@ export function TemplateConfigSection({ groups, visibleGroups }: {
|
||||
{restartNeeded ? <div className="template-config-notice" role="status"><span>{tr('restart')}</span>{status?.running ? <button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => void restart()}>{tr('restartNow')}</button> : null}</div> : null}
|
||||
{groups.map((group) => {
|
||||
const dirty = group.fields.some((field) => drafts[templateFieldKey(field.path)]);
|
||||
return <section key={group.id} className="config-card template-config-card" hidden={!visible.includes(group)} aria-labelledby={`template-group-${group.id}`}>
|
||||
<div className="template-config-heading"><div><h3 id={`template-group-${group.id}`}><Settings2 size={18} aria-hidden="true" />{templateText(group.title, locale)}</h3>{group.description ? <p>{templateText(group.description, locale)}</p> : null}</div></div>
|
||||
const needsRestart = group.fields.some((field) => field.restart);
|
||||
return <section key={group.id} id={`config-group-${group.id}`} tabIndex={-1} className={`config-card template-config-card${dirty ? ' template-config-card-dirty' : ''}`} hidden={!visible.includes(group)} aria-labelledby={`template-group-${group.id}`}>
|
||||
<div className="template-config-heading"><div className="template-config-heading-main"><h3 id={`template-group-${group.id}`}>{templateText(group.title, locale)}</h3>{group.description ? <SettingsHelp label={templateText(group.title, locale)}>{templateText(group.description, locale)}</SettingsHelp> : null}</div>{needsRestart ? <span className="template-config-restart-hint"><RotateCcw size={12} aria-hidden="true" />{templateText(restartHint, locale)}</span> : null}</div>
|
||||
<div className="template-config-fields">{group.fields.map((field, index) => {
|
||||
const key = templateFieldKey(field.path);
|
||||
const original = readTemplatePath(config, field.path);
|
||||
@@ -170,16 +185,16 @@ export function TemplateConfigSection({ groups, visibleGroups }: {
|
||||
const value = draft && !draft.remove ? draft.value : draft?.remove || !original.exists ? field.defaultValue : original.value;
|
||||
const validation = draft && !draft.remove ? templateFieldValidation(field, value) : null;
|
||||
const id = `template-${group.id}-${index}`;
|
||||
return <div key={key} className={`template-config-field${field.type === 'custom' || field.type === 'json' || field.type === 'string-list' ? ' template-config-field-wide' : ''}${field.type === 'boolean' ? ' template-config-toggle' : ''}`}>
|
||||
<div className="template-config-label">{field.type === 'custom' ? <span id={`${id}-label`}>{templateText(field.label, locale)}</span> : <label htmlFor={id}>{templateText(field.label, locale)}</label>}<span className={`template-config-origin${draft ? ' template-config-origin-dirty' : ''}`}>{draft ? tr('dirty') : exists ? tr('explicit') : tr('inherited')}</span></div>
|
||||
{field.type === 'custom' ? <details className="template-config-custom"><summary aria-labelledby={`${id}-label ${id}-edit`} aria-describedby={`${id}-description`}><span id={`${id}-edit`}>{tr('editDetails')}</span></summary><div className="template-config-control" role="group" aria-labelledby={`${id}-label`}>{control(group, field, value, id, Boolean(validation))}</div></details> : <div className="template-config-control">{control(group, field, value, id, Boolean(validation))}</div>}
|
||||
<small id={`${id}-description`}>{templateText(field.description, locale)}{field.type === 'string-list' ? ` ${tr('list')}` : ''}</small>
|
||||
{validation ? <small className="template-config-error" role="alert">{templateText(validation, locale)}</small> : null}
|
||||
{exists ? <button type="button" className="template-config-reset" disabled={busy !== null || !config} aria-label={`${tr('default')}: ${templateText(field.label, locale)}`} onClick={() => changeField(group, field, null, true)}><RotateCcw size={12} aria-hidden="true" />{tr('default')}</button> : null}
|
||||
const description = [templateText(field.description, locale), field.type === 'string-list' ? tr('list') : ''].filter(Boolean).join(' ');
|
||||
const resetLabel = `${tr('default')}: ${templateText(field.label, locale)}`;
|
||||
return <div key={key} id={`template-field-${group.id}-${index}`} tabIndex={-1} className={`template-config-field${field.type === 'custom' || field.type === 'json' || field.type === 'string-list' ? ' template-config-field-wide' : ''}${field.type === 'boolean' ? ' template-config-toggle' : ''}${draft ? ' template-config-field-dirty' : ''}`}>
|
||||
<div className="template-config-label">{field.type === 'custom' ? <span id={`${id}-label`}>{templateText(field.label, locale)}</span> : <label htmlFor={id}>{templateText(field.label, locale)}</label>}<div className="template-config-label-tools">{description ? <SettingsHelp label={templateText(field.label, locale)}>{description}</SettingsHelp> : null}{exists ? <button type="button" className="template-config-reset" disabled={busy !== null || !config} title={resetLabel} aria-label={resetLabel} onClick={() => changeField(group, field, null, true)}><RotateCcw size={14} aria-hidden="true" /></button> : null}</div></div>
|
||||
{field.type === 'custom' ? <details className="template-config-custom"><summary aria-labelledby={`${id}-label ${id}-edit`} aria-describedby={validation ? `${id}-error` : undefined}><span id={`${id}-edit`}>{tr('editDetails')}</span></summary><div className="template-config-control" role="group" aria-labelledby={`${id}-label`}>{control(group, field, value, id, Boolean(validation))}</div></details> : <div className="template-config-control">{control(group, field, value, id, Boolean(validation))}</div>}
|
||||
{validation ? <small id={`${id}-error`} className="template-config-error" role="alert">{templateText(validation, locale)}</small> : null}
|
||||
</div>;
|
||||
})}</div>
|
||||
{groupErrors[group.id] ? <p className="template-config-error" role="alert">{groupErrors[group.id]}</p> : null}
|
||||
<div className="template-config-actions"><span role="status">{dirty ? tr('dirty') : saved[group.id] ? tr('saved') : ''}</span><div>{dirty ? <button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => discardGroup(group)}>{tr('discard')}</button> : null}<button type="button" className="primary-button compact-button" disabled={!dirty || busy !== null || !config} onClick={() => void saveGroup(group)}><Save size={15} aria-hidden="true" />{busy === group.id ? tr('saving') : tr('save')}</button></div></div>
|
||||
<div className="template-config-actions"><span className={dirty ? 'template-config-action-status-dirty' : saved[group.id] ? 'template-config-action-status-saved' : undefined} role="status">{dirty ? tr('dirty') : saved[group.id] ? tr('saved') : ''}</span><div>{dirty ? <button type="button" className="secondary-button compact-button" disabled={busy !== null} onClick={() => discardGroup(group)}>{tr('discard')}</button> : null}<button type="button" className="primary-button compact-button" disabled={!dirty || busy !== null || !config} onClick={() => void saveGroup(group)}><Save size={15} aria-hidden="true" />{busy === group.id ? tr('saving') : tr('save')}</button></div></div>
|
||||
</section>;
|
||||
})}
|
||||
</div>;
|
||||
|
||||
@@ -926,7 +926,7 @@ export function createBrowserMockRuntime(
|
||||
else if (key === 'requestLog' || key === 'pluginsEnabled') syncValues[key] = false;
|
||||
}
|
||||
Object.assign(state.coreConfig, syncValues);
|
||||
const restartRequired = changes.some((change) => !sameTemplateValue(readTemplatePath(before, change.path), readTemplatePath(state.extendedConfig, change.path)) && ['server.host', 'server.port', 'server.tls', 'server.trusted-proxies', 'management', 'oauth.auth-dir'].some((prefix) => change.path.join('.') === prefix || change.path.join('.').startsWith(`${prefix}.`)));
|
||||
const restartRequired = changes.some((change) => !sameTemplateValue(readTemplatePath(before, change.path), readTemplatePath(state.extendedConfig, change.path)) && ['server.host', 'server.port', 'server.tls', 'management', 'oauth.auth-dir'].some((prefix) => change.path.join('.') === prefix || change.path.join('.').startsWith(`${prefix}.`)));
|
||||
emit('config-files-changed', { paths: ['cpa-core/config.yaml'], errors: [] });
|
||||
return { config: clone(state.extendedConfig), restartRequired };
|
||||
}
|
||||
|
||||
+484
-501
File diff suppressed because it is too large.
Load diff
@@ -96,7 +96,7 @@ function WordEditor({ provider, entries, composer, disabled, onAdd, onCompose, o
|
||||
);
|
||||
}
|
||||
|
||||
export function SensitiveWordsPage() {
|
||||
export function SensitiveWordsPage({ onDirtyChange }: { onDirtyChange?: (dirty: boolean) => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const nextEntryId = useRef(0);
|
||||
@@ -143,6 +143,8 @@ export function SensitiveWordsPage() {
|
||||
|| JSON.stringify(words.devinSensitiveWords) !== JSON.stringify(saved.devinSensitiveWords)
|
||||
);
|
||||
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
|
||||
const changeWord = (provider: Provider, id: number, value: string) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
import { SettingsHelp } from '../components/SettingsHelp';
|
||||
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
|
||||
|
||||
type PresetThinkingEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||
@@ -591,7 +592,6 @@ export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean }
|
||||
<span><GitFork size={18} /></span>
|
||||
<div>
|
||||
<h2 id="thinking-alias-editor-title">{t(editingEntry ? 'common.edit' : 'aliases.create.title')}</h2>
|
||||
<p>{t('aliases.create.description')}</p>
|
||||
</div>
|
||||
<button type="button" className="icon-button quiet" onClick={closeEditor} disabled={Boolean(busyAlias)} title={t('common.close')} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
@@ -601,7 +601,7 @@ export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean }
|
||||
<div className="thinking-alias-dialog-body">
|
||||
|
||||
<div className="thinking-alias-field thinking-model-field">
|
||||
<label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label>
|
||||
<div className="config-field-label"><label htmlFor="thinking-model-search">{t('aliases.originalModel')}</label><SettingsHelp label={t('aliases.originalModel')}>{t('aliases.sourceHint')}</SettingsHelp></div>
|
||||
<div className="thinking-model-picker" ref={modelPickerRef}>
|
||||
<div className="thinking-model-search">
|
||||
{loading ? <LoaderCircle size={15} className="spin" /> : <Search size={15} />}
|
||||
@@ -701,15 +701,12 @@ export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean }
|
||||
{t('aliases.sourceLabel', { source: thinkingAliasSourceDetail(selectedSource) })}
|
||||
</strong>
|
||||
</div>
|
||||
) : (
|
||||
<small className="thinking-model-hint">{t('aliases.sourceHint')}</small>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="thinking-alias-field">
|
||||
<div className="thinking-field-heading">
|
||||
<strong>{t('aliases.effort.title')}</strong>
|
||||
<span>{t('aliases.effort.description')}</span>
|
||||
</div>
|
||||
<div className="thinking-effort-options">
|
||||
<button
|
||||
@@ -750,7 +747,6 @@ export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean }
|
||||
<label className={`thinking-fast-option${fastEnabled ? ' active' : ''}`}>
|
||||
<span className="thinking-fast-option-copy">
|
||||
<span><Zap size={15} /> Fast</span>
|
||||
<small>{fastEnabled ? t('aliases.fast.enabled') : t('aliases.fast.disabled')}</small>
|
||||
</span>
|
||||
<span className="switch-control thinking-fast-switch">
|
||||
<input
|
||||
|
||||
@@ -25,27 +25,6 @@ export const generalTemplateGroups: readonly TemplateConfigGroup[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const networkTemplateGroups: readonly TemplateConfigGroup[] = [
|
||||
{
|
||||
id: 'trusted-proxies', title: text('可信代理', 'Trusted proxies', '信頼するプロキシ'),
|
||||
fields: [field('server.trusted-proxies', 'string-list', '可信代理 IP / CIDR', 'Trusted proxy IPs / CIDRs', '信頼するプロキシの IP / CIDR', [], text('仅信任这些代理提供的客户端 IP 转发头。空列表表示不信任转发头;修改后需重启内核。', 'Only trust forwarded client IP headers from these proxies. An empty list trusts none. Restart the core after changing this setting.', '指定したプロキシからのクライアント IP 転送ヘッダーのみを信頼します。空のリストでは信頼しません。変更後はコアを再起動してください。'), { restart: true })],
|
||||
},
|
||||
{
|
||||
id: 'discovery', title: text('局域网服务发现', 'Local network discovery', 'ローカルネットワークでのサービス検出'),
|
||||
description: text('通过 mDNS / DNS-SD 广播服务,运行中的内核会自动应用修改。', 'Advertise this service through mDNS / DNS-SD. The running core applies changes automatically.', 'mDNS / DNS-SD でサービスを通知します。実行中のコアが変更を自動で適用します。'),
|
||||
fields: [
|
||||
field('server.discovery.enabled', 'boolean', '启用服务发现', 'Enable service discovery', 'サービス検出を有効化', false),
|
||||
field('server.discovery.service-name', 'string', '服务名称前缀', 'Service name prefix', 'サービス名の接頭辞', '', text('留空使用 CPA;内核会自动追加短 ID。', 'Leave blank to use CPA. The core appends a short ID.', '空欄では CPA を使用します。コアが短い ID を追加します。')),
|
||||
field('server.discovery.service-type', 'string', '服务类型', 'Service type', 'サービス種別', '_ai-gateway._tcp'),
|
||||
field('server.discovery.subtypes', 'string-list', '广播的协议子类型', 'Advertised protocol subtypes', '通知するプロトコルのサブタイプ', undefined, text('未单独配置时由内核决定。示例:_chat-completions、_responses、_messages。', 'When unset, the core chooses subtypes. Examples: _chat-completions, _responses, _messages.', '未設定ではコアがサブタイプを選択します。例:_chat-completions、_responses、_messages。')),
|
||||
field('server.discovery.interfaces.include', 'string-list', '允许的网卡', 'Included interfaces', '使用するネットワークインターフェース', [], text('空列表表示自动检测物理网卡。', 'An empty list automatically detects physical interfaces.', '空のリストでは物理ネットワークインターフェースを自動検出します。')),
|
||||
field('server.discovery.interfaces.exclude', 'string-list', '排除的网卡模式', 'Excluded interface patterns', '除外するインターフェースのパターン', []),
|
||||
field('server.discovery.auth-required', 'boolean', '广播需要身份验证', 'Advertise authentication requirement', '認証が必要なことを通知', true),
|
||||
field('server.discovery.advertise-management', 'boolean', '广播管理接口可用性', 'Advertise management availability', '管理 API の利用可否を通知', false),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const routingTemplateGroups: readonly TemplateConfigGroup[] = [
|
||||
{
|
||||
id: 'routing-advanced', title: text('会话与冷却策略', 'Session & cooldown behavior', 'セッションとクールダウンの動作'),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TemplateText } from './templateConfig';
|
||||
import { generalTemplateGroups, requestTemplateGroups, routingTemplateGroups } from './generalTemplateFields';
|
||||
import { oauthTemplateGroups } from './oauthTemplateFields';
|
||||
import { extensionTemplateGroups } from './extensionTemplateFields';
|
||||
import { payloadTemplateGroups } from './payloadTemplateFields';
|
||||
|
||||
const text = (zh: string, en: string, ja: string): TemplateText => ({ zh, en, ja });
|
||||
export const settingsCategories = [
|
||||
{ id: 'general', title: text('服务与访问', 'Service & access', 'サービスとアクセス') },
|
||||
{ id: 'routing', title: text('路由与稳定性', 'Routing & reliability', 'ルーティングと安定性') },
|
||||
{ id: 'requests', title: text('模型与请求', 'Models & requests', 'モデルとリクエスト') },
|
||||
{ id: 'oauth', title: text('上游与凭据', 'Upstreams & credentials', '上流と認証情報') },
|
||||
{ id: 'diagnostics', title: text('日志与诊断', 'Logs & diagnostics', 'ログと診断') },
|
||||
{ id: 'extensions', title: text('扩展与集成', 'Extensions & integrations', '拡張と連携') },
|
||||
{ id: 'software', title: text('应用偏好', 'App preferences', 'アプリの設定') },
|
||||
] as const;
|
||||
export type SettingsCategory = typeof settingsCategories[number]['id'];
|
||||
export type SettingsView = 'access' | 'connection' | 'behavior' | 'aliases' | 'sensitive-words';
|
||||
export const settingsViews = {
|
||||
general: [
|
||||
{ id: 'access', title: text('访问与管理', 'Access & management', 'アクセスと管理') },
|
||||
{ id: 'connection', title: text('连接与 TLS', 'Connections & TLS', '接続と TLS') },
|
||||
],
|
||||
requests: [
|
||||
{ id: 'behavior', title: text('请求行为', 'Request behavior', 'リクエストの動作') },
|
||||
{ id: 'aliases', title: text('思考别名', 'Thinking aliases', '思考の別名') },
|
||||
{ id: 'sensitive-words', title: text('内容过滤', 'Content filters', 'コンテンツフィルター') },
|
||||
],
|
||||
} as const;
|
||||
export const settingsTemplateGroups = {
|
||||
general: generalTemplateGroups.filter(group => group.id === 'management'),
|
||||
routing: [...routingTemplateGroups, ...extensionTemplateGroups.filter(group => group.id === 'extensions-concurrency')],
|
||||
requests: [...requestTemplateGroups, ...payloadTemplateGroups],
|
||||
oauth: oauthTemplateGroups,
|
||||
diagnostics: [...generalTemplateGroups.filter(group => group.id === 'diagnostics'), ...extensionTemplateGroups.filter(group => group.id === 'extensions-inflight')],
|
||||
extensions: extensionTemplateGroups.filter(group => group.id === 'extensions-plugins'),
|
||||
software: [],
|
||||
};
|
||||
export const allSettingsTemplateGroups = Object.values(settingsTemplateGroups).flat();
|
||||
export const settingsMessages = {
|
||||
title: text('高级设置', 'Advanced settings', '詳細設定'),
|
||||
search: text('搜索设置', 'Search settings', '設定を検索'),
|
||||
searchHint: text('搜索设置…', 'Search settings…', '設定を検索…'),
|
||||
clearSearch: text('清空搜索', 'Clear search', '検索をクリア'),
|
||||
results: text('搜索结果', 'Search results', '検索結果'),
|
||||
noResults: text('没有找到匹配的设置,试试其他关键词。', 'No matching settings. Try another keyword.', '一致する設定がありません。別のキーワードをお試しください。'),
|
||||
restart: text('保存后重启内核', 'Core restarts after saving', '保存後にコアを再起動'),
|
||||
automatic: text('自动保存', 'Auto-save', '自動保存'),
|
||||
dirty: text('有未保存修改', 'Unsaved changes', '未保存の変更'),
|
||||
nativeNetwork: text('监听与上游代理', 'Listening & upstream proxy', '待ち受けと上流プロキシ'),
|
||||
nativeRouting: text('凭据选择与会话', 'Credential selection & sessions', '認証情報の選択とセッション'),
|
||||
nativeRetry: text('重试与冷却', 'Retries & cooldowns', '再試行とクールダウン'),
|
||||
} satisfies Record<string, TemplateText>;
|
||||
@@ -0,0 +1,103 @@
|
||||
.config-page { gap: 20px; min-width: 0; }
|
||||
.config-settings-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-width: 0; }
|
||||
.config-settings-header h1 { margin: 0; font-size: 25px; letter-spacing: -.035em; }
|
||||
.config-settings-search { display: flex; align-items: center; gap: 10px; flex: 0 1 370px; min-width: 0; padding: 9px 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-card); color: var(--text-tertiary); }
|
||||
.config-settings-search:focus-within { border-color: var(--primary-color); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent); }
|
||||
.config-settings-search > svg { flex: none; }
|
||||
.config-settings-search input { width: 100%; min-width: 0; padding: 3px 0; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 13px; }
|
||||
.config-settings-search input::-webkit-search-cancel-button { display: none; }
|
||||
.config-settings-search button { flex: none; width: 26px; height: 26px; }
|
||||
.config-settings-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; align-items: start; min-width: 0; }
|
||||
.config-settings-topnav { min-width: 0; }
|
||||
.config-settings-navigation.config-subpage-tabs { display: flex; flex-wrap: nowrap; gap: 4px; width: 100%; max-width: 100%; padding: 0 0 5px; margin: 0; border: 0; border-bottom: 1px solid var(--border-color); background: none; border-radius: 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; scrollbar-color: var(--border-color) transparent; scroll-padding-inline: 4px; }
|
||||
.config-settings-navigation::-webkit-scrollbar { height: 5px; }
|
||||
.config-settings-navigation::-webkit-scrollbar-thumb { border-radius: 999px; background: var(--border-color); }
|
||||
.config-page .config-settings-navigation.config-subpage-tabs button { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; gap: 7px; min-width: 0; width: auto; height: auto; min-height: 44px; margin: 0; padding: 10px 12px; border: 1px solid transparent; border-bottom: 2px solid transparent; border-radius: 8px 8px 0 0; background: transparent; color: var(--text-secondary); text-align: center; white-space: nowrap; font-size: 12px; line-height: 1.5; font-weight: 500; box-shadow: none; }
|
||||
.config-page .config-settings-navigation.config-subpage-tabs button:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.config-page .config-settings-navigation.config-subpage-tabs button.active { border-bottom-color: var(--primary-color); background: var(--ui-accent-soft); color: var(--primary-color); font-weight: 650; box-shadow: none; }
|
||||
.config-page .config-settings-navigation.config-subpage-tabs button:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -3px; }
|
||||
.config-settings-navigation button > svg { flex: none; color: var(--text-tertiary); }
|
||||
.config-settings-navigation button.active > svg { color: var(--primary-color); }
|
||||
.config-settings-navigation button > span:not(.config-nav-dirty) { min-width: 0; }
|
||||
.config-nav-dirty { flex: none; width: 6px; height: 6px; margin-left: auto; border-radius: 50%; background: var(--primary-color); }
|
||||
.config-settings-content { min-width: 0; }
|
||||
.config-settings-views { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 20px; padding: 4px; width: fit-content; max-width: 100%; border: 1px solid var(--border-color); background: var(--bg-secondary); border-radius: 9px; }
|
||||
.config-settings-views button { padding: 8px 14px; border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.config-settings-views button.active { background: var(--bg-card); color: var(--text-primary); font-weight: 650; box-shadow: 0 1px 4px rgba(0, 0, 0, .07); }
|
||||
.config-settings-cards { display: grid; gap: 16px; min-width: 0; }
|
||||
.config-settings-cards > [hidden], .config-settings-content > [hidden] { display: none; }
|
||||
.config-settings-cards > section, .config-settings-content .template-config-card { scroll-margin-top: 24px; border-radius: 12px; }
|
||||
.config-settings-cards > section:focus-visible, .config-settings-content .template-config-card:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 4px; }
|
||||
.config-settings-content .config-keys-panel { height: auto; min-height: 225px; max-height: 370px; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.config-settings-content .config-network-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: auto; }
|
||||
.config-settings-content .config-network-field { height: auto; min-height: 86px; gap: 10px; padding: 0; border: 0; background: transparent; }
|
||||
.config-settings-content .config-proxy-field { grid-column: 1 / -1; }
|
||||
.config-settings-content .config-network-routing-field { grid-column: 1 / -1; min-height: 0; }
|
||||
.config-settings-content .routing-segmented { grid-template-columns: repeat(3, minmax(0, 1fr)); height: auto; background: var(--bg-secondary); }
|
||||
.config-settings-content .routing-segmented button { min-height: 38px; padding: 8px 10px; overflow: visible; white-space: normal; text-overflow: clip; line-height: 1.5; }
|
||||
.config-settings-content .routing-segmented button.active { background: var(--bg-card); color: var(--primary-color); box-shadow: 0 0 0 1px var(--border-color); }
|
||||
.config-settings-content .config-network-section { padding: 20px; }
|
||||
.config-settings-content .config-panel-heading { flex-wrap: wrap; gap: 12px; }
|
||||
.config-settings-content .config-network-section-heading { flex-wrap: wrap; gap: 10px; }
|
||||
.config-card-status { margin-left: auto; color: var(--primary-color); font-size: 11px; }
|
||||
.config-settings-content .config-diagnostics-toggle-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); }
|
||||
.config-settings-content .config-diagnostics-fields { grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); }
|
||||
.config-settings-content .config-management-content { grid-template-columns: minmax(0, 1fr); }
|
||||
.config-settings-content .config-management-form { max-width: none; }
|
||||
.config-settings-content .config-tls-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.config-settings-content .thinking-alias-page { padding: 0; }
|
||||
.config-settings-content .template-config-section { margin-top: 18px; }
|
||||
.config-settings-content .config-settings-cards:empty + #config-template-panel .template-config-section { margin-top: 0; }
|
||||
.config-search-results { min-width: 0; }
|
||||
.config-search-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.config-search-heading h2 { margin: 0; font-size: 19px; }
|
||||
.config-search-heading > span { padding: 3px 8px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 11px; color: var(--text-secondary); }
|
||||
.config-search-result-list { display: grid; border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; background: var(--bg-card); }
|
||||
.config-search-result-list > button { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-width: 0; padding: 16px 18px; border: 0; background: transparent; color: var(--text-secondary); text-align: left; cursor: pointer; }
|
||||
.config-search-result-list > button + button { border-top: 1px solid var(--border-color); }
|
||||
.config-search-result-list > button:hover { background: var(--bg-hover); }
|
||||
.config-search-result-list > button:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -3px; }
|
||||
.config-search-result-copy { display: grid; min-width: 0; gap: 6px; overflow-wrap: anywhere; }
|
||||
.config-search-result-copy strong { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
.config-search-result-copy small { font-size: 11px; color: var(--text-tertiary); }
|
||||
.config-search-result-list > button > svg { flex: none; }
|
||||
.config-search-empty { padding: 40px 20px; border: 1px dashed var(--border-color); border-radius: 12px; color: var(--text-secondary); font-size: 13px; text-align: center; }
|
||||
|
||||
.config-field-label { display: flex; align-items: center; gap: 6px; min-width: 0; line-height: 1.5; }
|
||||
.config-field-label > label { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.config-inline-status { margin-left: auto; color: var(--text-tertiary); font-size: 11px; font-weight: 400; }
|
||||
.config-settings-content .config-network-port-field .config-network-input { width: 100%; }
|
||||
.config-settings-content .config-proxy-heading > .settings-help { margin-right: auto; }
|
||||
.config-settings-content .config-diagnostics-setting { padding: 8px 0; border: 0; background: transparent; grid-template-columns: 28px minmax(0, 1fr) 44px; gap: 10px; }
|
||||
.config-settings-content .config-diagnostics-setting-icon,
|
||||
.config-settings-content .config-software-setting-icon { width: 28px; height: 28px; border: 0; background: transparent; }
|
||||
.config-settings-content .config-diagnostics-field { padding: 0; border: 0; background: transparent; gap: 10px; }
|
||||
.config-settings-content .config-management-form { padding: 0; border: 0; background: transparent; }
|
||||
.config-settings-content .config-management-content > p { margin: 0; font-size: 12px; color: var(--text-secondary); }
|
||||
.config-settings-content .config-software-setting-row { min-height: 66px; padding: 14px 16px; gap: 16px; }
|
||||
.settings-help { display: inline-flex; flex: none; vertical-align: middle; }
|
||||
.settings-help-trigger { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--text-tertiary); cursor: pointer; }
|
||||
.settings-help-trigger:hover,
|
||||
.settings-help-trigger[aria-expanded="true"] { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.settings-help-trigger:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
||||
.settings-help-popover { position: fixed; z-index: 1000; display: block; width: min(320px, calc(100vw - 24px)); max-height: min(360px, calc(100vh - 24px)); overflow-y: auto; box-sizing: border-box; padding: 12px 14px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-card); box-shadow: 0 8px 28px rgba(0, 0, 0, .14); color: var(--text-secondary); font-size: 12px; font-weight: 400; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.config-settings-layout { gap: 20px; }
|
||||
.config-page .config-settings-navigation.config-subpage-tabs button { padding: 10px; }
|
||||
.config-settings-header { gap: 18px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.config-page { gap: 20px; }
|
||||
.config-settings-header { flex-direction: column; align-items: stretch; gap: 16px; }
|
||||
.config-settings-search { flex-basis: auto; }
|
||||
.config-settings-header h1 { font-size: 23px; }
|
||||
.config-settings-navigation button > svg { display: none; }
|
||||
.config-page .config-settings-navigation.config-subpage-tabs button { padding: 10px 12px; }
|
||||
.config-settings-content .config-network-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.config-settings-content .config-network-section { padding: 16px; }
|
||||
.config-settings-content .config-network-field { min-height: 0; }
|
||||
.config-settings-views { width: 100%; }
|
||||
.config-settings-views button { flex: 1 1 auto; padding: 9px 8px; }
|
||||
.config-search-result-list > button { padding: 14px; }
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
@import './responsive-providers.css';
|
||||
@import './responsive-agents.css';
|
||||
@import './responsive-settings.css';
|
||||
@import './config-settings.css';
|
||||
@@ -1,31 +1,57 @@
|
||||
.template-config-section { display: grid; gap: 20px; min-width: 0; margin-top: 20px; }
|
||||
.template-config-section { display: grid; gap: 18px; min-width: 0; margin-top: 20px; }
|
||||
.template-config-section[hidden], .template-config-card[hidden] { display: none; }
|
||||
.template-config-card { min-width: 0; padding: 22px; border: 1px solid var(--border-color, #dce5ee); border-radius: 14px; background: var(--bg-card, #fff); }
|
||||
.template-config-heading h3 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: 16px; }
|
||||
.template-config-heading p, .template-config-field small { color: var(--text-secondary, #586779); line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.template-config-heading p { margin: 9px 0 0; font-size: 13px; }
|
||||
.template-config-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 22px; margin-top: 20px; }
|
||||
.template-config-card { min-width: 0; padding: 20px; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-card); scroll-margin-top: 18px; transition: border-color 150ms ease, box-shadow 150ms ease; }
|
||||
.template-config-card-dirty { border-color: color-mix(in srgb, var(--primary-color) 55%, var(--border-color)); box-shadow: inset 3px 0 0 var(--primary-color); }
|
||||
.template-config-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; }
|
||||
.template-config-heading-main { display: flex; align-items: center; gap: 6px; flex: 1 1 220px; min-width: 0; }
|
||||
.template-config-heading h3 { min-width: 0; margin: 0; color: var(--text-primary); font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.template-config-restart-hint { display: inline-flex; align-items: center; gap: 5px; min-width: 0; color: var(--text-tertiary); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.template-config-restart-hint svg { flex: none; }
|
||||
.template-config-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr)); align-items: start; gap: 18px 22px; margin-top: 18px; }
|
||||
.template-config-field { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
.template-config-field-wide { grid-column: 1 / -1; }
|
||||
.template-config-label { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; font-weight: 600; font-size: 13px; width: 100%; min-width: 0; }
|
||||
.template-config-label label { overflow-wrap: anywhere; }
|
||||
.template-config-origin { font-size: 10px; font-weight: 400; color: var(--text-secondary, #64748b); border: 1px solid var(--border-color, #dce5ee); padding: 2px 6px; border-radius: 6px; }
|
||||
.template-config-origin-dirty { color: var(--primary-color, #087e79); border-color: currentColor; }
|
||||
.template-config-field-dirty > .template-config-label { color: var(--primary-color); }
|
||||
.template-config-field-dirty .config-network-input, .template-config-field-dirty .template-config-custom { border-color: color-mix(in srgb, var(--primary-color) 55%, var(--border-primary)); }
|
||||
.template-config-label { display: flex; align-items: center; gap: 8px; font-weight: 500; font-size: 13px; line-height: 1.5; width: 100%; min-width: 0; color: var(--text-primary); }
|
||||
.template-config-label label, .template-config-label > span:first-child { overflow-wrap: anywhere; }
|
||||
.template-config-label-tools { display: flex; align-items: center; gap: 2px; flex: none; margin-left: auto; }
|
||||
.template-config-control { min-width: 0; width: 100%; }
|
||||
.template-config-toggle { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-content: start; align-items: center; gap: 8px 12px; min-height: 38px; }
|
||||
.template-config-toggle > .template-config-label { grid-column: 1; }
|
||||
.template-config-toggle > .template-config-control { grid-column: 2; grid-row: 1; width: auto; }
|
||||
.template-config-toggle > small { grid-column: 1 / -1; }
|
||||
.template-config-switch input:checked + .switch-track { border-color: var(--primary-color); background: var(--primary-color); }
|
||||
.template-config-switch input:focus-visible + .switch-track { outline: 2px solid var(--primary-color); outline-offset: 3px; }
|
||||
.template-config-directory { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; }
|
||||
.template-config-directory > input { flex: 1 1 180px; min-width: 0; width: auto; }
|
||||
.template-config-directory > button { flex: 0 1 auto; white-space: normal; }
|
||||
.template-config-custom { width: 100%; min-width: 0; border: 1px solid var(--border-color); border-radius: 9px; padding: 10px 12px; box-sizing: border-box; }
|
||||
.template-config-custom { width: 100%; min-width: 0; border: 1px solid var(--border-color); border-radius: 8px; padding: 10px 12px; background: var(--bg-card); box-sizing: border-box; }
|
||||
.template-config-custom > summary { cursor: pointer; color: var(--primary-color); font-size: 12px; }
|
||||
.template-config-custom[open] > summary { margin-bottom: 14px; }
|
||||
.template-config-control > input, .template-config-control > select, .template-config-control > textarea { box-sizing: border-box; width: 100%; max-width: 100%; min-width: 0; }
|
||||
.template-config-control .config-network-input { border-color: var(--border-primary); background: var(--bg-card); color: var(--text-primary); }
|
||||
.template-config-control .config-network-input:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px var(--ui-focus-ring); }
|
||||
.template-config-control .config-network-input[aria-invalid='true'] { border-color: var(--danger-color); }
|
||||
.template-config-textarea { resize: vertical; line-height: 1.6; padding: 10px 12px; font-family: inherit; }
|
||||
.template-config-control > textarea.template-config-textarea { height: auto; min-height: 86px; padding: 10px 12px; }
|
||||
.template-config-reset { display: inline-flex; align-items: center; gap: 5px; padding: 0; border: 0; background: transparent; color: var(--primary-color, #087e79); cursor: pointer; font-size: 11px; }
|
||||
.template-config-field > small { font-size: 12px; line-height: 1.6; }
|
||||
.template-config-reset { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; flex: none; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--text-tertiary); cursor: pointer; }
|
||||
.template-config-reset svg { flex: none; }
|
||||
.template-config-reset:hover:not(:disabled) { background: var(--bg-hover); color: var(--primary-color); }
|
||||
.template-config-reset:disabled { opacity: .5; cursor: default; }
|
||||
.template-config-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color, #dce5ee); }
|
||||
.template-config-actions > span { font-size: 12px; color: var(--text-secondary, #64748b); }
|
||||
.template-config-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color); }
|
||||
.template-config-actions > span { min-width: 0; font-size: 12px; color: var(--text-tertiary); overflow-wrap: anywhere; }
|
||||
.template-config-actions > .template-config-action-status-dirty { color: var(--primary-color); font-weight: 600; }
|
||||
.template-config-actions > .template-config-action-status-saved { color: var(--success-color); }
|
||||
.template-config-actions > div { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
|
||||
.template-config-notice { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 14px; border: 1px solid var(--border-color, #dce5ee); border-radius: 10px; overflow-wrap: anywhere; font-size: 13px; }
|
||||
.template-config-error, .template-config-field small.template-config-error { color: var(--danger-color, #c0392b); overflow-wrap: anywhere; }
|
||||
@media (max-width: 680px) { .template-config-card { padding: 16px; } .template-config-actions > div { width: 100%; } .template-config-actions button { flex: 1 1 auto; white-space: normal; } }
|
||||
.template-config-card .compact-button { margin-top: 0; min-height: 36px; }
|
||||
.template-config-card :is(button, summary):focus-visible { outline: 2px solid var(--primary-color); outline-offset: 3px; }
|
||||
.template-config-notice { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 14px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-card); color: var(--text-secondary); overflow-wrap: anywhere; font-size: 13px; }
|
||||
.template-config-error, .template-config-field small.template-config-error { color: var(--danger-color); overflow-wrap: anywhere; }
|
||||
@media (max-width: 680px) {
|
||||
.template-config-card { padding: 16px; }
|
||||
.template-config-heading-main { flex-basis: 180px; }
|
||||
.template-config-actions > div { width: 100%; }
|
||||
.template-config-actions button { flex: 1 1 auto; white-space: normal; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .template-config-card { transition: none; } }
|
||||
@@ -42,9 +42,51 @@ const path = require('node:path');
|
||||
await page.locator('.nav-section').getByRole('button', { name: '高级功能' }).click();
|
||||
assert.equal(await page.locator('h1').count(), 1);
|
||||
const configTabs = page.locator('.config-subpage-tabs').getByRole('tab');
|
||||
assert.equal(await page.locator('.config-subpage-tabs').getAttribute('aria-orientation'), 'horizontal');
|
||||
assert.equal(await page.locator('.config-settings-sidebar').count(), 0);
|
||||
assert.equal(await page.locator('.config-settings-header p, .config-nav-save-hint, .config-category-heading, .config-section-index').count(), 0);
|
||||
assert.deepEqual(await configTabs.evaluateAll(elements => elements.map(element => element.id)), [
|
||||
'config-subpage-tab-general', 'config-subpage-tab-routing', 'config-subpage-tab-requests',
|
||||
'config-subpage-tab-oauth', 'config-subpage-tab-diagnostics', 'config-subpage-tab-extensions',
|
||||
'config-subpage-tab-software',
|
||||
]);
|
||||
assert.equal(await page.locator('.config-page [role="tabpanel"]').count(), 1);
|
||||
assert.deepEqual(await configTabs.evaluateAll((elements) => elements
|
||||
.map((element) => element.getAttribute('aria-controls'))
|
||||
.filter((id) => !id || !document.getElementById(id))), []);
|
||||
assert.equal(await configTabs.evaluateAll(elements => elements.every(element =>
|
||||
element.getAttribute('aria-controls') === 'config-subpage-panel')), true);
|
||||
await configTabs.first().focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await page.locator('#config-subpage-tab-routing').getAttribute('aria-selected'), 'true');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
await page.keyboard.press('End');
|
||||
assert.equal(await page.locator('#config-subpage-tab-software').getAttribute('aria-selected'), 'true');
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'config-subpage-tab-software');
|
||||
await page.keyboard.press('Home');
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'config-subpage-tab-general');
|
||||
for (const key of ['ArrowDown', 'ArrowUp']) {
|
||||
assert.equal(await configTabs.first().evaluate((element, pressedKey) => {
|
||||
const event = new KeyboardEvent('keydown', { key: pressedKey, bubbles: true, cancelable: true });
|
||||
element.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}, key), false, `${key} must remain available for normal page scrolling`);
|
||||
assert.equal(await configTabs.first().getAttribute('aria-selected'), 'true');
|
||||
}
|
||||
assert.equal(await page.locator('#config-view-access').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await page.locator('#config-view-connection').getAttribute('role'), null);
|
||||
assert.equal(await page.getByLabel('搜索设置', { exact: true }).count(), 1);
|
||||
const fieldHelp = page.locator('#template-field-management-0 .settings-help-trigger').first();
|
||||
assert.equal(await fieldHelp.getAttribute('aria-expanded'), 'false');
|
||||
assert.ok(await fieldHelp.getAttribute('aria-controls'));
|
||||
assert.ok(await fieldHelp.getAttribute('aria-label'), 'Help icons need a meaningful accessible name');
|
||||
await fieldHelp.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await fieldHelp.getAttribute('aria-expanded'), 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await fieldHelp.getAttribute('aria-expanded'), 'false');
|
||||
const addKey = page.getByRole('button', { name: /新增鉴权密钥/ }).first();
|
||||
await addKey.focus();
|
||||
await addKey.click();
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const categories = ['general', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software'];
|
||||
const viewports = [{ width: 640, height: 600 }, { width: 964, height: 700 }, { width: 1280, height: 800 }];
|
||||
|
||||
(async () => {
|
||||
let server;
|
||||
let browser;
|
||||
let base = 'http://127.0.0.1:1420/';
|
||||
const runtimeErrors = [];
|
||||
try {
|
||||
try {
|
||||
const response = await fetch(base, { signal: AbortSignal.timeout(3000) });
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
} catch {
|
||||
const { createServer } = await import('vite');
|
||||
const react = (await import('@vitejs/plugin-react')).default;
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
root: path.resolve(__dirname, '..'),
|
||||
plugins: [react()],
|
||||
logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 1421, strictPort: false },
|
||||
});
|
||||
await server.listen();
|
||||
base = `http://127.0.0.1:${server.httpServer.address().port}/`;
|
||||
}
|
||||
|
||||
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
|
||||
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
|
||||
|
||||
for (const locale of ['zh-CN', 'en']) {
|
||||
for (const viewport of viewports) {
|
||||
const label = `${locale} ${viewport.width}x${viewport.height}`;
|
||||
const page = await browser.newPage({ viewport });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on('pageerror', error => runtimeErrors.push(`${label}: ${error}`));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
|
||||
await page.addInitScript(language => localStorage.setItem('easy-cli-proxy-api.locale', language), locale);
|
||||
await page.goto(`${base}?mock=running`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.app-shell').waitFor();
|
||||
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
|
||||
await page.locator('.nav-section').getByRole('button', {
|
||||
name: locale === 'en' ? 'Advanced Features' : '高级功能', exact: true,
|
||||
}).click();
|
||||
await page.locator('#config-subpage-panel').waitFor();
|
||||
|
||||
const settle = () => page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
const tab = async id => {
|
||||
await page.locator(`#config-subpage-tab-${id}`).click();
|
||||
await page.locator(`#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`).waitFor();
|
||||
assert.equal(await page.locator(`#config-subpage-tab-${id}`).getAttribute('aria-selected'), 'true');
|
||||
await settle();
|
||||
};
|
||||
const view = async id => {
|
||||
await page.locator(`#config-view-${id}`).click();
|
||||
assert.equal(await page.locator(`#config-view-${id}`).getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await page.locator(`#config-view-${id}`).getAttribute('role'), null);
|
||||
await settle();
|
||||
};
|
||||
const templateVisible = async (id, visible = true) => {
|
||||
assert.equal(await page.locator(`#template-group-${id}`).isVisible(), visible, `${label}: ${id} visibility`);
|
||||
};
|
||||
const noOverflow = async context => {
|
||||
await settle();
|
||||
const overflowing = await page.evaluate(() => [
|
||||
document.documentElement, document.body, document.querySelector('.content'),
|
||||
document.querySelector('.config-page'), document.querySelector('#config-subpage-panel'),
|
||||
document.querySelector('.config-search-results'),
|
||||
].filter(Boolean).filter(element => element.scrollWidth > element.clientWidth + 1)
|
||||
.map(element => ({ element: element.className || element.tagName, width: element.clientWidth, scroll: element.scrollWidth })));
|
||||
assert.deepEqual(overflowing, [], `${label} ${context}: settings must fit without horizontal overflow`);
|
||||
};
|
||||
const reachable = async (locator, context) => {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
await locator.evaluate(element => element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }));
|
||||
await settle();
|
||||
const metrics = await locator.evaluate(element => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
|
||||
return {
|
||||
reachable: bounds.width > 0 && bounds.height > 0 && bounds.left >= -1 && bounds.right <= innerWidth + 1
|
||||
&& bounds.top >= -1 && bounds.bottom <= innerHeight + 1 && Boolean(hit && element.contains(hit)),
|
||||
bounds: { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom },
|
||||
hit: hit?.className || hit?.tagName,
|
||||
};
|
||||
});
|
||||
if (!metrics.reachable && process.env.SETTINGS_SCREENSHOT_DIR) {
|
||||
const fs = require('node:fs');
|
||||
fs.mkdirSync(path.resolve(process.env.SETTINGS_SCREENSHOT_DIR), { recursive: true });
|
||||
await page.screenshot({ path: path.join(path.resolve(process.env.SETTINGS_SCREENSHOT_DIR), 'settings-reachability-failure.png') });
|
||||
}
|
||||
assert.equal(metrics.reachable, true, `${label} ${context}: control must remain reachable: ${JSON.stringify(metrics)}`);
|
||||
};
|
||||
const hasDirtyMarker = async id => page.locator(`#config-subpage-tab-${id}`).evaluate(element =>
|
||||
Boolean(element.querySelector('.config-nav-dirty, .config-settings-dirty, [data-dirty="true"]'))
|
||||
|| /未保存|unsaved/i.test([element.getAttribute('aria-label'), element.getAttribute('title'), element.textContent].join(' ')));
|
||||
const search = page.getByLabel(locale === 'en' ? 'Search settings' : '搜索设置', { exact: true });
|
||||
const retrySection = page.locator('#config-retry-section-title').locator('xpath=ancestor::section[1]');
|
||||
const exerciseHelp = async (trigger, context) => {
|
||||
await reachable(trigger, `${context} help trigger`);
|
||||
const controlledId = await trigger.getAttribute('aria-controls');
|
||||
assert.ok(controlledId, `${label}: help triggers must identify their description`);
|
||||
const popover = page.locator(`[id="${controlledId}"]`);
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: help starts collapsed`);
|
||||
assert.equal(await popover.isVisible(), false, `${label}: descriptions stay hidden until requested`);
|
||||
await trigger.click();
|
||||
await popover.waitFor({ state: 'visible' });
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(await popover.getAttribute('role'), 'tooltip');
|
||||
assert.ok((await popover.innerText()).trim().length > 5, `${label}: expanded help must contain useful field guidance`);
|
||||
await noOverflow(`${context} help open`);
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: Escape closes requested help`);
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'true', `${label}: Enter opens field help`);
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: Space closes field help`);
|
||||
await trigger.click();
|
||||
await search.click();
|
||||
assert.equal(await trigger.getAttribute('aria-expanded'), 'false', `${label}: clicking outside closes field help`);
|
||||
};
|
||||
|
||||
assert.equal(await page.getByRole('heading', { level: 1, name: locale === 'en' ? 'Advanced settings' : '高级设置', exact: true }).count(), 1);
|
||||
assert.equal(await page.locator('.config-page [role="tabpanel"]').count(), 1);
|
||||
assert.equal(await page.locator('.config-settings-header p').count(), 0,
|
||||
`${label}: settings header must not repeat layout explanations`);
|
||||
assert.equal(await page.locator('.config-nav-save-hint, .config-category-heading, .config-section-index').count(), 0,
|
||||
`${label}: categories should lead directly to their settings without redundant descriptions or jump navigation`);
|
||||
assert.deepEqual(await page.locator('.config-subpage-tabs [role="tab"]').evaluateAll(elements =>
|
||||
elements.map(element => element.id.replace('config-subpage-tab-', ''))), categories);
|
||||
assert.equal(await page.locator('.config-subpage-tabs [role="tab"]').evaluateAll(elements =>
|
||||
elements.every(element => element.getAttribute('aria-controls') === 'config-subpage-panel')), true);
|
||||
const navigation = page.locator('.config-subpage-tabs');
|
||||
assert.equal(await navigation.getAttribute('aria-orientation'), 'horizontal');
|
||||
assert.equal(await page.locator('.config-settings-sidebar, .config-page aside').count(), 0,
|
||||
`${label}: settings must not add another vertical sidebar`);
|
||||
await settle();
|
||||
const navigationLayout = await navigation.evaluate(element => {
|
||||
const header = document.querySelector('.config-settings-header').getBoundingClientRect();
|
||||
const panel = document.querySelector('#config-subpage-panel').getBoundingClientRect();
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const elements = [...element.querySelectorAll('[role="tab"]')];
|
||||
const buttons = elements.map(button => button.getBoundingClientRect());
|
||||
return {
|
||||
aboveContent: bounds.top >= header.bottom - 1 && bounds.bottom <= panel.top + 1,
|
||||
singleRow: Math.max(...buttons.map(button => button.top)) - Math.min(...buttons.map(button => button.top)) <= 1,
|
||||
labelsFit: elements.every((button, index) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(button);
|
||||
const text = range.getBoundingClientRect();
|
||||
return text.left >= buttons[index].left - 1 && text.right <= buttons[index].right + 1
|
||||
&& text.top >= buttons[index].top - 1 && text.bottom <= buttons[index].bottom + 1;
|
||||
}),
|
||||
overflowing: element.scrollWidth > element.clientWidth + 1,
|
||||
scrollable: ['auto', 'scroll'].includes(getComputedStyle(element).overflowX),
|
||||
};
|
||||
});
|
||||
assert.equal(navigationLayout.aboveContent, true, `${label}: category navigation belongs above settings content`);
|
||||
assert.equal(navigationLayout.singleRow, true, `${label}: all seven categories must stay in a single horizontal row`);
|
||||
assert.equal(navigationLayout.labelsFit, true, `${label}: complete category labels must fit their own buttons`);
|
||||
if (viewport.width === 640) {
|
||||
assert.equal(navigationLayout.overflowing, true, `${label}: narrow navigation keeps readable labels through scrolling`);
|
||||
assert.equal(navigationLayout.scrollable, true, `${label}: narrow category navigation exposes horizontal scrolling`);
|
||||
assert.equal(await navigation.evaluate(element => {
|
||||
element.scrollLeft = element.scrollWidth;
|
||||
return element.scrollLeft > 0;
|
||||
}), true, `${label}: users can scroll to categories at the right edge`);
|
||||
}
|
||||
const finalCategory = page.locator('#config-subpage-tab-software');
|
||||
await reachable(finalCategory, 'rightmost horizontal category');
|
||||
await tab('software');
|
||||
assert.equal(await page.locator('.config-software-panel').isVisible(), true,
|
||||
`${label}: rightmost category remains activatable`);
|
||||
await reachable(page.locator('#config-subpage-tab-general'), 'first horizontal category');
|
||||
|
||||
await tab('general');
|
||||
await view('access');
|
||||
assert.equal(await page.locator('.config-keys-panel').isVisible(), true);
|
||||
await templateVisible('management');
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), false);
|
||||
await templateVisible('diagnostics', false);
|
||||
await noOverflow('service access');
|
||||
await exerciseHelp(page.locator('#template-field-management-0 .settings-help-trigger').first(), 'management template');
|
||||
await view('connection');
|
||||
assert.equal(await page.locator('#config-network-section-title').isVisible(), true);
|
||||
assert.equal(await page.locator('#config-tls-section-title').isVisible(), true);
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), false);
|
||||
await noOverflow('service connection');
|
||||
const nativePort = page.locator('#config-native-network .config-network-port-field input');
|
||||
assert.equal(await page.getByLabel(locale === 'en' ? 'Port' : '端口', { exact: true }).count(), 1,
|
||||
`${label}: field help must not alter the input's accessible label`);
|
||||
const initialPort = await nativePort.inputValue();
|
||||
await exerciseHelp(page.locator('#config-native-network .settings-help-trigger').first(), 'network field');
|
||||
assert.equal(await nativePort.inputValue(), initialPort, `${label}: requesting help must not edit the field`);
|
||||
|
||||
await tab('routing');
|
||||
assert.equal(await page.locator('#config-routing-section-title').isVisible(), true);
|
||||
assert.equal(await page.locator('#config-retry-section-title').isVisible(), true);
|
||||
await templateVisible('routing-advanced');
|
||||
await templateVisible('extensions-concurrency');
|
||||
await templateVisible('extensions-plugins', false);
|
||||
await noOverflow('routing and stability');
|
||||
await tab('general');
|
||||
assert.equal(await page.locator('#config-view-connection').getAttribute('aria-pressed'), 'true',
|
||||
`${label}: returning to a category restores its last secondary view`);
|
||||
await tab('routing');
|
||||
const routingOptions = await page.locator('.routing-segmented').evaluate(group => {
|
||||
const container = group.getBoundingClientRect();
|
||||
return [...group.querySelectorAll('button')].map(button => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(button);
|
||||
const text = range.getBoundingClientRect();
|
||||
return {
|
||||
label: button.textContent.trim(),
|
||||
contained: bounds.left >= container.left - 1 && bounds.right <= container.right + 1
|
||||
&& bounds.top >= container.top - 1 && bounds.bottom <= container.bottom + 1,
|
||||
readable: text.left >= bounds.left - 1 && text.right <= bounds.right + 1
|
||||
&& text.top >= bounds.top - 1 && text.bottom <= bounds.bottom + 1,
|
||||
height: bounds.height,
|
||||
};
|
||||
});
|
||||
});
|
||||
assert.equal(routingOptions.length, 3);
|
||||
assert.ok(routingOptions.every(option => option.contained && option.readable && option.height >= 32),
|
||||
`${label}: all routing strategy labels need complete text and usable button bounds: ${JSON.stringify(routingOptions)}`);
|
||||
|
||||
await tab('requests');
|
||||
await view('behavior');
|
||||
for (const id of ['request-behavior', 'codex-client', 'multimedia', 'request-payload']) await templateVisible(id);
|
||||
await noOverflow('model and request behavior');
|
||||
await view('aliases');
|
||||
assert.equal(await page.locator('.thinking-alias-page').isVisible(), true);
|
||||
await reachable(page.locator('.thinking-alias-page').getByRole('button', { name: locale === 'en' ? 'Create Alias' : '创建别名', exact: true }), 'model alias creation');
|
||||
await noOverflow('model aliases');
|
||||
await view('sensitive-words');
|
||||
await page.locator('.config-sensitive-words-row input').first().waitFor();
|
||||
await noOverflow('sensitive words');
|
||||
|
||||
await tab('oauth');
|
||||
for (const id of ['oauth-common', 'oauth-models', 'oauth-codex', 'oauth-media', 'oauth-claude', 'oauth-others']) await templateVisible(id);
|
||||
await noOverflow('upstream credentials');
|
||||
await tab('requests');
|
||||
assert.equal(await page.locator('#config-view-sensitive-words').getAttribute('aria-pressed'), 'true',
|
||||
`${label}: content-filter navigation is remembered across category changes`);
|
||||
await tab('diagnostics');
|
||||
assert.equal(await page.locator('.config-diagnostics-panel').isVisible(), true);
|
||||
await templateVisible('diagnostics');
|
||||
await templateVisible('extensions-inflight');
|
||||
await noOverflow('diagnostics');
|
||||
await tab('extensions');
|
||||
await templateVisible('extensions-plugins');
|
||||
await templateVisible('extensions-concurrency', false);
|
||||
await templateVisible('extensions-inflight', false);
|
||||
await noOverflow('extensions');
|
||||
await tab('software');
|
||||
assert.equal(await page.locator('.config-software-panel').isVisible(), true);
|
||||
await noOverflow('application preferences');
|
||||
|
||||
// Search remains available across categories, and result activation must
|
||||
// select both the category and its secondary view before locating a card.
|
||||
await search.fill(locale === 'en' ? 'Retries & cooldowns' : '重试与冷却');
|
||||
const results = page.locator('.config-search-results');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await noOverflow('search results');
|
||||
await reachable(results.getByRole('button').first(), 'search result');
|
||||
await results.getByRole('button', { name: locale === 'en' ? /Retries & cooldowns/ : /重试与冷却/ }).first().click();
|
||||
assert.equal(await page.locator('#config-subpage-tab-routing').getAttribute('aria-selected'), 'true');
|
||||
await reachable(page.locator('#config-retry-section-title'), 'search target');
|
||||
await search.fill(locale === 'en' ? 'Sensitive Words' : '敏感词');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await results.getByRole('button').first().click();
|
||||
assert.equal(await page.locator('#config-subpage-tab-requests').getAttribute('aria-selected'), 'true');
|
||||
assert.equal(await page.locator('#config-view-sensitive-words').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(await page.locator('.config-sensitive-words-row input').first().isVisible(), true);
|
||||
await page.waitForFunction(() => document.querySelector('#config-native-sensitive-words')?.contains(document.activeElement));
|
||||
await search.fill(locale === 'en' ? 'thinking aliases' : '思考');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await results.getByRole('button').first().click();
|
||||
assert.equal(await page.locator('#config-view-aliases').getAttribute('aria-pressed'), 'true');
|
||||
await page.waitForFunction(() => document.querySelector('#config-native-aliases')?.contains(document.activeElement));
|
||||
await search.fill(locale === 'en' ? 'Default values' : '补充缺失参数');
|
||||
await results.getByRole('button').first().waitFor();
|
||||
await results.getByRole('button').first().click();
|
||||
assert.equal(await page.locator('#config-subpage-tab-requests').getAttribute('aria-selected'), 'true');
|
||||
assert.equal(await page.locator('#config-view-behavior').getAttribute('aria-pressed'), 'true');
|
||||
const payloadField = page.locator('#template-field-request-payload-0');
|
||||
assert.equal(await payloadField.locator('details.template-config-custom').evaluate(element => element.open), true,
|
||||
`${label}: search expands structured controls before locating a field`);
|
||||
await page.waitForFunction(() => document.querySelector('#template-field-request-payload-0')?.contains(document.activeElement));
|
||||
if (viewport.width === 1280) {
|
||||
await payloadField.locator('button.structured-add').first().click();
|
||||
await payloadField.locator('input').first().waitFor();
|
||||
await search.fill(locale === 'en' ? 'Default values' : '补充缺失参数');
|
||||
await results.getByRole('button').first().click();
|
||||
await page.waitForFunction(() => document.querySelector('#template-field-request-payload-0')?.contains(document.activeElement)
|
||||
&& document.activeElement?.matches('input, textarea, select'));
|
||||
await page.locator('#config-group-request-payload').getByRole('button', {
|
||||
name: locale === 'en' ? 'Discard changes' : '放弃本组修改', exact: true,
|
||||
}).click();
|
||||
}
|
||||
await search.fill('');
|
||||
await search.fill('settings-that-do-not-exist-2026');
|
||||
assert.equal(await results.getByRole('button').count(), 0);
|
||||
await noOverflow('empty search');
|
||||
await search.fill('');
|
||||
|
||||
if (process.env.SETTINGS_SCREENSHOT_DIR && viewport.width === 640) {
|
||||
const fs = require('node:fs');
|
||||
const directory = path.resolve(process.env.SETTINGS_SCREENSHOT_DIR);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
await page.setViewportSize({ width: 640, height: 900 });
|
||||
await tab('general');
|
||||
await view('access');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, locale === 'en' ? 'settings-access-640-en.png' : 'settings-access-640.png') });
|
||||
await tab('routing');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, locale === 'en' ? 'settings-routing-640-en.png' : 'settings-routing-640.png') });
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
if (process.env.SETTINGS_SCREENSHOT_DIR && viewport.width === 1280 && locale === 'zh-CN') {
|
||||
const fs = require('node:fs');
|
||||
const directory = path.resolve(process.env.SETTINGS_SCREENSHOT_DIR);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await tab('general');
|
||||
await view('access');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-access.png') });
|
||||
await tab('routing');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-routing.png') });
|
||||
await page.locator('.sidebar-theme-selector').getByRole('button', { name: '暗色', exact: true }).click();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-routing-dark.png') });
|
||||
await tab('general');
|
||||
await view('access');
|
||||
await page.locator('.config-settings-header').scrollIntoViewIfNeeded();
|
||||
await settle();
|
||||
await page.screenshot({ path: path.join(directory, 'settings-access-dark.png') });
|
||||
await page.locator('.sidebar-theme-selector').getByRole('button', { name: '亮色', exact: true }).click();
|
||||
await page.setViewportSize(viewport);
|
||||
}
|
||||
|
||||
// Exercise drafts and save feedback once per locale; the responsive cases
|
||||
// above use fresh browser contexts so preferences never leak between them.
|
||||
if (viewport.width === 1280) {
|
||||
await tab('general');
|
||||
await view('access');
|
||||
const remoteManagement = page.locator('#template-management-0');
|
||||
await remoteManagement.check();
|
||||
assert.equal(await hasDirtyMarker('general'), true, `${label}: template drafts mark their category`);
|
||||
await tab('routing');
|
||||
const retryInput = retrySection.locator('input[type="text"]').first();
|
||||
await retryInput.fill('5');
|
||||
assert.equal(await hasDirtyMarker('routing'), true, `${label}: core drafts mark their category`);
|
||||
await tab('software');
|
||||
assert.equal(await hasDirtyMarker('routing'), true, `${label}: dirty category remains marked while inactive`);
|
||||
await tab('routing');
|
||||
assert.equal(await retryInput.inputValue(), '5', `${label}: changing categories preserves core drafts`);
|
||||
const retrySave = retrySection.getByRole('button', { name: locale === 'en' ? /Save/ : /保存/ });
|
||||
await retrySave.click();
|
||||
await page.waitForFunction(button => {
|
||||
return button?.disabled && !/正在保存|保存中|saving/i.test(button.textContent);
|
||||
}, await retrySave.elementHandle());
|
||||
assert.equal(await hasDirtyMarker('routing'), false, `${label}: saving clears the category marker`);
|
||||
await tab('general');
|
||||
await view('access');
|
||||
assert.equal(await remoteManagement.isChecked(), true, `${label}: saving another category preserves template drafts`);
|
||||
assert.equal(await hasDirtyMarker('general'), true);
|
||||
const management = page.locator('#template-group-management').locator('xpath=ancestor::section[1]');
|
||||
await management.getByRole('button', { name: locale === 'en' ? 'Discard changes' : '放弃本组修改', exact: true }).click();
|
||||
assert.equal(await remoteManagement.isChecked(), false);
|
||||
assert.equal(await hasDirtyMarker('general'), false, `${label}: discarding clears the category marker`);
|
||||
|
||||
await tab('requests');
|
||||
await view('sensitive-words');
|
||||
const sensitiveWord = page.locator('.config-sensitive-words-row input').first();
|
||||
await sensitiveWord.fill('settings-navigation-draft');
|
||||
await tab('extensions');
|
||||
assert.equal(await hasDirtyMarker('requests'), true, `${label}: sensitive-word drafts mark their category`);
|
||||
await tab('requests');
|
||||
await view('sensitive-words');
|
||||
assert.equal(await sensitiveWord.inputValue(), 'settings-navigation-draft', `${label}: sensitive-word drafts survive category changes`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(runtimeErrors, [], 'Settings navigation must not produce runtime errors');
|
||||
console.log('PASS: seven settings categories, contextual views, search, retained drafts, save feedback, and Chinese/English responsive layouts.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -196,7 +196,7 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
const values: Array<[string, unknown]> = [
|
||||
['routing/strategy', 'weighted-round-robin'], ['routing/session-affinity-subagents', false],
|
||||
['routing/cooldown/transient-error-cooldown-seconds', -1], ['routing/retry/max-retry-interval', -1],
|
||||
['requests/streaming/keepalive-seconds', 0], ['server/trusted-proxies', []],
|
||||
['requests/streaming/keepalive-seconds', 0], ['plugins/store-sources', []],
|
||||
['client/codex/enable-apply-patch', false], ['client/codex/optimize-multi-agent-v2', true],
|
||||
['multimedia/disable-image-generation', 'passthrough'],
|
||||
['oauth/settings', { codex: [{ name: 'gpt-template', 'max-context-length': 524288 }] }],
|
||||
|
||||
@@ -214,10 +214,25 @@ const viewports = [
|
||||
}
|
||||
|
||||
await open('高级功能', '#config-subpage-panel');
|
||||
for (const id of ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words']) {
|
||||
for (const id of ['general', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software']) {
|
||||
await tab(`config-subpage-tab-${id}`, `#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`);
|
||||
await assertNoPageOverflow(`${label} settings ${id}`);
|
||||
}
|
||||
for (const [category, views] of [['general', ['access', 'connection']], ['requests', ['behavior', 'aliases', 'sensitive-words']]]) {
|
||||
await tab(`config-subpage-tab-${category}`, '#config-subpage-panel');
|
||||
for (const view of views) {
|
||||
await page.locator(`#config-view-${view}`).click();
|
||||
await settle();
|
||||
assert.equal(await page.locator(`#config-view-${view}`).getAttribute('aria-pressed'), 'true');
|
||||
await assertNoPageOverflow(`${label} settings ${category}/${view}`);
|
||||
await assertReachable(page.locator(`#config-view-${view}`), `${label} settings ${view} navigation`);
|
||||
}
|
||||
}
|
||||
await page.getByLabel('搜索设置', { exact: true }).fill('重试');
|
||||
await page.locator('.config-search-results button').first().waitFor();
|
||||
await settle();
|
||||
await assertNoPageOverflow(`${label} settings search results`);
|
||||
await page.getByLabel('搜索设置', { exact: true }).fill('');
|
||||
|
||||
await open('版本管理', '.version-list-item');
|
||||
await assertNoPageOverflow(`${label} versions`);
|
||||
@@ -233,7 +248,7 @@ const viewports = [
|
||||
await page.locator('.home-page').waitFor();
|
||||
await page.addStyleTag({ content: '#browser-mock-toolbar { display: none; }' });
|
||||
await open('Advanced Features', '.config-subpage-tabs');
|
||||
for (const id of ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words']) {
|
||||
for (const id of ['general', 'routing', 'requests', 'oauth', 'diagnostics', 'extensions', 'software']) {
|
||||
await tab(`config-subpage-tab-${id}`, `#config-subpage-panel[aria-labelledby="config-subpage-tab-${id}"]`);
|
||||
await assertNoPageOverflow(`640x600 English settings ${id}`);
|
||||
const button = page.locator(`#config-subpage-tab-${id}`);
|
||||
@@ -246,6 +261,10 @@ const viewports = [
|
||||
return text.left >= bounds.left - 1 && text.right <= bounds.right + 1;
|
||||
}), true, `English ${id} label must fit inside its own clickable tab`);
|
||||
}
|
||||
await page.getByLabel('Search settings', { exact: true }).fill('retry');
|
||||
await page.locator('.config-search-results button').first().waitFor();
|
||||
await settle();
|
||||
await assertNoPageOverflow('640x600 English settings search results');
|
||||
await page.close();
|
||||
assert.deepEqual(errors, [], 'Responsive navigation must not produce runtime errors');
|
||||
console.log('PASS: responsive pages, short-window navigation, reachable controls, and independent request-table scrolling.');
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
templateFieldSaveValue, templateFieldValidation, type TemplateConfigField,
|
||||
} from '../src/services/templateConfig';
|
||||
import { createBrowserMockRuntime } from '../src/mocks/browserMockRuntime';
|
||||
import { generalTemplateGroups, networkTemplateGroups, requestTemplateGroups, routingTemplateGroups } from '../src/services/generalTemplateFields';
|
||||
import { generalTemplateGroups, requestTemplateGroups, routingTemplateGroups } from '../src/services/generalTemplateFields';
|
||||
import { templateMessages } from '../src/i18n/templateConfig';
|
||||
import { oauthTemplateGroups } from '../src/services/oauthTemplateFields';
|
||||
|
||||
@@ -13,7 +13,7 @@ const change = (path: string[], value: unknown, expected: unknown = null, expect
|
||||
|
||||
describe('template configuration drafts', () => {
|
||||
test('all general settings labels and help messages include Japanese', () => {
|
||||
const groups = [...generalTemplateGroups, ...networkTemplateGroups, ...requestTemplateGroups, ...routingTemplateGroups];
|
||||
const groups = [...generalTemplateGroups, ...requestTemplateGroups, ...routingTemplateGroups];
|
||||
const messages = [...Object.values(templateMessages), ...groups.flatMap((group) => [group.title, group.description, ...group.fields.flatMap((item) => [item.label, item.description, ...item.options?.map((option) => option.label) ?? []])])].filter((value) => value !== undefined);
|
||||
for (const message of messages) {
|
||||
expect(typeof message).toBe('object');
|
||||
@@ -85,9 +85,9 @@ describe('template configuration drafts', () => {
|
||||
});
|
||||
|
||||
describe('extended browser mock persistence', () => {
|
||||
test('hot-reloadable discovery, diagnostics and plugins do not ask for a restart', async () => {
|
||||
test('hot-reloadable diagnostics and plugins do not ask for a restart', async () => {
|
||||
const runtime = createBrowserMockRuntime('running');
|
||||
const changes = [change(['server', 'discovery', 'enabled'], true), change(['observability', 'pprof', 'enable'], true), change(['plugins', 'enabled'], true), change(['plugins', 'dir'], 'custom-plugins')];
|
||||
const changes = [change(['observability', 'pprof', 'enable'], true), change(['plugins', 'enabled'], true), change(['plugins', 'dir'], 'custom-plugins')];
|
||||
expect(await runtime.invoke('save_extended_core_config', { changes })).toMatchObject({ restartRequired: false });
|
||||
});
|
||||
test('credential directory changes and restoring defaults require a restart', async () => {
|
||||
@@ -101,7 +101,7 @@ describe('extended browser mock persistence', () => {
|
||||
test('saves offline, supports explicit overrides, removal and preserves unrelated values', async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createBrowserMockRuntime('stopped', (event) => events.push(event));
|
||||
const changes = [change(['plugins', 'enabled'], false), change(['plugins', 'configs'], {}), change(['server', 'trusted-proxies'], [])];
|
||||
const changes = [change(['plugins', 'enabled'], false), change(['plugins', 'configs'], {}), change(['management', 'disable-auto-update-panel'], false)];
|
||||
const result = await runtime.invoke('save_extended_core_config', { changes }) as { config: Record<string, unknown>; restartRequired: boolean };
|
||||
expect(readTemplatePath(result.config, ['plugins', 'enabled'])).toEqual({ exists: true, value: false });
|
||||
expect(readTemplatePath(result.config, ['plugins', 'configs']).value).toEqual({});
|
||||
|
||||
Reference in new issue
Block a user